@sovereignfs/ui 0.33.1 → 0.37.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 (43) hide show
  1. package/README.md +1 -1
  2. package/dist/CheckableListRow.module.css +95 -0
  3. package/dist/IconPicker.module.css +63 -0
  4. package/dist/QuantityStepper.module.css +67 -0
  5. package/dist/SuggestionInput.module.css +103 -0
  6. package/dist/index.d.ts +128 -2
  7. package/dist/index.js +1007 -169
  8. package/package.json +1 -1
  9. package/src/components/CheckableListRow/CheckableListRow.module.css +95 -0
  10. package/src/components/CheckableListRow/CheckableListRow.stories.tsx +99 -0
  11. package/src/components/CheckableListRow/CheckableListRow.tsx +89 -0
  12. package/src/components/Icon/icons/apple.tsx +21 -0
  13. package/src/components/Icon/icons/banana.tsx +21 -0
  14. package/src/components/Icon/icons/beef.tsx +22 -0
  15. package/src/components/Icon/icons/beer.tsx +24 -0
  16. package/src/components/Icon/icons/candy.tsx +24 -0
  17. package/src/components/Icon/icons/carrot.tsx +22 -0
  18. package/src/components/Icon/icons/coffee.tsx +23 -0
  19. package/src/components/Icon/icons/cookie.tsx +25 -0
  20. package/src/components/Icon/icons/croissant.tsx +24 -0
  21. package/src/components/Icon/icons/cup-soda.tsx +23 -0
  22. package/src/components/Icon/icons/drumstick.tsx +21 -0
  23. package/src/components/Icon/icons/egg.tsx +20 -0
  24. package/src/components/Icon/icons/fish.tsx +25 -0
  25. package/src/components/Icon/icons/index.ts +81 -1
  26. package/src/components/Icon/icons/milk.tsx +22 -0
  27. package/src/components/Icon/icons/pizza.tsx +24 -0
  28. package/src/components/Icon/icons/salad.tsx +24 -0
  29. package/src/components/Icon/icons/shopping-basket.tsx +26 -0
  30. package/src/components/Icon/icons/snowflake.tsx +31 -0
  31. package/src/components/Icon/icons/spray-can.tsx +29 -0
  32. package/src/components/Icon/icons/wine.tsx +23 -0
  33. package/src/components/IconPicker/IconPicker.module.css +63 -0
  34. package/src/components/IconPicker/IconPicker.stories.tsx +84 -0
  35. package/src/components/IconPicker/IconPicker.tsx +90 -0
  36. package/src/components/QuantityStepper/QuantityStepper.module.css +67 -0
  37. package/src/components/QuantityStepper/QuantityStepper.stories.tsx +79 -0
  38. package/src/components/QuantityStepper/QuantityStepper.tsx +107 -0
  39. package/src/components/SuggestionInput/SuggestionInput.module.css +103 -0
  40. package/src/components/SuggestionInput/SuggestionInput.stories.tsx +144 -0
  41. package/src/components/SuggestionInput/SuggestionInput.tsx +207 -0
  42. package/src/index.ts +11 -0
  43. package/src/stories/DesignSystemOverview.stories.tsx +107 -0
@@ -29,6 +29,26 @@ import { AlertTriangleIcon } from './alert-triangle';
29
29
  import { CalendarIcon } from './calendar';
30
30
  import { SlidersHorizontalIcon } from './sliders-horizontal';
31
31
  import { EllipsisVerticalIcon } from './ellipsis-vertical';
32
+ import { BananaIcon } from './banana';
33
+ import { AppleIcon } from './apple';
34
+ import { CarrotIcon } from './carrot';
35
+ import { EggIcon } from './egg';
36
+ import { BeefIcon } from './beef';
37
+ import { FishIcon } from './fish';
38
+ import { CoffeeIcon } from './coffee';
39
+ import { WineIcon } from './wine';
40
+ import { BeerIcon } from './beer';
41
+ import { CookieIcon } from './cookie';
42
+ import { PizzaIcon } from './pizza';
43
+ import { CandyIcon } from './candy';
44
+ import { SaladIcon } from './salad';
45
+ import { MilkIcon } from './milk';
46
+ import { DrumstickIcon } from './drumstick';
47
+ import { CroissantIcon } from './croissant';
48
+ import { CupSodaIcon } from './cup-soda';
49
+ import { SprayCanIcon } from './spray-can';
50
+ import { SnowflakeIcon } from './snowflake';
51
+ import { ShoppingBasketIcon } from './shopping-basket';
32
52
 
33
53
  export type IconName =
34
54
  | 'house'
@@ -59,7 +79,27 @@ export type IconName =
59
79
  | 'alert-triangle'
60
80
  | 'calendar'
61
81
  | 'sliders-horizontal'
62
- | 'ellipsis-vertical';
82
+ | 'ellipsis-vertical'
83
+ | 'banana'
84
+ | 'apple'
85
+ | 'carrot'
86
+ | 'egg'
87
+ | 'beef'
88
+ | 'fish'
89
+ | 'coffee'
90
+ | 'wine'
91
+ | 'beer'
92
+ | 'cookie'
93
+ | 'pizza'
94
+ | 'candy'
95
+ | 'salad'
96
+ | 'milk'
97
+ | 'drumstick'
98
+ | 'croissant'
99
+ | 'cup-soda'
100
+ | 'spray-can'
101
+ | 'snowflake'
102
+ | 'shopping-basket';
63
103
 
64
104
  export type IconComponent = (props: SVGProps<SVGSVGElement>) => ReactElement;
65
105
 
@@ -93,6 +133,26 @@ export const ICONS: Record<IconName, IconComponent> = {
93
133
  calendar: CalendarIcon,
94
134
  'sliders-horizontal': SlidersHorizontalIcon,
95
135
  'ellipsis-vertical': EllipsisVerticalIcon,
136
+ banana: BananaIcon,
137
+ apple: AppleIcon,
138
+ carrot: CarrotIcon,
139
+ egg: EggIcon,
140
+ beef: BeefIcon,
141
+ fish: FishIcon,
142
+ coffee: CoffeeIcon,
143
+ wine: WineIcon,
144
+ beer: BeerIcon,
145
+ cookie: CookieIcon,
146
+ pizza: PizzaIcon,
147
+ candy: CandyIcon,
148
+ salad: SaladIcon,
149
+ milk: MilkIcon,
150
+ drumstick: DrumstickIcon,
151
+ croissant: CroissantIcon,
152
+ 'cup-soda': CupSodaIcon,
153
+ 'spray-can': SprayCanIcon,
154
+ snowflake: SnowflakeIcon,
155
+ 'shopping-basket': ShoppingBasketIcon,
96
156
  };
97
157
 
98
158
  export { HouseIcon } from './house';
@@ -124,3 +184,23 @@ export { AlertTriangleIcon } from './alert-triangle';
124
184
  export { CalendarIcon } from './calendar';
125
185
  export { SlidersHorizontalIcon } from './sliders-horizontal';
126
186
  export { EllipsisVerticalIcon } from './ellipsis-vertical';
187
+ export { BananaIcon } from './banana';
188
+ export { AppleIcon } from './apple';
189
+ export { CarrotIcon } from './carrot';
190
+ export { EggIcon } from './egg';
191
+ export { BeefIcon } from './beef';
192
+ export { FishIcon } from './fish';
193
+ export { CoffeeIcon } from './coffee';
194
+ export { WineIcon } from './wine';
195
+ export { BeerIcon } from './beer';
196
+ export { CookieIcon } from './cookie';
197
+ export { PizzaIcon } from './pizza';
198
+ export { CandyIcon } from './candy';
199
+ export { SaladIcon } from './salad';
200
+ export { MilkIcon } from './milk';
201
+ export { DrumstickIcon } from './drumstick';
202
+ export { CroissantIcon } from './croissant';
203
+ export { CupSodaIcon } from './cup-soda';
204
+ export { SprayCanIcon } from './spray-can';
205
+ export { SnowflakeIcon } from './snowflake';
206
+ export { ShoppingBasketIcon } from './shopping-basket';
@@ -0,0 +1,22 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function MilkIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="M8 2h8" />
18
+ <path d="M9 2v2.789a4 4 0 0 1-.672 2.219l-.656.984A4 4 0 0 0 7 10.212V20a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-9.789a4 4 0 0 0-.672-2.219l-.656-.984A4 4 0 0 1 15 4.788V2" />
19
+ <path d="M7 15a6.472 6.472 0 0 1 5 0 6.47 6.47 0 0 0 5 0" />
20
+ </svg>
21
+ );
22
+ }
@@ -0,0 +1,24 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function PizzaIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="m12 14-1 1" />
18
+ <path d="m13.75 18.25-1.25 1.42" />
19
+ <path d="M17.775 5.654a15.68 15.68 0 0 0-12.121 12.12" />
20
+ <path d="M18.8 9.3a1 1 0 0 0 2.1 7.7" />
21
+ <path d="M21.964 20.732a1 1 0 0 1-1.232 1.232l-18-5a1 1 0 0 1-.695-1.232A19.68 19.68 0 0 1 15.732 2.037a1 1 0 0 1 1.232.695z" />
22
+ </svg>
23
+ );
24
+ }
@@ -0,0 +1,24 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function SaladIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="M7 21h10" />
18
+ <path d="M12 21a9 9 0 0 0 9-9H3a9 9 0 0 0 9 9Z" />
19
+ <path d="M11.38 12a2.4 2.4 0 0 1-.4-4.77 2.4 2.4 0 0 1 3.2-2.77 2.4 2.4 0 0 1 3.47-.63 2.4 2.4 0 0 1 3.37 3.37 2.4 2.4 0 0 1-1.1 3.7 2.51 2.51 0 0 1 .03 1.1" />
20
+ <path d="m13 12 4-4" />
21
+ <path d="M10.9 7.25A3.99 3.99 0 0 0 4 10c0 .73.2 1.41.54 2" />
22
+ </svg>
23
+ );
24
+ }
@@ -0,0 +1,26 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function ShoppingBasketIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="m15 11-1 9" />
18
+ <path d="m19 11-4-7" />
19
+ <path d="M2 11h20" />
20
+ <path d="m3.5 11 1.6 7.4a2 2 0 0 0 2 1.6h9.8a2 2 0 0 0 2-1.6l1.7-7.4" />
21
+ <path d="M4.5 15.5h15" />
22
+ <path d="m5 11 4-7" />
23
+ <path d="m9 11 1 9" />
24
+ </svg>
25
+ );
26
+ }
@@ -0,0 +1,31 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function SnowflakeIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="m10 20-1.25-2.5L6 18" />
18
+ <path d="M10 4 8.75 6.5 6 6" />
19
+ <path d="m14 20 1.25-2.5L18 18" />
20
+ <path d="m14 4 1.25 2.5L18 6" />
21
+ <path d="m17 21-3-6h-4" />
22
+ <path d="m17 3-3 6 1.5 3" />
23
+ <path d="M2 12h6.5L10 9" />
24
+ <path d="m20 10-1.5 2 1.5 2" />
25
+ <path d="M22 12h-6.5L14 15" />
26
+ <path d="m4 10 1.5 2L4 14" />
27
+ <path d="m7 21 3-6-1.5-3" />
28
+ <path d="m7 3 3 6h4" />
29
+ </svg>
30
+ );
31
+ }
@@ -0,0 +1,29 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function SprayCanIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="M3 3h.01" />
18
+ <path d="M7 5h.01" />
19
+ <path d="M11 7h.01" />
20
+ <path d="M3 7h.01" />
21
+ <path d="M7 9h.01" />
22
+ <path d="M3 11h.01" />
23
+ <rect width="4" height="4" x="15" y="5" />
24
+ <path d="m19 9 2 2v10c0 .6-.4 1-1 1h-6c-.6 0-1-.4-1-1V11l2-2" />
25
+ <path d="m13 14 8-2" />
26
+ <path d="m13 19 8-2" />
27
+ </svg>
28
+ );
29
+ }
@@ -0,0 +1,23 @@
1
+ // Generated by scripts/generate-icons.ts — do not edit.
2
+ // Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
3
+ import type { SVGProps } from 'react';
4
+
5
+ export function WineIcon(props: SVGProps<SVGSVGElement>) {
6
+ return (
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="currentColor"
12
+ strokeWidth="2"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ {...props}
16
+ >
17
+ <path d="M8 22h8" />
18
+ <path d="M7 10h10" />
19
+ <path d="M12 15v7" />
20
+ <path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z" />
21
+ </svg>
22
+ );
23
+ }
@@ -0,0 +1,63 @@
1
+ .trigger {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-2);
5
+ padding: var(--sv-space-2);
6
+ border: 1px solid var(--sv-color-border-strong);
7
+ border-radius: var(--sv-radius-md);
8
+ background: var(--sv-color-surface);
9
+ color: var(--sv-color-text-primary);
10
+ cursor: pointer;
11
+ }
12
+
13
+ .trigger:hover {
14
+ background: var(--sv-color-surface-sunken);
15
+ }
16
+
17
+ .trigger:disabled {
18
+ opacity: 0.5;
19
+ cursor: not-allowed;
20
+ }
21
+
22
+ .placeholder {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ width: var(--sv-icon-size-md);
27
+ height: var(--sv-icon-size-md);
28
+ color: var(--sv-color-text-subtle);
29
+ font-size: var(--sv-font-size-sm);
30
+ }
31
+
32
+ .triggerLabel {
33
+ font-size: var(--sv-font-size-sm);
34
+ color: var(--sv-color-text-primary);
35
+ }
36
+
37
+ .grid {
38
+ display: grid;
39
+ grid-template-columns: repeat(5, 1fr);
40
+ gap: var(--sv-space-1);
41
+ padding: var(--sv-space-2);
42
+ }
43
+
44
+ .option {
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ width: var(--sv-touch-target-min);
49
+ height: var(--sv-touch-target-min);
50
+ border: none;
51
+ border-radius: var(--sv-radius-sm);
52
+ background: transparent;
53
+ color: var(--sv-color-text-primary);
54
+ cursor: pointer;
55
+ }
56
+
57
+ .option:hover {
58
+ background: var(--sv-color-surface-sunken);
59
+ }
60
+
61
+ .optionSelected {
62
+ background: var(--sv-color-accent-subtle);
63
+ }
@@ -0,0 +1,84 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { IconPicker } from './IconPicker';
4
+ import type { IconName } from '../Icon/Icon';
5
+
6
+ const meta = {
7
+ title: 'Components/IconPicker',
8
+ component: IconPicker,
9
+ parameters: {
10
+ layout: 'padded',
11
+ docs: {
12
+ description: {
13
+ component:
14
+ 'A trigger button showing the current icon that opens a Popover grid of selectable icons — for a curated, bounded icon set rather than the full design-system library.',
15
+ },
16
+ },
17
+ },
18
+ args: {
19
+ value: null,
20
+ onChange: () => {},
21
+ options: [],
22
+ 'aria-label': 'Icon',
23
+ },
24
+ } satisfies Meta<typeof IconPicker>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ const GROCERY_ICONS: IconName[] = [
30
+ 'banana',
31
+ 'apple',
32
+ 'carrot',
33
+ 'egg',
34
+ 'milk',
35
+ 'beef',
36
+ 'drumstick',
37
+ 'fish',
38
+ 'croissant',
39
+ 'cookie',
40
+ 'pizza',
41
+ 'salad',
42
+ 'coffee',
43
+ 'wine',
44
+ 'beer',
45
+ 'cup-soda',
46
+ 'candy',
47
+ 'spray-can',
48
+ 'snowflake',
49
+ 'shopping-basket',
50
+ ];
51
+
52
+ function ControlledIconPicker({ initial = null }: { initial?: IconName | null }) {
53
+ const [value, setValue] = useState<IconName | null>(initial);
54
+ return (
55
+ <IconPicker
56
+ value={value}
57
+ onChange={setValue}
58
+ options={GROCERY_ICONS}
59
+ aria-label="Item icon"
60
+ triggerLabel={value ?? 'Choose icon'}
61
+ />
62
+ );
63
+ }
64
+
65
+ export const Default: Story = {
66
+ render: () => <ControlledIconPicker />,
67
+ };
68
+
69
+ export const Preselected: Story = {
70
+ render: () => <ControlledIconPicker initial="banana" />,
71
+ };
72
+
73
+ export const Disabled: Story = {
74
+ render: () => (
75
+ <IconPicker
76
+ value="milk"
77
+ onChange={() => {}}
78
+ options={GROCERY_ICONS}
79
+ aria-label="Item icon"
80
+ triggerLabel="Milk"
81
+ disabled
82
+ />
83
+ ),
84
+ };
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Icon, type IconName } from '../Icon/Icon';
5
+ import { Popover } from '../Popover/Popover';
6
+ import styles from './IconPicker.module.css';
7
+
8
+ export interface IconPickerProps {
9
+ value: IconName | null;
10
+ onChange: (icon: IconName) => void;
11
+ /** Icons offered in the grid, in display order. */
12
+ options: readonly IconName[];
13
+ 'aria-label': string;
14
+ /** Shown next to the trigger's icon preview, e.g. the item's name. */
15
+ triggerLabel?: string;
16
+ disabled?: boolean;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * IconPicker — a trigger button showing the current icon that opens a
22
+ * `Popover` grid of selectable icons. Built for cases with a curated,
23
+ * bounded icon set (e.g. Sovereign Shopper's grocery category icons,
24
+ * SHP-05) rather than the full design-system icon library.
25
+ *
26
+ * Selecting an icon both closes the picker and calls `onChange` — a
27
+ * consumer's `onChange` never needs to close the picker itself.
28
+ */
29
+ export function IconPicker({
30
+ value,
31
+ onChange,
32
+ options,
33
+ 'aria-label': ariaLabel,
34
+ triggerLabel,
35
+ disabled = false,
36
+ className,
37
+ }: IconPickerProps) {
38
+ const [open, setOpen] = useState(false);
39
+
40
+ const trigger = (
41
+ <button
42
+ type="button"
43
+ className={[styles.trigger, className].filter(Boolean).join(' ')}
44
+ onClick={() => setOpen((o) => !o)}
45
+ disabled={disabled}
46
+ aria-label={ariaLabel}
47
+ aria-haspopup="true"
48
+ aria-expanded={open}
49
+ >
50
+ {value ? (
51
+ <Icon name={value} size="md" aria-hidden />
52
+ ) : (
53
+ <span className={styles.placeholder} aria-hidden>
54
+ ?
55
+ </span>
56
+ )}
57
+ {triggerLabel && <span className={styles.triggerLabel}>{triggerLabel}</span>}
58
+ </button>
59
+ );
60
+
61
+ return (
62
+ <Popover
63
+ trigger={trigger}
64
+ open={open}
65
+ onClose={() => setOpen(false)}
66
+ width={240}
67
+ aria-label={`${ariaLabel} options`}
68
+ >
69
+ <div className={styles.grid} role="listbox" aria-label={ariaLabel}>
70
+ {options.map((name) => (
71
+ <button
72
+ key={name}
73
+ type="button"
74
+ role="option"
75
+ aria-selected={name === value}
76
+ className={[styles.option, name === value ? styles.optionSelected : '']
77
+ .filter(Boolean)
78
+ .join(' ')}
79
+ onClick={() => {
80
+ onChange(name);
81
+ setOpen(false);
82
+ }}
83
+ >
84
+ <Icon name={name} size="md" aria-hidden />
85
+ </button>
86
+ ))}
87
+ </div>
88
+ </Popover>
89
+ );
90
+ }
@@ -0,0 +1,67 @@
1
+ .root {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-1);
5
+ padding: var(--sv-space-1);
6
+ border: 1px solid var(--sv-color-border-strong);
7
+ border-radius: var(--sv-radius-full);
8
+ background: var(--sv-color-surface-sunken);
9
+ }
10
+
11
+ .button {
12
+ all: unset;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ width: var(--sv-space-6);
17
+ height: var(--sv-space-6);
18
+ border-radius: var(--sv-radius-full);
19
+ color: var(--sv-color-text-primary);
20
+ font-size: var(--sv-font-size-md);
21
+ line-height: 1;
22
+ cursor: pointer;
23
+ }
24
+
25
+ .button:hover:not(:disabled) {
26
+ background: var(--sv-color-surface);
27
+ }
28
+
29
+ .button:disabled {
30
+ color: var(--sv-color-text-subtle);
31
+ cursor: not-allowed;
32
+ }
33
+
34
+ .input {
35
+ all: unset;
36
+ width: 3ch;
37
+ text-align: center;
38
+ font-family: var(--sv-font-family);
39
+ font-size: var(--sv-font-size-sm);
40
+ color: var(--sv-color-text-primary);
41
+ /* Hide the native spinner — the +/- buttons are the stepper affordance,
42
+ a second one from the browser would be redundant and often looks
43
+ inconsistent across browsers anyway. */
44
+ -moz-appearance: textfield;
45
+ }
46
+
47
+ .input::-webkit-outer-spin-button,
48
+ .input::-webkit-inner-spin-button {
49
+ -webkit-appearance: none;
50
+ margin: 0;
51
+ }
52
+
53
+ .input:focus-visible {
54
+ outline: none;
55
+ box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
56
+ border-radius: var(--sv-radius-sm);
57
+ }
58
+
59
+ .input:disabled {
60
+ color: var(--sv-color-text-muted);
61
+ }
62
+
63
+ .unit {
64
+ font-size: var(--sv-font-size-xs);
65
+ color: var(--sv-color-text-subtle);
66
+ white-space: nowrap;
67
+ }
@@ -0,0 +1,79 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { QuantityStepper } from './QuantityStepper';
4
+
5
+ const meta = {
6
+ title: 'Components/QuantityStepper',
7
+ component: QuantityStepper,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'A numeric input with +/- buttons and an optional read-only unit suffix. Supports fractional step values for quantities like "1.5 kg".',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ value: 1,
19
+ onChange: () => {},
20
+ 'aria-label': 'Quantity',
21
+ },
22
+ } satisfies Meta<typeof QuantityStepper>;
23
+
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ function ControlledStepper({
28
+ initial = 1,
29
+ step = 1,
30
+ min = 0,
31
+ max,
32
+ unit,
33
+ }: {
34
+ initial?: number;
35
+ step?: number;
36
+ min?: number;
37
+ max?: number;
38
+ unit?: string;
39
+ }) {
40
+ const [value, setValue] = useState(initial);
41
+ return (
42
+ <QuantityStepper
43
+ value={value}
44
+ onChange={setValue}
45
+ step={step}
46
+ min={min}
47
+ max={max}
48
+ unit={unit}
49
+ aria-label="Quantity"
50
+ />
51
+ );
52
+ }
53
+
54
+ export const Default: Story = {
55
+ render: () => <ControlledStepper initial={1} />,
56
+ };
57
+
58
+ export const WithUnit: Story = {
59
+ render: () => <ControlledStepper initial={6} unit="pcs" />,
60
+ };
61
+
62
+ export const FractionalStep: Story = {
63
+ name: 'Fractional step (0.5 kg)',
64
+ render: () => <ControlledStepper initial={1.5} step={0.5} unit="kg" />,
65
+ };
66
+
67
+ export const AtMinimum: Story = {
68
+ render: () => <ControlledStepper initial={0} min={0} unit="pcs" />,
69
+ };
70
+
71
+ export const WithMax: Story = {
72
+ render: () => <ControlledStepper initial={9} max={10} unit="pcs" />,
73
+ };
74
+
75
+ export const Disabled: Story = {
76
+ render: () => (
77
+ <QuantityStepper value={3} onChange={() => {}} unit="pcs" aria-label="Quantity" disabled />
78
+ ),
79
+ };