@sovereignfs/ui 0.37.0 → 0.41.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 (29) hide show
  1. package/dist/BalanceChip.module.css +32 -0
  2. package/dist/FileDropzone.module.css +63 -0
  3. package/dist/MemberMultiSelect.module.css +27 -0
  4. package/dist/index.d.ts +151 -3
  5. package/dist/index.js +630 -370
  6. package/package.json +1 -1
  7. package/src/components/BalanceChip/BalanceChip.module.css +32 -0
  8. package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
  9. package/src/components/BalanceChip/BalanceChip.tsx +33 -0
  10. package/src/components/CurrencyInput/CurrencyInput.stories.tsx +50 -0
  11. package/src/components/CurrencyInput/CurrencyInput.tsx +60 -0
  12. package/src/components/FileDropzone/FileDropzone.module.css +63 -0
  13. package/src/components/FileDropzone/FileDropzone.tsx +109 -0
  14. package/src/components/FileDropzone/__tests__/FileDropzone.test.tsx +67 -0
  15. package/src/components/Icon/icons/external-link.tsx +22 -0
  16. package/src/components/Icon/icons/file.tsx +21 -0
  17. package/src/components/Icon/icons/index.ts +12 -0
  18. package/src/components/Icon/icons/upload.tsx +22 -0
  19. package/src/components/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
  20. package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
  21. package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -0
  22. package/src/components/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
  23. package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -0
  24. package/src/hooks/index.ts +2 -0
  25. package/src/hooks/useCommitOnEnterOrBlur.ts +39 -0
  26. package/src/index.ts +24 -2
  27. package/src/stories/DesignSystemOverview.stories.tsx +119 -1
  28. package/src/stories/FileDropzone.stories.tsx +69 -0
  29. package/src/stories/InteractionHooks.stories.tsx +55 -1
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { Checkbox } from '../Checkbox/Checkbox';
5
+ import styles from './MemberMultiSelect.module.css';
6
+
7
+ export interface MemberMultiSelectOption {
8
+ id: string;
9
+ label: string;
10
+ }
11
+
12
+ export interface MemberMultiSelectProps {
13
+ options: MemberMultiSelectOption[];
14
+ selectedIds: Set<string>;
15
+ onToggle: (id: string, checked: boolean) => void;
16
+ /** Rendered to the right of a selected row only — e.g. a per-person `CurrencyInput` or share count. */
17
+ renderTrailing?: (id: string) => ReactNode;
18
+ /** Section label above the list, e.g. "Paid by" or "Split between". */
19
+ label?: string;
20
+ /** Helper/validation text below the list, e.g. a running total. */
21
+ hint?: ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * MemberMultiSelect — checkbox list for picking any number of people from an
27
+ * already-resolved set of options (group/project members, collaborators,
28
+ * etc.). Deliberately domain-agnostic about *who* an option represents —
29
+ * instance users and guest members are both just `{id, label}` here, so
30
+ * nothing special is needed to include guests alongside instance users; the
31
+ * caller's `options` array is the only place that distinction exists.
32
+ *
33
+ * Not a directory search or an "add a new guest" flow — pair with
34
+ * `SuggestionInput` (search + add) for that; this component only selects
35
+ * among options already passed in.
36
+ */
37
+ export function MemberMultiSelect({
38
+ options,
39
+ selectedIds,
40
+ onToggle,
41
+ renderTrailing,
42
+ label,
43
+ hint,
44
+ className,
45
+ }: MemberMultiSelectProps) {
46
+ return (
47
+ <div className={[styles.group, className].filter(Boolean).join(' ')}>
48
+ {label && <span className={styles.label}>{label}</span>}
49
+ {options.map((option) => {
50
+ const checked = selectedIds.has(option.id);
51
+ return (
52
+ <div key={option.id} className={styles.row}>
53
+ <Checkbox
54
+ checked={checked}
55
+ onChange={(next) => onToggle(option.id, next)}
56
+ label={option.label}
57
+ />
58
+ {checked && renderTrailing && (
59
+ <span className={styles.trailing}>{renderTrailing(option.id)}</span>
60
+ )}
61
+ </div>
62
+ );
63
+ })}
64
+ {hint && <span className={styles.hint}>{hint}</span>}
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,33 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { SplitMethodSelector, type SplitMethod } from './SplitMethodSelector';
4
+
5
+ const meta = {
6
+ title: 'Components/SplitMethodSelector',
7
+ component: SplitMethodSelector,
8
+ parameters: {
9
+ layout: 'centered',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'The four-way Equal/Amount/Percentage/Shares picker shared by any cost-splitting plugin. A thin SegmentedControl preset that centralizes the option list and labels.',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ value: 'equal',
19
+ onChange: () => {},
20
+ },
21
+ } satisfies Meta<typeof SplitMethodSelector>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ function ControlledSelector() {
27
+ const [value, setValue] = useState<SplitMethod>('equal');
28
+ return <SplitMethodSelector value={value} onChange={setValue} />;
29
+ }
30
+
31
+ export const Default: Story = {
32
+ render: () => <ControlledSelector />,
33
+ };
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { SegmentedControl } from '../SegmentedControl/SegmentedControl';
4
+
5
+ export type SplitMethod = 'equal' | 'amount' | 'percentage' | 'shares';
6
+
7
+ export interface SplitMethodSelectorProps {
8
+ value: SplitMethod;
9
+ onChange: (value: SplitMethod) => void;
10
+ size?: 'sm' | 'md';
11
+ 'aria-label'?: string;
12
+ }
13
+
14
+ const OPTIONS: { label: string; value: SplitMethod }[] = [
15
+ { label: 'Equal', value: 'equal' },
16
+ { label: 'Amount', value: 'amount' },
17
+ { label: 'Percentage', value: 'percentage' },
18
+ { label: 'Shares', value: 'shares' },
19
+ ];
20
+
21
+ /**
22
+ * SplitMethodSelector — the four-way equal/amount/percentage/shares picker
23
+ * shared by any plugin that splits a cost between people. A thin
24
+ * `SegmentedControl` preset: centralizes the option list and labels so every
25
+ * expense-splitting plugin presents the same four choices in the same order,
26
+ * instead of each one re-typing its own copy of this array.
27
+ */
28
+ export function SplitMethodSelector({
29
+ value,
30
+ onChange,
31
+ size = 'sm',
32
+ 'aria-label': ariaLabel = 'Split method',
33
+ }: SplitMethodSelectorProps) {
34
+ return (
35
+ <SegmentedControl
36
+ value={value}
37
+ onChange={onChange}
38
+ options={OPTIONS}
39
+ size={size}
40
+ aria-label={ariaLabel}
41
+ />
42
+ );
43
+ }
@@ -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,26 @@ 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 { CurrencyInput } from './components/CurrencyInput/CurrencyInput';
92
+ export type { CurrencyInputProps } from './components/CurrencyInput/CurrencyInput';
93
+ export { BalanceChip } from './components/BalanceChip/BalanceChip';
94
+ export type { BalanceChipProps } from './components/BalanceChip/BalanceChip';
95
+ export { SplitMethodSelector } from './components/SplitMethodSelector/SplitMethodSelector';
96
+ export type {
97
+ SplitMethodSelectorProps,
98
+ SplitMethod,
99
+ } from './components/SplitMethodSelector/SplitMethodSelector';
100
+ export { MemberMultiSelect } from './components/MemberMultiSelect/MemberMultiSelect';
101
+ export type {
102
+ MemberMultiSelectProps,
103
+ MemberMultiSelectOption,
104
+ } from './components/MemberMultiSelect/MemberMultiSelect';
105
+ export {
106
+ useLongPress,
107
+ useDoubleTapHandler,
108
+ useSingleOrDoubleTap,
109
+ useIsMobile,
110
+ useCommitOnEnterOrBlur,
111
+ } from './hooks';
90
112
  export { MOBILE_BREAKPOINT_PX } from './hooks';
91
- export type { UseLongPressOptions, LongPressHandlers } from './hooks';
113
+ export type { UseLongPressOptions, LongPressHandlers, CommitOnEnterOrBlurHandlers } from './hooks';
@@ -34,12 +34,23 @@ 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';
40
41
  import { Menu } from '../components/Menu/Menu';
41
42
  import { Calendar } from '../components/Calendar/Calendar';
42
43
  import { DatePicker } from '../components/DatePicker/DatePicker';
44
+ import { CurrencyInput } from '../components/CurrencyInput/CurrencyInput';
45
+ import { BalanceChip } from '../components/BalanceChip/BalanceChip';
46
+ import {
47
+ SplitMethodSelector,
48
+ type SplitMethod,
49
+ } from '../components/SplitMethodSelector/SplitMethodSelector';
50
+ import {
51
+ MemberMultiSelect,
52
+ type MemberMultiSelectOption,
53
+ } from '../components/MemberMultiSelect/MemberMultiSelect';
43
54
 
44
55
  // ---------------------------------------------------------------------------
45
56
  // Shared primitives
@@ -755,6 +766,65 @@ function CheckableListRowDemo() {
755
766
  );
756
767
  }
757
768
 
769
+ function CurrencyInputDemo() {
770
+ const [cents, setCents] = useState<number | null>(4250);
771
+ return (
772
+ <CurrencyInput
773
+ valueCents={cents}
774
+ onValueChange={setCents}
775
+ placeholder="0.00"
776
+ aria-label="Amount"
777
+ />
778
+ );
779
+ }
780
+
781
+ function SplitMethodSelectorDemo() {
782
+ const [value, setValue] = useState<SplitMethod>('equal');
783
+ return <SplitMethodSelector value={value} onChange={setValue} />;
784
+ }
785
+
786
+ const MEMBER_MULTI_SELECT_OPTIONS: MemberMultiSelectOption[] = [
787
+ { id: '1', label: 'Priya' },
788
+ { id: '2', label: 'Jamie (guest)' },
789
+ { id: '3', label: 'Sam' },
790
+ ];
791
+
792
+ function MemberMultiSelectDemo() {
793
+ const [selected, setSelected] = useState<Set<string>>(new Set(['1', '3']));
794
+ return (
795
+ <div style={{ width: '100%' }}>
796
+ <MemberMultiSelect
797
+ options={MEMBER_MULTI_SELECT_OPTIONS}
798
+ selectedIds={selected}
799
+ onToggle={(id, checked) => {
800
+ setSelected((prev) => {
801
+ const next = new Set(prev);
802
+ if (checked) next.add(id);
803
+ else next.delete(id);
804
+ return next;
805
+ });
806
+ }}
807
+ label="Split between"
808
+ />
809
+ </div>
810
+ );
811
+ }
812
+
813
+ function FileDropzoneDemo() {
814
+ const [file, setFile] = useState<File | null>(null);
815
+ return (
816
+ <div style={{ width: '100%' }}>
817
+ <FileDropzone
818
+ ariaLabel="Upload ZIP file"
819
+ accept=".zip,application/zip"
820
+ label={file ? file.name : 'Choose a ZIP file'}
821
+ hint={file ? `${(file.size / 1024).toFixed(0)} KB` : 'or drag and drop here'}
822
+ onFileSelect={setFile}
823
+ />
824
+ </div>
825
+ );
826
+ }
827
+
758
828
  function IconPickerDemo() {
759
829
  const [value, setValue] = useState<IconName | null>('banana');
760
830
  const options: IconName[] = [
@@ -1477,6 +1547,17 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1477
1547
  <StatusBadge status="pending-delete" />
1478
1548
  </ComponentCard>
1479
1549
 
1550
+ {/* BalanceChip */}
1551
+ <ComponentCard
1552
+ name="BalanceChip"
1553
+ importLine="import { BalanceChip } from '@sovereignfs/ui';"
1554
+ usage="Inline net-balance indicator — green when owed to them, red when they owe, neutral when settled. Not tied to expense-splitting specifically."
1555
+ >
1556
+ <BalanceChip amountCents={2500} currency="USD" />
1557
+ <BalanceChip amountCents={-1350} currency="USD" />
1558
+ <BalanceChip amountCents={0} currency="USD" />
1559
+ </ComponentCard>
1560
+
1480
1561
  {/* Input */}
1481
1562
  <ComponentCard
1482
1563
  name="Input"
@@ -1489,6 +1570,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1489
1570
  </div>
1490
1571
  </ComponentCard>
1491
1572
 
1573
+ {/* CurrencyInput */}
1574
+ <ComponentCard
1575
+ name="CurrencyInput"
1576
+ importLine="import { CurrencyInput } from '@sovereignfs/ui';"
1577
+ usage="Decimal amount entry that reports its value as integer cents. Preserves in-progress typing instead of reformatting on every keystroke."
1578
+ >
1579
+ <CurrencyInputDemo />
1580
+ </ComponentCard>
1581
+
1492
1582
  {/* TagInput */}
1493
1583
  <ComponentCard
1494
1584
  name="TagInput"
@@ -1569,6 +1659,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1569
1659
  <SegmentedDemo />
1570
1660
  </ComponentCard>
1571
1661
 
1662
+ {/* SplitMethodSelector */}
1663
+ <ComponentCard
1664
+ name="SplitMethodSelector"
1665
+ importLine="import { SplitMethodSelector } from '@sovereignfs/ui';"
1666
+ usage="The four-way Equal/Amount/Percentage/Shares picker shared by any cost-splitting plugin. A thin SegmentedControl preset."
1667
+ >
1668
+ <SplitMethodSelectorDemo />
1669
+ </ComponentCard>
1670
+
1572
1671
  {/* Tabs */}
1573
1672
  <ComponentCard
1574
1673
  name="Tabs"
@@ -1582,7 +1681,7 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1582
1681
  <ComponentCard
1583
1682
  name="Icon"
1584
1683
  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."
1684
+ usage="SVG icon primitive. 52 bundled icons. Decorative: aria-hidden. Meaningful: aria-label. Three sizes: sm, md, lg."
1586
1685
  >
1587
1686
  {(
1588
1687
  [
@@ -1595,6 +1694,7 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1595
1694
  'search',
1596
1695
  'plus',
1597
1696
  'trash-2',
1697
+ 'external-link',
1598
1698
  ] as const
1599
1699
  ).map((n) => (
1600
1700
  <Icon key={n} name={n} size="md" aria-hidden />
@@ -1905,6 +2005,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1905
2005
  <CheckableListRowDemo />
1906
2006
  </ComponentCard>
1907
2007
 
2008
+ {/* MemberMultiSelect */}
2009
+ <ComponentCard
2010
+ name="MemberMultiSelect"
2011
+ importLine="import { MemberMultiSelect } from '@sovereignfs/ui';"
2012
+ usage="Checkbox list for picking any number of people from an already-resolved option set. Domain-agnostic about guest vs. instance-user options; pair with SuggestionInput for search + add."
2013
+ >
2014
+ <MemberMultiSelectDemo />
2015
+ </ComponentCard>
2016
+
1908
2017
  {/* Checkbox */}
1909
2018
  <ComponentCard
1910
2019
  name="Checkbox"
@@ -1946,6 +2055,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1946
2055
  ))}
1947
2056
  </div>
1948
2057
  </ComponentCard>
2058
+
2059
+ {/* FileDropzone */}
2060
+ <ComponentCard
2061
+ name="FileDropzone"
2062
+ importLine="import { FileDropzone } from '@sovereignfs/ui';"
2063
+ 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."
2064
+ >
2065
+ <FileDropzoneDemo />
2066
+ </ComponentCard>
1949
2067
  </div>
1950
2068
  </section>
1951
2069
 
@@ -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"