@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.
- package/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- 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(
|
|
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
|
|
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:
|
|
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
|
|
15
|
-
"component
|
|
16
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
66
|
-
"
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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 === "
|
|
210
|
-
|
|
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 `
|
|
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
|
+
};
|