@elabs-ai/components-ui 4.0.0 → 4.2.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -1,10 +1,19 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
2
3
  import { Input } from "./input";
3
4
 
4
5
  const meta = {
5
6
  title: "Core/Input",
6
7
  component: Input,
7
8
  tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The plain text field. A search box with a leading icon and a clear button is `Data/SearchInput`, which is built on this one; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
14
+ },
15
+ },
16
+ },
8
17
  args: { placeholder: "you@example.com" },
9
18
  argTypes: {
10
19
  type: {
@@ -42,3 +51,115 @@ type Story = StoryObj<typeof meta>;
42
51
  export const Default: Story = {};
43
52
  export const Invalid: Story = { args: { "aria-invalid": true, defaultValue: "not-an-email" } };
44
53
  export const Disabled: Story = { args: { disabled: true, value: "Disabled" } };
54
+
55
+ /**
56
+ * The element whose `data-theme` actually GOVERNS `el` — the nearest ancestor
57
+ * carrying the attribute, falling back to the document element.
58
+ *
59
+ * The earlier version of this helper guessed between `document.body` and
60
+ * `document.documentElement`. When the decorator wrote `data-theme` onto a
61
+ * wrapper closer to the story than either of those, flipping the far ancestor
62
+ * was a silent no-op: the nearer attribute still won the cascade, so BOTH
63
+ * branches of the loop below asserted in whatever theme the run had already
64
+ * chosen, and the docblock's "checked in both themes" claim was false. That
65
+ * stayed invisible until the suite was run under `STORYBOOK_THEME=dark`, where
66
+ * the same two branches both asserted in dark and the guard fired. Resolving
67
+ * the governing element from the input itself cannot drift that way.
68
+ */
69
+ function themeHost(el: Element): HTMLElement {
70
+ return (el.closest("[data-theme]") as HTMLElement | null) ?? document.documentElement;
71
+ }
72
+
73
+ /**
74
+ * Regression lock for #49: `Input` must set its OWN text colour rather than
75
+ * inherit whatever ambient ink its container declares. Nested inside a
76
+ * `text-sidebar-foreground` wrapper (the `sidebar-04` mail search field's real
77
+ * shape), the input's resolved `color` must still match an un-nested `Input`'s
78
+ * — not the wrapper's near-invisible-on-`--background` sidebar ink. Runs in
79
+ * the Storybook browser project so `getComputedStyle` reflects the real
80
+ * Tailwind/token cascade (a jsdom unit test has none — `css: false` in
81
+ * `vitest.config.ts` — so it can only assert the class list, which would stay
82
+ * green even if a later change kept the class string but lost the cascade).
83
+ * Checked in BOTH themes via an in-place `data-theme` flip (no remount), so
84
+ * the fix is verified theme-safe rather than merely assumed to be from the
85
+ * class being additive.
86
+ */
87
+ export const InAmbientTextContainer: Story = {
88
+ render: () => (
89
+ <div className="flex flex-col gap-4">
90
+ <Input aria-label="reference input" placeholder="reference (no ambient wrapper)" />
91
+ <div className="text-sidebar-foreground">
92
+ <Input aria-label="nested input" placeholder="nested under text-sidebar-foreground" />
93
+ </div>
94
+ <div className="text-muted-foreground">
95
+ <Input aria-label="muted input" placeholder="nested under text-muted-foreground" />
96
+ </div>
97
+ </div>
98
+ ),
99
+ play: async ({ canvas, userEvent }) => {
100
+ const reference = canvas.getByLabelText<HTMLInputElement>("reference input");
101
+ const nested = canvas.getByLabelText<HTMLInputElement>("nested input");
102
+ const muted = canvas.getByLabelText<HTMLInputElement>("muted input");
103
+ await userEvent.type(nested, "search query");
104
+
105
+ const assertMatchesReference = async () => {
106
+ await waitFor(() => {
107
+ expect(getComputedStyle(nested).color).toBe(getComputedStyle(reference).color);
108
+ expect(getComputedStyle(muted).color).toBe(getComputedStyle(reference).color);
109
+ });
110
+ /*
111
+ * Guard against a vacuous pass: an ambient wrapper's ink must actually
112
+ * differ from the input's own, or this lock could never fail.
113
+ *
114
+ * The guard rides on the `text-muted-foreground` wrapper, NOT the
115
+ * sidebar one. `--sidebar-foreground` and `--foreground` are separate
116
+ * roles that happen to carry the same literal in the dark reference
117
+ * theme (both `oklch(0.95 0.004 257)`) — a legitimate coincidence, not
118
+ * an undeclared alias: light's sidebar ink is near-white because that
119
+ * theme's sidebar ground is DARK, and dark's is near-white because
120
+ * everything is. So in dark the sidebar wrapper cannot express a
121
+ * difference, and asserting one there is unsatisfiable.
122
+ * `--muted-foreground` differs from `--foreground` in both reference
123
+ * themes (light 0.5 vs 0.3, dark 0.72 vs 0.95), so it can carry the
124
+ * guard in either. The sidebar wrapper stays because it is the real
125
+ * shape this locks (the `sidebar-04` mail search field).
126
+ */
127
+ await expect(getComputedStyle(muted).color).not.toBe(
128
+ getComputedStyle(muted.parentElement!).color,
129
+ );
130
+ };
131
+
132
+ const host = themeHost(nested);
133
+ const original = host.getAttribute("data-theme");
134
+ /*
135
+ * Prove the flip actually took, rather than assuming it. A flip that lands
136
+ * on an element the cascade ignores turns the whole cross-theme claim into
137
+ * a no-op, which is precisely how this story passed for two themes while
138
+ * only ever measuring one.
139
+ */
140
+ const assertThemeApplied = async (expected: string) => {
141
+ await waitFor(() => {
142
+ expect(getComputedStyle(host).getPropertyValue("--foreground").trim()).not.toBe("");
143
+ });
144
+ expect(host.getAttribute("data-theme")).toBe(expected);
145
+ expect(nested.closest("[data-theme]")).toBe(host);
146
+ };
147
+
148
+ try {
149
+ host.setAttribute("data-theme", "light");
150
+ await assertThemeApplied("light");
151
+ const lightInk = getComputedStyle(nested).color;
152
+ await assertMatchesReference();
153
+
154
+ host.setAttribute("data-theme", "dark");
155
+ await assertThemeApplied("dark");
156
+ await assertMatchesReference();
157
+ // The two themes must actually resolve to different ink, or "checked in
158
+ // both themes" is a claim about an attribute nobody read.
159
+ expect(getComputedStyle(nested).color).not.toBe(lightInk);
160
+ } finally {
161
+ if (original === null) host.removeAttribute("data-theme");
162
+ else host.setAttribute("data-theme", original);
163
+ }
164
+ },
165
+ };
@@ -16,4 +16,17 @@ describe("Input", () => {
16
16
  const el = screen.getByLabelText<HTMLInputElement>("email");
17
17
  expect(el).toBeDisabled();
18
18
  });
19
+
20
+ it("applies text-foreground class to ensure text color is not inherited from parent", () => {
21
+ // Render Input inside a container with a different text color (simulating sidebar)
22
+ render(
23
+ <div className="text-sidebar-foreground">
24
+ <Input aria-label="search" />
25
+ </div>,
26
+ );
27
+ const el = screen.getByLabelText<HTMLInputElement>("search");
28
+ // Verify that the Input element has the text-foreground class
29
+ // This ensures its text color is explicitly set and not inherited
30
+ expect(el.className).toContain("text-foreground");
31
+ });
19
32
  });
@@ -12,12 +12,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
12
12
  ref={ref}
13
13
  type={type}
14
14
  className={cn(
15
- "flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
15
+ "flex h-control w-full rounded-control border border-input bg-input-background text-foreground px-3 py-1 text-body shadow-input transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
16
16
  "placeholder:text-muted-foreground",
17
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
18
- "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-muted disabled:border-border",
17
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
18
+ // One disabled mechanism (opacity fade), matching every `Button`
19
+ // variant — `disabled:bg-muted` used to also fire here, stacking a
20
+ // fill swap on top of the fade (#286); `disabled:border-border` stays,
21
+ // it is the separate/subtle "not editable" edge, not a fill.
22
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:border-border",
19
23
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
20
- "file:border-0 file:bg-transparent file:text-sm file:font-medium",
24
+ "file:border-0 file:bg-transparent file:text-body file:font-medium",
21
25
  className,
22
26
  )}
23
27
  {...props}
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor } from "storybook/test";
2
3
  import { Search } from "lucide-react";
3
4
  import {
4
5
  InputGroup,
@@ -85,3 +86,76 @@ export const Card: Story = {
85
86
  </div>
86
87
  ),
87
88
  };
89
+
90
+ /**
91
+ * Regression lock for #67 (fix round 2) — the compound focus indicator must be
92
+ * ONE mark on ONE element.
93
+ *
94
+ * `InputGroup` delegates its indicator to the wrapper, because the control is
95
+ * borderless and full-bleed and a ring around IT would be drawn inside the
96
+ * well. Before this lock the delegation was only half done and the indicator
97
+ * split across two elements: the wrapper painted the brand ring and no contour
98
+ * (its own base `outline-none` set `--tw-outline-style: none`, and a following
99
+ * `outline-1` re-declared `outline-style` from that variable), while the inner
100
+ * control painted the contour and no ring, ~10px inside, at a different radius.
101
+ * On light that read as a stray dark rectangle across the composer, and the
102
+ * well kept the 1.23:1 un-contoured lime ring #67 exists to remove.
103
+ *
104
+ * THE ASSERTION IS ON COMPUTED STYLE, NOT ON THE CLASS STRING. The class string
105
+ * was already "correct" while the bug shipped — `has-[…]:focus-ring-static` was
106
+ * present and cancelled by a sibling utility — so a class-name assertion (or a
107
+ * jsdom unit test, which has no CSS at all: `css: false` in `vitest.config.ts`)
108
+ * stays green through the exact regression this locks. It therefore lives in
109
+ * the Storybook browser project, where `getComputedStyle` reflects the real
110
+ * Tailwind cascade.
111
+ */
112
+ export const CompoundFocusIndicator: Story = {
113
+ tags: ["!autodocs"],
114
+ render: () => (
115
+ <InputGroup className="max-w-sm">
116
+ <InputGroupAddon>
117
+ <Search />
118
+ </InputGroupAddon>
119
+ <InputGroupInput aria-label="locked control" placeholder="Search…" />
120
+ </InputGroup>
121
+ ),
122
+ play: async ({ canvas, userEvent }) => {
123
+ const control = canvas.getByLabelText<HTMLInputElement>("locked control");
124
+ const wrapper = control.closest<HTMLElement>("[data-slot=input-group]")!;
125
+ await userEvent.tab();
126
+ await expect(control).toHaveFocus();
127
+
128
+ /*
129
+ * Settle past `transition-colors`: reading immediately after focus catches
130
+ * `outline-color` mid-transition and reports the element's own ink instead
131
+ * of the contour token. That artefact is why the utility now declares
132
+ * `outline-color` at rest (themes.css); the wait keeps this lock honest
133
+ * about which property it is measuring.
134
+ */
135
+ await waitFor(() => {
136
+ const w = getComputedStyle(wrapper);
137
+ // The wrapper owns BOTH layers.
138
+ expect(w.outlineStyle).not.toBe("none");
139
+ expect(w.outlineWidth).toBe("1px");
140
+ expect(ringSpread(w)).toBeGreaterThan(0);
141
+ // …and they are flush: the contour sits exactly on the ring's outer edge.
142
+ expect(parseFloat(w.outlineOffset)).toBe(ringSpread(w));
143
+ });
144
+
145
+ // The control paints NEITHER layer — no second mark inside the well.
146
+ const c = getComputedStyle(control);
147
+ await expect(c.outlineStyle).toBe("none");
148
+ await expect(ringSpread(c)).toBe(0);
149
+ },
150
+ };
151
+
152
+ /**
153
+ * `--tw-ring-shadow` is a `0 0 0 calc(<offset> + <width>) <colour>` string; the
154
+ * painted reach is that `calc`. Reading it (rather than `boxShadow`) keeps the
155
+ * measurement independent of whatever elevation rung the element also carries.
156
+ */
157
+ function ringSpread(style: CSSStyleDeclaration): number {
158
+ const shadow = style.getPropertyValue("--tw-ring-shadow");
159
+ const match = /calc\(\s*([\d.]+)px\s*\+\s*([\d.]+)px\s*\)/.exec(shadow);
160
+ return match ? parseFloat(match[1]) + parseFloat(match[2]) : 0;
161
+ }
@@ -7,12 +7,21 @@ import { Textarea } from "../textarea";
7
7
 
8
8
  export const inputGroupVariants = cva(
9
9
  cn(
10
- "group/input-group relative flex w-full flex-wrap items-center rounded-md outline-none transition-[color,box-shadow] duration-fast ease-standard",
10
+ "group/input-group relative flex w-full flex-wrap items-center rounded-control transition-[color,box-shadow] duration-fast ease-standard",
11
11
  // Grow to fit a textarea even when it's nested (e.g. wrapped in a
12
12
  // `display:contents` body like PromptInputBody). `:has(>textarea)`
13
13
  // misses through that wrapper, so match any descendant textarea.
14
- "h-9 has-[textarea]:h-auto",
15
- "has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-offset-1 has-[[data-slot=input-group-control]:focus-visible]:ring-offset-background",
14
+ "h-control has-[textarea]:h-auto",
15
+ // The compound indicator is the wrapper's, not the control's — the control
16
+ // is borderless and full-bleed, so a ring around IT would draw inside the
17
+ // well. `focus-ring-static` paints BOTH layers; the only geometry knob is
18
+ // `ring-offset-1`, which `outline-offset: calc(--tw-ring-offset-width + 2px)`
19
+ // tracks so ring and contour stay flush at the same radius. Do NOT add an
20
+ // `outline-*` utility here: `outline-1` re-declares
21
+ // `outline-style: var(--tw-outline-style)`, which `outline-none` on this
22
+ // same element used to pin to `none` — that pairing shipped, and it cancelled
23
+ // the contour on every composer, NumberInput and TagInput (#67 fix round 2).
24
+ "has-[[data-slot=input-group-control]:focus-visible]:focus-ring-static has-[[data-slot=input-group-control]:focus-visible]:ring-offset-1 has-[[data-slot=input-group-control]:focus-visible]:ring-offset-background",
16
25
  "has-[[data-slot=input-group-control][aria-invalid=true]]:ring-2 has-[[data-slot=input-group-control][aria-invalid=true]]:ring-destructive",
17
26
  ),
18
27
  {
@@ -20,7 +29,7 @@ export const inputGroupVariants = cva(
20
29
  variant: {
21
30
  // The form-field look: the strong `--input` boundary is the field's
22
31
  // sole structural cue, so it stays on the strong rung.
23
- outline: "border border-input shadow-sm",
32
+ outline: "border border-input shadow-input",
24
33
  // The composer look (#194, research 08 §C.1/§D): a soft fill with the
25
34
  // focus-within ring carrying focus — NO hard border. Separation comes
26
35
  // from the fill (the chat footer already draws its own `border-t`).
@@ -59,7 +68,7 @@ export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(function I
59
68
  });
60
69
 
61
70
  export const inputGroupAddonVariants = cva(
62
- "flex h-auto cursor-text items-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none [&>svg:not([class*='size-'])]:size-4",
71
+ "flex h-auto cursor-text items-center gap-2 py-1.5 text-body font-medium text-muted-foreground select-none [&>svg:not([class*='size-'])]:size-4",
63
72
  {
64
73
  variants: {
65
74
  align: {
@@ -89,7 +98,7 @@ export const InputGroupAddon = forwardRef<
89
98
  );
90
99
  });
91
100
 
92
- const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
101
+ const inputGroupButtonVariants = cva("flex items-center gap-2 text-body shadow-none", {
93
102
  variants: {
94
103
  size: {
95
104
  xs: "h-6 gap-1 rounded-sm px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
@@ -128,7 +137,7 @@ export const InputGroupText = forwardRef<HTMLSpanElement, ComponentProps<"span">
128
137
  <span
129
138
  ref={ref}
130
139
  className={cn(
131
- "flex items-center gap-2 text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4",
140
+ "flex items-center gap-2 text-body text-muted-foreground [&_svg:not([class*='size-'])]:size-4",
132
141
  className,
133
142
  )}
134
143
  {...props}
@@ -144,7 +153,7 @@ export const InputGroupInput = forwardRef<HTMLInputElement, ComponentProps<typeo
144
153
  ref={ref}
145
154
  data-slot="input-group-control"
146
155
  className={cn(
147
- "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:ring-offset-0",
156
+ "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
148
157
  className,
149
158
  )}
150
159
  {...props}
@@ -160,7 +169,7 @@ export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, ComponentProps
160
169
  ref={ref}
161
170
  data-slot="input-group-control"
162
171
  className={cn(
163
- "flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0",
172
+ "flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
164
173
  className,
165
174
  )}
166
175
  {...props}
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { forwardRef, useContext, type ComponentPropsWithoutRef, type ElementRef } from "react";
2
4
  import { OTPInput, OTPInputContext } from "input-otp";
3
5
  import { Minus } from "lucide-react";
@@ -41,8 +43,12 @@ export const InputOTPSlot = forwardRef<
41
43
  <div
42
44
  ref={ref}
43
45
  className={cn(
44
- "relative flex size-9 items-center justify-center border-y border-e border-input text-sm shadow-sm transition-[color,border-color,box-shadow] duration-fast ease-standard first:rounded-s-md first:border-s last:rounded-e-md",
45
- isActive && "z-10 ring-2 ring-ring",
46
+ "relative flex size-9 items-center justify-center border-y border-e border-input text-body shadow-sm transition-[color,border-color,box-shadow] duration-fast ease-standard first:rounded-s-md first:border-s last:rounded-e-md",
47
+ // The OTP slot IS the field's focus indicator: the real focused element is
48
+ // InputOTP's visually-hidden <input>, and `isActive` is the caret's slot. It
49
+ // therefore carries the compound indicator (#67) — the state is JS-driven, so
50
+ // it is the `-static` flavour rather than a `:focus-visible` one.
51
+ isActive && "z-10 focus-ring-static",
46
52
  className,
47
53
  )}
48
54
  {...props}
@@ -0,0 +1,6 @@
1
+ export {
2
+ KeyboardShortcuts,
3
+ type KeyboardShortcutsProps,
4
+ type Shortcut,
5
+ type ShortcutGroup,
6
+ } from "./keyboard-shortcuts";
@@ -0,0 +1,177 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect } from "storybook/test";
3
+ import { useState } from "react";
4
+ import { KeyboardShortcuts, type ShortcutGroup } from "./keyboard-shortcuts";
5
+
6
+ const groups: ShortcutGroup[] = [
7
+ {
8
+ id: "navigation",
9
+ label: "Navigation",
10
+ defaultOpen: true,
11
+ items: [
12
+ { action: "Open command palette", keys: ["⌘", "K"] },
13
+ { action: "Go to file", keys: ["⌘", "P"] },
14
+ { action: "Go to line", keys: ["⌘", "G"] },
15
+ ],
16
+ },
17
+ {
18
+ id: "general",
19
+ label: "General",
20
+ items: [
21
+ { action: "Close tab", keys: ["⌘", "W"] },
22
+ { action: "Save", keys: ["⌘", "S"] },
23
+ { action: "New window", keys: ["⌘", "⇧", "N"] },
24
+ ],
25
+ },
26
+ {
27
+ id: "editing",
28
+ label: "Editing",
29
+ items: [
30
+ { action: "Duplicate line", keys: ["⌘", "⇧", "D"] },
31
+ { action: "Comment line", keys: ["⌘", "/"] },
32
+ ],
33
+ },
34
+ ];
35
+
36
+ const meta = {
37
+ title: "Disclosure/KeyboardShortcuts",
38
+ component: KeyboardShortcuts,
39
+ tags: ["autodocs"],
40
+ parameters: {
41
+ docs: {
42
+ description: {
43
+ component:
44
+ "A grouped, searchable presentation of a shortcut set. Renders content only — " +
45
+ "the app supplies the Dialog/Sheet shell, so the same component works in a modal, " +
46
+ "a settings page or a sidebar. Group counts are always derived from `items.length` " +
47
+ "(or the number of matching items while filtering), never passed in.",
48
+ },
49
+ },
50
+ },
51
+ argTypes: {
52
+ groups: {
53
+ description:
54
+ "Shortcut groups (`ShortcutGroup[]`). Each group's displayed count is derived from `items.length`.",
55
+ control: false,
56
+ table: { category: "Content" },
57
+ },
58
+ searchable: {
59
+ description: "Show the built-in search field.",
60
+ control: "boolean",
61
+ table: { category: "Behavior" },
62
+ },
63
+ query: {
64
+ description: "Controlled search query, if the app owns it.",
65
+ control: "text",
66
+ table: { category: "State" },
67
+ },
68
+ onQueryChange: {
69
+ description: "Callback fired when the search query changes.",
70
+ control: false,
71
+ table: { category: "Behavior" },
72
+ },
73
+ className: {
74
+ description: "Extra Tailwind classes merged via cn().",
75
+ control: "text",
76
+ table: { category: "Appearance" },
77
+ },
78
+ },
79
+ args: {
80
+ groups,
81
+ },
82
+ } satisfies Meta<typeof KeyboardShortcuts>;
83
+ export default meta;
84
+ type Story = StoryObj<typeof meta>;
85
+
86
+ export const Default: Story = {
87
+ args: {
88
+ groups,
89
+ },
90
+ render: (args) => (
91
+ <div className="w-96">
92
+ <KeyboardShortcuts {...args} />
93
+ </div>
94
+ ),
95
+ };
96
+
97
+ /** Typing a query hides non-matching groups and re-derives the visible group's count. */
98
+ export const Filtered: Story = {
99
+ args: {
100
+ groups,
101
+ },
102
+ render: (args) => (
103
+ <div className="w-96">
104
+ <KeyboardShortcuts {...args} query="close" />
105
+ </div>
106
+ ),
107
+ play: async ({ canvas }) => {
108
+ // Only "General" contains a "Close tab" action — the other two groups hide entirely.
109
+ await expect(canvas.getByText("General")).toBeInTheDocument();
110
+ await expect(canvas.queryByText("Navigation")).not.toBeInTheDocument();
111
+ await expect(canvas.queryByText("Editing")).not.toBeInTheDocument();
112
+ // The visible group's badge reflects the single matching item, not its total (3).
113
+ await expect(canvas.getByText("1")).toBeInTheDocument();
114
+ await expect(canvas.getByText("Close tab")).toBeInTheDocument();
115
+ },
116
+ };
117
+
118
+ /** A query that matches nothing renders a designed empty message, not a blank area. */
119
+ export const NoMatches: Story = {
120
+ args: {
121
+ groups,
122
+ },
123
+ render: (args) => (
124
+ <div className="w-96">
125
+ <KeyboardShortcuts {...args} query="xyz-nonexistent" />
126
+ </div>
127
+ ),
128
+ play: async ({ canvas }) => {
129
+ await expect(canvas.getByText("No shortcuts found")).toBeInTheDocument();
130
+ },
131
+ };
132
+
133
+ /** Uncontrolled search — the app doesn't own the query; the component manages it internally. */
134
+ export const Uncontrolled: Story = {
135
+ args: {
136
+ groups,
137
+ },
138
+ render: (args) => (
139
+ <div className="w-96">
140
+ <KeyboardShortcuts {...args} />
141
+ </div>
142
+ ),
143
+ play: async ({ canvas, userEvent }) => {
144
+ const input = canvas.getByLabelText("Search shortcuts");
145
+ await userEvent.type(input, "save");
146
+ await expect(canvas.getByText("General")).toBeInTheDocument();
147
+ await expect(canvas.queryByText("Navigation")).not.toBeInTheDocument();
148
+ },
149
+ };
150
+
151
+ /** The app owns the query — e.g. it lives alongside a `Dialog` header search field. */
152
+ export const ControlledQuery: Story = {
153
+ render: () => {
154
+ function Demo() {
155
+ const [query, setQuery] = useState("");
156
+ return (
157
+ <div className="w-96">
158
+ <KeyboardShortcuts groups={groups} query={query} onQueryChange={setQuery} />
159
+ </div>
160
+ );
161
+ }
162
+ return <Demo />;
163
+ },
164
+ };
165
+
166
+ /** No search field — just the grouped, collapsible list. */
167
+ export const NotSearchable: Story = {
168
+ args: {
169
+ groups,
170
+ searchable: false,
171
+ },
172
+ render: (args) => (
173
+ <div className="w-96">
174
+ <KeyboardShortcuts {...args} />
175
+ </div>
176
+ ),
177
+ };
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { KeyboardShortcuts, type ShortcutGroup } from "./keyboard-shortcuts";
4
+
5
+ const groups: ShortcutGroup[] = [
6
+ {
7
+ id: "navigation",
8
+ label: "Navigation",
9
+ items: [
10
+ { action: "Open command palette", keys: ["⌘", "K"] },
11
+ { action: "Go to file", keys: ["⌘", "P"] },
12
+ ],
13
+ },
14
+ {
15
+ id: "general",
16
+ label: "General",
17
+ items: [
18
+ { action: "Close tab", keys: ["⌘", "W"] },
19
+ { action: "Save", keys: ["⌘", "S"] },
20
+ { action: "New window", keys: ["⌘", "⇧", "N"] },
21
+ ],
22
+ },
23
+ ];
24
+
25
+ describe("KeyboardShortcuts", () => {
26
+ it("renders every group with a count derived from items.length", () => {
27
+ render(<KeyboardShortcuts groups={groups} searchable={false} />);
28
+ expect(screen.getByText("Navigation")).toBeInTheDocument();
29
+ expect(screen.getByText("General")).toBeInTheDocument();
30
+ // "Navigation" has 2 items, "General" has 3 — neither count was passed in.
31
+ expect(screen.getByText("2")).toBeInTheDocument();
32
+ expect(screen.getByText("3")).toBeInTheDocument();
33
+ });
34
+
35
+ it("a query matching only one group hides the others, and derives the visible group's count from the matches", () => {
36
+ render(<KeyboardShortcuts groups={groups} query="close" />);
37
+
38
+ // Only "General" has an item matching "close" — "Navigation" is hidden entirely,
39
+ // not rendered empty.
40
+ expect(screen.getByText("General")).toBeInTheDocument();
41
+ expect(screen.queryByText("Navigation")).not.toBeInTheDocument();
42
+
43
+ // The visible group's displayed count is the number of MATCHING items (1),
44
+ // never its total item count (3) — this is the "count is derived" lock.
45
+ expect(screen.getByText("1")).toBeInTheDocument();
46
+ expect(screen.queryByText("3")).not.toBeInTheDocument();
47
+ expect(screen.getByText("Close tab")).toBeInTheDocument();
48
+ });
49
+
50
+ it("matches on key tokens as well as action text", () => {
51
+ render(<KeyboardShortcuts groups={groups} query="⇧" />);
52
+ expect(screen.getByText("General")).toBeInTheDocument();
53
+ expect(screen.queryByText("Navigation")).not.toBeInTheDocument();
54
+ expect(screen.getByText("New window")).toBeInTheDocument();
55
+ });
56
+
57
+ it("renders a designed empty message, not a blank area, when nothing matches", () => {
58
+ render(<KeyboardShortcuts groups={groups} query="does-not-exist" />);
59
+ expect(screen.queryByText("Navigation")).not.toBeInTheDocument();
60
+ expect(screen.queryByText("General")).not.toBeInTheDocument();
61
+ expect(screen.getByText("No shortcuts found")).toBeInTheDocument();
62
+ });
63
+
64
+ it("supports an uncontrolled query without a query prop", () => {
65
+ render(<KeyboardShortcuts groups={groups} />);
66
+ expect(screen.getByLabelText("Search shortcuts")).toHaveValue("");
67
+ });
68
+
69
+ it("renders no search field when searchable is false", () => {
70
+ render(<KeyboardShortcuts groups={groups} searchable={false} />);
71
+ expect(screen.queryByLabelText("Search shortcuts")).not.toBeInTheDocument();
72
+ });
73
+ });