From 36cc7a3481250d761a94714fe258637bbe30e1b7 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 12 Mar 2026 16:40:45 +0530 Subject: [PATCH] feat: add NumberField component based on Base UI primitive Adds a new NumberField component wrapping @base-ui/react NumberField with Apsara design tokens. Supports standalone usage () and composed usage with sub-components (Group, Input, Decrement, Increment, ScrubArea). Co-Authored-By: Claude Opus 4.6 --- apps/www/src/components/playground/index.ts | 1 + .../playground/number-field-examples.tsx | 34 ++++ .../docs/components/number-field/demo.ts | 59 ++++++ .../docs/components/number-field/index.mdx | 123 ++++++++++++ .../docs/components/number-field/props.ts | 89 +++++++++ .../__tests__/number-field.test.tsx | 178 ++++++++++++++++++ .../components/number-field/index.tsx | 2 + .../number-field/number-field.module.css | 81 ++++++++ .../components/number-field/number-field.tsx | 153 +++++++++++++++ packages/raystack/index.tsx | 1 + 10 files changed, 721 insertions(+) create mode 100644 apps/www/src/components/playground/number-field-examples.tsx create mode 100644 apps/www/src/content/docs/components/number-field/demo.ts create mode 100644 apps/www/src/content/docs/components/number-field/index.mdx create mode 100644 apps/www/src/content/docs/components/number-field/props.ts create mode 100644 packages/raystack/components/number-field/__tests__/number-field.test.tsx create mode 100644 packages/raystack/components/number-field/index.tsx create mode 100644 packages/raystack/components/number-field/number-field.module.css create mode 100644 packages/raystack/components/number-field/number-field.tsx diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index 62ec07833..a3d25a167 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -28,6 +28,7 @@ export * from './label-examples'; export * from './link-examples'; export * from './list-examples'; export * from './menu-examples'; +export * from './number-field-examples'; export * from './popover-examples'; export * from './preview-card-examples'; export * from './radio-examples'; diff --git a/apps/www/src/components/playground/number-field-examples.tsx b/apps/www/src/components/playground/number-field-examples.tsx new file mode 100644 index 000000000..16d4bd701 --- /dev/null +++ b/apps/www/src/components/playground/number-field-examples.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { Flex, NumberField, Text } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; + +export function NumberFieldExamples() { + return ( + + + Default: + + + With Min/Max (0-10): + + + With Step (5): + + + Disabled: + + + Composed with ScrubArea: + + + + + + + + + + + ); +} diff --git a/apps/www/src/content/docs/components/number-field/demo.ts b/apps/www/src/content/docs/components/number-field/demo.ts new file mode 100644 index 000000000..2c6d5d67f --- /dev/null +++ b/apps/www/src/content/docs/components/number-field/demo.ts @@ -0,0 +1,59 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: any) => { + return ``; +}; + +export const playground = { + type: 'playground', + controls: { + defaultValue: { type: 'text', initialValue: '0' }, + min: { type: 'text', initialValue: '' }, + max: { type: 'text', initialValue: '' }, + step: { type: 'text', initialValue: '1' }, + disabled: { type: 'checkbox', initialValue: false, defaultValue: false } + }, + getCode +}; + +export const basicDemo = { + type: 'code', + code: `` +}; + +export const minMaxDemo = { + type: 'code', + code: `` +}; + +export const stepDemo = { + type: 'code', + code: `` +}; + +export const disabledDemo = { + type: 'code', + code: `` +}; + +export const composedDemo = { + type: 'code', + code: ` + + + + + + +` +}; + +export const formatDemo = { + type: 'code', + code: `` +}; diff --git a/apps/www/src/content/docs/components/number-field/index.mdx b/apps/www/src/content/docs/components/number-field/index.mdx new file mode 100644 index 000000000..983df8f57 --- /dev/null +++ b/apps/www/src/content/docs/components/number-field/index.mdx @@ -0,0 +1,123 @@ +--- +title: Number Field +description: A numeric input component with increment and decrement buttons, supporting scrub interaction. +source: packages/raystack/components/number-field +tag: new +--- + +import { + playground, + basicDemo, + minMaxDemo, + stepDemo, + disabledDemo, + composedDemo, + formatDemo, +} from "./demo.ts"; + + + +## Anatomy + +Import and use the component standalone or composed: + +```tsx +import { NumberField } from "@raystack/apsara"; + +{/* Standalone — renders decrement, input, and increment internally */} + + +{/* Composed — full control over sub-components */} + + + + + + + + +``` + +## API Reference + +### Root + +The root component that manages numeric state. When no children are provided, it renders a default group with decrement, input, and increment controls. + + + +### Group + +Groups the input and button controls together. + + + +### Input + +The numeric input element. + + + +### Decrement + +Button to decrease the value. + + + +### Increment + +Button to increase the value. + + + +### ScrubArea + +An interactive area that allows adjusting the value by dragging. Renders a Label component internally. + + + +## Examples + +### Basic + +A standalone number field with default controls. + + + +### Min / Max + +Number field constrained to a range. + + + +### Step + +Number field with a custom step amount. + + + +### Disabled + +Number field in disabled state. + + + +### Composed with ScrubArea + +Full control with scrub area for drag-to-adjust interaction. + + + +### Formatted + +Number field with currency formatting. + + + +## Accessibility + +- The input has `role="textbox"` with `aria-roledescription="Number field"` +- Increment and decrement buttons are keyboard accessible +- ScrubArea associates its label with the input via `htmlFor` +- Supports `aria-valuemin`, `aria-valuemax`, and `aria-valuenow` +- Keyboard: Arrow Up/Down to increment/decrement, Shift for large step, Meta for small step diff --git a/apps/www/src/content/docs/components/number-field/props.ts b/apps/www/src/content/docs/components/number-field/props.ts new file mode 100644 index 000000000..1dbc84b4b --- /dev/null +++ b/apps/www/src/content/docs/components/number-field/props.ts @@ -0,0 +1,89 @@ +export interface NumberFieldProps { + /** Controlled numeric value. */ + value?: number | null; + + /** + * Initial uncontrolled value. + * @defaultValue 0 + */ + defaultValue?: number; + + /** Event handler called when the value changes. */ + onValueChange?: (value: number | null, event: Event) => void; + + /** Minimum allowed value. */ + min?: number; + + /** Maximum allowed value. */ + max?: number; + + /** + * Step amount for increment/decrement. + * @defaultValue 1 + */ + step?: number; + + /** + * Whether the field is disabled. + * @defaultValue false + */ + disabled?: boolean; + + /** + * Whether the field is read-only. + * @defaultValue false + */ + readOnly?: boolean; + + /** + * Whether the field is required. + * @defaultValue false + */ + required?: boolean; + + /** Number formatting options (Intl.NumberFormatOptions). */ + format?: Intl.NumberFormatOptions; + + /** Additional CSS class names. */ + className?: string; +} + +export interface NumberFieldGroupProps { + /** Additional CSS class names. */ + className?: string; +} + +export interface NumberFieldInputProps { + /** Additional CSS class names. */ + className?: string; +} + +export interface NumberFieldDecrementProps { + /** Custom content for the decrement button. Defaults to a minus icon. */ + children?: React.ReactNode; + + /** Additional CSS class names. */ + className?: string; +} + +export interface NumberFieldIncrementProps { + /** Custom content for the increment button. Defaults to a plus icon. */ + children?: React.ReactNode; + + /** Additional CSS class names. */ + className?: string; +} + +export interface NumberFieldScrubAreaProps { + /** Label text displayed in the scrub area. */ + label: string; + + /** + * Scrub direction. + * @defaultValue "horizontal" + */ + direction?: 'horizontal' | 'vertical'; + + /** Additional CSS class names. */ + className?: string; +} diff --git a/packages/raystack/components/number-field/__tests__/number-field.test.tsx b/packages/raystack/components/number-field/__tests__/number-field.test.tsx new file mode 100644 index 000000000..4de48e224 --- /dev/null +++ b/packages/raystack/components/number-field/__tests__/number-field.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { NumberField } from '../number-field'; +import styles from '../number-field.module.css'; + +describe('NumberField', () => { + describe('Basic Rendering', () => { + it('renders default structure when no children provided', () => { + render(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('5'); + }); + + it('renders decrement and increment buttons', () => { + render(); + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + }); + + it('applies custom className to root', () => { + const { container } = render( + + ); + const root = container.firstElementChild; + expect(root).toHaveClass(styles.root); + expect(root).toHaveClass('custom-class'); + }); + + it('forwards ref to root', () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLElement); + }); + }); + + describe('Composed Usage', () => { + it('renders children when provided', () => { + render( + + + + + + + + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getAllByRole('button')).toHaveLength(2); + }); + }); + + describe('Interaction', () => { + it('increments value on increment button click', () => { + const onValueChange = vi.fn(); + render(); + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[1]); + expect(onValueChange).toHaveBeenCalled(); + }); + + it('decrements value on decrement button click', () => { + const onValueChange = vi.fn(); + render(); + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[0]); + expect(onValueChange).toHaveBeenCalled(); + }); + + it('supports keyboard input', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('textbox'); + await user.clear(input); + await user.type(input, '42'); + expect(input).toHaveValue('42'); + }); + }); + + describe('Controlled', () => { + it('renders controlled value', () => { + render(); + expect(screen.getByRole('textbox')).toHaveValue('10'); + }); + }); + + describe('Constraints', () => { + it('respects min and max', () => { + render(); + const buttons = screen.getAllByRole('button'); + const input = screen.getByRole('textbox'); + // Try to increment beyond max + fireEvent.click(buttons[1]); + expect(input).toHaveValue('10'); + }); + }); + + describe('Disabled State', () => { + it('disables all controls when disabled', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('disabled'); + }); + }); + + describe('Sub-components', () => { + it('Group applies custom className', () => { + render( + + + + + + ); + const group = screen.getByRole('group'); + expect(group).toHaveClass('custom-group'); + expect(group).toHaveClass(styles.group); + }); + + it('Input applies custom className', () => { + render( + + + + + + ); + const input = screen.getByRole('textbox'); + expect(input).toHaveClass('custom-input'); + expect(input).toHaveClass(styles.input); + }); + + it('Decrement forwards ref', () => { + const ref = createRef(); + render( + + + + + + + + ); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + + it('Increment forwards ref', () => { + const ref = createRef(); + render( + + + + + + + + ); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + }); + + describe('ScrubArea', () => { + it('renders label inside scrub area', () => { + render( + + + + + + + + + ); + expect(screen.getByText('Amount')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/raystack/components/number-field/index.tsx b/packages/raystack/components/number-field/index.tsx new file mode 100644 index 000000000..5c1d2e28b --- /dev/null +++ b/packages/raystack/components/number-field/index.tsx @@ -0,0 +1,2 @@ +export type { NumberFieldScrubAreaProps } from './number-field'; +export { NumberField } from './number-field'; diff --git a/packages/raystack/components/number-field/number-field.module.css b/packages/raystack/components/number-field/number-field.module.css new file mode 100644 index 000000000..c4721ffdc --- /dev/null +++ b/packages/raystack/components/number-field/number-field.module.css @@ -0,0 +1,81 @@ +.root { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--rs-space-2); +} + +.group { + display: inline-flex; + align-items: center; +} + +.decrement, +.increment { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + background: var(--rs-color-background-base-secondary); + border: 0.5px solid var(--rs-color-border-base-primary); + color: var(--rs-color-foreground-base-primary); + cursor: pointer; + padding: 0; +} + +.decrement { + border-radius: var(--rs-radius-1) 0 0 var(--rs-radius-1); +} + +.increment { + border-radius: 0 var(--rs-radius-1) var(--rs-radius-1) 0; +} + +.decrement:hover, +.increment:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.decrement[data-disabled], +.increment[data-disabled] { + pointer-events: none; + opacity: 0.5; +} + +.input { + height: 24px; + min-width: 0; + flex: 1 0 0; + background: var(--rs-color-background-base-primary); + border: 0.5px solid var(--rs-color-border-base-primary); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-primary); + text-align: center; + font-variant-numeric: tabular-nums; + outline: none; + padding: 0 var(--rs-space-2); +} + +.input[data-disabled] { + pointer-events: none; + opacity: 0.5; +} + +.scrub-area { + display: flex; + cursor: ew-resize; +} + +.scrub-area-label { + cursor: ew-resize; +} + +.scrub-area-cursor { + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5)); +} diff --git a/packages/raystack/components/number-field/number-field.tsx b/packages/raystack/components/number-field/number-field.tsx new file mode 100644 index 000000000..4f14846ac --- /dev/null +++ b/packages/raystack/components/number-field/number-field.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { NumberField as NumberFieldPrimitive } from '@base-ui/react'; +import { MinusIcon, PlusIcon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { + createContext, + ElementRef, + forwardRef, + type ReactNode, + useContext, + useId +} from 'react'; +import { Label } from '../label'; +import styles from './number-field.module.css'; + +const NumberFieldContext = createContext<{ fieldId: string } | null>(null); + +const NumberFieldRoot = forwardRef< + ElementRef, + NumberFieldPrimitive.Root.Props & { children?: ReactNode } +>(({ className, children, id, ...props }, ref) => { + const generatedId = useId(); + const fieldId = id ?? generatedId; + + return ( + + + {children ?? ( + + + + + + )} + + + ); +}); +NumberFieldRoot.displayName = 'NumberField'; + +const NumberFieldGroup = forwardRef< + ElementRef, + NumberFieldPrimitive.Group.Props +>(({ className, ...props }, ref) => ( + +)); +NumberFieldGroup.displayName = 'NumberField.Group'; + +const NumberFieldInput = forwardRef< + ElementRef, + NumberFieldPrimitive.Input.Props +>(({ className, ...props }, ref) => ( + +)); +NumberFieldInput.displayName = 'NumberField.Input'; + +const NumberFieldDecrement = forwardRef< + ElementRef, + NumberFieldPrimitive.Decrement.Props +>(({ className, children, ...props }, ref) => ( + + {children ?? } + +)); +NumberFieldDecrement.displayName = 'NumberField.Decrement'; + +const NumberFieldIncrement = forwardRef< + ElementRef, + NumberFieldPrimitive.Increment.Props +>(({ className, children, ...props }, ref) => ( + + {children ?? } + +)); +NumberFieldIncrement.displayName = 'NumberField.Increment'; + +export interface NumberFieldScrubAreaProps + extends NumberFieldPrimitive.ScrubArea.Props { + label: string; +} + +const NumberFieldScrubArea = forwardRef< + ElementRef, + NumberFieldScrubAreaProps +>(({ className, label, ...props }, ref) => { + const context = useContext(NumberFieldContext); + + return ( + + + + + + + ); +}); +NumberFieldScrubArea.displayName = 'NumberField.ScrubArea'; + +function CursorGrowIcon(props: React.ComponentProps<'svg'>) { + return ( + + ); +} + +export const NumberField = Object.assign(NumberFieldRoot, { + Group: NumberFieldGroup, + Input: NumberFieldInput, + Decrement: NumberFieldDecrement, + Increment: NumberFieldIncrement, + ScrubArea: NumberFieldScrubArea, + ScrubAreaCursor: NumberFieldPrimitive.ScrubAreaCursor +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 37885b603..1404b7a14 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -44,6 +44,7 @@ export { Link } from './components/link'; export { List } from './components/list'; export { Menu } from './components/menu'; export { Navbar } from './components/navbar'; +export { NumberField } from './components/number-field'; export { Popover } from './components/popover'; export { PreviewCard } from './components/preview-card'; export { Radio } from './components/radio';