@sovereignfs/ui 0.33.1 → 0.37.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/IconPicker.module.css +63 -0
- package/dist/QuantityStepper.module.css +67 -0
- package/dist/SuggestionInput.module.css +103 -0
- package/dist/index.d.ts +128 -2
- package/dist/index.js +1007 -169
- 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/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/fish.tsx +25 -0
- package/src/components/Icon/icons/index.ts +81 -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/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/index.ts +11 -0
- package/src/stories/DesignSystemOverview.stories.tsx +107 -0
|
@@ -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
|
+
};
|
|
@@ -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/index.ts
CHANGED
|
@@ -75,6 +75,17 @@ export { Calendar } from './components/Calendar/Calendar';
|
|
|
75
75
|
export type { CalendarProps } from './components/Calendar/Calendar';
|
|
76
76
|
export { DatePicker } from './components/DatePicker/DatePicker';
|
|
77
77
|
export type { DatePickerProps } from './components/DatePicker/DatePicker';
|
|
78
|
+
export { SuggestionInput } from './components/SuggestionInput/SuggestionInput';
|
|
79
|
+
export type {
|
|
80
|
+
SuggestionInputProps,
|
|
81
|
+
SuggestionOption,
|
|
82
|
+
} from './components/SuggestionInput/SuggestionInput';
|
|
83
|
+
export { IconPicker } from './components/IconPicker/IconPicker';
|
|
84
|
+
export type { IconPickerProps } from './components/IconPicker/IconPicker';
|
|
85
|
+
export { QuantityStepper } from './components/QuantityStepper/QuantityStepper';
|
|
86
|
+
export type { QuantityStepperProps } from './components/QuantityStepper/QuantityStepper';
|
|
87
|
+
export { CheckableListRow } from './components/CheckableListRow/CheckableListRow';
|
|
88
|
+
export type { CheckableListRowProps } from './components/CheckableListRow/CheckableListRow';
|
|
78
89
|
export { useLongPress, useDoubleTapHandler, useSingleOrDoubleTap, useIsMobile } from './hooks';
|
|
79
90
|
export { MOBILE_BREAKPOINT_PX } from './hooks';
|
|
80
91
|
export type { UseLongPressOptions, LongPressHandlers } from './hooks';
|