@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,122 @@
1
+ /* Trigger styled to match Input/DatePicker's height/border/radius/focus ring
2
+ * for visual consistency as a form field. */
3
+
4
+ .trigger {
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: space-between;
8
+ gap: var(--sv-space-2);
9
+ width: 100%;
10
+ height: 36px;
11
+ box-sizing: border-box;
12
+ padding: 4px var(--sv-space-3);
13
+ font-family: var(--sv-font-family);
14
+ font-size: var(--sv-font-size-sm);
15
+ line-height: 1.5;
16
+ color: var(--sv-color-text-primary);
17
+ background-color: var(--sv-color-surface);
18
+ border: 1px solid var(--sv-color-border-strong);
19
+ border-radius: var(--sv-radius-md);
20
+ cursor: pointer;
21
+ transition:
22
+ border-color 0.15s ease,
23
+ box-shadow 0.15s ease;
24
+ }
25
+
26
+ @media (pointer: coarse) {
27
+ .trigger {
28
+ font-size: var(--sv-font-size-md);
29
+ }
30
+ }
31
+
32
+ .trigger:focus-visible {
33
+ outline: none;
34
+ border-color: var(--sv-color-accent);
35
+ box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
36
+ }
37
+
38
+ .trigger:disabled {
39
+ opacity: 0.5;
40
+ cursor: not-allowed;
41
+ }
42
+
43
+ .triggerValue {
44
+ overflow: hidden;
45
+ color: var(--sv-color-text-primary);
46
+ text-overflow: ellipsis;
47
+ white-space: nowrap;
48
+ }
49
+
50
+ .triggerPlaceholder {
51
+ color: var(--sv-color-text-muted);
52
+ }
53
+
54
+ .body {
55
+ display: flex;
56
+ flex-direction: column;
57
+ max-height: min(60vh, 360px);
58
+ }
59
+
60
+ .search {
61
+ flex-shrink: 0;
62
+ width: 100%;
63
+ border: none;
64
+ border-bottom: 1px solid var(--sv-color-border);
65
+ background: transparent;
66
+ color: var(--sv-color-text-primary);
67
+ font: inherit;
68
+ font-size: var(--sv-font-size-md);
69
+ padding: var(--sv-space-3);
70
+ }
71
+
72
+ .search:focus {
73
+ outline: none;
74
+ }
75
+
76
+ .search::placeholder {
77
+ color: var(--sv-color-text-muted);
78
+ }
79
+
80
+ .list {
81
+ list-style: none;
82
+ margin: 0;
83
+ padding: var(--sv-space-1);
84
+ overflow-y: auto;
85
+ }
86
+
87
+ .option {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: var(--sv-space-2);
91
+ width: 100%;
92
+ min-height: var(--sv-touch-target-min, 44px);
93
+ padding: var(--sv-space-2) var(--sv-space-3);
94
+ border: none;
95
+ border-radius: var(--sv-radius-sm);
96
+ background: transparent;
97
+ color: var(--sv-color-text-primary);
98
+ font: inherit;
99
+ font-size: var(--sv-font-size-sm);
100
+ text-align: left;
101
+ cursor: pointer;
102
+ }
103
+
104
+ .optionHighlighted {
105
+ background: var(--sv-color-surface-sunken);
106
+ }
107
+
108
+ .check {
109
+ display: inline-flex;
110
+ flex-shrink: 0;
111
+ width: var(--sv-icon-size-sm);
112
+ height: var(--sv-icon-size-sm);
113
+ align-items: center;
114
+ justify-content: center;
115
+ }
116
+
117
+ .empty {
118
+ padding: var(--sv-space-4) var(--sv-space-3);
119
+ color: var(--sv-color-text-muted);
120
+ font-size: var(--sv-font-size-sm);
121
+ text-align: center;
122
+ }
@@ -0,0 +1,79 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Combobox } from './Combobox';
4
+
5
+ const OPTIONS = [
6
+ { value: 'tasks', label: 'sovereign-tasks' },
7
+ { value: 'ledger', label: 'sovereign-ledger' },
8
+ { value: 'shopper', label: 'sovereign-shopper' },
9
+ { value: 'healthlog', label: 'sovereign-healthlog' },
10
+ { value: 'plainwrite', label: 'sovereign-plainwrite' },
11
+ ];
12
+
13
+ const meta = {
14
+ title: 'Components/Combobox',
15
+ component: Combobox,
16
+ parameters: {
17
+ layout: 'padded',
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "A searchable single-select: Popover on desktop, a bottom-sheet Drawer on mobile — the same adaptive pattern as Menu and DatePicker. For a short fixed list where search adds no value, use Select's native <select> instead.",
22
+ },
23
+ },
24
+ },
25
+ args: { options: OPTIONS, value: null, onChange: () => {}, 'aria-label': 'Plugin' },
26
+ } satisfies Meta<typeof Combobox>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function ComboboxDemo() {
34
+ const [value, setValue] = useState<string | null>(null);
35
+ return (
36
+ <div style={{ width: 280 }}>
37
+ <Combobox
38
+ options={OPTIONS}
39
+ value={value}
40
+ onChange={setValue}
41
+ placeholder="Select a plugin"
42
+ aria-label="Plugin"
43
+ />
44
+ </div>
45
+ );
46
+ }
47
+
48
+ export const Default: Story = {
49
+ render: () => <ComboboxDemo />,
50
+ };
51
+
52
+ export const Preselected: Story = {
53
+ render: () => {
54
+ function Demo() {
55
+ const [value, setValue] = useState<string | null>('ledger');
56
+ return (
57
+ <div style={{ width: 280 }}>
58
+ <Combobox options={OPTIONS} value={value} onChange={setValue} aria-label="Plugin" />
59
+ </div>
60
+ );
61
+ }
62
+ return <Demo />;
63
+ },
64
+ };
65
+
66
+ export const Disabled: Story = {
67
+ render: () => (
68
+ <div style={{ width: 280 }}>
69
+ <Combobox
70
+ options={OPTIONS}
71
+ value={null}
72
+ onChange={() => {}}
73
+ placeholder="Select a plugin"
74
+ aria-label="Plugin"
75
+ disabled
76
+ />
77
+ </div>
78
+ ),
79
+ };
@@ -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
+ }