@sovereignfs/ui 0.41.0 → 0.45.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/Accordion.module.css +3 -0
- package/dist/Alert.module.css +62 -0
- package/dist/AspectRatio.module.css +15 -0
- package/dist/Badge.module.css +75 -9
- package/dist/Breadcrumb.module.css +49 -0
- package/dist/Button.module.css +12 -3
- package/dist/ButtonGroup.module.css +29 -0
- package/dist/Card.module.css +6 -0
- package/dist/Checkbox.module.css +3 -1
- package/dist/Collapsible.module.css +70 -0
- package/dist/Combobox.module.css +122 -0
- package/dist/Command.module.css +80 -0
- package/dist/ConfirmDialog.module.css +5 -1
- package/dist/DataTable.module.css +28 -0
- package/dist/DatePicker.module.css +6 -2
- package/dist/Dialog.module.css +3 -0
- package/dist/HoverCard.module.css +3 -0
- package/dist/Input.module.css +6 -1
- package/dist/Item.module.css +70 -0
- package/dist/Kbd.module.css +14 -0
- package/dist/Label.module.css +11 -0
- package/dist/Marker.module.css +33 -0
- package/dist/Menu.module.css +8 -1
- package/dist/Message.module.css +99 -0
- package/dist/MessageScroller.module.css +54 -0
- package/dist/NavigationMenu.module.css +53 -0
- package/dist/Pagination.module.css +98 -0
- package/dist/Progress.module.css +20 -0
- package/dist/RadioGroup.module.css +88 -0
- package/dist/Resizable.module.css +70 -0
- package/dist/ScrollArea.module.css +20 -0
- package/dist/Select.module.css +7 -3
- package/dist/Slider.module.css +95 -0
- package/dist/Table.module.css +45 -0
- package/dist/Tabs.module.css +6 -1
- package/dist/Toggle.module.css +6 -1
- package/dist/Typography.module.css +47 -0
- package/dist/index.d.ts +617 -5
- package/dist/index.js +1847 -499
- package/dist/tokens/primitives.css +22 -8
- package/package.json +1 -1
- package/src/components/Accordion/Accordion.module.css +3 -0
- package/src/components/Accordion/Accordion.stories.tsx +110 -0
- package/src/components/Accordion/Accordion.tsx +53 -0
- package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
- package/src/components/Alert/Alert.module.css +62 -0
- package/src/components/Alert/Alert.stories.tsx +96 -0
- package/src/components/Alert/Alert.tsx +58 -0
- package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
- package/src/components/AspectRatio/AspectRatio.module.css +15 -0
- package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
- package/src/components/AspectRatio/AspectRatio.tsx +21 -0
- package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
- package/src/components/Badge/Badge.module.css +75 -9
- package/src/components/Badge/Badge.stories.tsx +109 -1
- package/src/components/Badge/Badge.tsx +30 -2
- package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
- package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
- package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
- package/src/components/Button/Button.module.css +12 -3
- package/src/components/Button/Button.stories.tsx +3 -2
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Button/__tests__/Button.test.tsx +10 -0
- package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
- package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
- package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
- package/src/components/Card/Card.module.css +6 -0
- package/src/components/Checkbox/Checkbox.module.css +3 -1
- package/src/components/Collapsible/Collapsible.module.css +70 -0
- package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
- package/src/components/Collapsible/Collapsible.tsx +66 -0
- package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
- package/src/components/Combobox/Combobox.module.css +122 -0
- package/src/components/Combobox/Combobox.stories.tsx +79 -0
- package/src/components/Combobox/Combobox.tsx +186 -0
- package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
- package/src/components/Command/Command.module.css +80 -0
- package/src/components/Command/Command.stories.tsx +104 -0
- package/src/components/Command/Command.tsx +168 -0
- package/src/components/Command/__tests__/Command.test.tsx +162 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
- package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
- package/src/components/ContextMenu/ContextMenu.tsx +100 -0
- package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
- package/src/components/DataTable/DataTable.module.css +28 -0
- package/src/components/DataTable/DataTable.stories.tsx +74 -0
- package/src/components/DataTable/DataTable.tsx +154 -0
- package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
- package/src/components/DatePicker/DatePicker.module.css +6 -2
- package/src/components/Dialog/Dialog.module.css +3 -0
- package/src/components/HoverCard/HoverCard.module.css +3 -0
- package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
- package/src/components/HoverCard/HoverCard.tsx +111 -0
- package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
- package/src/components/Icon/icons/circle-check.tsx +21 -0
- package/src/components/Icon/icons/circle-x.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -0
- package/src/components/Input/Input.module.css +6 -1
- package/src/components/Item/Item.module.css +70 -0
- package/src/components/Item/Item.stories.tsx +62 -0
- package/src/components/Item/Item.tsx +54 -0
- package/src/components/Item/__tests__/Item.test.tsx +39 -0
- package/src/components/Kbd/Kbd.module.css +14 -0
- package/src/components/Kbd/Kbd.stories.tsx +32 -0
- package/src/components/Kbd/Kbd.tsx +12 -0
- package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
- package/src/components/Label/Label.module.css +11 -0
- package/src/components/Label/Label.stories.tsx +40 -0
- package/src/components/Label/Label.tsx +24 -0
- package/src/components/Label/__tests__/Label.test.tsx +32 -0
- package/src/components/Marker/Marker.module.css +33 -0
- package/src/components/Marker/Marker.stories.tsx +39 -0
- package/src/components/Marker/Marker.tsx +34 -0
- package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
- package/src/components/Menu/Menu.module.css +8 -1
- package/src/components/Menu/Menu.tsx +30 -8
- package/src/components/Menubar/Menubar.stories.tsx +52 -0
- package/src/components/Menubar/Menubar.tsx +42 -0
- package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
- package/src/components/Message/Message.module.css +99 -0
- package/src/components/Message/Message.stories.tsx +79 -0
- package/src/components/Message/Message.tsx +41 -0
- package/src/components/Message/__tests__/Message.test.tsx +46 -0
- package/src/components/MessageScroller/MessageScroller.module.css +54 -0
- package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
- package/src/components/MessageScroller/MessageScroller.tsx +72 -0
- package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
- package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
- package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
- package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
- package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
- package/src/components/Pagination/Pagination.module.css +98 -0
- package/src/components/Pagination/Pagination.stories.tsx +49 -0
- package/src/components/Pagination/Pagination.tsx +87 -0
- package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
- package/src/components/Progress/Progress.module.css +20 -0
- package/src/components/Progress/Progress.stories.tsx +40 -0
- package/src/components/Progress/Progress.tsx +35 -0
- package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
- package/src/components/RadioGroup/RadioGroup.module.css +88 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
- package/src/components/RadioGroup/RadioGroup.tsx +99 -0
- package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
- package/src/components/Resizable/Resizable.module.css +70 -0
- package/src/components/Resizable/Resizable.stories.tsx +82 -0
- package/src/components/Resizable/Resizable.tsx +313 -0
- package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
- package/src/components/ScrollArea/ScrollArea.module.css +20 -0
- package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
- package/src/components/ScrollArea/ScrollArea.tsx +27 -0
- package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
- package/src/components/Select/Select.module.css +7 -3
- package/src/components/Slider/Slider.module.css +95 -0
- package/src/components/Slider/Slider.stories.tsx +64 -0
- package/src/components/Slider/Slider.tsx +77 -0
- package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
- package/src/components/Table/Table.module.css +45 -0
- package/src/components/Table/Table.stories.tsx +125 -0
- package/src/components/Table/Table.tsx +51 -0
- package/src/components/Table/__tests__/Table.test.tsx +50 -0
- package/src/components/Tabs/Tabs.module.css +6 -1
- package/src/components/Toggle/Toggle.module.css +6 -1
- package/src/components/Typography/Typography.module.css +47 -0
- package/src/components/Typography/Typography.stories.tsx +62 -0
- package/src/components/Typography/Typography.tsx +30 -0
- package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
- package/src/index.ts +78 -1
- package/src/stories/DesignSystemOverview.stories.tsx +614 -2
- package/src/stories/MobilePatterns.stories.tsx +8 -2
- package/src/stories/TokenGallery.stories.tsx +112 -16
- package/src/tokens/primitives.css +22 -8
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
5
|
+
import { useIsMobile } from '../../hooks';
|
|
6
|
+
import { Drawer } from '../Drawer/Drawer';
|
|
7
|
+
import { Icon } from '../Icon/Icon';
|
|
8
|
+
import { Popover } from '../Popover/Popover';
|
|
9
|
+
import styles from './Combobox.module.css';
|
|
10
|
+
|
|
11
|
+
export interface ComboboxOption {
|
|
12
|
+
value: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ComboboxProps {
|
|
17
|
+
options: ComboboxOption[];
|
|
18
|
+
value: string | null;
|
|
19
|
+
onChange: (value: string) => void;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
searchPlaceholder?: string;
|
|
22
|
+
emptyMessage?: string;
|
|
23
|
+
'aria-label': string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function matches(option: ComboboxOption, query: string): boolean {
|
|
29
|
+
if (!query) return true;
|
|
30
|
+
return option.label.toLowerCase().includes(query.toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Combobox — a searchable single-select: `Popover` on desktop, a bottom-sheet
|
|
35
|
+
* `Drawer` on mobile (the platform's standard adaptive-surface pattern,
|
|
36
|
+
* matching `Menu` and `DatePicker`). For a fixed, short option list where
|
|
37
|
+
* search adds no value, use `Select`'s native `<select>` instead — this is
|
|
38
|
+
* for option lists long enough that typing to filter beats scanning.
|
|
39
|
+
*
|
|
40
|
+
* The trigger is built in, like `DatePicker` — a combobox is a form field,
|
|
41
|
+
* not an arbitrary action-menu trigger.
|
|
42
|
+
*/
|
|
43
|
+
export function Combobox({
|
|
44
|
+
options,
|
|
45
|
+
value,
|
|
46
|
+
onChange,
|
|
47
|
+
placeholder = 'Select…',
|
|
48
|
+
searchPlaceholder = 'Search…',
|
|
49
|
+
emptyMessage = 'No matches.',
|
|
50
|
+
'aria-label': ariaLabel,
|
|
51
|
+
disabled = false,
|
|
52
|
+
className,
|
|
53
|
+
}: ComboboxProps) {
|
|
54
|
+
const [open, setOpen] = useState(false);
|
|
55
|
+
const [query, setQuery] = useState('');
|
|
56
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
57
|
+
const isMobile = useIsMobile();
|
|
58
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
59
|
+
const listId = useId();
|
|
60
|
+
|
|
61
|
+
const selected = options.find((o) => o.value === value) ?? null;
|
|
62
|
+
const filtered = useMemo(
|
|
63
|
+
() => options.filter((option) => matches(option, query)),
|
|
64
|
+
[options, query],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
setHighlightedIndex(0);
|
|
69
|
+
}, [query, open]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (open) {
|
|
73
|
+
setQuery('');
|
|
74
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
75
|
+
}
|
|
76
|
+
}, [open]);
|
|
77
|
+
|
|
78
|
+
function commit(option: ComboboxOption) {
|
|
79
|
+
onChange(option.value);
|
|
80
|
+
setOpen(false);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
|
|
84
|
+
if (e.key === 'ArrowDown') {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
setHighlightedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
|
87
|
+
} else if (e.key === 'ArrowUp') {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setHighlightedIndex((i) => Math.max(i - 1, 0));
|
|
90
|
+
} else if (e.key === 'Enter') {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
const option = filtered[highlightedIndex];
|
|
93
|
+
if (option) commit(option);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const highlightedId = filtered[highlightedIndex]
|
|
98
|
+
? `${listId}-${filtered[highlightedIndex].value}`
|
|
99
|
+
: undefined;
|
|
100
|
+
|
|
101
|
+
const trigger = (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className={[styles.trigger, className].filter(Boolean).join(' ')}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
aria-label={ariaLabel}
|
|
107
|
+
aria-haspopup="listbox"
|
|
108
|
+
aria-expanded={open}
|
|
109
|
+
onClick={() => setOpen((o) => !o)}
|
|
110
|
+
>
|
|
111
|
+
<span className={selected ? styles.triggerValue : styles.triggerPlaceholder}>
|
|
112
|
+
{selected ? selected.label : placeholder}
|
|
113
|
+
</span>
|
|
114
|
+
<Icon name="chevron-down" size="sm" aria-hidden />
|
|
115
|
+
</button>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const body = (
|
|
119
|
+
<div className={styles.body}>
|
|
120
|
+
<input
|
|
121
|
+
ref={inputRef}
|
|
122
|
+
type="text"
|
|
123
|
+
role="combobox"
|
|
124
|
+
aria-expanded={filtered.length > 0}
|
|
125
|
+
aria-controls={listId}
|
|
126
|
+
aria-activedescendant={highlightedId}
|
|
127
|
+
aria-autocomplete="list"
|
|
128
|
+
className={styles.search}
|
|
129
|
+
placeholder={searchPlaceholder}
|
|
130
|
+
value={query}
|
|
131
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
132
|
+
onKeyDown={handleKeyDown}
|
|
133
|
+
/>
|
|
134
|
+
<ul id={listId} role="listbox" className={styles.list}>
|
|
135
|
+
{filtered.length === 0 && <li className={styles.empty}>{emptyMessage}</li>}
|
|
136
|
+
{filtered.map((option, index) => {
|
|
137
|
+
const isHighlighted = index === highlightedIndex;
|
|
138
|
+
const isSelected = option.value === value;
|
|
139
|
+
return (
|
|
140
|
+
<li key={option.value}>
|
|
141
|
+
<button
|
|
142
|
+
id={`${listId}-${option.value}`}
|
|
143
|
+
type="button"
|
|
144
|
+
role="option"
|
|
145
|
+
aria-selected={isSelected}
|
|
146
|
+
className={[styles.option, isHighlighted ? styles.optionHighlighted : '']
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join(' ')}
|
|
149
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
150
|
+
onClick={() => commit(option)}
|
|
151
|
+
>
|
|
152
|
+
<span className={styles.check}>
|
|
153
|
+
{isSelected && <Icon name="check" size="sm" aria-hidden />}
|
|
154
|
+
</span>
|
|
155
|
+
{option.label}
|
|
156
|
+
</button>
|
|
157
|
+
</li>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</ul>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (isMobile) {
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
{trigger}
|
|
168
|
+
<Drawer open={open} onClose={() => setOpen(false)} aria-label={ariaLabel}>
|
|
169
|
+
{body}
|
|
170
|
+
</Drawer>
|
|
171
|
+
</>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Popover
|
|
177
|
+
trigger={trigger}
|
|
178
|
+
open={open}
|
|
179
|
+
onClose={() => setOpen(false)}
|
|
180
|
+
aria-label={ariaLabel}
|
|
181
|
+
width="trigger"
|
|
182
|
+
>
|
|
183
|
+
{body}
|
|
184
|
+
</Popover>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Combobox, type ComboboxOption } from '../Combobox';
|
|
5
|
+
|
|
6
|
+
let mobile = false;
|
|
7
|
+
function installMatchMedia() {
|
|
8
|
+
vi.stubGlobal(
|
|
9
|
+
'matchMedia',
|
|
10
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
11
|
+
// useIsMobile's query is '(max-width: ...)'; Drawer's own
|
|
12
|
+
// prefers-reduced-motion check defaults to false — see DatePicker's
|
|
13
|
+
// identical test setup for why.
|
|
14
|
+
matches: query.includes('max-width') ? mobile : false,
|
|
15
|
+
media: query,
|
|
16
|
+
addEventListener: vi.fn(),
|
|
17
|
+
removeEventListener: vi.fn(),
|
|
18
|
+
addListener: vi.fn(),
|
|
19
|
+
removeListener: vi.fn(),
|
|
20
|
+
dispatchEvent: vi.fn(),
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const options: ComboboxOption[] = [
|
|
26
|
+
{ value: 'tasks', label: 'sovereign-tasks' },
|
|
27
|
+
{ value: 'ledger', label: 'sovereign-ledger' },
|
|
28
|
+
{ value: 'shopper', label: 'sovereign-shopper' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Popover's width="trigger" mode observes the trigger's size via
|
|
32
|
+
// ResizeObserver, which jsdom does not implement.
|
|
33
|
+
class MockResizeObserver {
|
|
34
|
+
observe() {}
|
|
35
|
+
unobserve() {}
|
|
36
|
+
disconnect() {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('Combobox', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mobile = false;
|
|
42
|
+
installMatchMedia();
|
|
43
|
+
vi.stubGlobal('ResizeObserver', MockResizeObserver);
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('shows the placeholder when nothing is selected', () => {
|
|
51
|
+
render(
|
|
52
|
+
<Combobox
|
|
53
|
+
options={options}
|
|
54
|
+
value={null}
|
|
55
|
+
onChange={() => {}}
|
|
56
|
+
placeholder="Pick a plugin"
|
|
57
|
+
aria-label="Plugin"
|
|
58
|
+
/>,
|
|
59
|
+
);
|
|
60
|
+
expect(screen.getByRole('button', { name: 'Plugin' }).textContent).toContain('Pick a plugin');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('shows the selected option label', () => {
|
|
64
|
+
render(<Combobox options={options} value="ledger" onChange={() => {}} aria-label="Plugin" />);
|
|
65
|
+
expect(screen.getByRole('button', { name: 'Plugin' }).textContent).toContain(
|
|
66
|
+
'sovereign-ledger',
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('opens as a Popover on desktop', () => {
|
|
71
|
+
render(<Combobox options={options} value={null} onChange={() => {}} aria-label="Plugin" />);
|
|
72
|
+
fireEvent.click(screen.getByRole('button', { name: 'Plugin' }));
|
|
73
|
+
const panel = screen.getByRole('dialog', { name: 'Plugin' });
|
|
74
|
+
expect(panel.getAttribute('aria-modal')).toBe('false');
|
|
75
|
+
expect(screen.getAllByRole('option')).toHaveLength(3);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('opens as a Drawer on mobile', () => {
|
|
79
|
+
mobile = true;
|
|
80
|
+
installMatchMedia();
|
|
81
|
+
render(<Combobox options={options} value={null} onChange={() => {}} aria-label="Plugin" />);
|
|
82
|
+
fireEvent.click(screen.getByRole('button', { name: 'Plugin' }));
|
|
83
|
+
const panel = screen.getByRole('dialog', { name: 'Plugin' });
|
|
84
|
+
expect(panel.getAttribute('aria-modal')).toBe('true');
|
|
85
|
+
expect(screen.getAllByRole('option')).toHaveLength(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('filters options by label', () => {
|
|
89
|
+
render(<Combobox options={options} value={null} onChange={() => {}} aria-label="Plugin" />);
|
|
90
|
+
fireEvent.click(screen.getByRole('button', { name: 'Plugin' }));
|
|
91
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'led' } });
|
|
92
|
+
expect(screen.getAllByRole('option')).toHaveLength(1);
|
|
93
|
+
expect(screen.getByRole('option', { name: 'sovereign-ledger' })).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('shows an empty message when nothing matches', () => {
|
|
97
|
+
render(
|
|
98
|
+
<Combobox
|
|
99
|
+
options={options}
|
|
100
|
+
value={null}
|
|
101
|
+
onChange={() => {}}
|
|
102
|
+
aria-label="Plugin"
|
|
103
|
+
emptyMessage="No plugins"
|
|
104
|
+
/>,
|
|
105
|
+
);
|
|
106
|
+
fireEvent.click(screen.getByRole('button', { name: 'Plugin' }));
|
|
107
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'zzz' } });
|
|
108
|
+
expect(screen.queryAllByRole('option')).toHaveLength(0);
|
|
109
|
+
expect(screen.getByText('No plugins')).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('marks the currently selected option with aria-selected', () => {
|
|
113
|
+
render(<Combobox options={options} value="shopper" onChange={() => {}} aria-label="Plugin" />);
|
|
114
|
+
fireEvent.click(screen.getByRole('button', { name: 'Plugin' }));
|
|
115
|
+
expect(
|
|
116
|
+
screen.getByRole('option', { name: 'sovereign-shopper' }).getAttribute('aria-selected'),
|
|
117
|
+
).toBe('true');
|
|
118
|
+
expect(
|
|
119
|
+
screen.getByRole('option', { name: 'sovereign-tasks' }).getAttribute('aria-selected'),
|
|
120
|
+
).toBe('false');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('clicking an option calls onChange with its value and closes', () => {
|
|
124
|
+
const onChange = vi.fn();
|
|
125
|
+
render(<Combobox options={options} value={null} onChange={onChange} aria-label="Plugin" />);
|
|
126
|
+
fireEvent.click(screen.getByRole('button', { name: 'Plugin' }));
|
|
127
|
+
fireEvent.click(screen.getByRole('option', { name: 'sovereign-ledger' }));
|
|
128
|
+
expect(onChange).toHaveBeenCalledWith('ledger');
|
|
129
|
+
expect(screen.queryByRole('listbox')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('Enter selects the highlighted option', () => {
|
|
133
|
+
const onChange = vi.fn();
|
|
134
|
+
render(<Combobox options={options} value={null} onChange={onChange} aria-label="Plugin" />);
|
|
135
|
+
fireEvent.click(screen.getByRole('button', { name: 'Plugin' }));
|
|
136
|
+
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' });
|
|
137
|
+
expect(onChange).toHaveBeenCalledWith('tasks');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('ArrowDown moves the highlighted option before Enter selects it', () => {
|
|
141
|
+
const onChange = vi.fn();
|
|
142
|
+
render(<Combobox options={options} value={null} onChange={onChange} aria-label="Plugin" />);
|
|
143
|
+
fireEvent.click(screen.getByRole('button', { name: 'Plugin' }));
|
|
144
|
+
const input = screen.getByRole('combobox');
|
|
145
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
146
|
+
fireEvent.keyDown(input, { key: 'Enter' });
|
|
147
|
+
expect(onChange).toHaveBeenCalledWith('ledger');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('disables the trigger when disabled', () => {
|
|
151
|
+
render(
|
|
152
|
+
<Combobox options={options} value={null} onChange={() => {}} aria-label="Plugin" disabled />,
|
|
153
|
+
);
|
|
154
|
+
expect((screen.getByRole('button', { name: 'Plugin' }) as HTMLButtonElement).disabled).toBe(
|
|
155
|
+
true,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* Command — rendered inside Dialog's own panel/scroll chrome; this file only
|
|
2
|
+
* owns the search input and the filtered result list beneath it. */
|
|
3
|
+
|
|
4
|
+
.root {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
/* Panel content area is otherwise unbounded; cap so a long result list
|
|
8
|
+
scrolls internally instead of growing the dialog indefinitely. */
|
|
9
|
+
max-height: min(60vh, 420px);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.input {
|
|
13
|
+
flex-shrink: 0;
|
|
14
|
+
width: 100%;
|
|
15
|
+
border: none;
|
|
16
|
+
border-bottom: 1px solid var(--sv-color-border);
|
|
17
|
+
background: transparent;
|
|
18
|
+
color: var(--sv-color-text-primary);
|
|
19
|
+
font: inherit;
|
|
20
|
+
font-size: var(--sv-font-size-md);
|
|
21
|
+
padding: var(--sv-space-3);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.input:focus {
|
|
25
|
+
outline: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.input::placeholder {
|
|
29
|
+
color: var(--sv-color-text-muted);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.list {
|
|
33
|
+
list-style: none;
|
|
34
|
+
margin: 0;
|
|
35
|
+
padding: var(--sv-space-1);
|
|
36
|
+
overflow-y: auto;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.groupList {
|
|
40
|
+
list-style: none;
|
|
41
|
+
margin: 0;
|
|
42
|
+
padding: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.groupLabel {
|
|
46
|
+
padding: var(--sv-space-2) var(--sv-space-3) var(--sv-space-1);
|
|
47
|
+
font-size: var(--sv-font-size-label);
|
|
48
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
49
|
+
color: var(--sv-color-text-muted);
|
|
50
|
+
text-transform: uppercase;
|
|
51
|
+
letter-spacing: 0.04em;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.item {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: var(--sv-space-2);
|
|
58
|
+
width: 100%;
|
|
59
|
+
min-height: var(--sv-touch-target-min, 44px);
|
|
60
|
+
padding: var(--sv-space-2) var(--sv-space-3);
|
|
61
|
+
border: none;
|
|
62
|
+
border-radius: var(--sv-radius-sm);
|
|
63
|
+
background: transparent;
|
|
64
|
+
color: var(--sv-color-text-primary);
|
|
65
|
+
font: inherit;
|
|
66
|
+
font-size: var(--sv-font-size-sm);
|
|
67
|
+
text-align: left;
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.itemHighlighted {
|
|
72
|
+
background: var(--sv-color-surface-sunken);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.empty {
|
|
76
|
+
padding: var(--sv-space-4) var(--sv-space-3);
|
|
77
|
+
color: var(--sv-color-text-muted);
|
|
78
|
+
font-size: var(--sv-font-size-sm);
|
|
79
|
+
text-align: center;
|
|
80
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { Command } from './Command';
|
|
4
|
+
import { Button } from '../Button/Button';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/Command',
|
|
8
|
+
component: Command,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
'⌘K-style command palette. Opened via Dialog, which supplies the modal shell — this adds the search input, substring filtering, and arrow-key/Enter selection on top of it. Controlled: the consumer owns the open state and any global shortcut listener that flips it.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
args: { open: false, onClose: () => {}, items: [] },
|
|
19
|
+
} satisfies Meta<typeof Command>;
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function CommandDemo() {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const [lastSelected, setLastSelected] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div>
|
|
32
|
+
<Button onClick={() => setOpen(true)}>Open command palette</Button>
|
|
33
|
+
{lastSelected && (
|
|
34
|
+
<p
|
|
35
|
+
style={{
|
|
36
|
+
marginTop: 'var(--sv-space-3)',
|
|
37
|
+
fontSize: 14,
|
|
38
|
+
color: 'var(--sv-color-text-muted)',
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
Last selected: {lastSelected}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
<Command
|
|
45
|
+
open={open}
|
|
46
|
+
onClose={() => setOpen(false)}
|
|
47
|
+
aria-label="Command palette"
|
|
48
|
+
items={[
|
|
49
|
+
{
|
|
50
|
+
id: 'new',
|
|
51
|
+
label: 'New conversation',
|
|
52
|
+
group: 'Actions',
|
|
53
|
+
icon: 'plus',
|
|
54
|
+
onSelect: () => setLastSelected('New conversation'),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'export',
|
|
58
|
+
label: 'Export chat',
|
|
59
|
+
group: 'Actions',
|
|
60
|
+
icon: 'upload',
|
|
61
|
+
keywords: 'download save',
|
|
62
|
+
onSelect: () => setLastSelected('Export chat'),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'profile',
|
|
66
|
+
label: 'Go to profile',
|
|
67
|
+
group: 'Navigation',
|
|
68
|
+
icon: 'user',
|
|
69
|
+
onSelect: () => setLastSelected('Go to profile'),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'settings',
|
|
73
|
+
label: 'Open settings',
|
|
74
|
+
group: 'Navigation',
|
|
75
|
+
icon: 'settings',
|
|
76
|
+
onSelect: () => setLastSelected('Open settings'),
|
|
77
|
+
},
|
|
78
|
+
]}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const Default: Story = {
|
|
85
|
+
render: () => <CommandDemo />,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const OpenWithNoResults: Story = {
|
|
89
|
+
render: () => {
|
|
90
|
+
function Demo() {
|
|
91
|
+
const [open, setOpen] = useState(true);
|
|
92
|
+
return (
|
|
93
|
+
<Command
|
|
94
|
+
open={open}
|
|
95
|
+
onClose={() => setOpen(false)}
|
|
96
|
+
aria-label="Command palette"
|
|
97
|
+
placeholder="Try typing something with no matches…"
|
|
98
|
+
items={[{ id: 'a', label: 'Alpha', onSelect: () => {} }]}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return <Demo />;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
5
|
+
import { Dialog } from '../Dialog/Dialog';
|
|
6
|
+
import { Icon, type IconName } from '../Icon/Icon';
|
|
7
|
+
import styles from './Command.module.css';
|
|
8
|
+
|
|
9
|
+
export interface CommandItem {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
onSelect: () => void;
|
|
13
|
+
icon?: IconName;
|
|
14
|
+
/** Section header this item is grouped under. */
|
|
15
|
+
group?: string;
|
|
16
|
+
/** Extra searchable text beyond the label (keywords, description). */
|
|
17
|
+
keywords?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CommandProps {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
items: CommandItem[];
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
'aria-label'?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Case-insensitive substring match against label + keywords. Not a scored
|
|
29
|
+
* fuzzy matcher — a real fuzzy-match algorithm is a meaningfully bigger
|
|
30
|
+
* undertaking than this primitive needs until a consumer's command list
|
|
31
|
+
* is large enough that substring filtering stops being good enough. */
|
|
32
|
+
function matches(item: CommandItem, query: string): boolean {
|
|
33
|
+
if (!query) return true;
|
|
34
|
+
const haystack = `${item.label} ${item.keywords ?? ''}`.toLowerCase();
|
|
35
|
+
return haystack.includes(query.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Command — a ⌘K-style palette. Opened via `Dialog`, which supplies the
|
|
40
|
+
* modal shell (scrim, panel, mobile full-screen sheet, focus handling) —
|
|
41
|
+
* this only adds the search input, filtering, and arrow-key/Enter
|
|
42
|
+
* selection on top of it. Controlled (`open`/`onClose`): the consumer wires
|
|
43
|
+
* up their own global shortcut listener to flip `open` — a DS primitive
|
|
44
|
+
* shouldn't own a document-level keydown listener itself.
|
|
45
|
+
*/
|
|
46
|
+
export function Command({
|
|
47
|
+
open,
|
|
48
|
+
onClose,
|
|
49
|
+
items,
|
|
50
|
+
placeholder = 'Type a command…',
|
|
51
|
+
'aria-label': ariaLabel = 'Command palette',
|
|
52
|
+
}: CommandProps) {
|
|
53
|
+
const [query, setQuery] = useState('');
|
|
54
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
55
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
56
|
+
const listId = useId();
|
|
57
|
+
|
|
58
|
+
const filtered = useMemo(() => items.filter((item) => matches(item, query)), [items, query]);
|
|
59
|
+
|
|
60
|
+
// Reset on every open, and whenever the query narrows/widens the result
|
|
61
|
+
// set — the top match should always be the one Enter would select.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setHighlightedIndex(0);
|
|
64
|
+
}, [query, open]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (open) {
|
|
68
|
+
setQuery('');
|
|
69
|
+
// Dialog mounts its content in the same tick it flips `open`; focus
|
|
70
|
+
// needs the next paint to land on an element that actually exists.
|
|
71
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
72
|
+
}
|
|
73
|
+
}, [open]);
|
|
74
|
+
|
|
75
|
+
function handleKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
|
|
76
|
+
if (e.key === 'ArrowDown') {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
setHighlightedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
|
79
|
+
} else if (e.key === 'ArrowUp') {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
setHighlightedIndex((i) => Math.max(i - 1, 0));
|
|
82
|
+
} else if (e.key === 'Enter') {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
const item = filtered[highlightedIndex];
|
|
85
|
+
if (item) {
|
|
86
|
+
onClose();
|
|
87
|
+
item.onSelect();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Group items in their original order, without moving an item's position
|
|
93
|
+
// relative to its own group even if groups aren't contiguous in `items`.
|
|
94
|
+
const groups = useMemo(() => {
|
|
95
|
+
const order: (string | undefined)[] = [];
|
|
96
|
+
const byGroup = new Map<string | undefined, CommandItem[]>();
|
|
97
|
+
for (const item of filtered) {
|
|
98
|
+
let bucket = byGroup.get(item.group);
|
|
99
|
+
if (!bucket) {
|
|
100
|
+
bucket = [];
|
|
101
|
+
order.push(item.group);
|
|
102
|
+
byGroup.set(item.group, bucket);
|
|
103
|
+
}
|
|
104
|
+
bucket.push(item);
|
|
105
|
+
}
|
|
106
|
+
return order.map((group) => ({ group, items: byGroup.get(group) ?? [] }));
|
|
107
|
+
}, [filtered]);
|
|
108
|
+
|
|
109
|
+
const highlightedId = filtered[highlightedIndex]
|
|
110
|
+
? `${listId}-${filtered[highlightedIndex].id}`
|
|
111
|
+
: undefined;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Dialog open={open} onClose={onClose} aria-label={ariaLabel} size="sm">
|
|
115
|
+
<div className={styles.root}>
|
|
116
|
+
<input
|
|
117
|
+
ref={inputRef}
|
|
118
|
+
type="text"
|
|
119
|
+
role="combobox"
|
|
120
|
+
aria-expanded={filtered.length > 0}
|
|
121
|
+
aria-controls={listId}
|
|
122
|
+
aria-activedescendant={highlightedId}
|
|
123
|
+
aria-autocomplete="list"
|
|
124
|
+
className={styles.input}
|
|
125
|
+
placeholder={placeholder}
|
|
126
|
+
value={query}
|
|
127
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
128
|
+
onKeyDown={handleKeyDown}
|
|
129
|
+
/>
|
|
130
|
+
<ul id={listId} role="listbox" className={styles.list}>
|
|
131
|
+
{filtered.length === 0 && <li className={styles.empty}>No matching commands.</li>}
|
|
132
|
+
{groups.map(({ group, items: groupItems }) => (
|
|
133
|
+
<li key={group ?? '__ungrouped'}>
|
|
134
|
+
{group && <div className={styles.groupLabel}>{group}</div>}
|
|
135
|
+
<ul className={styles.groupList}>
|
|
136
|
+
{groupItems.map((item) => {
|
|
137
|
+
const index = filtered.indexOf(item);
|
|
138
|
+
const isHighlighted = index === highlightedIndex;
|
|
139
|
+
return (
|
|
140
|
+
<li key={item.id}>
|
|
141
|
+
<button
|
|
142
|
+
id={`${listId}-${item.id}`}
|
|
143
|
+
type="button"
|
|
144
|
+
role="option"
|
|
145
|
+
aria-selected={isHighlighted}
|
|
146
|
+
className={[styles.item, isHighlighted ? styles.itemHighlighted : '']
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join(' ')}
|
|
149
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
150
|
+
onClick={() => {
|
|
151
|
+
onClose();
|
|
152
|
+
item.onSelect();
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{item.icon && <Icon name={item.icon} size="sm" aria-hidden />}
|
|
156
|
+
{item.label}
|
|
157
|
+
</button>
|
|
158
|
+
</li>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</ul>
|
|
162
|
+
</li>
|
|
163
|
+
))}
|
|
164
|
+
</ul>
|
|
165
|
+
</div>
|
|
166
|
+
</Dialog>
|
|
167
|
+
);
|
|
168
|
+
}
|