base-number-field

PreviousNext
Docs
reuiui

Preview

Loading preview…
registry/default/ui/base-number-field.tsx
import * as React from 'react';
import { cn } from '@/registry/default/lib/utils';
import { NumberField as NumberFieldPrimitive } from '@base-ui-components/react/number-field';
import { cva, VariantProps } from 'class-variance-authority';
import { MinusIcon, MoveHorizontalIcon, PlusIcon } from 'lucide-react';

type NumberFieldContextType = {
  id: string;
};

const NumberFieldContext = React.createContext<NumberFieldContextType | null>(null);

const inputVariants = cva(
  [
    'border border-input flex items-center justify-center transition-colors select-none disabled:opacity-50 disabled:pointer-events-none w-20 text-center',
    'focus-visible:ring-ring/30  focus-visible:border-ring focus-visible:outline-none focus-visible:ring-[3px]',
    'aria-invalid:border-destructive/60 aria-invalid:ring-destructive/10 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/20',
  ],
  {
    variants: {
      size: {
        lg: 'h-10 text-base',
        md: 'h-9 text-sm',
        sm: 'h-8 text-sm',
      },
    },
    defaultVariants: {
      size: 'md',
    },
  },
);

const buttonVariants = cva(
  [
    'cursor-pointer focus-visible:outline-hidden inline-flex items-center justify-center text-foreground border border-input',
    'focus-visible:ring-ring/30  focus-visible:border-ring focus-visible:outline-none focus-visible:ring-[3px]',
    'hover:bg-muted',
    'whitespace-nowrap text-sm font-medium ring-offset-background transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
  ],
  {
    variants: {
      size: {
        lg: 'size-10 [&_svg:not([class*=size-])]:size-4',
        md: 'size-9 [&_svg:not([class*=size-])]:size-3.5',
        sm: 'size-8 [&_svg:not([class*=size-])]:size-3',
      },
    },
    defaultVariants: {
      size: 'md',
    },
  },
);

const useNumberField = () => {
  const context = React.useContext(NumberFieldContext);

  if (!context) {
    throw new Error('useNumberField must be used within a NumberField');
  }

  return context;
};

function NumberField({
  id,
  className,
  children,
  size = 'md',
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.Root> & VariantProps<typeof inputVariants>) {
  let fieldId = React.useId();

  if (id) {
    fieldId = id;
  }

  return (
    <NumberFieldContext.Provider value={{ id: fieldId }}>
      <NumberFieldPrimitive.Root
        id={fieldId}
        className={cn('flex flex-col items-start gap-1', className)}
        data-slot="number-field"
        {...props}
      >
        {children}
        <NumberFieldPrimitive.Group className="shadow-xs shadow-black/5 text-foreground flex rounded-md transition-shadow">
          <NumberFieldPrimitive.Decrement
            className={cn(buttonVariants({ size }), 'rounded-s-md border-e-0')}
            data-slot="number-field-decrement"
          >
            <MinusIcon />
          </NumberFieldPrimitive.Decrement>
          <NumberFieldPrimitive.Input className={inputVariants({ size })} data-slot="number-field-input" />
          <NumberFieldPrimitive.Increment
            className={cn(buttonVariants({ size }), 'rounded-e-md border-s-0')}
            data-slot="number-field-increment"
          >
            <PlusIcon />
          </NumberFieldPrimitive.Increment>
        </NumberFieldPrimitive.Group>
      </NumberFieldPrimitive.Root>
    </NumberFieldContext.Provider>
  );
}

function NumberFieldScrubArea({
  className,
  children,
  ...props
}: React.ComponentProps<typeof NumberFieldPrimitive.ScrubArea>) {
  const { id: fieldId } = useNumberField();

  return (
    <NumberFieldPrimitive.ScrubArea
      className={cn('cursor-ew-resize', className)}
      data-slot="number-field-scrub-area"
      {...props}
    >
      <label
        htmlFor={fieldId}
        className="text-foreground cursor-ew-resize text-sm font-medium"
        data-slot="number-field-label"
      >
        {children}
      </label>
      <NumberFieldPrimitive.ScrubAreaCursor
        className="drop-shadow-sm filter"
        data-slot="number-field-scrub-area-cursor"
      >
        <MoveHorizontalIcon className="size-4.5" />
      </NumberFieldPrimitive.ScrubAreaCursor>
    </NumberFieldPrimitive.ScrubArea>
  );
}

export { NumberField, NumberFieldScrubArea };

Installation

npx shadcn@latest add @reui/base-number-field

Usage

import { BaseNumberField } from "@/components/ui/base-number-field"
<BaseNumberField />