@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.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 +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -20,6 +20,7 @@ import {
20
20
  import { Slot } from "@radix-ui/react-slot";
21
21
  import { cn } from "../../lib/cn";
22
22
  import { mergeRefs } from "../../lib/merge-refs";
23
+ import { findTriggerQuery } from "../../lib/trigger-query";
23
24
  import { useLocale } from "../locale-provider";
24
25
  import { Popover, PopoverAnchor, PopoverContent } from "../popover";
25
26
  import { Textarea } from "../textarea";
@@ -88,43 +89,6 @@ export function useMentionInput(): MentionInputContextValue {
88
89
  // Query detection
89
90
  // ---------------------------------------------------------------------------
90
91
 
91
- /** A trigger only starts a query at the start of a word — never inside one. */
92
- function isWordBoundary(text: string, index: number): boolean {
93
- if (index === 0) return true;
94
- const previous = text[index - 1] ?? "";
95
- return /\s/.test(previous) || previous === "(" || previous === "[";
96
- }
97
-
98
- /**
99
- * Is the caret currently sitting in a `trigger + query` run?
100
- *
101
- * Derived from the committed text + caret rather than intercepted at keydown,
102
- * on purpose: a keydown-driven trigger would have to `preventDefault()` every
103
- * printable character and re-insert it by hand, which is exactly what destroys
104
- * IME composition, native undo, spellcheck and paste — the things a real
105
- * `<textarea>` gives us for free. Deriving instead means the popup opens
106
- * identically whether the trigger arrived by typing, pasting, an IME commit or
107
- * a mouse click that moved the caret back into a half-typed query.
108
- */
109
- function findQuery(
110
- value: MentionValue,
111
- caret: number,
112
- trigger: string,
113
- ): { start: number; query: string } | null {
114
- const { text } = value;
115
- if (caret < trigger.length) return null;
116
- const start = text.lastIndexOf(trigger, caret - trigger.length);
117
- if (start < 0) return null;
118
-
119
- const query = text.slice(start + trigger.length, caret);
120
- if (/\s/.test(query)) return null;
121
- if (!isWordBoundary(text, start)) return null;
122
- // The trigger char of an ALREADY-inserted mention must not re-open the popup.
123
- if (mentionAt(value, start, trigger)) return null;
124
-
125
- return { start, query };
126
- }
127
-
128
92
  /** Next enabled index in `list`, wrapping; `-1` when none is selectable. */
129
93
  function step(list: MentionOption[], from: number, direction: 1 | -1): number {
130
94
  const count = list.length;
@@ -354,7 +318,13 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
354
318
  // ------------------------------------------------------------------
355
319
  // Query / open state
356
320
  // ------------------------------------------------------------------
357
- const queryInfo = useMemo(() => findQuery(value, caret, trigger), [value, caret, trigger]);
321
+ const queryInfo = useMemo(
322
+ () =>
323
+ findTriggerQuery(value.text, caret, trigger, {
324
+ isTriggerConsumed: (start) => mentionAt(value, start, trigger) !== undefined,
325
+ }),
326
+ [value, caret, trigger],
327
+ );
358
328
 
359
329
  // Escape (and an outside dismissal) suppress the popup for the run it was
360
330
  // dismissed on, without touching the text — the query is still there, the
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { replaceTriggerRun } from "../../lib/trigger-query";
2
3
 
3
4
  /**
4
5
  * The pure offset algebra behind `MentionInput`.
@@ -138,7 +139,12 @@ export function insertMention(
138
139
  caret: number,
139
140
  ): InsertMentionResult {
140
141
  const inserted = `${trigger}${option.label} `;
141
- const nextText = value.text.slice(0, queryStart) + inserted + value.text.slice(caret);
142
+ const { text: nextText, caret: nextCaret } = replaceTriggerRun(
143
+ value.text,
144
+ queryStart,
145
+ caret,
146
+ inserted,
147
+ );
142
148
  const delta = inserted.length - (caret - queryStart);
143
149
 
144
150
  const mentions: Mention[] = [];
@@ -160,7 +166,7 @@ export function insertMention(
160
166
  (m) => nextText.slice(m.start, mentionEnd(m, trigger)) === trigger + m.label,
161
167
  ),
162
168
  },
163
- caret: queryStart + inserted.length,
169
+ caret: nextCaret,
164
170
  };
165
171
  }
166
172
 
@@ -11,9 +11,11 @@ const meta = {
11
11
  docs: {
12
12
  description: {
13
13
  component:
14
- "The canonical KPI tile, owned by @elabs-ai/components-ui (ADR 0012). @elabs-ai/components-charts re-exports this exact " +
15
- "component as Charts/MetricCard they are the same component, so import from @elabs-ai/components-ui for app " +
16
- "UI, or from @elabs-ai/components-charts when already in a charts context. Don't fork a second KPI tile.",
14
+ "The canonical KPI tile, owned by @elabs-ai/components-ui (ADR 0012). @elabs-ai/components-charts re-exports this " +
15
+ "EXACT component, so import it from @elabs-ai/components-ui for app UI, or from @elabs-ai/components-charts when " +
16
+ "you are already in a charts context both resolve to the component documented here, which is why the " +
17
+ "sidebar carries one MetricCard entry and not two. Don't fork a second KPI tile. The `visual` slot is " +
18
+ "shown by the WithSparkline story, which fills it with a real @elabs-ai/components-charts mini-chart.",
17
19
  },
18
20
  },
19
21
  },
@@ -166,9 +166,28 @@ export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(function M
166
166
  {loading ? (
167
167
  <Skeleton className="h-4 w-24" />
168
168
  ) : (
169
- <span className="text-body font-medium text-muted-foreground">{label}</span>
169
+ /* `min-w-0` + `truncate`, not just the text utility: a flex child's
170
+ default `min-width: auto` refuses to shrink below its content, so
171
+ without it the label OVERFLOWS the row and `Card`'s own
172
+ `overflow-hidden` hard-clips it — characters lost mid-word, no
173
+ ellipsis, no title. Measured on a 230px pane: "Awaiting approval"
174
+ rendered as "Awaiting approva". See interaction-guidelines.md
175
+ § Content handling ("flex children need `min-w-0`"). `title` only
176
+ when the label is a string — there is nothing to put in the
177
+ attribute otherwise, and the ellipsis needs a way back to the
178
+ full words. */
179
+ <span
180
+ className="min-w-0 truncate text-body font-medium text-muted-foreground"
181
+ title={typeof label === "string" ? label : undefined}
182
+ >
183
+ {label}
184
+ </span>
170
185
  )}
171
- {icon ? <span className="text-muted-foreground [&_svg]:size-4">{icon}</span> : null}
186
+ {/* `shrink-0`: the icon is a fixed 16px mark, so the LABEL is what
187
+ gives way when the row runs out of width. */}
188
+ {icon ? (
189
+ <span className="shrink-0 text-muted-foreground [&_svg]:size-4">{icon}</span>
190
+ ) : null}
172
191
  </div>
173
192
  <div className="flex items-baseline gap-2">
174
193
  {loading ? (
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { AppWindow, Bot, Sparkles } from "lucide-react";
3
3
  import { useState } from "react";
4
+ import { within } from "storybook/test";
4
5
  import { ModelPicker } from "./model-picker";
5
6
  import type { ModelPickerGroup } from "./model-picker-state";
6
7
 
@@ -61,9 +62,15 @@ const meta = {
61
62
  docs: {
62
63
  description: {
63
64
  component:
65
+ "The INLINE composer pill; a full palette in a dialog is a `CommandDialog` " +
66
+ "you compose yourself — see " +
67
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
64
68
  "A compact pill that opens a grouped, searchable target list anchored under itself — " +
65
- "sized for a composer footer. The inline sibling of `@elabs-ai/components-ai`'s " +
66
- "`ModelSelector`, which is the same `Command` internals in a modal `Dialog`.\n\n" +
69
+ "sized for a composer footer.\n\n" +
70
+ "**Which one to reach for.** An inline pill in a composer footer → this component. " +
71
+ "A full ⌘K palette → compose `CommandDialog` + `Command*` from " +
72
+ "`@elabs-ai/components-ui` directly; there is no separate selector component " +
73
+ "wrapping them.\n\n" +
67
74
  "**`Command` lives in a `Popover`, never a `DropdownMenu`.** `DropdownMenuContent` owns " +
68
75
  "roving tabindex and its own typeahead; both fight a real `<input>` in its subtree, so " +
69
76
  "arrow keys move the menu's focus instead of cmdk's highlight and typing never reaches " +
@@ -80,9 +87,19 @@ type Story = StoryObj<typeof meta>;
80
87
 
81
88
  export const Default: Story = {};
82
89
 
83
- /** Skeleton rows while the first load is in flight. */
90
+ /**
91
+ * Skeleton rows while the first load is in flight.
92
+ *
93
+ * The `play` opens the popover — issue #121: axe never rendered `CommandList`
94
+ * (cmdk's `role="listbox"`) until a story actually opened it, which is how a
95
+ * structural `aria-required-children` violation shipped unnoticed.
96
+ */
84
97
  export const Loading: Story = {
85
98
  args: { groups: [], status: "loading", triggerLabel: "Loading…", value: undefined },
99
+ play: async ({ canvas, canvasElement, userEvent }) => {
100
+ await userEvent.click(canvas.getByRole("combobox"));
101
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
102
+ },
86
103
  };
87
104
 
88
105
  /**
@@ -103,11 +120,54 @@ export const ErrorState: Story = {
103
120
  value: undefined,
104
121
  onRetry: () => {},
105
122
  },
123
+ play: async ({ canvas, canvasElement, userEvent }) => {
124
+ await userEvent.click(canvas.getByRole("combobox"));
125
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
126
+ },
106
127
  };
107
128
 
108
129
  /** Nothing to show, but nothing went wrong — a different message, deliberately. */
109
130
  export const Empty: Story = {
110
131
  args: { groups: [], status: "empty", triggerLabel: "No targets", value: undefined },
132
+ play: async ({ canvas, canvasElement, userEvent }) => {
133
+ await userEvent.click(canvas.getByRole("combobox"));
134
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
135
+ },
136
+ };
137
+
138
+ /**
139
+ * Nothing to show, nothing went wrong, AND a retry is offered — the empty
140
+ * panel's `Retry` button, the specific case where the button previously
141
+ * landed inside `role="listbox"` (issue #121).
142
+ */
143
+ export const EmptyWithRetry: Story = {
144
+ args: {
145
+ groups: [],
146
+ status: "empty",
147
+ triggerLabel: "No targets",
148
+ value: undefined,
149
+ onRetry: () => {},
150
+ },
151
+ play: async ({ canvas, canvasElement, userEvent }) => {
152
+ await userEvent.click(canvas.getByRole("combobox"));
153
+ await within(canvasElement.ownerDocument.body).findByRole("dialog");
154
+ },
155
+ };
156
+
157
+ /**
158
+ * The everyday path issue #121 was really about: a fully-populated, working
159
+ * list where the search query happens to match nothing. `CommandEmpty`
160
+ * renders while the list itself stays mounted with zero options — the case
161
+ * that shipped broken because no story ever typed a non-matching query.
162
+ */
163
+ export const ListNoSearchResults: Story = {
164
+ play: async ({ canvas, canvasElement, userEvent }) => {
165
+ await userEvent.click(canvas.getByRole("combobox"));
166
+ const body = within(canvasElement.ownerDocument.body);
167
+ await body.findByRole("dialog");
168
+ await userEvent.type(body.getByPlaceholderText("Search…"), "zzzzzznomatch");
169
+ await body.findByText("No results.");
170
+ },
111
171
  };
112
172
 
113
173
  export const Disabled: Story = { args: { disabled: true } };
@@ -228,6 +228,69 @@ describe("ModelPicker", () => {
228
228
  });
229
229
  });
230
230
 
231
+ // Issue #121 — `CommandList` IS the ARIA listbox (cmdk hardcodes
232
+ // role="listbox" on it); ARIA only allows a listbox to own `option`/`group`.
233
+ // This asserts the CAUSE (no disallowed descendant), not axe's message, so
234
+ // it survives an axe-core upgrade — the story-level `play` functions run
235
+ // the actual blocking axe rule.
236
+ describe("CommandList owns only option/group content (ARIA listbox contract)", () => {
237
+ const disallowedSelector =
238
+ '[role]:not([role="option"]):not([role="group"]):not([role="presentation"])';
239
+
240
+ const assertListClean = () => {
241
+ const list = document.querySelector('[data-slot="command-list"]')!;
242
+ expect(list).not.toBeNull();
243
+ expect(list.querySelector(disallowedSelector)).toBeNull();
244
+ return list as HTMLElement;
245
+ };
246
+
247
+ it("loading body: no role=status descendant inside the listbox", async () => {
248
+ render(<ModelPicker groups={[]} status="loading" triggerLabel="Pick" />);
249
+ await open();
250
+ // The list stays mounted (so CommandInput's aria-controls still
251
+ // resolves) but owns no option/group content while loading — the
252
+ // loading panel is a sibling, not a child.
253
+ assertListClean();
254
+ expect(document.querySelector('[data-slot="model-picker-loading"]')).not.toBeNull();
255
+ });
256
+
257
+ it("error body: the retry button is not a descendant of the listbox", async () => {
258
+ const onRetry = vi.fn();
259
+ render(<ModelPicker groups={[]} status="error" triggerLabel="Pick" onRetry={onRetry} />);
260
+ await open();
261
+ const list = assertListClean();
262
+ const retryButton = screen.getByRole("button", { name: "Retry" });
263
+ expect(list).not.toContainElement(retryButton);
264
+ });
265
+
266
+ it("empty body (with retry): the retry button is not a descendant of the listbox", async () => {
267
+ const onRetry = vi.fn();
268
+ render(<ModelPicker groups={[]} status="empty" triggerLabel="Pick" onRetry={onRetry} />);
269
+ await open();
270
+ const list = assertListClean();
271
+ const retryButton = screen.getByRole("button", { name: "Retry" });
272
+ expect(list).not.toContainElement(retryButton);
273
+ });
274
+
275
+ it("populated list with a non-matching search: CommandEmpty is not a descendant of the listbox", async () => {
276
+ render(<ModelPicker groups={groups} triggerLabel="Pick" />);
277
+ await open();
278
+ await userEvent.type(screen.getByPlaceholderText("Search…"), "zzzzzznomatch");
279
+ const noResults = await screen.findByText("No results.");
280
+ const list = assertListClean();
281
+ expect(list).not.toContainElement(noResults);
282
+ });
283
+
284
+ it("CommandInput's aria-controls still resolves to the mounted (empty) listbox", async () => {
285
+ render(<ModelPicker groups={[]} status="empty" triggerLabel="Pick" />);
286
+ await open();
287
+ const input = screen.getByPlaceholderText("Search…");
288
+ const controlsId = input.getAttribute("aria-controls");
289
+ expect(controlsId).toBeTruthy();
290
+ expect(document.getElementById(controlsId!)).not.toBeNull();
291
+ });
292
+ });
293
+
231
294
  it("uncontrolled mode: trigger click still opens and closes on its own (regression)", async () => {
232
295
  const onSelect = vi.fn();
233
296
  render(<ModelPicker groups={groups} triggerLabel="Pick" onSelect={onSelect} />);
@@ -52,6 +52,13 @@ export interface ModelPickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "
52
52
  open?: boolean;
53
53
  /** Fires whenever the popover would open/close — a trigger click, or a row select. */
54
54
  onOpenChange?: (open: boolean) => void;
55
+ /**
56
+ * Rendered inside the popover BELOW the list — a persistent action row that is
57
+ * not one of the options (a free-text entry, a "browse…" affordance).
58
+ * Outside `CommandList` on purpose: it must not be filtered by the search query
59
+ * and must not participate in cmdk's roving selection.
60
+ */
61
+ footer?: ReactNode;
55
62
  }
56
63
 
57
64
  function Row({
@@ -101,9 +108,10 @@ function Row({
101
108
  * A compact pill that opens a grouped, searchable target list anchored under
102
109
  * itself — sized to sit in a composer footer.
103
110
  *
104
- * The inline sibling of `@elabs-ai/components-ai`'s `ModelSelector`,
105
- * which is the same `Command` internals in a modal `Dialog`. Reach for that one
106
- * for a full command palette; reach for this one for an in-place picker.
111
+ * **Which one to reach for.** An inline pill in a composer footer, anchored
112
+ * under itself this component. A full ⌘K palette compose `CommandDialog` +
113
+ * `Command*` from this package directly; there is no separate "selector"
114
+ * component wrapping them.
107
115
  *
108
116
  * **`Command` lives inside a `Popover`, never a `DropdownMenu`, and that is not
109
117
  * a stylistic choice.** `DropdownMenuContent` owns roving tabindex and its own
@@ -127,6 +135,7 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
127
135
  className,
128
136
  open: openProp,
129
137
  onOpenChange,
138
+ footer,
130
139
  ...props
131
140
  },
132
141
  ref,
@@ -205,37 +214,20 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
205
214
  </div>
206
215
  ) : null}
207
216
 
217
+ {/*
218
+ #121 — `CommandList` IS the ARIA listbox (cmdk hardcodes
219
+ role="listbox" on it); ARIA only lets a listbox own `option`/
220
+ `group`. It stays MOUNTED in every body (never conditionally
221
+ removed — `CommandInput` hardcodes `aria-controls` to its id,
222
+ and axe's aria-valid-attr-value only tolerates a dangling
223
+ reference when aria-expanded="false"), but every non-option
224
+ body — loading, error/empty, and `CommandEmpty` — renders as a
225
+ SIBLING below it, in the same visual slot an empty `CommandList`
226
+ (which collapses to zero height) would otherwise occupy.
227
+ */}
208
228
  <CommandList>
209
- {body === "loading" ? (
210
- <div className="space-y-2 p-3" data-slot="model-picker-loading">
211
- <span className="sr-only" role="status">
212
- {t("loading")}
213
- </span>
214
- <Skeleton className="h-6 w-full" />
215
- <Skeleton className="h-6 w-4/5" />
216
- <Skeleton className="h-6 w-3/5" />
217
- </div>
218
- ) : null}
219
-
220
- {body === "error" || body === "empty" ? (
221
- <div className="space-y-2 p-4 text-center" data-slot="model-picker-state">
222
- <p className="text-body">
223
- {body === "error"
224
- ? t("ui.modelPicker.loadFailed")
225
- : t("ui.modelPicker.nothingYet")}
226
- </p>
227
- {onRetry ? (
228
- <Button variant="outline" size="sm" onClick={onRetry}>
229
- {t("ui.modelPicker.retry")}
230
- </Button>
231
- ) : null}
232
- </div>
233
- ) : null}
234
-
235
- {body === "list" ? (
236
- <>
237
- <CommandEmpty>{t("noResults")}</CommandEmpty>
238
- {groups.map((group) => (
229
+ {body === "list"
230
+ ? groups.map((group) => (
239
231
  <CommandGroup key={group.key} heading={group.label}>
240
232
  {group.items.map((item) => (
241
233
  <Row
@@ -249,10 +241,44 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
249
241
  />
250
242
  ))}
251
243
  </CommandGroup>
252
- ))}
253
- </>
254
- ) : null}
244
+ ))
245
+ : null}
255
246
  </CommandList>
247
+
248
+ {/*
249
+ cmdk's `Command.Empty` reads `filtered.count` off the `Command`
250
+ root store, not off a `CommandList` ancestor — it renders
251
+ correctly as a sibling of the list it used to nest inside.
252
+ */}
253
+ {body === "list" ? <CommandEmpty>{t("noResults")}</CommandEmpty> : null}
254
+
255
+ {body === "loading" ? (
256
+ <div className="space-y-2 p-3" data-slot="model-picker-loading">
257
+ <span className="sr-only" role="status">
258
+ {t("loading")}
259
+ </span>
260
+ <Skeleton className="h-6 w-full" />
261
+ <Skeleton className="h-6 w-4/5" />
262
+ <Skeleton className="h-6 w-3/5" />
263
+ </div>
264
+ ) : null}
265
+
266
+ {body === "error" || body === "empty" ? (
267
+ <div className="space-y-2 p-4 text-center" data-slot="model-picker-state">
268
+ <p className="text-body">
269
+ {body === "error"
270
+ ? t("ui.modelPicker.loadFailed")
271
+ : t("ui.modelPicker.nothingYet")}
272
+ </p>
273
+ {onRetry ? (
274
+ <Button variant="outline" size="sm" onClick={onRetry}>
275
+ {t("ui.modelPicker.retry")}
276
+ </Button>
277
+ ) : null}
278
+ </div>
279
+ ) : null}
280
+
281
+ {footer}
256
282
  </Command>
257
283
  </PopoverContent>
258
284
  </Popover>
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared collapsible sidebar navigation primitive (issue #99).
3
- * Promoted from `packages/ui/src/blocks/sidebar-02/nav-main.tsx`.
3
+ * Promoted from `registry/blocks/sidebar-02/nav-main.tsx`.
4
4
  *
5
5
  * Renders a `SidebarMenu` of routes; each route with sub-items uses a
6
6
  * `Collapsible` expand/collapse affordance. Collapses icon-only when the
@@ -0,0 +1,110 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, waitFor, within } from "storybook/test";
3
+ import { NavNotifications } from "./nav-notifications";
4
+
5
+ const meta = {
6
+ title: "Navigation/NavNotifications",
7
+ component: NavNotifications,
8
+ tags: ["autodocs"],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'A bell button that opens a short list of recent notifications. Placement is the caller\'s decision, not the component\'s: the defaults (`side="right"`, the `my-6` inset) suit a bell sitting in a left-hand nav rail, while a bell at the end of a top bar passes `side="bottom" align="end"` so the menu hangs below its trigger instead of being collision-flipped back across the controls beside it.',
14
+ },
15
+ },
16
+ },
17
+ argTypes: {
18
+ notifications: { control: false, table: { category: "Content" } },
19
+ viewAllLabel: { control: "text", table: { category: "Content" } },
20
+ side: { control: "inline-radio", table: { category: "Placement" } },
21
+ align: { control: "inline-radio", table: { category: "Placement" } },
22
+ className: { control: "text", table: { category: "Styling" } },
23
+ },
24
+ } satisfies Meta<typeof NavNotifications>;
25
+ export default meta;
26
+ type Story = StoryObj<typeof meta>;
27
+
28
+ const NOTIFICATIONS = [
29
+ { id: "n1", fallback: "OR", text: "Order 4821 is waiting on stock.", time: "10m ago" },
30
+ { id: "n2", fallback: "PY", text: "This week’s payout cleared.", time: "1h ago" },
31
+ { id: "n3", fallback: "CS", text: "Three customers asked about delivery.", time: "2h ago" },
32
+ ];
33
+
34
+ /** The rail placement — the shipped defaults, unchanged. */
35
+ export const Default: Story = {
36
+ args: { notifications: NOTIFICATIONS },
37
+ play: async ({ canvasElement }) => {
38
+ const canvas = within(canvasElement);
39
+ await userEvent.click(canvas.getByRole("button", { name: "Open notifications" }));
40
+ // The menu is portalled to <body>, so it is reached through the document,
41
+ // not the canvas.
42
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeVisible());
43
+ await expect(document.querySelector('[role="menu"]')).toHaveAttribute("data-side", "right");
44
+ // Close it before the story settles. While a Radix menu is open its
45
+ // siblings carry `aria-hidden`, and the trigger inside one of them is
46
+ // still focusable — which axe reports as `aria-hidden-focus` against the
47
+ // ANCESTOR, not against anything this component authored. Leaving the menu
48
+ // open would fail the a11y pass on library behaviour the story is not
49
+ // testing.
50
+ await userEvent.keyboard("{Escape}");
51
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull());
52
+ },
53
+ };
54
+
55
+ /**
56
+ * The top-bar placement: `side="bottom" align="end"` puts the menu under the
57
+ * bell and flush with its outer edge. The caller passes NOTHING else — the
58
+ * rail's 24px vertical inset is derived from `side` inside the component, so a
59
+ * bottom-side menu drops it on its own and there is no inset to cancel here.
60
+ * (`className` styles the bell TRIGGER, not the portalled menu, so a `my-0`
61
+ * override would land on the wrong element anyway.)
62
+ *
63
+ * Asserted on Radix's own resolved `data-side`/`data-align`, which report where
64
+ * the menu ACTUALLY landed after collision handling — a class-name assertion
65
+ * would pass on a menu that flipped back across the controls beside it.
66
+ */
67
+ export const TopBarPlacement: Story = {
68
+ args: {
69
+ notifications: NOTIFICATIONS,
70
+ side: "bottom",
71
+ align: "end",
72
+ },
73
+ play: async ({ canvasElement }) => {
74
+ const canvas = within(canvasElement);
75
+ await userEvent.click(canvas.getByRole("button", { name: "Open notifications" }));
76
+ const content = await waitFor(() => {
77
+ const node = document.querySelector('[role="menu"]');
78
+ expect(node).toBeVisible();
79
+ return node;
80
+ });
81
+ await expect(content).toHaveAttribute("data-side", "bottom");
82
+ await expect(content).toHaveAttribute("data-align", "end");
83
+ // Closed for the same reason as `Default` — see the note there.
84
+ await userEvent.keyboard("{Escape}");
85
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull());
86
+ },
87
+ };
88
+
89
+ /**
90
+ * `className` styles the component ROOT — the bell trigger — and merges LAST, so
91
+ * a caller-supplied radius replaces the shipped `rounded-full`.
92
+ */
93
+ export const CustomTriggerClass: Story = {
94
+ args: { notifications: NOTIFICATIONS, className: "rounded-md" },
95
+ play: async ({ canvasElement }) => {
96
+ const canvas = within(canvasElement);
97
+ const trigger = canvas.getByRole("button", { name: "Open notifications" });
98
+ // WHERE it landed: on the trigger, not on the portalled menu.
99
+ await expect(trigger).toHaveClass("rounded-md");
100
+ // …and it WON. `cn`'s tailwind-merge drops the losing radius outright, so
101
+ // the shipped `rounded-full` is gone from the class list rather than merely
102
+ // ordered behind it.
103
+ await expect(trigger).not.toHaveClass("rounded-full");
104
+ // Asserted on the painted result too, because a class list says nothing
105
+ // about what the browser resolved. `rounded-full` computes to a huge radius
106
+ // (the pill); the token radius is single-digit px.
107
+ const radius = Number.parseFloat(getComputedStyle(trigger).borderTopLeftRadius);
108
+ await expect(radius).toBeLessThan(100);
109
+ },
110
+ };