@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,84 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { IconPicker } from './IconPicker';
|
|
4
|
+
import type { IconName } from '../Icon/Icon';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/IconPicker',
|
|
8
|
+
component: IconPicker,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
'A trigger button showing the current icon that opens a Popover grid of selectable icons β for a curated, bounded icon set rather than the full design-system library.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
value: null,
|
|
20
|
+
onChange: () => {},
|
|
21
|
+
options: [],
|
|
22
|
+
'aria-label': 'Icon',
|
|
23
|
+
},
|
|
24
|
+
} satisfies Meta<typeof IconPicker>;
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof meta>;
|
|
28
|
+
|
|
29
|
+
const GROCERY_ICONS: IconName[] = [
|
|
30
|
+
'banana',
|
|
31
|
+
'apple',
|
|
32
|
+
'carrot',
|
|
33
|
+
'egg',
|
|
34
|
+
'milk',
|
|
35
|
+
'beef',
|
|
36
|
+
'drumstick',
|
|
37
|
+
'fish',
|
|
38
|
+
'croissant',
|
|
39
|
+
'cookie',
|
|
40
|
+
'pizza',
|
|
41
|
+
'salad',
|
|
42
|
+
'coffee',
|
|
43
|
+
'wine',
|
|
44
|
+
'beer',
|
|
45
|
+
'cup-soda',
|
|
46
|
+
'candy',
|
|
47
|
+
'spray-can',
|
|
48
|
+
'snowflake',
|
|
49
|
+
'shopping-basket',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function ControlledIconPicker({ initial = null }: { initial?: IconName | null }) {
|
|
53
|
+
const [value, setValue] = useState<IconName | null>(initial);
|
|
54
|
+
return (
|
|
55
|
+
<IconPicker
|
|
56
|
+
value={value}
|
|
57
|
+
onChange={setValue}
|
|
58
|
+
options={GROCERY_ICONS}
|
|
59
|
+
aria-label="Item icon"
|
|
60
|
+
triggerLabel={value ?? 'Choose icon'}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const Default: Story = {
|
|
66
|
+
render: () => <ControlledIconPicker />,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const Preselected: Story = {
|
|
70
|
+
render: () => <ControlledIconPicker initial="banana" />,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const Disabled: Story = {
|
|
74
|
+
render: () => (
|
|
75
|
+
<IconPicker
|
|
76
|
+
value="milk"
|
|
77
|
+
onChange={() => {}}
|
|
78
|
+
options={GROCERY_ICONS}
|
|
79
|
+
aria-label="Item icon"
|
|
80
|
+
triggerLabel="Milk"
|
|
81
|
+
disabled
|
|
82
|
+
/>
|
|
83
|
+
),
|
|
84
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Icon, type IconName } from '../Icon/Icon';
|
|
5
|
+
import { Popover } from '../Popover/Popover';
|
|
6
|
+
import styles from './IconPicker.module.css';
|
|
7
|
+
|
|
8
|
+
export interface IconPickerProps {
|
|
9
|
+
value: IconName | null;
|
|
10
|
+
onChange: (icon: IconName) => void;
|
|
11
|
+
/** Icons offered in the grid, in display order. */
|
|
12
|
+
options: readonly IconName[];
|
|
13
|
+
'aria-label': string;
|
|
14
|
+
/** Shown next to the trigger's icon preview, e.g. the item's name. */
|
|
15
|
+
triggerLabel?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* IconPicker β a trigger button showing the current icon that opens a
|
|
22
|
+
* `Popover` grid of selectable icons. Built for cases with a curated,
|
|
23
|
+
* bounded icon set (e.g. Sovereign Shopper's grocery category icons,
|
|
24
|
+
* SHP-05) rather than the full design-system icon library.
|
|
25
|
+
*
|
|
26
|
+
* Selecting an icon both closes the picker and calls `onChange` β a
|
|
27
|
+
* consumer's `onChange` never needs to close the picker itself.
|
|
28
|
+
*/
|
|
29
|
+
export function IconPicker({
|
|
30
|
+
value,
|
|
31
|
+
onChange,
|
|
32
|
+
options,
|
|
33
|
+
'aria-label': ariaLabel,
|
|
34
|
+
triggerLabel,
|
|
35
|
+
disabled = false,
|
|
36
|
+
className,
|
|
37
|
+
}: IconPickerProps) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
|
|
40
|
+
const trigger = (
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
className={[styles.trigger, className].filter(Boolean).join(' ')}
|
|
44
|
+
onClick={() => setOpen((o) => !o)}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
aria-label={ariaLabel}
|
|
47
|
+
aria-haspopup="true"
|
|
48
|
+
aria-expanded={open}
|
|
49
|
+
>
|
|
50
|
+
{value ? (
|
|
51
|
+
<Icon name={value} size="md" aria-hidden />
|
|
52
|
+
) : (
|
|
53
|
+
<span className={styles.placeholder} aria-hidden>
|
|
54
|
+
?
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
{triggerLabel && <span className={styles.triggerLabel}>{triggerLabel}</span>}
|
|
58
|
+
</button>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Popover
|
|
63
|
+
trigger={trigger}
|
|
64
|
+
open={open}
|
|
65
|
+
onClose={() => setOpen(false)}
|
|
66
|
+
width={240}
|
|
67
|
+
aria-label={`${ariaLabel} options`}
|
|
68
|
+
>
|
|
69
|
+
<div className={styles.grid} role="listbox" aria-label={ariaLabel}>
|
|
70
|
+
{options.map((name) => (
|
|
71
|
+
<button
|
|
72
|
+
key={name}
|
|
73
|
+
type="button"
|
|
74
|
+
role="option"
|
|
75
|
+
aria-selected={name === value}
|
|
76
|
+
className={[styles.option, name === value ? styles.optionSelected : '']
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(' ')}
|
|
79
|
+
onClick={() => {
|
|
80
|
+
onChange(name);
|
|
81
|
+
setOpen(false);
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<Icon name={name} size="md" aria-hidden />
|
|
85
|
+
</button>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</Popover>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--sv-space-1);
|
|
5
|
+
padding: var(--sv-space-1);
|
|
6
|
+
border: 1px solid var(--sv-color-border-strong);
|
|
7
|
+
border-radius: var(--sv-radius-full);
|
|
8
|
+
background: var(--sv-color-surface-sunken);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.button {
|
|
12
|
+
all: unset;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
width: var(--sv-space-6);
|
|
17
|
+
height: var(--sv-space-6);
|
|
18
|
+
border-radius: var(--sv-radius-full);
|
|
19
|
+
color: var(--sv-color-text-primary);
|
|
20
|
+
font-size: var(--sv-font-size-md);
|
|
21
|
+
line-height: 1;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.button:hover:not(:disabled) {
|
|
26
|
+
background: var(--sv-color-surface);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.button:disabled {
|
|
30
|
+
color: var(--sv-color-text-subtle);
|
|
31
|
+
cursor: not-allowed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.input {
|
|
35
|
+
all: unset;
|
|
36
|
+
width: 3ch;
|
|
37
|
+
text-align: center;
|
|
38
|
+
font-family: var(--sv-font-family);
|
|
39
|
+
font-size: var(--sv-font-size-sm);
|
|
40
|
+
color: var(--sv-color-text-primary);
|
|
41
|
+
/* Hide the native spinner β the +/- buttons are the stepper affordance,
|
|
42
|
+
a second one from the browser would be redundant and often looks
|
|
43
|
+
inconsistent across browsers anyway. */
|
|
44
|
+
-moz-appearance: textfield;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.input::-webkit-outer-spin-button,
|
|
48
|
+
.input::-webkit-inner-spin-button {
|
|
49
|
+
-webkit-appearance: none;
|
|
50
|
+
margin: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.input:focus-visible {
|
|
54
|
+
outline: none;
|
|
55
|
+
box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
|
|
56
|
+
border-radius: var(--sv-radius-sm);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.input:disabled {
|
|
60
|
+
color: var(--sv-color-text-muted);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.unit {
|
|
64
|
+
font-size: var(--sv-font-size-xs);
|
|
65
|
+
color: var(--sv-color-text-subtle);
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { QuantityStepper } from './QuantityStepper';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/QuantityStepper',
|
|
7
|
+
component: QuantityStepper,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'A numeric input with +/- buttons and an optional read-only unit suffix. Supports fractional step values for quantities like "1.5 kg".',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
value: 1,
|
|
19
|
+
onChange: () => {},
|
|
20
|
+
'aria-label': 'Quantity',
|
|
21
|
+
},
|
|
22
|
+
} satisfies Meta<typeof QuantityStepper>;
|
|
23
|
+
|
|
24
|
+
export default meta;
|
|
25
|
+
type Story = StoryObj<typeof meta>;
|
|
26
|
+
|
|
27
|
+
function ControlledStepper({
|
|
28
|
+
initial = 1,
|
|
29
|
+
step = 1,
|
|
30
|
+
min = 0,
|
|
31
|
+
max,
|
|
32
|
+
unit,
|
|
33
|
+
}: {
|
|
34
|
+
initial?: number;
|
|
35
|
+
step?: number;
|
|
36
|
+
min?: number;
|
|
37
|
+
max?: number;
|
|
38
|
+
unit?: string;
|
|
39
|
+
}) {
|
|
40
|
+
const [value, setValue] = useState(initial);
|
|
41
|
+
return (
|
|
42
|
+
<QuantityStepper
|
|
43
|
+
value={value}
|
|
44
|
+
onChange={setValue}
|
|
45
|
+
step={step}
|
|
46
|
+
min={min}
|
|
47
|
+
max={max}
|
|
48
|
+
unit={unit}
|
|
49
|
+
aria-label="Quantity"
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Default: Story = {
|
|
55
|
+
render: () => <ControlledStepper initial={1} />,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const WithUnit: Story = {
|
|
59
|
+
render: () => <ControlledStepper initial={6} unit="pcs" />,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const FractionalStep: Story = {
|
|
63
|
+
name: 'Fractional step (0.5 kg)',
|
|
64
|
+
render: () => <ControlledStepper initial={1.5} step={0.5} unit="kg" />,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const AtMinimum: Story = {
|
|
68
|
+
render: () => <ControlledStepper initial={0} min={0} unit="pcs" />,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const WithMax: Story = {
|
|
72
|
+
render: () => <ControlledStepper initial={9} max={10} unit="pcs" />,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const Disabled: Story = {
|
|
76
|
+
render: () => (
|
|
77
|
+
<QuantityStepper value={3} onChange={() => {}} unit="pcs" aria-label="Quantity" disabled />
|
|
78
|
+
),
|
|
79
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useId, type ChangeEvent } from 'react';
|
|
4
|
+
import styles from './QuantityStepper.module.css';
|
|
5
|
+
|
|
6
|
+
export interface QuantityStepperProps {
|
|
7
|
+
value: number;
|
|
8
|
+
onChange: (value: number) => void;
|
|
9
|
+
/** Smallest selectable value. Default `0`. */
|
|
10
|
+
min?: number;
|
|
11
|
+
/** Largest selectable value. Default unbounded. */
|
|
12
|
+
max?: number;
|
|
13
|
+
/** Amount +/- adjusts by. Default `1` β pass e.g. `0.5` for fractional
|
|
14
|
+
* quantities (kg, L). */
|
|
15
|
+
step?: number;
|
|
16
|
+
/** Read-only suffix shown after the number, e.g. "pcs" or "kg" β for
|
|
17
|
+
* compact contexts (a list row) where a separate unit selector doesn't
|
|
18
|
+
* fit. Editable unit selection (a `<Select>` of units) lives outside
|
|
19
|
+
* this component. */
|
|
20
|
+
unit?: string;
|
|
21
|
+
'aria-label': string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
id?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* QuantityStepper β a numeric input with +/- buttons and an optional
|
|
29
|
+
* read-only unit suffix. Built for grocery/inventory-style quantities
|
|
30
|
+
* (Sovereign Shopper, SHP-06) β supports fractional `step` values since
|
|
31
|
+
* quantities like "1.5 kg" are common, unlike a typical integer counter.
|
|
32
|
+
*
|
|
33
|
+
* The middle field is a real `<input type="number">` so keyboard entry,
|
|
34
|
+
* paste, and the browser's native numeric affordances all work β the
|
|
35
|
+
* buttons are a convenience on top, not the only way to change the value.
|
|
36
|
+
*/
|
|
37
|
+
export function QuantityStepper({
|
|
38
|
+
value,
|
|
39
|
+
onChange,
|
|
40
|
+
min = 0,
|
|
41
|
+
max,
|
|
42
|
+
step = 1,
|
|
43
|
+
unit,
|
|
44
|
+
'aria-label': ariaLabel,
|
|
45
|
+
disabled = false,
|
|
46
|
+
id,
|
|
47
|
+
className,
|
|
48
|
+
}: QuantityStepperProps) {
|
|
49
|
+
const generatedId = useId();
|
|
50
|
+
const inputId = id ?? generatedId;
|
|
51
|
+
|
|
52
|
+
function clamp(next: number): number {
|
|
53
|
+
let result = next;
|
|
54
|
+
if (min !== undefined) result = Math.max(min, result);
|
|
55
|
+
if (max !== undefined) result = Math.min(max, result);
|
|
56
|
+
// Avoid float drift (0.1 + 0.2 style errors) from repeated +/- clicks.
|
|
57
|
+
return Math.round(result * 1000) / 1000;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
|
|
61
|
+
const parsed = Number.parseFloat(e.target.value);
|
|
62
|
+
if (!Number.isNaN(parsed)) onChange(clamp(parsed));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const decrementDisabled = disabled || (min !== undefined && value <= min);
|
|
66
|
+
const incrementDisabled = disabled || (max !== undefined && value >= max);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
className={styles.button}
|
|
73
|
+
onClick={() => onChange(clamp(value - step))}
|
|
74
|
+
disabled={decrementDisabled}
|
|
75
|
+
aria-label={`Decrease ${ariaLabel}`}
|
|
76
|
+
>
|
|
77
|
+
β
|
|
78
|
+
</button>
|
|
79
|
+
<input
|
|
80
|
+
id={inputId}
|
|
81
|
+
type="number"
|
|
82
|
+
className={styles.input}
|
|
83
|
+
value={value}
|
|
84
|
+
onChange={handleInputChange}
|
|
85
|
+
min={min}
|
|
86
|
+
max={max}
|
|
87
|
+
step={step}
|
|
88
|
+
disabled={disabled}
|
|
89
|
+
aria-label={ariaLabel}
|
|
90
|
+
/>
|
|
91
|
+
{unit && (
|
|
92
|
+
<span className={styles.unit} aria-hidden>
|
|
93
|
+
{unit}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className={styles.button}
|
|
99
|
+
onClick={() => onChange(clamp(value + step))}
|
|
100
|
+
disabled={incrementDisabled}
|
|
101
|
+
aria-label={`Increase ${ariaLabel}`}
|
|
102
|
+
>
|
|
103
|
+
+
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
.input {
|
|
2
|
+
display: block;
|
|
3
|
+
width: 100%;
|
|
4
|
+
padding: var(--sv-space-2) var(--sv-space-3);
|
|
5
|
+
font-family: var(--sv-font-family);
|
|
6
|
+
font-size: var(--sv-font-size-sm);
|
|
7
|
+
line-height: 1.5;
|
|
8
|
+
color: var(--sv-color-text-primary);
|
|
9
|
+
background-color: var(--sv-color-surface);
|
|
10
|
+
border: 1px solid var(--sv-color-border-strong);
|
|
11
|
+
border-radius: var(--sv-radius-md);
|
|
12
|
+
transition:
|
|
13
|
+
border-color 0.15s ease,
|
|
14
|
+
box-shadow 0.15s ease;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* iOS Safari auto-zooms on focused inputs under 16px β see Input.module.css
|
|
18
|
+
for the full rationale; kept in sync here since this isn't built on Input. */
|
|
19
|
+
@media (pointer: coarse) {
|
|
20
|
+
.input {
|
|
21
|
+
font-size: var(--sv-font-size-md);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.input::placeholder {
|
|
26
|
+
color: var(--sv-color-text-muted);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.input:focus-visible {
|
|
30
|
+
outline: none;
|
|
31
|
+
border-color: var(--sv-color-accent);
|
|
32
|
+
box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.input:disabled {
|
|
36
|
+
opacity: 0.5;
|
|
37
|
+
cursor: not-allowed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.list {
|
|
41
|
+
list-style: none;
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: var(--sv-space-1);
|
|
44
|
+
max-height: 320px;
|
|
45
|
+
overflow-y: auto;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.status {
|
|
49
|
+
padding: var(--sv-space-2) var(--sv-space-3);
|
|
50
|
+
font-size: var(--sv-font-size-sm);
|
|
51
|
+
color: var(--sv-color-text-muted);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.option {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: var(--sv-space-2);
|
|
58
|
+
width: 100%;
|
|
59
|
+
padding: var(--sv-space-2) var(--sv-space-3);
|
|
60
|
+
border: none;
|
|
61
|
+
border-radius: var(--sv-radius-sm);
|
|
62
|
+
background: transparent;
|
|
63
|
+
font-family: var(--sv-font-family);
|
|
64
|
+
font-size: var(--sv-font-size-sm);
|
|
65
|
+
color: var(--sv-color-text-primary);
|
|
66
|
+
text-align: left;
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.option:hover,
|
|
71
|
+
.optionActive {
|
|
72
|
+
background: var(--sv-color-surface-sunken);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.optionIcon {
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: center;
|
|
78
|
+
flex-shrink: 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.optionLabel {
|
|
82
|
+
flex: 1;
|
|
83
|
+
min-width: 0;
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
text-overflow: ellipsis;
|
|
86
|
+
white-space: nowrap;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.optionMeta {
|
|
90
|
+
flex-shrink: 0;
|
|
91
|
+
font-size: var(--sv-font-size-xs);
|
|
92
|
+
color: var(--sv-color-text-subtle);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.optionCreate {
|
|
96
|
+
font-style: italic;
|
|
97
|
+
color: var(--sv-color-text-muted);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.divider {
|
|
101
|
+
margin: var(--sv-space-1) var(--sv-space-2);
|
|
102
|
+
border-top: 1px solid var(--sv-color-border);
|
|
103
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { SuggestionInput, type SuggestionOption } from './SuggestionInput';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/SuggestionInput',
|
|
7
|
+
component: SuggestionInput,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'A text field with an anchored, keyboard-navigable list of async suggestions, plus an optional trailing "create new" row so free text is always a valid commit.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
value: '',
|
|
19
|
+
onChange: () => {},
|
|
20
|
+
options: [],
|
|
21
|
+
onSelect: () => {},
|
|
22
|
+
'aria-label': 'Add an item',
|
|
23
|
+
},
|
|
24
|
+
} satisfies Meta<typeof SuggestionInput>;
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof meta>;
|
|
28
|
+
|
|
29
|
+
const CATALOG: SuggestionOption[] = [
|
|
30
|
+
{ id: '1', label: 'Bananas', icon: 'π', meta: 'bought 4Γ recently' },
|
|
31
|
+
{ id: '2', label: 'Banana bread mix', icon: 'π' },
|
|
32
|
+
{ id: '3', label: 'Milk', icon: 'π₯' },
|
|
33
|
+
{ id: '4', label: 'Eggs', icon: 'π₯' },
|
|
34
|
+
{ id: '5', label: 'Olive oil', icon: 'π«' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function ControlledSuggestionInput({ withCreateRow = true }: { withCreateRow?: boolean }) {
|
|
38
|
+
const [value, setValue] = useState('');
|
|
39
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
40
|
+
const options = useMemo(
|
|
41
|
+
() =>
|
|
42
|
+
value.trim()
|
|
43
|
+
? CATALOG.filter((c) => c.label.toLowerCase().includes(value.trim().toLowerCase()))
|
|
44
|
+
: [],
|
|
45
|
+
[value],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ width: 320 }}>
|
|
50
|
+
<SuggestionInput
|
|
51
|
+
value={value}
|
|
52
|
+
onChange={setValue}
|
|
53
|
+
options={options}
|
|
54
|
+
onSelect={(option) => {
|
|
55
|
+
setSelected(option.label);
|
|
56
|
+
setValue('');
|
|
57
|
+
}}
|
|
58
|
+
placeholder="Add an itemβ¦"
|
|
59
|
+
aria-label="Add an item"
|
|
60
|
+
createLabel={withCreateRow ? (v) => `Add "${v}" as a new item` : undefined}
|
|
61
|
+
onCreate={
|
|
62
|
+
withCreateRow
|
|
63
|
+
? (v) => {
|
|
64
|
+
setSelected(v);
|
|
65
|
+
setValue('');
|
|
66
|
+
}
|
|
67
|
+
: undefined
|
|
68
|
+
}
|
|
69
|
+
/>
|
|
70
|
+
{selected && <p style={{ marginTop: 12, fontSize: 13 }}>Added: {selected}</p>}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const Default: Story = {
|
|
76
|
+
render: () => <ControlledSuggestionInput />,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const NoCreateRow: Story = {
|
|
80
|
+
name: 'Without create row (matched options only)',
|
|
81
|
+
render: () => <ControlledSuggestionInput withCreateRow={false} />,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const OpenWithOptions: Story = {
|
|
85
|
+
render: () => (
|
|
86
|
+
<div style={{ width: 320 }}>
|
|
87
|
+
<SuggestionInput
|
|
88
|
+
value="ban"
|
|
89
|
+
onChange={() => {}}
|
|
90
|
+
options={CATALOG.slice(0, 2)}
|
|
91
|
+
onSelect={() => {}}
|
|
92
|
+
aria-label="Add an item"
|
|
93
|
+
createLabel={(v) => `Add "${v}" as a new item`}
|
|
94
|
+
onCreate={() => {}}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const Loading: Story = {
|
|
101
|
+
render: () => (
|
|
102
|
+
<div style={{ width: 320 }}>
|
|
103
|
+
<SuggestionInput
|
|
104
|
+
value="ban"
|
|
105
|
+
onChange={() => {}}
|
|
106
|
+
options={[]}
|
|
107
|
+
onSelect={() => {}}
|
|
108
|
+
loading
|
|
109
|
+
aria-label="Add an item"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const Empty: Story = {
|
|
116
|
+
name: 'Empty (no matches, no create row)',
|
|
117
|
+
render: () => (
|
|
118
|
+
<div style={{ width: 320 }}>
|
|
119
|
+
<SuggestionInput
|
|
120
|
+
value="xyz"
|
|
121
|
+
onChange={() => {}}
|
|
122
|
+
options={[]}
|
|
123
|
+
onSelect={() => {}}
|
|
124
|
+
aria-label="Add an item"
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const Disabled: Story = {
|
|
131
|
+
render: () => (
|
|
132
|
+
<div style={{ width: 320 }}>
|
|
133
|
+
<SuggestionInput
|
|
134
|
+
value=""
|
|
135
|
+
onChange={() => {}}
|
|
136
|
+
options={[]}
|
|
137
|
+
onSelect={() => {}}
|
|
138
|
+
disabled
|
|
139
|
+
placeholder="Add an itemβ¦"
|
|
140
|
+
aria-label="Add an item"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
),
|
|
144
|
+
};
|