@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.
- package/dist/BalanceChip.module.css +32 -0
- package/dist/FileDropzone.module.css +63 -0
- package/dist/MemberMultiSelect.module.css +27 -0
- package/dist/index.d.ts +151 -3
- package/dist/index.js +630 -370
- package/package.json +1 -1
- package/src/components/BalanceChip/BalanceChip.module.css +32 -0
- package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
- package/src/components/BalanceChip/BalanceChip.tsx +33 -0
- package/src/components/CurrencyInput/CurrencyInput.stories.tsx +50 -0
- package/src/components/CurrencyInput/CurrencyInput.tsx +60 -0
- package/src/components/FileDropzone/FileDropzone.module.css +63 -0
- package/src/components/FileDropzone/FileDropzone.tsx +109 -0
- package/src/components/FileDropzone/__tests__/FileDropzone.test.tsx +67 -0
- package/src/components/Icon/icons/external-link.tsx +22 -0
- package/src/components/Icon/icons/file.tsx +21 -0
- package/src/components/Icon/icons/index.ts +12 -0
- package/src/components/Icon/icons/upload.tsx +22 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -0
- package/src/components/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
- package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useCommitOnEnterOrBlur.ts +39 -0
- package/src/index.ts +24 -2
- package/src/stories/DesignSystemOverview.stories.tsx +119 -1
- package/src/stories/FileDropzone.stories.tsx +69 -0
- 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
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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 {
|
|
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"
|