@sovereignfs/ui 0.41.0 → 0.54.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 (256) 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/LegalLinks.module.css +24 -0
  23. package/dist/Markdown.module.css +49 -0
  24. package/dist/Marker.module.css +33 -0
  25. package/dist/Menu.module.css +8 -1
  26. package/dist/Message.module.css +99 -0
  27. package/dist/MessageScroller.module.css +54 -0
  28. package/dist/MobileAppsDrawer.module.css +81 -0
  29. package/dist/MobileFooter.module.css +73 -0
  30. package/dist/MobileHeader.module.css +63 -0
  31. package/dist/NavigationMenu.module.css +53 -0
  32. package/dist/PageContainer.module.css +20 -0
  33. package/dist/Pagination.module.css +98 -0
  34. package/dist/Progress.module.css +20 -0
  35. package/dist/RadioGroup.module.css +88 -0
  36. package/dist/Resizable.module.css +70 -0
  37. package/dist/ScrollArea.module.css +20 -0
  38. package/dist/Select.module.css +7 -3
  39. package/dist/Slider.module.css +95 -0
  40. package/dist/SwipableMobileCarousel.module.css +55 -0
  41. package/dist/SwipableMobileCarouselDots.module.css +49 -0
  42. package/dist/SwipableMobileCarouselSlideParts.module.css +29 -0
  43. package/dist/Table.module.css +45 -0
  44. package/dist/Tabs.module.css +6 -1
  45. package/dist/Toggle.module.css +6 -1
  46. package/dist/Typography.module.css +47 -0
  47. package/dist/index.d.ts +1268 -8
  48. package/dist/index.js +3046 -975
  49. package/dist/tokens/primitives.css +47 -8
  50. package/package.json +13 -9
  51. package/src/components/Accordion/Accordion.module.css +3 -0
  52. package/src/components/Accordion/Accordion.stories.tsx +110 -0
  53. package/src/components/Accordion/Accordion.tsx +53 -0
  54. package/src/components/Accordion/__tests__/Accordion.test.tsx +61 -0
  55. package/src/components/Alert/Alert.module.css +62 -0
  56. package/src/components/Alert/Alert.stories.tsx +96 -0
  57. package/src/components/Alert/Alert.tsx +58 -0
  58. package/src/components/Alert/__tests__/Alert.test.tsx +75 -0
  59. package/src/components/AspectRatio/AspectRatio.module.css +15 -0
  60. package/src/components/AspectRatio/AspectRatio.stories.tsx +78 -0
  61. package/src/components/AspectRatio/AspectRatio.tsx +21 -0
  62. package/src/components/AspectRatio/__tests__/AspectRatio.test.tsx +27 -0
  63. package/src/components/Badge/Badge.module.css +75 -9
  64. package/src/components/Badge/Badge.stories.tsx +109 -1
  65. package/src/components/Badge/Badge.tsx +31 -9
  66. package/src/components/Badge/__tests__/Badge.test.tsx +26 -2
  67. package/src/components/Breadcrumb/Breadcrumb.module.css +49 -0
  68. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +47 -0
  69. package/src/components/Breadcrumb/Breadcrumb.tsx +57 -0
  70. package/src/components/Breadcrumb/__tests__/Breadcrumb.test.tsx +52 -0
  71. package/src/components/Button/Button.module.css +12 -3
  72. package/src/components/Button/Button.stories.tsx +3 -2
  73. package/src/components/Button/Button.tsx +1 -1
  74. package/src/components/Button/__tests__/Button.test.tsx +10 -0
  75. package/src/components/ButtonGroup/ButtonGroup.module.css +29 -0
  76. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +57 -0
  77. package/src/components/ButtonGroup/ButtonGroup.tsx +27 -0
  78. package/src/components/ButtonGroup/__tests__/ButtonGroup.test.tsx +29 -0
  79. package/src/components/Card/Card.module.css +6 -0
  80. package/src/components/Checkbox/Checkbox.module.css +3 -1
  81. package/src/components/Collapsible/Collapsible.module.css +70 -0
  82. package/src/components/Collapsible/Collapsible.stories.tsx +62 -0
  83. package/src/components/Collapsible/Collapsible.tsx +66 -0
  84. package/src/components/Collapsible/__tests__/Collapsible.test.tsx +71 -0
  85. package/src/components/Combobox/Combobox.module.css +122 -0
  86. package/src/components/Combobox/Combobox.stories.tsx +79 -0
  87. package/src/components/Combobox/Combobox.tsx +186 -0
  88. package/src/components/Combobox/__tests__/Combobox.test.tsx +158 -0
  89. package/src/components/Command/Command.module.css +80 -0
  90. package/src/components/Command/Command.stories.tsx +104 -0
  91. package/src/components/Command/Command.tsx +168 -0
  92. package/src/components/Command/__tests__/Command.test.tsx +162 -0
  93. package/src/components/ConfirmDialog/ConfirmDialog.module.css +5 -1
  94. package/src/components/ContextMenu/ContextMenu.stories.tsx +81 -0
  95. package/src/components/ContextMenu/ContextMenu.tsx +100 -0
  96. package/src/components/ContextMenu/__tests__/ContextMenu.test.tsx +87 -0
  97. package/src/components/DataTable/DataTable.module.css +28 -0
  98. package/src/components/DataTable/DataTable.stories.tsx +74 -0
  99. package/src/components/DataTable/DataTable.tsx +154 -0
  100. package/src/components/DataTable/__tests__/DataTable.test.tsx +123 -0
  101. package/src/components/DatePicker/DatePicker.module.css +6 -2
  102. package/src/components/DeviceOnlyGate/DeviceOnlyGate.tsx +58 -0
  103. package/src/components/DeviceOnlyGate/__tests__/DeviceOnlyGate.test.tsx +45 -0
  104. package/src/components/DeviceStorageKeyGate/DeviceStorageKeyGate.tsx +100 -0
  105. package/src/components/DeviceStorageKeyGate/__tests__/DeviceStorageKeyGate.test.tsx +119 -0
  106. package/src/components/Dialog/Dialog.module.css +3 -0
  107. package/src/components/Dialog/Dialog.tsx +8 -59
  108. package/src/components/Drawer/Drawer.stories.tsx +44 -0
  109. package/src/components/Drawer/Drawer.tsx +18 -56
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +22 -0
  111. package/src/components/HoverCard/HoverCard.module.css +3 -0
  112. package/src/components/HoverCard/HoverCard.stories.tsx +66 -0
  113. package/src/components/HoverCard/HoverCard.tsx +111 -0
  114. package/src/components/HoverCard/__tests__/HoverCard.test.tsx +99 -0
  115. package/src/components/Icon/icons/circle-check.tsx +21 -0
  116. package/src/components/Icon/icons/circle-x.tsx +22 -0
  117. package/src/components/Icon/icons/index.ts +16 -0
  118. package/src/components/Icon/icons/menu.tsx +22 -0
  119. package/src/components/Icon/icons/smartphone.tsx +21 -0
  120. package/src/components/Input/Input.module.css +6 -1
  121. package/src/components/Item/Item.module.css +70 -0
  122. package/src/components/Item/Item.stories.tsx +62 -0
  123. package/src/components/Item/Item.tsx +54 -0
  124. package/src/components/Item/__tests__/Item.test.tsx +39 -0
  125. package/src/components/Kbd/Kbd.module.css +14 -0
  126. package/src/components/Kbd/Kbd.stories.tsx +32 -0
  127. package/src/components/Kbd/Kbd.tsx +12 -0
  128. package/src/components/Kbd/__tests__/Kbd.test.tsx +14 -0
  129. package/src/components/Label/Label.module.css +11 -0
  130. package/src/components/Label/Label.stories.tsx +40 -0
  131. package/src/components/Label/Label.tsx +24 -0
  132. package/src/components/Label/__tests__/Label.test.tsx +32 -0
  133. package/src/components/LegalLinks/LegalLinks.module.css +24 -0
  134. package/src/components/LegalLinks/LegalLinks.stories.tsx +22 -0
  135. package/src/components/LegalLinks/LegalLinks.tsx +40 -0
  136. package/src/components/Markdown/Markdown.module.css +49 -0
  137. package/src/components/Markdown/Markdown.stories.tsx +30 -0
  138. package/src/components/Markdown/Markdown.tsx +168 -0
  139. package/src/components/Marker/Marker.module.css +33 -0
  140. package/src/components/Marker/Marker.stories.tsx +39 -0
  141. package/src/components/Marker/Marker.tsx +34 -0
  142. package/src/components/Marker/__tests__/Marker.test.tsx +23 -0
  143. package/src/components/Menu/Menu.module.css +8 -1
  144. package/src/components/Menu/Menu.tsx +30 -8
  145. package/src/components/Menubar/Menubar.stories.tsx +52 -0
  146. package/src/components/Menubar/Menubar.tsx +42 -0
  147. package/src/components/Menubar/__tests__/Menubar.test.tsx +45 -0
  148. package/src/components/Message/Message.module.css +99 -0
  149. package/src/components/Message/Message.stories.tsx +79 -0
  150. package/src/components/Message/Message.tsx +41 -0
  151. package/src/components/Message/__tests__/Message.test.tsx +46 -0
  152. package/src/components/MessageScroller/MessageScroller.module.css +54 -0
  153. package/src/components/MessageScroller/MessageScroller.stories.tsx +84 -0
  154. package/src/components/MessageScroller/MessageScroller.tsx +72 -0
  155. package/src/components/MessageScroller/__tests__/MessageScroller.test.tsx +105 -0
  156. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +81 -0
  157. package/src/components/MobileAppsDrawer/MobileAppsDrawer.tsx +78 -0
  158. package/src/components/MobileAppsDrawer/__tests__/MobileAppsDrawer.test.tsx +80 -0
  159. package/src/components/MobileFooter/MobileFooter.module.css +73 -0
  160. package/src/components/MobileFooter/MobileFooter.stories.tsx +47 -0
  161. package/src/components/MobileFooter/MobileFooter.tsx +109 -0
  162. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +71 -0
  163. package/src/components/MobileHeader/MobileHeader.module.css +63 -0
  164. package/src/components/MobileHeader/MobileHeader.stories.tsx +57 -0
  165. package/src/components/MobileHeader/MobileHeader.tsx +77 -0
  166. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +58 -0
  167. package/src/components/NavTabs/NavTabs.tsx +39 -11
  168. package/src/components/NavigationMenu/NavigationMenu.module.css +53 -0
  169. package/src/components/NavigationMenu/NavigationMenu.stories.tsx +58 -0
  170. package/src/components/NavigationMenu/NavigationMenu.tsx +125 -0
  171. package/src/components/NavigationMenu/__tests__/NavigationMenu.test.tsx +93 -0
  172. package/src/components/OfflineGate/OfflineGate.tsx +44 -0
  173. package/src/components/OfflineGate/__tests__/OfflineGate.test.tsx +66 -0
  174. package/src/components/PageContainer/PageContainer.module.css +20 -0
  175. package/src/components/PageContainer/PageContainer.tsx +31 -0
  176. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +37 -0
  177. package/src/components/PageHeader/PageHeader.tsx +16 -2
  178. package/src/components/Pagination/Pagination.module.css +98 -0
  179. package/src/components/Pagination/Pagination.stories.tsx +49 -0
  180. package/src/components/Pagination/Pagination.tsx +87 -0
  181. package/src/components/Pagination/__tests__/Pagination.test.tsx +56 -0
  182. package/src/components/Progress/Progress.module.css +20 -0
  183. package/src/components/Progress/Progress.stories.tsx +40 -0
  184. package/src/components/Progress/Progress.tsx +35 -0
  185. package/src/components/Progress/__tests__/Progress.test.tsx +31 -0
  186. package/src/components/RadioGroup/RadioGroup.module.css +88 -0
  187. package/src/components/RadioGroup/RadioGroup.stories.tsx +69 -0
  188. package/src/components/RadioGroup/RadioGroup.tsx +99 -0
  189. package/src/components/RadioGroup/__tests__/RadioGroup.test.tsx +59 -0
  190. package/src/components/Resizable/Resizable.module.css +70 -0
  191. package/src/components/Resizable/Resizable.stories.tsx +82 -0
  192. package/src/components/Resizable/Resizable.tsx +313 -0
  193. package/src/components/Resizable/__tests__/Resizable.test.tsx +128 -0
  194. package/src/components/ResponsiveSurface/ResponsiveSurface.tsx +24 -0
  195. package/src/components/ResponsiveSurface/__tests__/ResponsiveSurface.test.tsx +73 -0
  196. package/src/components/ScrollArea/ScrollArea.module.css +20 -0
  197. package/src/components/ScrollArea/ScrollArea.stories.tsx +57 -0
  198. package/src/components/ScrollArea/ScrollArea.tsx +27 -0
  199. package/src/components/ScrollArea/__tests__/ScrollArea.test.tsx +39 -0
  200. package/src/components/Select/Select.module.css +7 -3
  201. package/src/components/Sheet/Sheet.tsx +9 -51
  202. package/src/components/Slider/Slider.module.css +95 -0
  203. package/src/components/Slider/Slider.stories.tsx +64 -0
  204. package/src/components/Slider/Slider.tsx +77 -0
  205. package/src/components/Slider/__tests__/Slider.test.tsx +45 -0
  206. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +55 -0
  207. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +227 -0
  208. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlide.tsx +35 -0
  209. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideBody.tsx +42 -0
  210. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideFooter.tsx +18 -0
  211. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideHeader.tsx +21 -0
  212. package/src/components/SwipableMobileCarousel/SwipableMobileCarouselSlideParts.module.css +29 -0
  213. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +133 -0
  214. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarouselDevGuard.test.tsx +61 -0
  215. package/src/components/SwipableMobileCarousel/context.ts +13 -0
  216. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +49 -0
  217. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +62 -0
  218. package/src/components/SwipableMobileCarouselDots/__tests__/SwipableMobileCarouselDots.test.tsx +63 -0
  219. package/src/components/Table/Table.module.css +45 -0
  220. package/src/components/Table/Table.stories.tsx +125 -0
  221. package/src/components/Table/Table.tsx +51 -0
  222. package/src/components/Table/__tests__/Table.test.tsx +50 -0
  223. package/src/components/Tabs/Tabs.module.css +6 -1
  224. package/src/components/Toggle/Toggle.module.css +6 -1
  225. package/src/components/Typography/Typography.module.css +47 -0
  226. package/src/components/Typography/Typography.stories.tsx +62 -0
  227. package/src/components/Typography/Typography.tsx +30 -0
  228. package/src/components/Typography/__tests__/Typography.test.tsx +34 -0
  229. package/src/hooks/__tests__/useCarouselRouteSync.test.tsx +126 -0
  230. package/src/hooks/__tests__/useIsOffline.test.tsx +53 -0
  231. package/src/hooks/__tests__/useOfflineTileState.test.tsx +69 -0
  232. package/src/hooks/__tests__/useResponsiveLayout.test.tsx +87 -0
  233. package/src/hooks/__tests__/useSnapCarousel.test.tsx +93 -0
  234. package/src/hooks/__tests__/useSwipeReveal.test.tsx +110 -0
  235. package/src/hooks/index.ts +18 -0
  236. package/src/hooks/useCarouselRouteSync.ts +92 -0
  237. package/src/hooks/useIsOffline.ts +41 -0
  238. package/src/hooks/useOfflineTileState.ts +45 -0
  239. package/src/hooks/useResponsiveLayout.ts +36 -0
  240. package/src/hooks/useSnapCarousel.ts +79 -0
  241. package/src/hooks/useSwipeReveal.ts +107 -0
  242. package/src/index.ts +141 -3
  243. package/src/overlay-shell.ts +94 -0
  244. package/src/stories/DesignSystemOverview.stories.tsx +936 -3
  245. package/src/stories/DeviceOnlyGate.stories.tsx +35 -0
  246. package/src/stories/DeviceStorageKeyGate.stories.tsx +57 -0
  247. package/src/stories/InteractionHooks.stories.tsx +483 -0
  248. package/src/stories/MobilePatterns.stories.tsx +255 -4
  249. package/src/stories/NavTabs.stories.tsx +18 -0
  250. package/src/stories/OfflineGate.stories.tsx +42 -0
  251. package/src/stories/PageContainer.stories.tsx +37 -0
  252. package/src/stories/PageHeader.stories.tsx +11 -0
  253. package/src/stories/ResponsiveSurface.stories.tsx +47 -0
  254. package/src/stories/SwipableMobileCarousel.stories.tsx +304 -0
  255. package/src/stories/TokenGallery.stories.tsx +177 -16
  256. package/src/tokens/primitives.css +47 -8
@@ -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
+ }
@@ -0,0 +1,162 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
4
+ import { Command, type CommandItem } from '../Command';
5
+
6
+ // Command renders inside Dialog, whose exit animation reads
7
+ // prefers-reduced-motion via matchMedia, which jsdom does not implement —
8
+ // see Dialog.test.tsx's identical setup.
9
+ function installMatchMedia() {
10
+ vi.stubGlobal(
11
+ 'matchMedia',
12
+ vi.fn().mockImplementation((query: string) => ({
13
+ matches: false,
14
+ media: query,
15
+ addEventListener: vi.fn(),
16
+ removeEventListener: vi.fn(),
17
+ addListener: vi.fn(),
18
+ removeListener: vi.fn(),
19
+ dispatchEvent: vi.fn(),
20
+ })),
21
+ );
22
+ }
23
+
24
+ beforeEach(installMatchMedia);
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals();
27
+ cleanup();
28
+ });
29
+
30
+ const items: CommandItem[] = [
31
+ { id: 'new', label: 'New conversation', onSelect: vi.fn(), group: 'Actions' },
32
+ {
33
+ id: 'export',
34
+ label: 'Export chat',
35
+ onSelect: vi.fn(),
36
+ group: 'Actions',
37
+ keywords: 'download save',
38
+ },
39
+ { id: 'profile', label: 'Go to profile', onSelect: vi.fn(), group: 'Navigation' },
40
+ ];
41
+
42
+ describe('Command', () => {
43
+ it('renders nothing when closed', () => {
44
+ render(<Command open={false} onClose={vi.fn()} items={items} />);
45
+ expect(screen.queryByRole('combobox')).toBeNull();
46
+ });
47
+
48
+ it('lists all items when the query is empty', () => {
49
+ render(<Command open onClose={vi.fn()} items={items} />);
50
+ expect(screen.getAllByRole('option')).toHaveLength(3);
51
+ });
52
+
53
+ it('filters by label', () => {
54
+ render(<Command open onClose={vi.fn()} items={items} />);
55
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'profile' } });
56
+ expect(screen.getAllByRole('option')).toHaveLength(1);
57
+ expect(screen.getByRole('option', { name: 'Go to profile' })).toBeDefined();
58
+ });
59
+
60
+ it('filters by keywords in addition to the label', () => {
61
+ render(<Command open onClose={vi.fn()} items={items} />);
62
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'download' } });
63
+ expect(screen.getAllByRole('option')).toHaveLength(1);
64
+ expect(screen.getByRole('option', { name: 'Export chat' })).toBeDefined();
65
+ });
66
+
67
+ it('shows an empty state when nothing matches', () => {
68
+ render(<Command open onClose={vi.fn()} items={items} />);
69
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'zzz' } });
70
+ expect(screen.queryAllByRole('option')).toHaveLength(0);
71
+ expect(screen.getByText('No matching commands.')).toBeDefined();
72
+ });
73
+
74
+ it('renders group headers', () => {
75
+ render(<Command open onClose={vi.fn()} items={items} />);
76
+ expect(screen.getByText('Actions')).toBeDefined();
77
+ expect(screen.getByText('Navigation')).toBeDefined();
78
+ });
79
+
80
+ it('highlights the first item by default', () => {
81
+ render(<Command open onClose={vi.fn()} items={items} />);
82
+ expect(
83
+ screen.getByRole('option', { name: 'New conversation' }).getAttribute('aria-selected'),
84
+ ).toBe('true');
85
+ });
86
+
87
+ it('ArrowDown/ArrowUp move the highlighted item', () => {
88
+ render(<Command open onClose={vi.fn()} items={items} />);
89
+ const input = screen.getByRole('combobox');
90
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
91
+ expect(screen.getByRole('option', { name: 'Export chat' }).getAttribute('aria-selected')).toBe(
92
+ 'true',
93
+ );
94
+ fireEvent.keyDown(input, { key: 'ArrowUp' });
95
+ expect(
96
+ screen.getByRole('option', { name: 'New conversation' }).getAttribute('aria-selected'),
97
+ ).toBe('true');
98
+ });
99
+
100
+ it('ArrowDown does not move past the last item', () => {
101
+ render(<Command open onClose={vi.fn()} items={items} />);
102
+ const input = screen.getByRole('combobox');
103
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
104
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
105
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
106
+ expect(
107
+ screen.getByRole('option', { name: 'Go to profile' }).getAttribute('aria-selected'),
108
+ ).toBe('true');
109
+ });
110
+
111
+ it('resets the highlighted index when the query changes', () => {
112
+ render(<Command open onClose={vi.fn()} items={items} />);
113
+ const input = screen.getByRole('combobox');
114
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
115
+ fireEvent.change(input, { target: { value: 'e' } });
116
+ const [firstMatch] = screen.getAllByRole('option');
117
+ expect(firstMatch?.getAttribute('aria-selected')).toBe('true');
118
+ });
119
+
120
+ it('Enter selects the highlighted item and closes', () => {
121
+ const onClose = vi.fn();
122
+ const onSelect = vi.fn();
123
+ const localItems: CommandItem[] = [{ id: 'a', label: 'Alpha', onSelect }];
124
+ render(<Command open onClose={onClose} items={localItems} />);
125
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Enter' });
126
+ expect(onSelect).toHaveBeenCalled();
127
+ expect(onClose).toHaveBeenCalled();
128
+ });
129
+
130
+ it('clicking an item selects it and closes', () => {
131
+ const onClose = vi.fn();
132
+ const onSelect = vi.fn();
133
+ const localItems: CommandItem[] = [{ id: 'a', label: 'Alpha', onSelect }];
134
+ render(<Command open onClose={onClose} items={localItems} />);
135
+ fireEvent.click(screen.getByRole('option', { name: 'Alpha' }));
136
+ expect(onSelect).toHaveBeenCalled();
137
+ expect(onClose).toHaveBeenCalled();
138
+ });
139
+
140
+ it('sets aria-activedescendant to the highlighted option', () => {
141
+ render(<Command open onClose={vi.fn()} items={items} />);
142
+ const input = screen.getByRole('combobox');
143
+ const firstOption = screen.getByRole('option', { name: 'New conversation' });
144
+ expect(input.getAttribute('aria-activedescendant')).toBe(firstOption.id);
145
+ });
146
+
147
+ it('resets the query each time it opens', () => {
148
+ const { rerender } = render(<Command open onClose={vi.fn()} items={items} />);
149
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'profile' } });
150
+ rerender(<Command open={false} onClose={vi.fn()} items={items} />);
151
+ rerender(<Command open onClose={vi.fn()} items={items} />);
152
+ expect(screen.getByRole('combobox').getAttribute('value')).toBe('');
153
+ });
154
+
155
+ it('focuses the input on open', async () => {
156
+ render(<Command open onClose={vi.fn()} items={items} />);
157
+ await act(async () => {
158
+ await new Promise((resolve) => requestAnimationFrame(resolve));
159
+ });
160
+ expect(document.activeElement).toBe(screen.getByRole('combobox'));
161
+ });
162
+ });
@@ -65,7 +65,11 @@
65
65
  align-items: center;
66
66
  justify-content: center;
67
67
  gap: var(--sv-space-2);
68
- padding: 13px var(--sv-space-4);
68
+ /* Kept in sync with Button's .md rule by hand (see design-system.md
69
+ "Button and Checkbox touch ergonomics" — not a literal reuse of
70
+ Button). Matches Button's md padding, updated alongside it (10px,
71
+ 36px rendered height). */
72
+ padding: 10px var(--sv-space-4);
69
73
  border: 1px solid transparent;
70
74
  border-radius: var(--sv-radius-md);
71
75
  font-family: var(--sv-font-family);
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ContextMenu } from './ContextMenu';
3
+
4
+ const meta = {
5
+ title: 'Components/ContextMenu',
6
+ component: ContextMenu,
7
+ parameters: {
8
+ layout: 'padded',
9
+ docs: {
10
+ description: {
11
+ component:
12
+ "Right-click menu on desktop, long-press on touch. Desktop positioning reuses Popover's existing collision detection anchored to the click point; touch opens the same items in a Drawer bottom sheet — a floating panel at a touch point isn't a pattern touch interfaces have.",
13
+ },
14
+ },
15
+ },
16
+ args: {
17
+ children: <span />,
18
+ items: [
19
+ { label: 'Rename', onSelect: () => {} },
20
+ { label: 'Duplicate', onSelect: () => {} },
21
+ { type: 'separator' },
22
+ { label: 'Delete', destructive: true, onSelect: () => {} },
23
+ ],
24
+ 'aria-label': 'Row actions',
25
+ },
26
+ } satisfies Meta<typeof ContextMenu>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export const Default: Story = {
34
+ render: (args) => (
35
+ <ContextMenu {...args}>
36
+ <div
37
+ style={{
38
+ width: 240,
39
+ padding: 'var(--sv-space-4)',
40
+ border: '1px dashed var(--sv-color-border-strong)',
41
+ borderRadius: 'var(--sv-radius-md)',
42
+ textAlign: 'center',
43
+ fontSize: 14,
44
+ color: 'var(--sv-color-text-muted)',
45
+ }}
46
+ >
47
+ Right-click here (or long-press on touch)
48
+ </div>
49
+ </ContextMenu>
50
+ ),
51
+ };
52
+
53
+ export const WithIconsAndChecked: Story = {
54
+ args: {
55
+ items: [
56
+ { type: 'label', label: 'Sort by' },
57
+ { label: 'Name', checked: true, onSelect: () => {} },
58
+ { label: 'Date modified', checked: false, onSelect: () => {} },
59
+ { type: 'separator' },
60
+ { label: 'Rename', icon: 'pencil', onSelect: () => {} },
61
+ { label: 'Delete', icon: 'trash-2', destructive: true, onSelect: () => {} },
62
+ ],
63
+ },
64
+ render: (args) => (
65
+ <ContextMenu {...args}>
66
+ <div
67
+ style={{
68
+ width: 240,
69
+ padding: 'var(--sv-space-4)',
70
+ border: '1px dashed var(--sv-color-border-strong)',
71
+ borderRadius: 'var(--sv-radius-md)',
72
+ textAlign: 'center',
73
+ fontSize: 14,
74
+ color: 'var(--sv-color-text-muted)',
75
+ }}
76
+ >
77
+ Right-click here
78
+ </div>
79
+ </ContextMenu>
80
+ ),
81
+ };
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import { cloneElement, useState } from 'react';
4
+ import type { ReactElement } from 'react';
5
+ import type { MouseEvent as ReactMouseEvent } from 'react';
6
+ import { useIsMobile, useLongPress } from '../../hooks';
7
+ import { Popover } from '../Popover/Popover';
8
+ import { Drawer } from '../Drawer/Drawer';
9
+ import { MenuEntries, type MenuEntry, type MenuItem } from '../Menu/Menu';
10
+
11
+ export interface ContextMenuProps {
12
+ /** The element that gets right-click (desktop) / long-press (touch)
13
+ * behavior — cloned with the trigger handlers, rendered as-is otherwise.
14
+ * Typed with `<any>` props (not a bare `ReactElement`, whose implicit
15
+ * `unknown` props type makes `cloneElement` reject *both* branches of
16
+ * the long-press/onContextMenu union below — an interface value like
17
+ * `LongPressHandlers` isn't assignable to an indexed props type without
18
+ * its own index signature) — the standard, narrowly-scoped escape hatch
19
+ * for "clone an arbitrary child with props not known ahead of time". */
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above
21
+ children: ReactElement<any>;
22
+ items: MenuEntry[];
23
+ 'aria-label': string;
24
+ }
25
+
26
+ /**
27
+ * ContextMenu — right-click menu on desktop, long-press on touch.
28
+ *
29
+ * Desktop positioning reuses `Popover`'s existing collision-detection logic
30
+ * rather than reimplementing it: the panel is wrapped in a zero-size,
31
+ * `position: fixed` anchor placed at the click coordinates, so `Popover`
32
+ * measures and flips against *that* point exactly as it would a normal
33
+ * trigger element.
34
+ *
35
+ * Touch uses `useLongPress` (which already handles the OS's own long-press
36
+ * callout/context-menu suppression) and opens the same items in a `Drawer`
37
+ * bottom sheet — the same adaptive-surface split `Menu` already uses,
38
+ * because a floating panel positioned at a touch point isn't a pattern
39
+ * touch interfaces have in the first place.
40
+ */
41
+ export function ContextMenu({ children, items, 'aria-label': ariaLabel }: ContextMenuProps) {
42
+ const isMobile = useIsMobile();
43
+ const [open, setOpen] = useState(false);
44
+ const [point, setPoint] = useState({ x: 0, y: 0 });
45
+
46
+ const longPressHandlers = useLongPress({
47
+ onLongPress: () => setOpen(true),
48
+ disabled: !isMobile,
49
+ });
50
+
51
+ function handleContextMenu(e: ReactMouseEvent) {
52
+ e.preventDefault();
53
+ setPoint({ x: e.clientX, y: e.clientY });
54
+ setOpen(true);
55
+ }
56
+
57
+ const wrappedChild = cloneElement(
58
+ children,
59
+ isMobile ? longPressHandlers : { onContextMenu: handleContextMenu },
60
+ );
61
+
62
+ const list = (
63
+ <MenuEntries
64
+ items={items}
65
+ onSelect={(entry: MenuItem) => {
66
+ setOpen(false);
67
+ entry.onSelect();
68
+ }}
69
+ />
70
+ );
71
+
72
+ if (isMobile) {
73
+ return (
74
+ <>
75
+ {wrappedChild}
76
+ <Drawer open={open} onClose={() => setOpen(false)} aria-label={ariaLabel}>
77
+ {list}
78
+ </Drawer>
79
+ </>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <>
85
+ {wrappedChild}
86
+ {open && (
87
+ <div style={{ position: 'fixed', left: point.x, top: point.y, width: 0, height: 0 }}>
88
+ <Popover
89
+ trigger={<span />}
90
+ open={open}
91
+ onClose={() => setOpen(false)}
92
+ aria-label={ariaLabel}
93
+ >
94
+ {list}
95
+ </Popover>
96
+ </div>
97
+ )}
98
+ </>
99
+ );
100
+ }