@sixthshift/design-system 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/components/DataTable/DataTable.d.ts +89 -0
  2. package/dist/components/DataTable/DataTable.d.ts.map +1 -0
  3. package/dist/components/DataTable/DataTable.js +107 -0
  4. package/dist/components/DataTable/DataTable.js.map +1 -0
  5. package/dist/components/DataTable/index.d.ts +2 -0
  6. package/dist/components/DataTable/index.d.ts.map +1 -0
  7. package/dist/components/DataTable/index.js +2 -0
  8. package/dist/components/DataTable/index.js.map +1 -0
  9. package/dist/components/FileDropzone/FileDropzone.d.ts +87 -0
  10. package/dist/components/FileDropzone/FileDropzone.d.ts.map +1 -0
  11. package/dist/components/FileDropzone/FileDropzone.js +146 -0
  12. package/dist/components/FileDropzone/FileDropzone.js.map +1 -0
  13. package/dist/components/FileDropzone/index.d.ts +2 -0
  14. package/dist/components/FileDropzone/index.d.ts.map +1 -0
  15. package/dist/components/FileDropzone/index.js +2 -0
  16. package/dist/components/FileDropzone/index.js.map +1 -0
  17. package/dist/components/ImageGallery/ImageGallery.d.ts +93 -0
  18. package/dist/components/ImageGallery/ImageGallery.d.ts.map +1 -0
  19. package/dist/components/ImageGallery/ImageGallery.js +121 -0
  20. package/dist/components/ImageGallery/ImageGallery.js.map +1 -0
  21. package/dist/components/ImageGallery/index.d.ts +2 -0
  22. package/dist/components/ImageGallery/index.d.ts.map +1 -0
  23. package/dist/components/ImageGallery/index.js +2 -0
  24. package/dist/components/ImageGallery/index.js.map +1 -0
  25. package/dist/components/NumberStepper/NumberStepper.d.ts +78 -0
  26. package/dist/components/NumberStepper/NumberStepper.d.ts.map +1 -0
  27. package/dist/components/NumberStepper/NumberStepper.js +129 -0
  28. package/dist/components/NumberStepper/NumberStepper.js.map +1 -0
  29. package/dist/components/NumberStepper/index.d.ts +2 -0
  30. package/dist/components/NumberStepper/index.d.ts.map +1 -0
  31. package/dist/components/NumberStepper/index.js +2 -0
  32. package/dist/components/NumberStepper/index.js.map +1 -0
  33. package/dist/components/Steps/Steps.d.ts +65 -0
  34. package/dist/components/Steps/Steps.d.ts.map +1 -0
  35. package/dist/components/Steps/Steps.js +62 -0
  36. package/dist/components/Steps/Steps.js.map +1 -0
  37. package/dist/components/Steps/index.d.ts +2 -0
  38. package/dist/components/Steps/index.d.ts.map +1 -0
  39. package/dist/components/Steps/index.js +2 -0
  40. package/dist/components/Steps/index.js.map +1 -0
  41. package/package.json +21 -1
  42. package/src/components/DataTable/DataTable.tsx +261 -0
  43. package/src/components/DataTable/data-table.recipe.css +28 -0
  44. package/src/components/DataTable/index.ts +10 -0
  45. package/src/components/FileDropzone/FileDropzone.tsx +239 -0
  46. package/src/components/FileDropzone/file-dropzone.recipe.css +38 -0
  47. package/src/components/FileDropzone/index.ts +1 -0
  48. package/src/components/ImageGallery/ImageGallery.tsx +285 -0
  49. package/src/components/ImageGallery/image-gallery.recipe.css +50 -0
  50. package/src/components/ImageGallery/index.ts +7 -0
  51. package/src/components/NumberStepper/NumberStepper.tsx +227 -0
  52. package/src/components/NumberStepper/index.ts +1 -0
  53. package/src/components/NumberStepper/number-stepper.recipe.css +31 -0
  54. package/src/components/Steps/Steps.tsx +149 -0
  55. package/src/components/Steps/index.ts +1 -0
  56. package/src/components/Steps/steps.recipe.css +51 -0
  57. package/src/theming/tailwind.css +5 -0
@@ -0,0 +1,285 @@
1
+ "use client";
2
+
3
+ import { useControllableState } from "@sixthshift/design-system/hooks";
4
+ import { cn } from "@sixthshift/design-system/utils";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { ChevronLeft, ChevronRight } from "lucide-react";
7
+ import * as React from "react";
8
+
9
+ /**
10
+ * Geometry only. Every colour reads an `--image-gallery-*` component token
11
+ * whose value is decided by src/components/ImageGallery/image-gallery.recipe.css.
12
+ *
13
+ * `ratio` is the frame's shape, not the images': every slide is cropped to it
14
+ * with `object-cover`, so a mixed set of uploads still produces a steady
15
+ * frame that does not jump as you swipe. `portrait` is 4:5, the tallest crop
16
+ * Instagram allows in a feed — photography shot for the feed lands on it
17
+ * uncropped.
18
+ */
19
+ const frameVariants = cva("relative overflow-hidden rounded-lg bg-(--image-gallery-bg)", {
20
+ variants: {
21
+ ratio: {
22
+ square: "aspect-square",
23
+ portrait: "aspect-[4/5]",
24
+ landscape: "aspect-[4/3]",
25
+ wide: "aspect-video",
26
+ },
27
+ },
28
+ defaultVariants: {
29
+ ratio: "square",
30
+ },
31
+ });
32
+
33
+ export type ImageGalleryRatio = NonNullable<VariantProps<typeof frameVariants>["ratio"]>;
34
+
35
+ export type ImageGalleryImage = {
36
+ src: string;
37
+ /** Required: describe the photo. The gallery adds position ("2 of 5") on its own. */
38
+ alt: string;
39
+ srcSet?: string;
40
+ sizes?: string;
41
+ width?: number;
42
+ height?: number;
43
+ };
44
+
45
+ export type ImageGalleryProps = Omit<React.HTMLAttributes<HTMLElement>, "onChange"> &
46
+ VariantProps<typeof frameVariants> & {
47
+ images: readonly ImageGalleryImage[];
48
+ /** Controlled index of the image shown. */
49
+ index?: number;
50
+ /** Initial index in uncontrolled mode. */
51
+ defaultIndex?: number;
52
+ /** Called when the shown image changes — by swipe, arrow, key or thumbnail. */
53
+ onIndexChange?: (index: number) => void;
54
+ /**
55
+ * What sits under the frame: a strip of `thumbnails`, a row of `dots`, or
56
+ * `none`. Only rendered when there is more than one image.
57
+ */
58
+ indicator?: "thumbnails" | "dots" | "none";
59
+ /** Accessible name for the carousel, e.g. the product's name. */
60
+ label?: string;
61
+ /** Accessible name for the previous-image button. */
62
+ previousLabel?: string;
63
+ /** Accessible name for the next-image button. */
64
+ nextLabel?: string;
65
+ /** Shown in the frame when `images` is empty. */
66
+ placeholder?: React.ReactNode;
67
+ };
68
+
69
+ function prefersReducedMotion(): boolean {
70
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
71
+ }
72
+
73
+ /**
74
+ * A product photo carousel: one frame, swipeable on touch, with previous/next
75
+ * buttons, arrow-key navigation and an optional thumbnail strip or dots.
76
+ *
77
+ * Swiping is native scrolling — a horizontal `scroll-snap` track, not a
78
+ * pointer-event reimplementation — so it has the platform's own momentum and
79
+ * edge behaviour on phones. The shown index follows the scroll position, and
80
+ * setting the index (by button, key, thumbnail or a controlled `index`)
81
+ * scrolls the track. Both directions meet at one rule: the effect only scrolls
82
+ * when the track is not already showing the index, so a swipe that updates
83
+ * the index never fights itself.
84
+ *
85
+ * Controlled or uncontrolled through `index`/`defaultIndex`/`onIndexChange`.
86
+ *
87
+ * Follows the WAI-ARIA carousel pattern: the root is a `<section>` with
88
+ * `aria-roledescription="carousel"` (a landmark region once `label` names
89
+ * it), each slide a group named "n of total", and slides out of view are
90
+ * `aria-hidden`. The first image loads eagerly and
91
+ * the rest lazily, so a gallery of ten costs one download up front.
92
+ */
93
+ export const ImageGallery = React.forwardRef<HTMLElement, ImageGalleryProps>(
94
+ (
95
+ {
96
+ className,
97
+ images,
98
+ index: controlledIndex,
99
+ defaultIndex = 0,
100
+ onIndexChange,
101
+ ratio = "square",
102
+ indicator = "thumbnails",
103
+ label,
104
+ previousLabel = "Previous image",
105
+ nextLabel = "Next image",
106
+ placeholder,
107
+ onKeyDown,
108
+ ...props
109
+ },
110
+ ref
111
+ ) => {
112
+ const count = images.length;
113
+ const [rawIndex, setIndex] = useControllableState({
114
+ value: controlledIndex,
115
+ defaultValue: defaultIndex,
116
+ onChange: onIndexChange,
117
+ });
118
+ const index = count === 0 ? 0 : Math.min(Math.max(rawIndex, 0), count - 1);
119
+
120
+ const trackRef = React.useRef<HTMLDivElement | null>(null);
121
+ // The slide a programmatic scroll is heading for. While set, the slides it
122
+ // passes on the way are not reported — jumping from 1 to 4 reports 4, not
123
+ // 2, 3 and 4. Any touch, wheel or pointer on the track hands control back.
124
+ const scrollTargetRef = React.useRef<number | null>(null);
125
+
126
+ // Index → scroll. Skipped when the track already shows `index`, which is
127
+ // always the case right after a swipe reported it.
128
+ React.useEffect(() => {
129
+ const track = trackRef.current;
130
+ if (!track || track.clientWidth === 0) return;
131
+ if (Math.round(track.scrollLeft / track.clientWidth) === index) return;
132
+ scrollTargetRef.current = index;
133
+ track.scrollTo({ left: index * track.clientWidth, behavior: prefersReducedMotion() ? "auto" : "smooth" });
134
+ }, [index]);
135
+
136
+ // Scroll → index. Rounds, so the index flips at the halfway point of a swipe.
137
+ const handleScroll = () => {
138
+ const track = trackRef.current;
139
+ if (!track || track.clientWidth === 0) return;
140
+ const shown = Math.round(track.scrollLeft / track.clientWidth);
141
+ if (scrollTargetRef.current !== null) {
142
+ if (shown !== scrollTargetRef.current) return;
143
+ scrollTargetRef.current = null;
144
+ }
145
+ if (shown !== index && shown >= 0 && shown < count) setIndex(shown);
146
+ };
147
+
148
+ const releaseScroll = () => {
149
+ scrollTargetRef.current = null;
150
+ };
151
+
152
+ const go = (next: number) => {
153
+ if (next >= 0 && next < count && next !== index) setIndex(next);
154
+ };
155
+
156
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
157
+ onKeyDown?.(event);
158
+ if (event.defaultPrevented) return;
159
+ const targets: Record<string, number> = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: count - 1 };
160
+ const target = targets[event.key];
161
+ if (target === undefined) return;
162
+ event.preventDefault();
163
+ go(target);
164
+ };
165
+
166
+ const controlClass =
167
+ "image-gallery-control focus-visible:ring-(color:--image-gallery-control-ring) absolute top-1/2 flex size-9 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-(--image-gallery-control-bg) text-(--image-gallery-control-fg) shadow transition-opacity hover:bg-(--image-gallery-control-bg-hovered) focus-visible:outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-0 max-sm:hidden [&_svg]:size-5";
168
+
169
+ return (
170
+ <section
171
+ ref={ref}
172
+ aria-roledescription="carousel"
173
+ aria-label={label}
174
+ className={cn("image-gallery flex flex-col gap-3", className)}
175
+ onKeyDown={handleKeyDown}
176
+ {...props}
177
+ >
178
+ <div className={frameVariants({ ratio })}>
179
+ {count === 0 ? (
180
+ <div className="flex h-full w-full items-center justify-center text-(--image-gallery-placeholder-fg) text-sm">{placeholder}</div>
181
+ ) : (
182
+ <div
183
+ ref={trackRef}
184
+ onScroll={handleScroll}
185
+ onPointerDown={releaseScroll}
186
+ onTouchStart={releaseScroll}
187
+ onWheel={releaseScroll}
188
+ // Focusable so the arrow keys work without first reaching a button,
189
+ // and because a scrollable region must be keyboard-reachable.
190
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: scrollable regions must be focusable (axe `scrollable-region-focusable`)
191
+ tabIndex={0}
192
+ className="focus-visible:ring-(color:--image-gallery-control-ring) flex h-full w-full snap-x snap-mandatory overflow-x-auto overscroll-x-contain [scrollbar-width:none] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset [&::-webkit-scrollbar]:hidden"
193
+ >
194
+ {images.map((image, i) => (
195
+ // biome-ignore lint/a11y/useSemanticElements: the WAI-ARIA carousel pattern names each slide a `group`; a <fieldset> is for form controls
196
+ <div
197
+ // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional — "2 of 5" is their identity, and the same photo may appear twice
198
+ key={i}
199
+ role="group"
200
+ aria-roledescription="slide"
201
+ aria-label={`${i + 1} of ${count}`}
202
+ aria-hidden={i !== index}
203
+ className="h-full w-full shrink-0 snap-center snap-always"
204
+ >
205
+ <img
206
+ src={image.src}
207
+ srcSet={image.srcSet}
208
+ sizes={image.sizes}
209
+ width={image.width}
210
+ height={image.height}
211
+ alt={image.alt}
212
+ loading={i === 0 ? "eager" : "lazy"}
213
+ decoding="async"
214
+ draggable={false}
215
+ className="h-full w-full select-none object-cover"
216
+ />
217
+ </div>
218
+ ))}
219
+ </div>
220
+ )}
221
+ {count > 1 && (
222
+ <>
223
+ <button type="button" aria-label={previousLabel} disabled={index === 0} onClick={() => go(index - 1)} className={cn(controlClass, "left-2")}>
224
+ <ChevronLeft aria-hidden="true" />
225
+ </button>
226
+ <button type="button" aria-label={nextLabel} disabled={index === count - 1} onClick={() => go(index + 1)} className={cn(controlClass, "right-2")}>
227
+ <ChevronRight aria-hidden="true" />
228
+ </button>
229
+ </>
230
+ )}
231
+ </div>
232
+
233
+ {count > 1 && indicator === "thumbnails" && (
234
+ <div className="flex gap-2 overflow-x-auto p-0.5">
235
+ {images.map((image, i) => (
236
+ <button
237
+ // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional — "2 of 5" is their identity, and the same photo may appear twice
238
+ key={i}
239
+ type="button"
240
+ aria-label={`Show image ${i + 1} of ${count}`}
241
+ aria-current={i === index ? "true" : undefined}
242
+ data-selected={i === index}
243
+ onClick={() => go(i)}
244
+ className="image-gallery-thumb border-(color:--image-gallery-thumb-border) focus-visible:ring-(color:--image-gallery-control-ring) hover:border-(color:--image-gallery-thumb-border-hovered) size-16 shrink-0 cursor-pointer overflow-hidden rounded-md border-2 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
245
+ >
246
+ <img
247
+ src={image.src}
248
+ srcSet={image.srcSet}
249
+ sizes="64px"
250
+ alt=""
251
+ loading="lazy"
252
+ decoding="async"
253
+ draggable={false}
254
+ className="h-full w-full object-cover"
255
+ />
256
+ </button>
257
+ ))}
258
+ </div>
259
+ )}
260
+
261
+ {count > 1 && indicator === "dots" && (
262
+ <div className="flex justify-center gap-1.5">
263
+ {images.map((_, i) => (
264
+ <button
265
+ // biome-ignore lint/suspicious/noArrayIndexKey: slides are positional — "2 of 5" is their identity, and the same photo may appear twice
266
+ key={i}
267
+ type="button"
268
+ aria-label={`Show image ${i + 1} of ${count}`}
269
+ aria-current={i === index ? "true" : undefined}
270
+ data-selected={i === index}
271
+ onClick={() => go(i)}
272
+ className="image-gallery-dot focus-visible:ring-(color:--image-gallery-control-ring) flex size-6 cursor-pointer items-center justify-center rounded-full focus-visible:outline-hidden focus-visible:ring-2"
273
+ >
274
+ <span aria-hidden="true" className="block size-2 rounded-full bg-(--image-gallery-dot-bg) transition-colors" />
275
+ </button>
276
+ ))}
277
+ </div>
278
+ )}
279
+ </section>
280
+ );
281
+ }
282
+ );
283
+ ImageGallery.displayName = "ImageGallery";
284
+
285
+ export { frameVariants as imageGalleryFrameVariants };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ImageGallery recipe — the component-token layer for ImageGallery.
3
+ *
4
+ * No variant/intent axis. The one selectable cell is `data-selected` on a
5
+ * thumbnail or dot, the same shape tabs.css uses for its selected trigger.
6
+ *
7
+ * The grammar is `--image-gallery-[{part}-]{context}[-{state}]`. Parts:
8
+ *
9
+ * control the previous/next buttons floating over the photo. They sit
10
+ * on arbitrary imagery, so they take the page's resting surface
11
+ * with an elevation shadow rather than a transparent ghost —
12
+ * legible over a white cake and a chocolate one alike.
13
+ * thumb a thumbnail button's frame.
14
+ * dot a dot indicator's fill.
15
+ * placeholder the text shown when there are no images.
16
+ *
17
+ * `--image-gallery-bg` shows while a lazy image loads and behind an empty
18
+ * gallery, so it is the subtle surface, not the page's.
19
+ *
20
+ * `@layer components` is load-bearing, not tidiness — see button.css.
21
+ */
22
+
23
+ @layer components {
24
+ .image-gallery {
25
+ --image-gallery-bg: var(--bg-subtle);
26
+ --image-gallery-placeholder-fg: var(--fg-subtle);
27
+ --image-gallery-control-bg: var(--bg-normal);
28
+ --image-gallery-control-bg-hovered: var(--bg-normal-hovered);
29
+ --image-gallery-control-fg: var(--fg-normal);
30
+ --image-gallery-control-ring: var(--focus-ring);
31
+ }
32
+
33
+ .image-gallery-thumb {
34
+ --image-gallery-thumb-border: transparent;
35
+ --image-gallery-thumb-border-hovered: var(--border-strong);
36
+ }
37
+
38
+ .image-gallery-thumb[data-selected="true"] {
39
+ --image-gallery-thumb-border: var(--border-brand);
40
+ --image-gallery-thumb-border-hovered: var(--image-gallery-thumb-border);
41
+ }
42
+
43
+ .image-gallery-dot {
44
+ --image-gallery-dot-bg: var(--border-strong);
45
+ }
46
+
47
+ .image-gallery-dot[data-selected="true"] {
48
+ --image-gallery-dot-bg: var(--bg-brand);
49
+ }
50
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ ImageGallery,
3
+ type ImageGalleryImage,
4
+ type ImageGalleryProps,
5
+ type ImageGalleryRatio,
6
+ imageGalleryFrameVariants,
7
+ } from "./ImageGallery";
@@ -0,0 +1,227 @@
1
+ "use client";
2
+
3
+ import { useControllableState } from "@sixthshift/design-system/hooks";
4
+ import { cn } from "@sixthshift/design-system/utils";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { Minus, Plus } from "lucide-react";
7
+ import * as React from "react";
8
+
9
+ /**
10
+ * Geometry only. Every colour reads a `--number-stepper-*` component token
11
+ * whose value is decided by src/components/NumberStepper/number-stepper.recipe.css.
12
+ *
13
+ * `size` tracks Input's and Button's heights (`sm` 32px, `md` 36px, `lg`
14
+ * 40px) so a stepper sits on the same baseline as the fields beside it.
15
+ */
16
+ const numberStepperVariants = cva(
17
+ // One literal, deliberately — see Button.tsx for why a `+` concatenation is a trap.
18
+ "number-stepper border-(color:--number-stepper-border) focus-within:ring-(color:--number-stepper-ring) inline-flex w-fit items-stretch overflow-hidden rounded-md border bg-(--number-stepper-bg) text-(--number-stepper-fg) shadow-xs transition-colors focus-within:ring-2 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
19
+ {
20
+ variants: {
21
+ size: {
22
+ sm: "h-8 text-xs [&_svg]:size-3.5",
23
+ md: "h-9 text-sm [&_svg]:size-4",
24
+ lg: "h-10 text-sm [&_svg]:size-4",
25
+ },
26
+ },
27
+ defaultVariants: {
28
+ size: "md",
29
+ },
30
+ }
31
+ );
32
+
33
+ /** Button width per size — square against the field's height. */
34
+ const buttonGeometry: Record<string, string> = {
35
+ sm: "w-8",
36
+ md: "w-9",
37
+ lg: "w-10",
38
+ };
39
+
40
+ /** Input width per size — room for three digits before it grows. */
41
+ const inputGeometry: Record<string, string> = {
42
+ sm: "w-10",
43
+ md: "w-12",
44
+ lg: "w-14",
45
+ };
46
+
47
+ export type NumberStepperSize = NonNullable<VariantProps<typeof numberStepperVariants>["size"]>;
48
+
49
+ export type NumberStepperProps = Omit<
50
+ React.InputHTMLAttributes<HTMLInputElement>,
51
+ "value" | "defaultValue" | "onChange" | "size" | "min" | "max" | "step" | "type"
52
+ > &
53
+ VariantProps<typeof numberStepperVariants> & {
54
+ /** Controlled value. */
55
+ value?: number;
56
+ /** Initial value in uncontrolled mode. Defaults to `min`, or `0` without one. */
57
+ defaultValue?: number;
58
+ /** Called with the new, already-clamped value. */
59
+ onValueChange?: (value: number) => void;
60
+ /** Lowest reachable value. Defaults to `0` — quantities are the common case. */
61
+ min?: number;
62
+ /** Highest reachable value. Unbounded when omitted. */
63
+ max?: number;
64
+ /** Increment for the buttons and arrow keys. `PageUp`/`PageDown` move ten of them. */
65
+ step?: number;
66
+ /** Accessible name for the decrement button. */
67
+ decrementLabel?: string;
68
+ /** Accessible name for the increment button. */
69
+ incrementLabel?: string;
70
+ /** Extra classes for the inner `<input>`; `className` styles the surrounding group. */
71
+ inputClassName?: string;
72
+ };
73
+
74
+ function clamp(value: number, min: number, max: number | undefined): number {
75
+ const floored = Math.max(min, value);
76
+ return max === undefined ? floored : Math.min(max, floored);
77
+ }
78
+
79
+ /**
80
+ * A whole-number field with decrement and increment buttons — a cart
81
+ * quantity, a guest count, a per-day capacity.
82
+ *
83
+ * Implements the WAI-ARIA spinbutton pattern on the `<input>` itself:
84
+ * `role="spinbutton"` with `aria-valuenow`/`min`/`max`, `ArrowUp`/`ArrowDown`
85
+ * step, `PageUp`/`PageDown` step by ten, and `Home`/`End` jump to the bounds
86
+ * that exist. The two buttons are pointer affordances and stay out of the tab
87
+ * order, so the field is one tab stop — the same reason RadioButtonGroup is.
88
+ *
89
+ * Typing is free-form while focused and committed on blur or `Enter`: the
90
+ * draft is parsed, rounded to the nearest `step` from `min`, and clamped.
91
+ * Anything unparseable reverts to the last committed value rather than
92
+ * reporting `NaN`. Every value `onValueChange` receives is in range.
93
+ *
94
+ * Controlled or uncontrolled through the `value`/`defaultValue`/
95
+ * `onValueChange` triad. `name` submits the committed value with a form, since
96
+ * the input is a real `<input>`. The ref and every unlisted prop land on that
97
+ * input — so `FormField`'s `id`/`aria-describedby` wiring reaches the control
98
+ * — while `className` styles the surrounding group.
99
+ */
100
+ export const NumberStepper = React.forwardRef<HTMLInputElement, NumberStepperProps>(
101
+ (
102
+ {
103
+ className,
104
+ inputClassName,
105
+ size = "md",
106
+ value: controlledValue,
107
+ defaultValue,
108
+ onValueChange,
109
+ min = 0,
110
+ max,
111
+ step = 1,
112
+ disabled = false,
113
+ readOnly = false,
114
+ decrementLabel = "Decrease",
115
+ incrementLabel = "Increase",
116
+ onKeyDown,
117
+ onBlur,
118
+ onFocus,
119
+ ...props
120
+ },
121
+ ref
122
+ ) => {
123
+ const [value, setValue] = useControllableState({
124
+ value: controlledValue,
125
+ defaultValue: clamp(defaultValue ?? min, min, max),
126
+ onChange: onValueChange,
127
+ });
128
+
129
+ // The text being typed, or `null` when the field shows the committed value.
130
+ const [draft, setDraft] = React.useState<string | null>(null);
131
+
132
+ const commit = (next: number) => {
133
+ const snapped = min + Math.round((next - min) / step) * step;
134
+ const clamped = clamp(snapped, min, max);
135
+ if (clamped !== value) setValue(clamped);
136
+ };
137
+
138
+ const commitDraft = () => {
139
+ if (draft === null) return;
140
+ const parsed = Number.parseFloat(draft);
141
+ setDraft(null);
142
+ if (Number.isFinite(parsed)) commit(parsed);
143
+ };
144
+
145
+ const interactive = !disabled && !readOnly;
146
+ const atMin = value <= min;
147
+ const atMax = max !== undefined && value >= max;
148
+
149
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
150
+ onKeyDown?.(event);
151
+ if (event.defaultPrevented || !interactive) return;
152
+
153
+ const moves: Record<string, (() => number) | undefined> = {
154
+ ArrowUp: () => value + step,
155
+ ArrowDown: () => value - step,
156
+ PageUp: () => value + step * 10,
157
+ PageDown: () => value - step * 10,
158
+ Home: () => min,
159
+ End: max === undefined ? undefined : () => max,
160
+ };
161
+ const move = moves[event.key];
162
+
163
+ if (event.key === "Enter") {
164
+ commitDraft();
165
+ return;
166
+ }
167
+ if (!move) return;
168
+
169
+ event.preventDefault();
170
+ setDraft(null);
171
+ commit(move());
172
+ };
173
+
174
+ const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
175
+ commitDraft();
176
+ onBlur?.(event);
177
+ };
178
+
179
+ const stepBy = (direction: 1 | -1) => {
180
+ setDraft(null);
181
+ commit(value + step * direction);
182
+ };
183
+
184
+ const buttonClass = cn(
185
+ "number-stepper-button flex shrink-0 cursor-pointer items-center justify-center text-(--number-stepper-button-fg) transition-colors enabled:active:bg-(--number-stepper-button-bg-pressed) enabled:hover:bg-(--number-stepper-button-bg-hovered) disabled:cursor-not-allowed disabled:text-(--number-stepper-button-fg-disabled)",
186
+ buttonGeometry[size ?? "md"]
187
+ );
188
+
189
+ return (
190
+ // biome-ignore lint/a11y/useSemanticElements: a <fieldset> brings a border, min-width and legend semantics this inline control does not want; role="group" only ties the buttons to the field
191
+ <div role="group" data-disabled={disabled} className={numberStepperVariants({ size, className })}>
192
+ <button type="button" tabIndex={-1} aria-label={decrementLabel} disabled={!interactive || atMin} onClick={() => stepBy(-1)} className={buttonClass}>
193
+ <Minus aria-hidden="true" />
194
+ </button>
195
+ <input
196
+ ref={ref}
197
+ type="text"
198
+ inputMode="numeric"
199
+ role="spinbutton"
200
+ autoComplete="off"
201
+ aria-valuenow={value}
202
+ aria-valuemin={min}
203
+ aria-valuemax={max}
204
+ disabled={disabled}
205
+ readOnly={readOnly}
206
+ value={draft ?? String(value)}
207
+ onChange={(event) => setDraft(event.target.value)}
208
+ onKeyDown={handleKeyDown}
209
+ onBlur={handleBlur}
210
+ onFocus={onFocus}
211
+ className={cn(
212
+ "border-(color:--number-stepper-divider-border) min-w-0 border-x bg-transparent text-center text-[length:inherit] tabular-nums outline-hidden disabled:cursor-not-allowed",
213
+ inputGeometry[size ?? "md"],
214
+ inputClassName
215
+ )}
216
+ {...props}
217
+ />
218
+ <button type="button" tabIndex={-1} aria-label={incrementLabel} disabled={!interactive || atMax} onClick={() => stepBy(1)} className={buttonClass}>
219
+ <Plus aria-hidden="true" />
220
+ </button>
221
+ </div>
222
+ );
223
+ }
224
+ );
225
+ NumberStepper.displayName = "NumberStepper";
226
+
227
+ export { numberStepperVariants };
@@ -0,0 +1 @@
1
+ export { NumberStepper, type NumberStepperProps, type NumberStepperSize, numberStepperVariants } from "./NumberStepper";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * NumberStepper recipe — the component-token layer for NumberStepper.
3
+ *
4
+ * No variant/intent axis: a stepper is a form field, and like Input every
5
+ * colour is a fixed semantic token. The field surface deliberately mirrors
6
+ * input.css's values (bg-normal, border-normal, focus-ring) so a stepper next
7
+ * to a text input reads as the same kind of control — but through its own
8
+ * tokens, so a consumer can re-point one without the other.
9
+ *
10
+ * The grammar is `--number-stepper-[{part}-]{context}[-{state}]`. Parts are
11
+ * `button` (the two step buttons) and `divider` (the rules either side of the
12
+ * value). `--number-stepper-button-fg-disabled` is the colour a button takes
13
+ * at a bound; the whole group dims via opacity only when the field itself is
14
+ * disabled, the same way Input does.
15
+ *
16
+ * `@layer components` is load-bearing, not tidiness — see button.css.
17
+ */
18
+
19
+ @layer components {
20
+ .number-stepper {
21
+ --number-stepper-bg: var(--bg-normal);
22
+ --number-stepper-fg: var(--fg-normal);
23
+ --number-stepper-border: var(--border-normal);
24
+ --number-stepper-ring: var(--focus-ring);
25
+ --number-stepper-divider-border: var(--border-normal);
26
+ --number-stepper-button-fg: var(--fg-normal);
27
+ --number-stepper-button-fg-disabled: var(--fg-normal-disabled);
28
+ --number-stepper-button-bg-hovered: var(--bg-normal-hovered);
29
+ --number-stepper-button-bg-pressed: var(--bg-normal-pressed);
30
+ }
31
+ }