Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/propel/src/components/number-field/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { NumberField, type NumberFieldProps } from "./number-field";
export {
NumberFieldButtonIcon,
type NumberFieldButtonIconProps,
NumberFieldDecrement,
type NumberFieldDecrementProps,
NumberFieldGroup,
Expand All @@ -8,4 +10,5 @@ export {
type NumberFieldIncrementProps,
NumberFieldInput,
type NumberFieldInputProps,
type NumberFieldMagnitude,
} from "../../ui/number-field";
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ type Story = StoryObj<typeof meta>;

/** 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");
Expand Down
26 changes: 20 additions & 6 deletions packages/propel/src/components/number-field/number-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,18 +28,27 @@ export type NumberFieldProps = NumberFieldRootProps;
export function NumberField({
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
magnitude,
...props
}: NumberFieldProps) {
return (
<NumberFieldRoot {...props}>
<NumberFieldGroup>
<NumberFieldDecrement aria-label="Decrease">
<Minus aria-hidden className="size-4" />
<NumberFieldDecrement magnitude={magnitude} aria-label="Decrease">
<NumberFieldButtonIcon>
<Minus />
</NumberFieldButtonIcon>
</NumberFieldDecrement>
{/* The accessible name belongs on the input, not the root div. */}
<NumberFieldInput aria-label={ariaLabel} aria-labelledby={ariaLabelledby} />
<NumberFieldIncrement aria-label="Increase">
<Plus aria-hidden className="size-4" />
<NumberFieldInput
magnitude={magnitude}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
/>
<NumberFieldIncrement magnitude={magnitude} aria-label="Increase">
<NumberFieldButtonIcon>
<Plus />
</NumberFieldButtonIcon>
</NumberFieldIncrement>
</NumberFieldGroup>
</NumberFieldRoot>
Expand Down
2 changes: 2 additions & 0 deletions packages/propel/src/ui/number-field/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
17 changes: 17 additions & 0 deletions packages/propel/src/ui/number-field/number-field-button-icon.tsx
Original file line number Diff line number Diff line change
@@ -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 <span aria-hidden className={numberFieldButtonIconVariants()} {...props} />;
}
16 changes: 12 additions & 4 deletions packages/propel/src/ui/number-field/number-field-decrement.tsx
Original file line number Diff line number Diff line change
@@ -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 <BaseNumberField.Decrement className={numberFieldButtonVariants()} {...props} />;
export function NumberFieldDecrement({ magnitude, ...props }: NumberFieldDecrementProps) {
return (
<BaseNumberField.Decrement className={numberFieldButtonVariants({ magnitude })} {...props} />
);
}
16 changes: 12 additions & 4 deletions packages/propel/src/ui/number-field/number-field-increment.tsx
Original file line number Diff line number Diff line change
@@ -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 <BaseNumberField.Increment className={numberFieldButtonVariants()} {...props} />;
export function NumberFieldIncrement({ magnitude, ...props }: NumberFieldIncrementProps) {
return (
<BaseNumberField.Increment className={numberFieldButtonVariants({ magnitude })} {...props} />
);
}
11 changes: 7 additions & 4 deletions packages/propel/src/ui/number-field/number-field-input.tsx
Original file line number Diff line number Diff line change
@@ -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<BaseNumberField.Input.Props, "className" | "style">;
export type NumberFieldInputProps = Omit<BaseNumberField.Input.Props, "className" | "style"> & {
/** Visual size of the input. Required — must match the stepper buttons' magnitude. */
magnitude: NumberFieldMagnitude;
};

export function NumberFieldInput(props: NumberFieldInputProps) {
return <BaseNumberField.Input className={numberFieldInputVariants()} {...props} />;
export function NumberFieldInput({ magnitude, ...props }: NumberFieldInputProps) {
return <BaseNumberField.Input className={numberFieldInputVariants({ magnitude })} {...props} />;
}
37 changes: 26 additions & 11 deletions packages/propel/src/ui/number-field/number-field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expect } from "storybook/test";

import {
NumberField,
NumberFieldButtonIcon,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
Expand All @@ -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<typeof NumberField>;

export default meta;
Expand All @@ -28,12 +35,16 @@ export const Default: Story = {
render: (args) => (
<NumberField {...args} aria-label="Number of instances">
<NumberFieldGroup>
<NumberFieldDecrement aria-label="Decrease instances">
<Minus aria-hidden className="size-4" />
<NumberFieldDecrement magnitude="xl" aria-label="Decrease instances">
<NumberFieldButtonIcon>
<Minus />
</NumberFieldButtonIcon>
</NumberFieldDecrement>
<NumberFieldInput aria-label="Number of instances" />
<NumberFieldIncrement aria-label="Increase instances">
<Plus aria-hidden className="size-4" />
<NumberFieldInput magnitude="xl" aria-label="Number of instances" />
<NumberFieldIncrement magnitude="xl" aria-label="Increase instances">
<NumberFieldButtonIcon>
<Plus />
</NumberFieldButtonIcon>
</NumberFieldIncrement>
</NumberFieldGroup>
</NumberField>
Expand All @@ -47,12 +58,16 @@ export const IncrementAndDecrement: Story = {
render: (args) => (
<NumberField {...args} aria-label="Number of instances">
<NumberFieldGroup>
<NumberFieldDecrement aria-label="Decrease instances">
<Minus aria-hidden className="size-4" />
<NumberFieldDecrement magnitude="xl" aria-label="Decrease instances">
<NumberFieldButtonIcon>
<Minus />
</NumberFieldButtonIcon>
</NumberFieldDecrement>
<NumberFieldInput aria-label="Number of instances" />
<NumberFieldIncrement aria-label="Increase instances">
<Plus aria-hidden className="size-4" />
<NumberFieldInput magnitude="xl" aria-label="Number of instances" />
<NumberFieldIncrement magnitude="xl" aria-label="Increase instances">
<NumberFieldButtonIcon>
<Plus />
</NumberFieldButtonIcon>
</NumberFieldIncrement>
</NumberFieldGroup>
</NumberField>
Expand Down
45 changes: 42 additions & 3 deletions packages/propel/src/ui/number-field/variants.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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<typeof numberFieldButtonVariants>["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",
},
},
},
);