@sovereignfs/ui 0.37.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovereignfs/ui",
3
- "version": "0.37.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "description": "Sovereign Design System — design tokens and React components.",
6
6
  "keywords": [
@@ -0,0 +1,63 @@
1
+ .dropzone {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--sv-space-4);
5
+ width: 100%;
6
+ box-sizing: border-box;
7
+ padding: var(--sv-space-4) var(--sv-space-5);
8
+ border: 1.5px dashed var(--sv-color-border);
9
+ border-radius: var(--sv-radius-md);
10
+ background-color: var(--sv-color-surface-raised);
11
+ cursor: pointer;
12
+ transition:
13
+ background-color 0.12s ease,
14
+ border-color 0.12s ease;
15
+ }
16
+
17
+ @media (hover: hover) {
18
+ .dropzone:hover {
19
+ background-color: var(--sv-color-surface-sunken);
20
+ border-color: var(--sv-color-border-strong);
21
+ }
22
+ }
23
+
24
+ .dropzoneActive {
25
+ background-color: var(--sv-color-surface-sunken);
26
+ border-color: var(--sv-color-border-strong);
27
+ }
28
+
29
+ .dropzoneDisabled {
30
+ opacity: 0.5;
31
+ cursor: not-allowed;
32
+ }
33
+
34
+ .icon {
35
+ display: inline-flex;
36
+ flex-shrink: 0;
37
+ color: var(--sv-color-text-muted);
38
+ }
39
+
40
+ .text {
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: 2px;
44
+ min-width: 0;
45
+ }
46
+
47
+ .title {
48
+ font-size: var(--sv-font-size-sm);
49
+ font-weight: var(--sv-font-weight-medium);
50
+ color: var(--sv-color-text-primary);
51
+ overflow: hidden;
52
+ text-overflow: ellipsis;
53
+ white-space: nowrap;
54
+ }
55
+
56
+ .hint {
57
+ font-size: var(--sv-font-size-xs);
58
+ color: var(--sv-color-text-muted);
59
+ }
60
+
61
+ .hiddenInput {
62
+ display: none;
63
+ }
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import type { DragEvent, ReactNode } from 'react';
4
+ import { useRef, useState } from 'react';
5
+ import { Icon } from '../Icon/Icon';
6
+ import styles from './FileDropzone.module.css';
7
+
8
+ export interface FileDropzoneProps {
9
+ /** Native `<input type="file">` `name` attribute — set this to submit the
10
+ * file as part of a native `<form action={...}>` (Server Action) request. */
11
+ name?: string;
12
+ /** Native `<input type="file">` `accept` attribute, e.g. `".zip,application/zip"`. */
13
+ accept?: string;
14
+ /** Icon shown beside the label — a file-type glyph. Defaults to a generic file icon. */
15
+ icon?: ReactNode;
16
+ /** Primary label — e.g. "Choose a ZIP file", or the selected file's name. */
17
+ label: string;
18
+ /** Secondary hint — e.g. "or drag and drop here", or the selected file's size. */
19
+ hint?: string;
20
+ /** Called with the picked or dropped file, or `null` if the input is cleared. */
21
+ onFileSelect: (file: File | null) => void;
22
+ /** Accessible name for the control — describe what will be uploaded. */
23
+ ariaLabel: string;
24
+ disabled?: boolean;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * A styled drag-and-drop file picker: a dashed-border dropzone wrapping a
30
+ * visually-hidden native `<input type="file">`, so the native picker still
31
+ * opens on click and the field participates in normal form semantics. A
32
+ * dropped file is written into the input's own `FileList` (via `DataTransfer`)
33
+ * so `name` + native `<form action>` submission sees it exactly like a
34
+ * click-picked file — not just the `onFileSelect` callback.
35
+ *
36
+ * Caller owns the selected-file state and passes `label`/`hint` accordingly
37
+ * (e.g. swap to the picked file's name/size) — this component is otherwise
38
+ * uncontrolled and stateless beyond its own drag-hover visual.
39
+ *
40
+ * Do not pass `required` through to a wrapping form's validation via this
41
+ * field — `display: none` on the hidden input does not exempt it from HTML5
42
+ * constraint validation, so a `required` hidden input can block submission
43
+ * with no visible field for the user to focus. Validate `onFileSelect`'s
44
+ * result in application code instead.
45
+ */
46
+ export function FileDropzone({
47
+ name,
48
+ accept,
49
+ icon,
50
+ label,
51
+ hint,
52
+ onFileSelect,
53
+ ariaLabel,
54
+ disabled,
55
+ className,
56
+ }: FileDropzoneProps) {
57
+ const [dragging, setDragging] = useState(false);
58
+ const inputRef = useRef<HTMLInputElement>(null);
59
+
60
+ return (
61
+ <label
62
+ className={[
63
+ styles.dropzone,
64
+ dragging ? styles.dropzoneActive : '',
65
+ disabled ? styles.dropzoneDisabled : '',
66
+ className,
67
+ ]
68
+ .filter(Boolean)
69
+ .join(' ')}
70
+ onDragOver={(event: DragEvent) => {
71
+ if (disabled) return;
72
+ event.preventDefault();
73
+ setDragging(true);
74
+ }}
75
+ onDragLeave={() => setDragging(false)}
76
+ onDrop={(event: DragEvent) => {
77
+ if (disabled) return;
78
+ event.preventDefault();
79
+ setDragging(false);
80
+ const dropped = event.dataTransfer.files[0];
81
+ if (!dropped) return;
82
+ if (inputRef.current && typeof DataTransfer !== 'undefined') {
83
+ const transfer = new DataTransfer();
84
+ transfer.items.add(dropped);
85
+ inputRef.current.files = transfer.files;
86
+ }
87
+ onFileSelect(dropped);
88
+ }}
89
+ >
90
+ <span className={styles.icon} aria-hidden="true">
91
+ {icon ?? <Icon name="file" size="lg" aria-hidden />}
92
+ </span>
93
+ <span className={styles.text}>
94
+ <span className={styles.title}>{label}</span>
95
+ {hint && <span className={styles.hint}>{hint}</span>}
96
+ </span>
97
+ <input
98
+ ref={inputRef}
99
+ type="file"
100
+ name={name}
101
+ aria-label={ariaLabel}
102
+ accept={accept}
103
+ disabled={disabled}
104
+ onChange={(event) => onFileSelect(event.target.files?.[0] ?? null)}
105
+ className={styles.hiddenInput}
106
+ />
107
+ </label>
108
+ );
109
+ }
@@ -0,0 +1,67 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { FileDropzone } from '../FileDropzone';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('FileDropzone', () => {
9
+ it('renders the label and hint', () => {
10
+ render(
11
+ <FileDropzone
12
+ label="Choose a ZIP file"
13
+ hint="or drag and drop here"
14
+ ariaLabel="Upload ZIP file"
15
+ onFileSelect={vi.fn()}
16
+ />,
17
+ );
18
+ expect(screen.getByText('Choose a ZIP file')).toBeDefined();
19
+ expect(screen.getByText('or drag and drop here')).toBeDefined();
20
+ });
21
+
22
+ it('exposes an accessible name via aria-label', () => {
23
+ render(
24
+ <FileDropzone label="Choose a file" ariaLabel="Upload font file" onFileSelect={vi.fn()} />,
25
+ );
26
+ expect(screen.getByLabelText('Upload font file')).toBeDefined();
27
+ });
28
+
29
+ it('forwards accept to the native file input', () => {
30
+ render(
31
+ <FileDropzone
32
+ label="Choose a file"
33
+ ariaLabel="Upload"
34
+ accept=".zip"
35
+ onFileSelect={vi.fn()}
36
+ />,
37
+ );
38
+ const input = screen.getByLabelText('Upload') as HTMLInputElement;
39
+ expect(input.accept).toBe('.zip');
40
+ });
41
+
42
+ it('calls onFileSelect with the picked file', () => {
43
+ const onFileSelect = vi.fn();
44
+ render(<FileDropzone label="Choose a file" ariaLabel="Upload" onFileSelect={onFileSelect} />);
45
+ const input = screen.getByLabelText('Upload') as HTMLInputElement;
46
+ const file = new File(['content'], 'font.woff2');
47
+ fireEvent.change(input, { target: { files: [file] } });
48
+ expect(onFileSelect).toHaveBeenCalledWith(file);
49
+ });
50
+
51
+ it('calls onFileSelect with the dropped file', () => {
52
+ const onFileSelect = vi.fn();
53
+ render(<FileDropzone label="Choose a file" ariaLabel="Upload" onFileSelect={onFileSelect} />);
54
+ const dropzone = screen.getByLabelText('Upload').closest('label');
55
+ if (!dropzone) throw new Error('dropzone label not found');
56
+ const file = new File(['content'], 'font.woff2');
57
+ fireEvent.drop(dropzone, { dataTransfer: { files: [file] } });
58
+ expect(onFileSelect).toHaveBeenCalledWith(file);
59
+ });
60
+
61
+ it('is disabled when the disabled prop is set', () => {
62
+ render(
63
+ <FileDropzone label="Choose a file" ariaLabel="Upload" disabled onFileSelect={vi.fn()} />,
64
+ );
65
+ expect((screen.getByLabelText('Upload') as HTMLInputElement).disabled).toBe(true);
66
+ });
67
+ });
@@ -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 ExternalLinkIcon(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 3h6v6" />
18
+ <path d="M10 14 21 3" />
19
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
20
+ </svg>
21
+ );
22
+ }
@@ -0,0 +1,21 @@
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 FileIcon(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="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
18
+ <path d="M14 2v5a1 1 0 0 0 1 1h5" />
19
+ </svg>
20
+ );
21
+ }
@@ -29,6 +29,9 @@ 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 { FileIcon } from './file';
33
+ import { UploadIcon } from './upload';
34
+ import { ExternalLinkIcon } from './external-link';
32
35
  import { BananaIcon } from './banana';
33
36
  import { AppleIcon } from './apple';
34
37
  import { CarrotIcon } from './carrot';
@@ -80,6 +83,9 @@ export type IconName =
80
83
  | 'calendar'
81
84
  | 'sliders-horizontal'
82
85
  | 'ellipsis-vertical'
86
+ | 'file'
87
+ | 'upload'
88
+ | 'external-link'
83
89
  | 'banana'
84
90
  | 'apple'
85
91
  | 'carrot'
@@ -133,6 +139,9 @@ export const ICONS: Record<IconName, IconComponent> = {
133
139
  calendar: CalendarIcon,
134
140
  'sliders-horizontal': SlidersHorizontalIcon,
135
141
  'ellipsis-vertical': EllipsisVerticalIcon,
142
+ file: FileIcon,
143
+ upload: UploadIcon,
144
+ 'external-link': ExternalLinkIcon,
136
145
  banana: BananaIcon,
137
146
  apple: AppleIcon,
138
147
  carrot: CarrotIcon,
@@ -184,6 +193,9 @@ export { AlertTriangleIcon } from './alert-triangle';
184
193
  export { CalendarIcon } from './calendar';
185
194
  export { SlidersHorizontalIcon } from './sliders-horizontal';
186
195
  export { EllipsisVerticalIcon } from './ellipsis-vertical';
196
+ export { FileIcon } from './file';
197
+ export { UploadIcon } from './upload';
198
+ export { ExternalLinkIcon } from './external-link';
187
199
  export { BananaIcon } from './banana';
188
200
  export { AppleIcon } from './apple';
189
201
  export { CarrotIcon } from './carrot';
@@ -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 UploadIcon(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 3v12" />
18
+ <path d="m17 8-5-5-5 5" />
19
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
20
+ </svg>
21
+ );
22
+ }
@@ -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,6 +71,8 @@ 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';
@@ -86,6 +88,12 @@ export { QuantityStepper } from './components/QuantityStepper/QuantityStepper';
86
88
  export type { QuantityStepperProps } from './components/QuantityStepper/QuantityStepper';
87
89
  export { CheckableListRow } from './components/CheckableListRow/CheckableListRow';
88
90
  export type { CheckableListRowProps } from './components/CheckableListRow/CheckableListRow';
89
- export { useLongPress, useDoubleTapHandler, useSingleOrDoubleTap, useIsMobile } from './hooks';
91
+ export {
92
+ useLongPress,
93
+ useDoubleTapHandler,
94
+ useSingleOrDoubleTap,
95
+ useIsMobile,
96
+ useCommitOnEnterOrBlur,
97
+ } from './hooks';
90
98
  export { MOBILE_BREAKPOINT_PX } from './hooks';
91
- export type { UseLongPressOptions, LongPressHandlers } from './hooks';
99
+ export type { UseLongPressOptions, LongPressHandlers, CommitOnEnterOrBlurHandlers } from './hooks';
@@ -34,6 +34,7 @@ import { Toggle } from '../components/Toggle/Toggle';
34
34
  import { Tooltip } from '../components/Tooltip/Tooltip';
35
35
  import { Checkbox } from '../components/Checkbox/Checkbox';
36
36
  import { DragHandleRow } from '../components/DragHandleRow/DragHandleRow';
37
+ import { FileDropzone } from '../components/FileDropzone/FileDropzone';
37
38
  import { OverlayHeader } from '../components/OverlayHeader/OverlayHeader';
38
39
  import { Sheet } from '../components/Sheet/Sheet';
39
40
  import { ConfirmDialog } from '../components/ConfirmDialog/ConfirmDialog';
@@ -755,6 +756,21 @@ function CheckableListRowDemo() {
755
756
  );
756
757
  }
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
+
758
774
  function IconPickerDemo() {
759
775
  const [value, setValue] = useState<IconName | null>('banana');
760
776
  const options: IconName[] = [
@@ -1582,7 +1598,7 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1582
1598
  <ComponentCard
1583
1599
  name="Icon"
1584
1600
  importLine="import { Icon } from '@sovereignfs/ui';"
1585
- 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."
1586
1602
  >
1587
1603
  {(
1588
1604
  [
@@ -1595,6 +1611,7 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1595
1611
  'search',
1596
1612
  'plus',
1597
1613
  'trash-2',
1614
+ 'external-link',
1598
1615
  ] as const
1599
1616
  ).map((n) => (
1600
1617
  <Icon key={n} name={n} size="md" aria-hidden />
@@ -1946,6 +1963,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1946
1963
  ))}
1947
1964
  </div>
1948
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>
1949
1975
  </div>
1950
1976
  </section>
1951
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"