@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.
Files changed (174) hide show
  1. package/dist/Accordion.module.css +3 -0
  2. package/dist/Alert.module.css +62 -0
  3. package/dist/AspectRatio.module.css +15 -0
  4. package/dist/Badge.module.css +75 -9
  5. package/dist/Breadcrumb.module.css +49 -0
  6. package/dist/Button.module.css +12 -3
  7. package/dist/ButtonGroup.module.css +29 -0
  8. package/dist/Card.module.css +6 -0
  9. package/dist/Checkbox.module.css +3 -1
  10. package/dist/Collapsible.module.css +70 -0
  11. package/dist/Combobox.module.css +122 -0
  12. package/dist/Command.module.css +80 -0
  13. package/dist/ConfirmDialog.module.css +5 -1
  14. package/dist/DataTable.module.css +28 -0
  15. package/dist/DatePicker.module.css +6 -2
  16. package/dist/Dialog.module.css +3 -0
  17. package/dist/HoverCard.module.css +3 -0
  18. package/dist/Input.module.css +6 -1
  19. package/dist/Item.module.css +70 -0
  20. package/dist/Kbd.module.css +14 -0
  21. package/dist/Label.module.css +11 -0
  22. package/dist/Marker.module.css +33 -0
  23. package/dist/Menu.module.css +8 -1
  24. package/dist/Message.module.css +99 -0
  25. package/dist/MessageScroller.module.css +54 -0
  26. package/dist/NavigationMenu.module.css +53 -0
  27. package/dist/Pagination.module.css +98 -0
  28. package/dist/Progress.module.css +20 -0
  29. package/dist/RadioGroup.module.css +88 -0
  30. package/dist/Resizable.module.css +70 -0
  31. package/dist/ScrollArea.module.css +20 -0
  32. package/dist/Select.module.css +7 -3
  33. package/dist/Slider.module.css +95 -0
  34. package/dist/Table.module.css +45 -0
  35. package/dist/Tabs.module.css +6 -1
  36. package/dist/Toggle.module.css +6 -1
  37. package/dist/Typography.module.css +47 -0
  38. package/dist/index.d.ts +617 -5
  39. package/dist/index.js +1847 -499
  40. package/dist/tokens/primitives.css +22 -8
  41. package/package.json +1 -1
  42. package/src/components/Accordion/Accordion.module.css +3 -0
  43. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  44. package/src/components/Accordion/Accordion.tsx +53 -0
  45. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  46. package/src/components/Alert/Alert.module.css +62 -0
  47. package/src/components/Alert/Alert.stories.tsx +96 -0
  48. package/src/components/Alert/Alert.tsx +58 -0
  49. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  50. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  51. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  52. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  53. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  54. package/src/components/Badge/Badge.module.css +75 -9
  55. package/src/components/Badge/Badge.stories.tsx +109 -1
  56. package/src/components/Badge/Badge.tsx +30 -2
  57. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  58. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  59. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  60. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  61. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  62. package/src/components/Button/Button.module.css +12 -3
  63. package/src/components/Button/Button.stories.tsx +3 -2
  64. package/src/components/Button/Button.tsx +1 -1
  65. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  66. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  67. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  68. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  69. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  70. package/src/components/Card/Card.module.css +6 -0
  71. package/src/components/Checkbox/Checkbox.module.css +3 -1
  72. package/src/components/Collapsible/Collapsible.module.css +70 -0
  73. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  74. package/src/components/Collapsible/Collapsible.tsx +66 -0
  75. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  76. package/src/components/Combobox/Combobox.module.css +122 -0
  77. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  78. package/src/components/Combobox/Combobox.tsx +186 -0
  79. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  80. package/src/components/Command/Command.module.css +80 -0
  81. package/src/components/Command/Command.stories.tsx +104 -0
  82. package/src/components/Command/Command.tsx +168 -0
  83. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  84. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  85. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  86. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  87. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  88. package/src/components/DataTable/DataTable.module.css +28 -0
  89. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  90. package/src/components/DataTable/DataTable.tsx +154 -0
  91. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  92. package/src/components/DatePicker/DatePicker.module.css +6 -2
  93. package/src/components/Dialog/Dialog.module.css +3 -0
  94. package/src/components/HoverCard/HoverCard.module.css +3 -0
  95. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  96. package/src/components/HoverCard/HoverCard.tsx +111 -0
  97. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  98. package/src/components/Icon/icons/circle-check.tsx +21 -0
  99. package/src/components/Icon/icons/circle-x.tsx +22 -0
  100. package/src/components/Icon/icons/index.ts +8 -0
  101. package/src/components/Input/Input.module.css +6 -1
  102. package/src/components/Item/Item.module.css +70 -0
  103. package/src/components/Item/Item.stories.tsx +62 -0
  104. package/src/components/Item/Item.tsx +54 -0
  105. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  106. package/src/components/Kbd/Kbd.module.css +14 -0
  107. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  108. package/src/components/Kbd/Kbd.tsx +12 -0
  109. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  110. package/src/components/Label/Label.module.css +11 -0
  111. package/src/components/Label/Label.stories.tsx +40 -0
  112. package/src/components/Label/Label.tsx +24 -0
  113. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  114. package/src/components/Marker/Marker.module.css +33 -0
  115. package/src/components/Marker/Marker.stories.tsx +39 -0
  116. package/src/components/Marker/Marker.tsx +34 -0
  117. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  118. package/src/components/Menu/Menu.module.css +8 -1
  119. package/src/components/Menu/Menu.tsx +30 -8
  120. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  121. package/src/components/Menubar/Menubar.tsx +42 -0
  122. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  123. package/src/components/Message/Message.module.css +99 -0
  124. package/src/components/Message/Message.stories.tsx +79 -0
  125. package/src/components/Message/Message.tsx +41 -0
  126. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  127. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  128. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  129. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  130. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  131. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  132. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  133. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  134. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  135. package/src/components/Pagination/Pagination.module.css +98 -0
  136. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  137. package/src/components/Pagination/Pagination.tsx +87 -0
  138. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  139. package/src/components/Progress/Progress.module.css +20 -0
  140. package/src/components/Progress/Progress.stories.tsx +40 -0
  141. package/src/components/Progress/Progress.tsx +35 -0
  142. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  143. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  144. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  145. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  146. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  147. package/src/components/Resizable/Resizable.module.css +70 -0
  148. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  149. package/src/components/Resizable/Resizable.tsx +313 -0
  150. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  151. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  152. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  153. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  154. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  155. package/src/components/Select/Select.module.css +7 -3
  156. package/src/components/Slider/Slider.module.css +95 -0
  157. package/src/components/Slider/Slider.stories.tsx +64 -0
  158. package/src/components/Slider/Slider.tsx +77 -0
  159. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  160. package/src/components/Table/Table.module.css +45 -0
  161. package/src/components/Table/Table.stories.tsx +125 -0
  162. package/src/components/Table/Table.tsx +51 -0
  163. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  164. package/src/components/Tabs/Tabs.module.css +6 -1
  165. package/src/components/Toggle/Toggle.module.css +6 -1
  166. package/src/components/Typography/Typography.module.css +47 -0
  167. package/src/components/Typography/Typography.stories.tsx +62 -0
  168. package/src/components/Typography/Typography.tsx +30 -0
  169. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  170. package/src/index.ts +78 -1
  171. package/src/stories/DesignSystemOverview.stories.tsx +614 -2
  172. package/src/stories/MobilePatterns.stories.tsx +8 -2
  173. package/src/stories/TokenGallery.stories.tsx +112 -16
  174. 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
+ }