@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.
- package/README.md +1 -1
- package/dist/CheckableListRow.module.css +95 -0
- package/dist/FileDropzone.module.css +63 -0
- package/dist/IconPicker.module.css +63 -0
- package/dist/QuantityStepper.module.css +67 -0
- package/dist/SuggestionInput.module.css +103 -0
- package/dist/index.d.ts +196 -3
- package/dist/index.js +1175 -185
- package/package.json +1 -1
- package/src/components/CheckableListRow/CheckableListRow.module.css +95 -0
- package/src/components/CheckableListRow/CheckableListRow.stories.tsx +99 -0
- package/src/components/CheckableListRow/CheckableListRow.tsx +89 -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/apple.tsx +21 -0
- package/src/components/Icon/icons/banana.tsx +21 -0
- package/src/components/Icon/icons/beef.tsx +22 -0
- package/src/components/Icon/icons/beer.tsx +24 -0
- package/src/components/Icon/icons/candy.tsx +24 -0
- package/src/components/Icon/icons/carrot.tsx +22 -0
- package/src/components/Icon/icons/coffee.tsx +23 -0
- package/src/components/Icon/icons/cookie.tsx +25 -0
- package/src/components/Icon/icons/croissant.tsx +24 -0
- package/src/components/Icon/icons/cup-soda.tsx +23 -0
- package/src/components/Icon/icons/drumstick.tsx +21 -0
- package/src/components/Icon/icons/egg.tsx +20 -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/fish.tsx +25 -0
- package/src/components/Icon/icons/index.ts +93 -1
- package/src/components/Icon/icons/milk.tsx +22 -0
- package/src/components/Icon/icons/pizza.tsx +24 -0
- package/src/components/Icon/icons/salad.tsx +24 -0
- package/src/components/Icon/icons/shopping-basket.tsx +26 -0
- package/src/components/Icon/icons/snowflake.tsx +31 -0
- package/src/components/Icon/icons/spray-can.tsx +29 -0
- package/src/components/Icon/icons/upload.tsx +22 -0
- package/src/components/Icon/icons/wine.tsx +23 -0
- package/src/components/IconPicker/IconPicker.module.css +63 -0
- package/src/components/IconPicker/IconPicker.stories.tsx +84 -0
- package/src/components/IconPicker/IconPicker.tsx +90 -0
- package/src/components/QuantityStepper/QuantityStepper.module.css +67 -0
- package/src/components/QuantityStepper/QuantityStepper.stories.tsx +79 -0
- package/src/components/QuantityStepper/QuantityStepper.tsx +107 -0
- package/src/components/SuggestionInput/SuggestionInput.module.css +103 -0
- package/src/components/SuggestionInput/SuggestionInput.stories.tsx +144 -0
- package/src/components/SuggestionInput/SuggestionInput.tsx +207 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useCommitOnEnterOrBlur.ts +39 -0
- package/src/index.ts +21 -2
- package/src/stories/DesignSystemOverview.stories.tsx +134 -1
- package/src/stories/FileDropzone.stories.tsx +69 -0
- 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
|
+
}
|
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,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 {
|
|
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.
|
|
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 {
|
|
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"
|