@sovereignfs/ui 0.33.1 → 0.40.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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/CheckableListRow.module.css +95 -0
  3. package/dist/FileDropzone.module.css +63 -0
  4. package/dist/IconPicker.module.css +63 -0
  5. package/dist/QuantityStepper.module.css +67 -0
  6. package/dist/SuggestionInput.module.css +103 -0
  7. package/dist/index.d.ts +196 -3
  8. package/dist/index.js +1175 -185
  9. package/package.json +1 -1
  10. package/src/components/CheckableListRow/CheckableListRow.module.css +95 -0
  11. package/src/components/CheckableListRow/CheckableListRow.stories.tsx +99 -0
  12. package/src/components/CheckableListRow/CheckableListRow.tsx +89 -0
  13. package/src/components/FileDropzone/FileDropzone.module.css +63 -0
  14. package/src/components/FileDropzone/FileDropzone.tsx +109 -0
  15. package/src/components/FileDropzone/__tests__/FileDropzone.test.tsx +67 -0
  16. package/src/components/Icon/icons/apple.tsx +21 -0
  17. package/src/components/Icon/icons/banana.tsx +21 -0
  18. package/src/components/Icon/icons/beef.tsx +22 -0
  19. package/src/components/Icon/icons/beer.tsx +24 -0
  20. package/src/components/Icon/icons/candy.tsx +24 -0
  21. package/src/components/Icon/icons/carrot.tsx +22 -0
  22. package/src/components/Icon/icons/coffee.tsx +23 -0
  23. package/src/components/Icon/icons/cookie.tsx +25 -0
  24. package/src/components/Icon/icons/croissant.tsx +24 -0
  25. package/src/components/Icon/icons/cup-soda.tsx +23 -0
  26. package/src/components/Icon/icons/drumstick.tsx +21 -0
  27. package/src/components/Icon/icons/egg.tsx +20 -0
  28. package/src/components/Icon/icons/external-link.tsx +22 -0
  29. package/src/components/Icon/icons/file.tsx +21 -0
  30. package/src/components/Icon/icons/fish.tsx +25 -0
  31. package/src/components/Icon/icons/index.ts +93 -1
  32. package/src/components/Icon/icons/milk.tsx +22 -0
  33. package/src/components/Icon/icons/pizza.tsx +24 -0
  34. package/src/components/Icon/icons/salad.tsx +24 -0
  35. package/src/components/Icon/icons/shopping-basket.tsx +26 -0
  36. package/src/components/Icon/icons/snowflake.tsx +31 -0
  37. package/src/components/Icon/icons/spray-can.tsx +29 -0
  38. package/src/components/Icon/icons/upload.tsx +22 -0
  39. package/src/components/Icon/icons/wine.tsx +23 -0
  40. package/src/components/IconPicker/IconPicker.module.css +63 -0
  41. package/src/components/IconPicker/IconPicker.stories.tsx +84 -0
  42. package/src/components/IconPicker/IconPicker.tsx +90 -0
  43. package/src/components/QuantityStepper/QuantityStepper.module.css +67 -0
  44. package/src/components/QuantityStepper/QuantityStepper.stories.tsx +79 -0
  45. package/src/components/QuantityStepper/QuantityStepper.tsx +107 -0
  46. package/src/components/SuggestionInput/SuggestionInput.module.css +103 -0
  47. package/src/components/SuggestionInput/SuggestionInput.stories.tsx +144 -0
  48. package/src/components/SuggestionInput/SuggestionInput.tsx +207 -0
  49. package/src/hooks/index.ts +2 -0
  50. package/src/hooks/useCommitOnEnterOrBlur.ts +39 -0
  51. package/src/index.ts +21 -2
  52. package/src/stories/DesignSystemOverview.stories.tsx +134 -1
  53. package/src/stories/FileDropzone.stories.tsx +69 -0
  54. package/src/stories/InteractionHooks.stories.tsx +55 -1
@@ -0,0 +1,207 @@
1
+ 'use client';
2
+
3
+ import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
4
+ import { Popover } from '../Popover/Popover';
5
+ import styles from './SuggestionInput.module.css';
6
+
7
+ export interface SuggestionOption {
8
+ id: string;
9
+ label: string;
10
+ icon?: ReactNode;
11
+ /** Secondary text shown right-aligned on the row, e.g. "bought 4× recently". */
12
+ meta?: string;
13
+ }
14
+
15
+ export interface SuggestionInputProps {
16
+ value: string;
17
+ onChange: (value: string) => void;
18
+ options: SuggestionOption[];
19
+ onSelect: (option: SuggestionOption) => void;
20
+ placeholder?: string;
21
+ 'aria-label': string;
22
+ id?: string;
23
+ disabled?: boolean;
24
+ /** Shown in place of the options list while a suggestion fetch is in flight. */
25
+ loading?: boolean;
26
+ /**
27
+ * Label for a trailing, always-selectable row that commits the raw typed
28
+ * text instead of a matched option — e.g. `(v) => \`Add "${v}" as a new item\`}`.
29
+ * Shown whenever `value` is non-empty and `onCreate` is provided, even
30
+ * when `options` is empty, so the input is never a dead end.
31
+ */
32
+ createLabel?: (value: string) => string;
33
+ onCreate?: (value: string) => void;
34
+ className?: string;
35
+ }
36
+
37
+ /**
38
+ * SuggestionInput — a text field with an anchored, keyboard-navigable list
39
+ * of async suggestions (a combobox), plus an optional trailing "create new"
40
+ * row so free text is always a valid commit, not just a matched option.
41
+ *
42
+ * Fully controlled: the caller owns `value`/`onChange` and supplies
43
+ * `options` (e.g. debounced server-fetched results) — this component only
44
+ * handles open/close, keyboard navigation, and selection. Built on `Popover`
45
+ * for positioning (`width: 'trigger'`, so the panel always matches the
46
+ * input's own width).
47
+ */
48
+ export function SuggestionInput({
49
+ value,
50
+ onChange,
51
+ options,
52
+ onSelect,
53
+ placeholder,
54
+ 'aria-label': ariaLabel,
55
+ id,
56
+ disabled = false,
57
+ loading = false,
58
+ createLabel,
59
+ onCreate,
60
+ className,
61
+ }: SuggestionInputProps) {
62
+ const generatedId = useId();
63
+ const inputId = id ?? generatedId;
64
+ const listId = `${inputId}-suggestions`;
65
+ const inputRef = useRef<HTMLInputElement>(null);
66
+ const [focused, setFocused] = useState(false);
67
+ const [activeIndex, setActiveIndex] = useState(-1);
68
+
69
+ const showCreateRow = Boolean(onCreate && createLabel && value.trim());
70
+ const rowCount = options.length + (showCreateRow ? 1 : 0);
71
+ const open = focused && (rowCount > 0 || loading);
72
+
73
+ function close() {
74
+ setFocused(false);
75
+ setActiveIndex(-1);
76
+ }
77
+
78
+ function commitIndex(index: number) {
79
+ if (index < 0 || index >= rowCount) return;
80
+ const option = options[index];
81
+ if (option) {
82
+ onSelect(option);
83
+ } else if (onCreate) {
84
+ onCreate(value);
85
+ }
86
+ close();
87
+ }
88
+
89
+ function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
90
+ if (!open) return;
91
+ // Some synthetic input sources (seen from automated/CDP-driven key
92
+ // dispatch) send a keydown with `key`/`code` as "Unidentified" or empty
93
+ // and no `keyCode` either — nothing here can special-case that further,
94
+ // but checking the legacy numeric code costs nothing and covers sources
95
+ // that do populate it even when `key` doesn't (older browsers, some
96
+ // assistive tech, IME edge cases).
97
+ const isArrowDown = e.key === 'ArrowDown' || e.keyCode === 40;
98
+ const isArrowUp = e.key === 'ArrowUp' || e.keyCode === 38;
99
+ const isEnter = e.key === 'Enter' || e.keyCode === 13;
100
+ const isEscape = e.key === 'Escape' || e.keyCode === 27;
101
+
102
+ if (isArrowDown) {
103
+ e.preventDefault();
104
+ setActiveIndex((i) => (i + 1) % rowCount);
105
+ } else if (isArrowUp) {
106
+ e.preventDefault();
107
+ setActiveIndex((i) => (i <= 0 ? rowCount - 1 : i - 1));
108
+ } else if (isEnter) {
109
+ e.preventDefault();
110
+ commitIndex(activeIndex === -1 ? 0 : activeIndex);
111
+ } else if (isEscape) {
112
+ close();
113
+ }
114
+ }
115
+
116
+ const trigger = (
117
+ <input
118
+ ref={inputRef}
119
+ id={inputId}
120
+ type="text"
121
+ role="combobox"
122
+ aria-expanded={open}
123
+ aria-controls={listId}
124
+ aria-autocomplete="list"
125
+ aria-activedescendant={activeIndex >= 0 ? `${listId}-option-${activeIndex}` : undefined}
126
+ aria-label={ariaLabel}
127
+ className={[styles.input, className].filter(Boolean).join(' ')}
128
+ value={value}
129
+ placeholder={placeholder}
130
+ disabled={disabled}
131
+ autoComplete="off"
132
+ onChange={(e) => {
133
+ onChange(e.target.value);
134
+ setActiveIndex(-1);
135
+ }}
136
+ onFocus={() => setFocused(true)}
137
+ onKeyDown={handleKeyDown}
138
+ />
139
+ );
140
+
141
+ return (
142
+ <Popover
143
+ trigger={trigger}
144
+ open={open}
145
+ onClose={close}
146
+ width="trigger"
147
+ aria-label={`${ariaLabel} suggestions`}
148
+ >
149
+ <ul id={listId} role="listbox" className={styles.list}>
150
+ {loading ? (
151
+ <li className={styles.status}>Loading…</li>
152
+ ) : (
153
+ <>
154
+ {options.map((option, index) => (
155
+ <li key={option.id} role="none">
156
+ <button
157
+ id={`${listId}-option-${index}`}
158
+ role="option"
159
+ aria-selected={index === activeIndex}
160
+ type="button"
161
+ className={[styles.option, index === activeIndex ? styles.optionActive : '']
162
+ .filter(Boolean)
163
+ .join(' ')}
164
+ // Selecting via mouse fires before the input's blur — use
165
+ // onMouseDown (not onClick) so the click doesn't lose focus
166
+ // and close the popover before the selection registers.
167
+ onMouseDown={(e) => {
168
+ e.preventDefault();
169
+ commitIndex(index);
170
+ }}
171
+ >
172
+ {option.icon && <span className={styles.optionIcon}>{option.icon}</span>}
173
+ <span className={styles.optionLabel}>{option.label}</span>
174
+ {option.meta && <span className={styles.optionMeta}>{option.meta}</span>}
175
+ </button>
176
+ </li>
177
+ ))}
178
+ {showCreateRow && createLabel && (
179
+ <li role="none">
180
+ {options.length > 0 && <div className={styles.divider} />}
181
+ <button
182
+ id={`${listId}-option-${options.length}`}
183
+ role="option"
184
+ aria-selected={options.length === activeIndex}
185
+ type="button"
186
+ className={[
187
+ styles.option,
188
+ styles.optionCreate,
189
+ options.length === activeIndex ? styles.optionActive : '',
190
+ ]
191
+ .filter(Boolean)
192
+ .join(' ')}
193
+ onMouseDown={(e) => {
194
+ e.preventDefault();
195
+ commitIndex(options.length);
196
+ }}
197
+ >
198
+ {createLabel(value)}
199
+ </button>
200
+ </li>
201
+ )}
202
+ </>
203
+ )}
204
+ </ul>
205
+ </Popover>
206
+ );
207
+ }
@@ -2,3 +2,5 @@ export { useLongPress } from './useLongPress';
2
2
  export type { UseLongPressOptions, LongPressHandlers } from './useLongPress';
3
3
  export { useDoubleTapHandler, useSingleOrDoubleTap } from './useDoubleTap';
4
4
  export { useIsMobile, MOBILE_BREAKPOINT_PX } from './useIsMobile';
5
+ export { useCommitOnEnterOrBlur } from './useCommitOnEnterOrBlur';
6
+ export type { CommitOnEnterOrBlurHandlers } from './useCommitOnEnterOrBlur';
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import type { KeyboardEvent } from 'react';
4
+
5
+ export interface CommitOnEnterOrBlurHandlers {
6
+ onKeyDown: (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
7
+ onBlur: () => void;
8
+ }
9
+
10
+ /**
11
+ * useCommitOnEnterOrBlur — spreads onto a quick-entry input/textarea so
12
+ * Enter and losing focus both commit through the same callback.
13
+ *
14
+ * iOS Safari adds its own "Previous / Next / Done" toolbar above the
15
+ * software keyboard whenever more than one focusable field is nearby (a
16
+ * Sheet with several fields, a form, etc.) — there is no supported way to
17
+ * suppress it, since it's WebKit's own field-detection heuristic, not
18
+ * something the page controls. Tapping that toolbar's Done/checkmark only
19
+ * ever fires a native `blur`; it is not a form submit and dispatches no
20
+ * keydown, so a field that commits only on Enter silently discards whatever
21
+ * was typed the moment a user dismisses the keyboard that way instead of
22
+ * pressing the on-screen Return key — the two dismissal paths look
23
+ * identical to the user but produce different outcomes.
24
+ *
25
+ * `onCommit` is called unconditionally on both Enter and blur — it owns any
26
+ * empty-value no-op itself, matching how every call site's own commit
27
+ * function already guards against an empty value before doing anything.
28
+ * Callers needing more key handling (e.g. Escape-to-cancel) compose their
29
+ * own `onKeyDown` around the returned one rather than this hook growing
30
+ * options for every caller's own key bindings.
31
+ */
32
+ export function useCommitOnEnterOrBlur(onCommit: () => void): CommitOnEnterOrBlurHandlers {
33
+ return {
34
+ onKeyDown: (e) => {
35
+ if (e.key === 'Enter') onCommit();
36
+ },
37
+ onBlur: onCommit,
38
+ };
39
+ }
package/src/index.ts CHANGED
@@ -71,10 +71,29 @@ export { Checkbox } from './components/Checkbox/Checkbox';
71
71
  export type { CheckboxProps } from './components/Checkbox/Checkbox';
72
72
  export { DragHandleRow } from './components/DragHandleRow/DragHandleRow';
73
73
  export type { DragHandleRowProps } from './components/DragHandleRow/DragHandleRow';
74
+ export { FileDropzone } from './components/FileDropzone/FileDropzone';
75
+ export type { FileDropzoneProps } from './components/FileDropzone/FileDropzone';
74
76
  export { Calendar } from './components/Calendar/Calendar';
75
77
  export type { CalendarProps } from './components/Calendar/Calendar';
76
78
  export { DatePicker } from './components/DatePicker/DatePicker';
77
79
  export type { DatePickerProps } from './components/DatePicker/DatePicker';
78
- export { useLongPress, useDoubleTapHandler, useSingleOrDoubleTap, useIsMobile } from './hooks';
80
+ export { SuggestionInput } from './components/SuggestionInput/SuggestionInput';
81
+ export type {
82
+ SuggestionInputProps,
83
+ SuggestionOption,
84
+ } from './components/SuggestionInput/SuggestionInput';
85
+ export { IconPicker } from './components/IconPicker/IconPicker';
86
+ export type { IconPickerProps } from './components/IconPicker/IconPicker';
87
+ export { QuantityStepper } from './components/QuantityStepper/QuantityStepper';
88
+ export type { QuantityStepperProps } from './components/QuantityStepper/QuantityStepper';
89
+ export { CheckableListRow } from './components/CheckableListRow/CheckableListRow';
90
+ export type { CheckableListRowProps } from './components/CheckableListRow/CheckableListRow';
91
+ export {
92
+ useLongPress,
93
+ useDoubleTapHandler,
94
+ useSingleOrDoubleTap,
95
+ useIsMobile,
96
+ useCommitOnEnterOrBlur,
97
+ } from './hooks';
79
98
  export { MOBILE_BREAKPOINT_PX } from './hooks';
80
- export type { UseLongPressOptions, LongPressHandlers } from './hooks';
99
+ export type { UseLongPressOptions, LongPressHandlers, CommitOnEnterOrBlurHandlers } from './hooks';
@@ -10,12 +10,17 @@ import { Drawer } from '../components/Drawer/Drawer';
10
10
  import { EmptyState } from '../components/EmptyState/EmptyState';
11
11
  import { FormField } from '../components/FormField/FormField';
12
12
  import { Icon } from '../components/Icon/Icon';
13
+ import type { IconName } from '../components/Icon/Icon';
14
+ import { IconPicker } from '../components/IconPicker/IconPicker';
15
+ import { QuantityStepper } from '../components/QuantityStepper/QuantityStepper';
16
+ import { CheckableListRow } from '../components/CheckableListRow/CheckableListRow';
13
17
  import { Input } from '../components/Input/Input';
14
18
  import { CodeTextarea } from '../components/CodeTextarea/CodeTextarea';
15
19
  import { Textarea } from '../components/Textarea/Textarea';
16
20
  import { StatusBadge } from '../components/StatusBadge/StatusBadge';
17
21
  import { SplitPane } from '../components/SplitPane/SplitPane';
18
22
  import { TagInput } from '../components/TagInput/TagInput';
23
+ import { SuggestionInput } from '../components/SuggestionInput/SuggestionInput';
19
24
  import { NavTabs } from '../components/NavTabs/NavTabs';
20
25
  import { PageHeader } from '../components/PageHeader/PageHeader';
21
26
  import { Popover } from '../components/Popover/Popover';
@@ -29,6 +34,7 @@ import { Toggle } from '../components/Toggle/Toggle';
29
34
  import { Tooltip } from '../components/Tooltip/Tooltip';
30
35
  import { Checkbox } from '../components/Checkbox/Checkbox';
31
36
  import { DragHandleRow } from '../components/DragHandleRow/DragHandleRow';
37
+ import { FileDropzone } from '../components/FileDropzone/FileDropzone';
32
38
  import { OverlayHeader } from '../components/OverlayHeader/OverlayHeader';
33
39
  import { Sheet } from '../components/Sheet/Sheet';
34
40
  import { ConfirmDialog } from '../components/ConfirmDialog/ConfirmDialog';
@@ -730,6 +736,85 @@ function TagInputDemo() {
730
736
  return <TagInput value={tags} onChange={setTags} aria-label="Tags" />;
731
737
  }
732
738
 
739
+ function QuantityStepperDemo() {
740
+ const [value, setValue] = useState(6);
741
+ return <QuantityStepper value={value} onChange={setValue} unit="pcs" aria-label="Quantity" />;
742
+ }
743
+
744
+ function CheckableListRowDemo() {
745
+ const [checked, setChecked] = useState(false);
746
+ return (
747
+ <div style={{ width: '100%' }}>
748
+ <CheckableListRow
749
+ checked={checked}
750
+ onCheckedChange={setChecked}
751
+ label="Bananas"
752
+ icon={<Icon name="banana" size="md" aria-hidden />}
753
+ trailing={<span style={{ fontSize: 13, color: 'var(--sv-color-text-muted)' }}>6 pcs</span>}
754
+ />
755
+ </div>
756
+ );
757
+ }
758
+
759
+ function FileDropzoneDemo() {
760
+ const [file, setFile] = useState<File | null>(null);
761
+ return (
762
+ <div style={{ width: '100%' }}>
763
+ <FileDropzone
764
+ ariaLabel="Upload ZIP file"
765
+ accept=".zip,application/zip"
766
+ label={file ? file.name : 'Choose a ZIP file'}
767
+ hint={file ? `${(file.size / 1024).toFixed(0)} KB` : 'or drag and drop here'}
768
+ onFileSelect={setFile}
769
+ />
770
+ </div>
771
+ );
772
+ }
773
+
774
+ function IconPickerDemo() {
775
+ const [value, setValue] = useState<IconName | null>('banana');
776
+ const options: IconName[] = [
777
+ 'banana',
778
+ 'apple',
779
+ 'carrot',
780
+ 'egg',
781
+ 'milk',
782
+ 'beef',
783
+ 'drumstick',
784
+ 'fish',
785
+ 'croissant',
786
+ 'cookie',
787
+ ];
788
+ return (
789
+ <IconPicker
790
+ value={value}
791
+ onChange={setValue}
792
+ options={options}
793
+ aria-label="Item icon"
794
+ triggerLabel={value ?? 'Choose icon'}
795
+ />
796
+ );
797
+ }
798
+
799
+ function SuggestionInputDemo() {
800
+ const [value, setValue] = useState('ban');
801
+ const options = ['Bananas', 'Banana bread mix']
802
+ .filter((label) => label.toLowerCase().includes(value.trim().toLowerCase()))
803
+ .map((label, i) => ({ id: String(i), label }));
804
+ return (
805
+ <SuggestionInput
806
+ value={value}
807
+ onChange={setValue}
808
+ options={value.trim() ? options : []}
809
+ onSelect={(o) => setValue(o.label)}
810
+ placeholder="Add an item…"
811
+ aria-label="Add an item"
812
+ createLabel={(v) => `Add "${v}" as a new item`}
813
+ onCreate={setValue}
814
+ />
815
+ );
816
+ }
817
+
733
818
  // ---------------------------------------------------------------------------
734
819
  // Main component
735
820
  // ---------------------------------------------------------------------------
@@ -1429,6 +1514,35 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1429
1514
  <TagInputDemo />
1430
1515
  </ComponentCard>
1431
1516
 
1517
+ {/* SuggestionInput */}
1518
+ <ComponentCard
1519
+ name="SuggestionInput"
1520
+ importLine="import { SuggestionInput } from '@sovereignfs/ui';"
1521
+ usage="Text field with an anchored, keyboard-navigable async suggestion list, plus an optional trailing 'create new' row. Built on Popover."
1522
+ >
1523
+ <div style={{ width: '100%' }}>
1524
+ <SuggestionInputDemo />
1525
+ </div>
1526
+ </ComponentCard>
1527
+
1528
+ {/* IconPicker */}
1529
+ <ComponentCard
1530
+ name="IconPicker"
1531
+ importLine="import { IconPicker } from '@sovereignfs/ui';"
1532
+ usage="Trigger button + Popover grid for a curated, bounded icon set (e.g. a plugin's category icons) — not the full icon library."
1533
+ >
1534
+ <IconPickerDemo />
1535
+ </ComponentCard>
1536
+
1537
+ {/* QuantityStepper */}
1538
+ <ComponentCard
1539
+ name="QuantityStepper"
1540
+ importLine="import { QuantityStepper } from '@sovereignfs/ui';"
1541
+ usage="Numeric input with +/- buttons and an optional read-only unit suffix. Supports fractional step values (e.g. 0.5 kg)."
1542
+ >
1543
+ <QuantityStepperDemo />
1544
+ </ComponentCard>
1545
+
1432
1546
  {/* Select */}
1433
1547
  <ComponentCard
1434
1548
  name="Select"
@@ -1484,7 +1598,7 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1484
1598
  <ComponentCard
1485
1599
  name="Icon"
1486
1600
  importLine="import { Icon } from '@sovereignfs/ui';"
1487
- usage="SVG icon primitive. 29 bundled icons. Decorative: aria-hidden. Meaningful: aria-label. Three sizes: sm, md, lg."
1601
+ usage="SVG icon primitive. 52 bundled icons. Decorative: aria-hidden. Meaningful: aria-label. Three sizes: sm, md, lg."
1488
1602
  >
1489
1603
  {(
1490
1604
  [
@@ -1497,6 +1611,7 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1497
1611
  'search',
1498
1612
  'plus',
1499
1613
  'trash-2',
1614
+ 'external-link',
1500
1615
  ] as const
1501
1616
  ).map((n) => (
1502
1617
  <Icon key={n} name={n} size="md" aria-hidden />
@@ -1798,6 +1913,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1798
1913
  </div>
1799
1914
  </ComponentCard>
1800
1915
 
1916
+ {/* CheckableListRow */}
1917
+ <ComponentCard
1918
+ name="CheckableListRow"
1919
+ importLine="import { CheckableListRow } from '@sovereignfs/ui';"
1920
+ usage="Whole-row tap target that toggles a checked state, with strike-through on the label — for 'tap the row to mark it done' lists (not a form checkbox)."
1921
+ >
1922
+ <CheckableListRowDemo />
1923
+ </ComponentCard>
1924
+
1801
1925
  {/* Checkbox */}
1802
1926
  <ComponentCard
1803
1927
  name="Checkbox"
@@ -1839,6 +1963,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1839
1963
  ))}
1840
1964
  </div>
1841
1965
  </ComponentCard>
1966
+
1967
+ {/* FileDropzone */}
1968
+ <ComponentCard
1969
+ name="FileDropzone"
1970
+ importLine="import { FileDropzone } from '@sovereignfs/ui';"
1971
+ usage="Styled drag-and-drop file picker — a dashed-border dropzone wrapping a visually-hidden native file input. Caller owns selected-file state and passes label/hint accordingly."
1972
+ >
1973
+ <FileDropzoneDemo />
1974
+ </ComponentCard>
1842
1975
  </div>
1843
1976
  </section>
1844
1977
 
@@ -0,0 +1,69 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { FileDropzone } from '../components/FileDropzone/FileDropzone';
4
+ import { Icon } from '../components/Icon/Icon';
5
+
6
+ const meta: Meta<typeof FileDropzone> = {
7
+ title: 'Components/FileDropzone',
8
+ component: FileDropzone,
9
+ parameters: { layout: 'padded' },
10
+ tags: ['autodocs'],
11
+ };
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof FileDropzone>;
15
+
16
+ export const Default: Story = {
17
+ render: () => {
18
+ function Demo() {
19
+ const [file, setFile] = useState<File | null>(null);
20
+ return (
21
+ <div style={{ maxWidth: '420px' }}>
22
+ <FileDropzone
23
+ ariaLabel="Upload ZIP file"
24
+ accept=".zip,application/zip"
25
+ label={file ? file.name : 'Choose a ZIP file'}
26
+ hint={file ? `${(file.size / 1024).toFixed(0)} KB` : 'or drag and drop here'}
27
+ onFileSelect={setFile}
28
+ />
29
+ </div>
30
+ );
31
+ }
32
+ return <Demo />;
33
+ },
34
+ };
35
+
36
+ export const CustomIcon: Story = {
37
+ render: () => {
38
+ function Demo() {
39
+ const [file, setFile] = useState<File | null>(null);
40
+ return (
41
+ <div style={{ maxWidth: '420px' }}>
42
+ <FileDropzone
43
+ ariaLabel="Upload font file"
44
+ accept=".woff2,.woff,.ttf,.otf"
45
+ icon={<Icon name="upload" size="lg" aria-hidden />}
46
+ label={file ? file.name : 'Choose a font file'}
47
+ hint={file ? undefined : '.woff2, .woff, .ttf, or .otf'}
48
+ onFileSelect={setFile}
49
+ />
50
+ </div>
51
+ );
52
+ }
53
+ return <Demo />;
54
+ },
55
+ };
56
+
57
+ export const Disabled: Story = {
58
+ render: () => (
59
+ <div style={{ maxWidth: '420px' }}>
60
+ <FileDropzone
61
+ ariaLabel="Upload ZIP file"
62
+ label="Choose a ZIP file"
63
+ hint="or drag and drop here"
64
+ disabled
65
+ onFileSelect={() => {}}
66
+ />
67
+ </div>
68
+ ),
69
+ };
@@ -1,7 +1,13 @@
1
1
  import { useState } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { Card } from '../components/Card/Card';
4
- import { useDoubleTapHandler, useIsMobile, useLongPress, useSingleOrDoubleTap } from '../hooks';
4
+ import {
5
+ useCommitOnEnterOrBlur,
6
+ useDoubleTapHandler,
7
+ useIsMobile,
8
+ useLongPress,
9
+ useSingleOrDoubleTap,
10
+ } from '../hooks';
5
11
 
6
12
  // ---------------------------------------------------------------------------
7
13
  // Shared primitives (mirrors MobilePatterns.stories.tsx's doc styling)
@@ -268,6 +274,42 @@ function DoubleTapDemo() {
268
274
  );
269
275
  }
270
276
 
277
+ function CommitOnEnterOrBlurDemo() {
278
+ const [value, setValue] = useState('');
279
+ const [events, setEvents] = useState<string[]>([]);
280
+ const log = (msg: string) =>
281
+ setEvents((e) => [...e, `${new Date().toLocaleTimeString()} — ${msg}`]);
282
+ const commitHandlers = useCommitOnEnterOrBlur(() => log(`commit: "${value}"`));
283
+
284
+ return (
285
+ <div>
286
+ <input
287
+ value={value}
288
+ onChange={(e) => setValue(e.target.value)}
289
+ {...commitHandlers}
290
+ placeholder="Type, then press Enter or click away…"
291
+ style={{
292
+ width: '100%',
293
+ boxSizing: 'border-box',
294
+ padding: 'var(--sv-space-2) var(--sv-space-3)',
295
+ borderRadius: 'var(--sv-radius-md)',
296
+ border: '1px solid var(--sv-color-border-strong)',
297
+ background: 'var(--sv-color-surface)',
298
+ color: 'var(--sv-color-text-primary)',
299
+ fontFamily: ff,
300
+ fontSize: 'var(--sv-font-size-sm)',
301
+ }}
302
+ />
303
+ <EventLog events={events} />
304
+ <Callout type="info">
305
+ Tries to reproduce the bug this hook fixes: on iOS, tap into the field, type something, then
306
+ dismiss the keyboard via the native Done/checkmark toolbar instead of the Return key — both
307
+ now log a commit here, where an Enter-only handler would silently drop the checkmark path.
308
+ </Callout>
309
+ </div>
310
+ );
311
+ }
312
+
271
313
  function IsMobileDemo() {
272
314
  const isMobile = useIsMobile();
273
315
  return (
@@ -346,6 +388,18 @@ function InteractionHooksDoc() {
346
388
  </Callout>
347
389
  </section>
348
390
 
391
+ <section style={{ marginBottom: 'var(--sv-space-10)' }}>
392
+ <SectionHeader
393
+ title="useCommitOnEnterOrBlur"
394
+ subtitle="Unifies Enter-to-commit with iOS's native keyboard accessory toolbar, whose Done/checkmark only ever fires a blur, never a keydown."
395
+ />
396
+ <Card padding="md">
397
+ <CommitOnEnterOrBlurDemo />
398
+ </Card>
399
+ <CodeBlock>{`const commitHandlers = useCommitOnEnterOrBlur(() => createTask(title));
400
+ <input value={title} onChange={...} {...commitHandlers} />`}</CodeBlock>
401
+ </section>
402
+
349
403
  <section style={{ marginBottom: 'var(--sv-space-10)' }}>
350
404
  <SectionHeader
351
405
  title="useIsMobile"