diff --git a/packages/propel/src/components/number-field/index.tsx b/packages/propel/src/components/number-field/index.tsx index 7b6eeed9..a89d81ab 100644 --- a/packages/propel/src/components/number-field/index.tsx +++ b/packages/propel/src/components/number-field/index.tsx @@ -1,5 +1,7 @@ export { NumberField, type NumberFieldProps } from "./number-field"; export { + NumberFieldButtonIcon, + type NumberFieldButtonIconProps, NumberFieldDecrement, type NumberFieldDecrementProps, NumberFieldGroup, @@ -8,4 +10,5 @@ export { type NumberFieldIncrementProps, NumberFieldInput, type NumberFieldInputProps, + type NumberFieldMagnitude, } from "../../ui/number-field"; diff --git a/packages/propel/src/components/number-field/number-field.stories.tsx b/packages/propel/src/components/number-field/number-field.stories.tsx index 4dc55531..97ee5187 100644 --- a/packages/propel/src/components/number-field/number-field.stories.tsx +++ b/packages/propel/src/components/number-field/number-field.stories.tsx @@ -18,13 +18,13 @@ type Story = StoryObj; /** A bounded numeric input with stepper buttons. */ export const Default: Story = { - args: { defaultValue: 2, min: 1, max: 64 }, + args: { defaultValue: 2, min: 1, max: 64, magnitude: "xl" }, }; /** Clicking the +/- buttons steps the value within `min`/`max`. */ export const IncrementAndDecrement: Story = { tags: ["!dev", "!autodocs", "!manifest"], - args: { defaultValue: 2, min: 1, max: 64 }, + args: { defaultValue: 2, min: 1, max: 64, magnitude: "xl" }, play: async ({ canvas, userEvent }) => { const input = canvas.getByRole("textbox", { name: "Number of instances" }); await expect(input).toHaveDisplayValue("2"); diff --git a/packages/propel/src/components/number-field/number-field.tsx b/packages/propel/src/components/number-field/number-field.tsx index b063828b..bd24c07e 100644 --- a/packages/propel/src/components/number-field/number-field.tsx +++ b/packages/propel/src/components/number-field/number-field.tsx @@ -2,14 +2,19 @@ import { Minus, Plus } from "lucide-react"; import { NumberField as NumberFieldRoot, + NumberFieldButtonIcon, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, + type NumberFieldMagnitude, type NumberFieldProps as NumberFieldRootProps, } from "../../ui/number-field"; -export type NumberFieldProps = NumberFieldRootProps; +export type NumberFieldProps = NumberFieldRootProps & { + /** Visual size of the field: controls button square and input height. Required. */ + magnitude: NumberFieldMagnitude; +}; /** * The ready-made number field: a numeric input flanked by decrement / increment buttons. Drive it @@ -23,18 +28,27 @@ export type NumberFieldProps = NumberFieldRootProps; export function NumberField({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, + magnitude, ...props }: NumberFieldProps) { return ( - - + + + + {/* The accessible name belongs on the input, not the root div. */} - - - + + + + + diff --git a/packages/propel/src/ui/number-field/index.tsx b/packages/propel/src/ui/number-field/index.tsx index 0b310f2c..8e4e9f25 100644 --- a/packages/propel/src/ui/number-field/index.tsx +++ b/packages/propel/src/ui/number-field/index.tsx @@ -1,5 +1,7 @@ export { NumberField, type NumberFieldProps } from "./number-field"; +export { NumberFieldButtonIcon, type NumberFieldButtonIconProps } from "./number-field-button-icon"; export { NumberFieldDecrement, type NumberFieldDecrementProps } from "./number-field-decrement"; export { NumberFieldGroup, type NumberFieldGroupProps } from "./number-field-group"; export { NumberFieldIncrement, type NumberFieldIncrementProps } from "./number-field-increment"; export { NumberFieldInput, type NumberFieldInputProps } from "./number-field-input"; +export { type NumberFieldMagnitude } from "./variants"; diff --git a/packages/propel/src/ui/number-field/number-field-button-icon.tsx b/packages/propel/src/ui/number-field/number-field-button-icon.tsx new file mode 100644 index 00000000..9f949a92 --- /dev/null +++ b/packages/propel/src/ui/number-field/number-field-button-icon.tsx @@ -0,0 +1,17 @@ +import type * as React from "react"; + +import { numberFieldButtonIconVariants } from "./variants"; + +export type NumberFieldButtonIconProps = Omit< + React.ComponentPropsWithoutRef<"span">, + "className" | "style" +>; + +/** + * The icon slot inside a stepper button (`NumberFieldDecrement` or `NumberFieldIncrement`). Sizes + * its single child to the button's `--node-size` (set by the button's `magnitude`), so callers pass + * a bare icon with no size class. Decorative — the button carries the accessible name. + */ +export function NumberFieldButtonIcon(props: NumberFieldButtonIconProps) { + return ; +} diff --git a/packages/propel/src/ui/number-field/number-field-decrement.tsx b/packages/propel/src/ui/number-field/number-field-decrement.tsx index cf2d4ef8..952d773c 100644 --- a/packages/propel/src/ui/number-field/number-field-decrement.tsx +++ b/packages/propel/src/ui/number-field/number-field-decrement.tsx @@ -1,12 +1,20 @@ import { NumberField as BaseNumberField } from "@base-ui/react/number-field"; -import { numberFieldButtonVariants } from "./variants"; +import { type NumberFieldMagnitude, numberFieldButtonVariants } from "./variants"; export type NumberFieldDecrementProps = Omit< BaseNumberField.Decrement.Props, "className" | "style" ->; +> & { + /** + * Visual size of the stepper button. Required — pick the magnitude that matches the field's + * density. + */ + magnitude: NumberFieldMagnitude; +}; -export function NumberFieldDecrement(props: NumberFieldDecrementProps) { - return ; +export function NumberFieldDecrement({ magnitude, ...props }: NumberFieldDecrementProps) { + return ( + + ); } diff --git a/packages/propel/src/ui/number-field/number-field-increment.tsx b/packages/propel/src/ui/number-field/number-field-increment.tsx index f659f4c4..8fd10bc1 100644 --- a/packages/propel/src/ui/number-field/number-field-increment.tsx +++ b/packages/propel/src/ui/number-field/number-field-increment.tsx @@ -1,12 +1,20 @@ import { NumberField as BaseNumberField } from "@base-ui/react/number-field"; -import { numberFieldButtonVariants } from "./variants"; +import { type NumberFieldMagnitude, numberFieldButtonVariants } from "./variants"; export type NumberFieldIncrementProps = Omit< BaseNumberField.Increment.Props, "className" | "style" ->; +> & { + /** + * Visual size of the stepper button. Required — pick the magnitude that matches the field's + * density. + */ + magnitude: NumberFieldMagnitude; +}; -export function NumberFieldIncrement(props: NumberFieldIncrementProps) { - return ; +export function NumberFieldIncrement({ magnitude, ...props }: NumberFieldIncrementProps) { + return ( + + ); } diff --git a/packages/propel/src/ui/number-field/number-field-input.tsx b/packages/propel/src/ui/number-field/number-field-input.tsx index 38ee8698..29965b35 100644 --- a/packages/propel/src/ui/number-field/number-field-input.tsx +++ b/packages/propel/src/ui/number-field/number-field-input.tsx @@ -1,9 +1,12 @@ import { NumberField as BaseNumberField } from "@base-ui/react/number-field"; -import { numberFieldInputVariants } from "./variants"; +import { type NumberFieldMagnitude, numberFieldInputVariants } from "./variants"; -export type NumberFieldInputProps = Omit; +export type NumberFieldInputProps = Omit & { + /** Visual size of the input. Required — must match the stepper buttons' magnitude. */ + magnitude: NumberFieldMagnitude; +}; -export function NumberFieldInput(props: NumberFieldInputProps) { - return ; +export function NumberFieldInput({ magnitude, ...props }: NumberFieldInputProps) { + return ; } diff --git a/packages/propel/src/ui/number-field/number-field.stories.tsx b/packages/propel/src/ui/number-field/number-field.stories.tsx index 7341ea9d..fc0190a5 100644 --- a/packages/propel/src/ui/number-field/number-field.stories.tsx +++ b/packages/propel/src/ui/number-field/number-field.stories.tsx @@ -4,6 +4,7 @@ import { expect } from "storybook/test"; import { NumberField, + NumberFieldButtonIcon, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, @@ -16,7 +17,13 @@ import { const meta = { title: "UI/NumberField", component: NumberField, - subcomponents: { NumberFieldGroup, NumberFieldInput, NumberFieldDecrement, NumberFieldIncrement }, + subcomponents: { + NumberFieldGroup, + NumberFieldInput, + NumberFieldButtonIcon, + NumberFieldDecrement, + NumberFieldIncrement, + }, } satisfies Meta; export default meta; @@ -28,12 +35,16 @@ export const Default: Story = { render: (args) => ( - - + + + + - - - + + + + + @@ -47,12 +58,16 @@ export const IncrementAndDecrement: Story = { render: (args) => ( - - + + + + - - - + + + + + diff --git a/packages/propel/src/ui/number-field/variants.ts b/packages/propel/src/ui/number-field/variants.ts index 376376eb..d9057bcf 100644 --- a/packages/propel/src/ui/number-field/variants.ts +++ b/packages/propel/src/ui/number-field/variants.ts @@ -1,4 +1,6 @@ -import { cva, cx } from "class-variance-authority"; +import { type VariantProps, cva, cx } from "class-variance-authority"; + +import { nodeSlotClass } from "../../internal/node-slot"; export const numberFieldRootVariants = cva("flex min-w-0 flex-col gap-1.5"); export const numberFieldGroupVariants = cva( @@ -8,13 +10,50 @@ export const numberFieldGroupVariants = cva( "data-disabled:cursor-not-allowed data-disabled:border-subtle data-disabled:bg-layer-2", ), ); + +// Button geometry per magnitude. Each magnitude sets a square size and a `--node-size` +// CSS variable that the `NumberFieldButtonIcon` node-slot reads to size the child icon. +// Figma S/Base/L/XL map to sm/md/lg/xl (20/24/28/32 px). The icon runs one step +// smaller than the button (14/16/16/20 px) matching the icon-button glyph scale. export const numberFieldButtonVariants = cva( cx( - "flex size-8 items-center justify-center text-icon-secondary transition-colors outline-none", + "flex shrink-0 items-center justify-center text-icon-secondary transition-colors outline-none", "hover:bg-layer-transparent-hover focus-visible:bg-layer-transparent-hover", "data-disabled:cursor-not-allowed data-disabled:text-disabled", ), + { + variants: { + magnitude: { + sm: "size-5 [--node-size:0.875rem]", // 20 px box, 14 px glyph + md: "size-6 [--node-size:1rem]", // 24 px box, 16 px glyph + lg: "size-7 [--node-size:1rem]", // 28 px box, 16 px glyph + xl: "size-8 [--node-size:1.25rem]", // 32 px box, 20 px glyph + }, + }, + }, ); + +// The icon slot inside a stepper button. Sizes its single child to the button's +// `--node-size` (inherited from the button element above), so callers pass a bare icon. +export const numberFieldButtonIconVariants = cva(nodeSlotClass); + +// Shared magnitude type so all parts stay in sync. +export type NumberFieldMagnitude = NonNullable< + VariantProps["magnitude"] +>; + +// Input geometry per magnitude. Heights match the button square per magnitude so the +// group container stays flush. The width is fixed to accommodate up to ~4 digits. export const numberFieldInputVariants = cva( - "h-8 w-16 bg-transparent text-center text-14 text-primary outline-none disabled:text-disabled", + "w-14 bg-transparent text-center text-14 text-primary outline-none disabled:text-disabled", + { + variants: { + magnitude: { + sm: "h-5", + md: "h-6", + lg: "h-7", + xl: "h-8", + }, + }, + }, );