@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.
- package/README.md +10 -8
- package/dist/chunk-FCH4ZN6G.js +57 -0
- package/dist/chunk-FCH4ZN6G.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 +2597 -188
- package/dist/index.js +12855 -3578
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +21 -7
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +15 -4
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +56 -4
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +3 -1
- package/src/components/attribution-panel/attributions.generated.ts +83 -79
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +15 -5
- package/src/components/bento-grid/bento-grid.test.tsx +24 -3
- package/src/components/bento-grid/bento-grid.tsx +11 -1
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +10 -4
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +48 -7
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- 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 +214 -24
- package/src/components/checkbox/checkbox.tsx +2 -1
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +22 -15
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +88 -0
- package/src/components/command/command.tsx +38 -13
- 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-menu/context-menu.tsx +16 -7
- 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.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +13 -2
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +14 -6
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- 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 +342 -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 +50 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +90 -12
- package/src/components/file-upload/file-upload.tsx +164 -31
- package/src/components/form/form.tsx +8 -3
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +3 -3
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +8 -4
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +18 -9
- package/src/components/input-otp/input-otp.tsx +8 -2
- 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/label/label.tsx +1 -1
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- 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 +822 -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.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +66 -46
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/menubar/menubar.tsx +16 -6
- 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 +8 -4
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +67 -9
- package/src/components/nav-user/nav-user.tsx +5 -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 +204 -15
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- 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 +109 -0
- package/src/components/pagination/pagination.tsx +27 -19
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +3 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +32 -6
- 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 +53 -5
- 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-store.ts +506 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +1021 -0
- package/src/components/schema-form/schema-form.tsx +1381 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +14 -3
- package/src/components/select/select.tsx +13 -7
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- 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 +299 -0
- package/src/components/side-dock/side-dock.tsx +515 -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 +71 -2
- package/src/components/sidebar/sidebar.tsx +264 -38
- package/src/components/skeleton/skeleton.tsx +1 -0
- 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/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- 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 +110 -24
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +3 -1
- package/src/components/table/table.stories.tsx +64 -0
- package/src/components/table/table.test.tsx +149 -1
- package/src/components/table/table.tsx +71 -2
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +176 -72
- package/src/components/tag-input/tag-input.tsx +6 -3
- package/src/components/team-switcher/team-switcher.tsx +35 -6
- package/src/components/textarea/textarea.tsx +2 -2
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +140 -12
- 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 +10 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +91 -31
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- 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.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +29 -3
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +13 -5
- 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 +80 -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/cn.ts +9 -0
- package/src/lib/csv.ts +48 -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 +94 -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/merge-refs.ts +2 -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-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +34 -12
- 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
|
@@ -25,6 +25,14 @@ import type { MentionOption, MentionValue } from "./mention-value";
|
|
|
25
25
|
* lock that only holds when it runs alone is not a lock at all in CI — so it
|
|
26
26
|
* gets its own story file, which the runner loads as its own test file.
|
|
27
27
|
*
|
|
28
|
+
* It shares the `Forms/MentionInput` TITLE with the main story file so it reads
|
|
29
|
+
* as one sidebar entry rather than a sub-group of its own (RM-005). That is a
|
|
30
|
+
* sidebar concern only: `@storybook/addon-vitest` turns each story FILE into its
|
|
31
|
+
* own test file, so the isolation above survives the shared title. Do NOT
|
|
32
|
+
* "finish the job" by moving these stories into `mention-input.stories.tsx` —
|
|
33
|
+
* that is the exact co-residency the audit measured as green-with-the-bug (ADR
|
|
34
|
+
* 0023 §6).
|
|
35
|
+
*
|
|
28
36
|
* ### What it pins
|
|
29
37
|
*
|
|
30
38
|
* Every box-affecting property of the field is nailed to absolute pixels, so
|
|
@@ -39,7 +47,7 @@ import type { MentionOption, MentionValue } from "./mention-value";
|
|
|
39
47
|
* the test rather than argued for in a comment.
|
|
40
48
|
*/
|
|
41
49
|
const meta = {
|
|
42
|
-
title: "Forms/MentionInput
|
|
50
|
+
title: "Forms/MentionInput",
|
|
43
51
|
component: MentionInput,
|
|
44
52
|
parameters: {
|
|
45
53
|
docs: {
|
|
@@ -77,7 +77,7 @@ function Field({
|
|
|
77
77
|
}) {
|
|
78
78
|
const id = useId();
|
|
79
79
|
return (
|
|
80
|
-
<div className="w-[28rem] space-y-2">
|
|
80
|
+
<div className="w-full max-w-[28rem] space-y-2">
|
|
81
81
|
<Label htmlFor={id}>{label}</Label>
|
|
82
82
|
<MentionInput options={options} {...props}>
|
|
83
83
|
<MentionInputTextarea id={id} placeholder="Type @ to mention someone…" />
|
|
@@ -193,7 +193,7 @@ export const SerializedOutput: Story = {
|
|
|
193
193
|
const payload = useMemo(() => serializeMentions(value), [value]);
|
|
194
194
|
|
|
195
195
|
return (
|
|
196
|
-
<div className="w-[28rem] space-y-4">
|
|
196
|
+
<div className="w-full max-w-[28rem] space-y-4">
|
|
197
197
|
<div className="space-y-2">
|
|
198
198
|
<Label htmlFor={id}>Comment</Label>
|
|
199
199
|
<MentionInput options={ROSTER} value={value} onValueChange={setValue}>
|
|
@@ -235,7 +235,7 @@ export const AsyncRoster: Story = {
|
|
|
235
235
|
const [options, setOptions] = useState<MentionOption[]>([]);
|
|
236
236
|
|
|
237
237
|
return (
|
|
238
|
-
<div className="w-[28rem] space-y-2">
|
|
238
|
+
<div className="w-full max-w-[28rem] space-y-2">
|
|
239
239
|
<Label htmlFor={id}>Assign reviewers</Label>
|
|
240
240
|
<MentionInput
|
|
241
241
|
options={options}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRef, useState } from "react";
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { act, render, screen } from "@testing-library/react";
|
|
4
4
|
import userEvent from "@testing-library/user-event";
|
|
5
5
|
import {
|
|
6
6
|
MentionInput,
|
|
@@ -615,3 +615,53 @@ describe("onQueryChange", () => {
|
|
|
615
615
|
expect(onQueryChange).toHaveBeenLastCalledWith(null);
|
|
616
616
|
});
|
|
617
617
|
});
|
|
618
|
+
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// Mirror re-measure — scoped MutationObserver (perf review §3.3)
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
describe("mirror re-measure observer scope", () => {
|
|
624
|
+
/**
|
|
625
|
+
* Regression lock for the fix: the observer that used to watch `<html>`
|
|
626
|
+
* AND `<body>` for `class`/`style`/`data-theme`/`data-density`/
|
|
627
|
+
* `data-decoration` now watches only the field's closest `[data-theme]`
|
|
628
|
+
* ancestor (`<html>` here, since jsdom's default document carries none),
|
|
629
|
+
* filtered to `data-theme`/`data-density`/`data-decoration` — so a
|
|
630
|
+
* `style` mutation on `<body>` (what Radix's scroll-lock does on every
|
|
631
|
+
* `Sheet`/`Dialog`/`Popover` open, anywhere in the app) no longer causes a
|
|
632
|
+
* re-measure, while a real theme/density/decoration attribute flip still
|
|
633
|
+
* does.
|
|
634
|
+
*/
|
|
635
|
+
it("ignores an unrelated body style mutation but reacts to a theme attribute flip", async () => {
|
|
636
|
+
render(<Harness />);
|
|
637
|
+
// Let the mount-time layout effects (which call getComputedStyle
|
|
638
|
+
// themselves) settle before counting.
|
|
639
|
+
await Promise.resolve();
|
|
640
|
+
const spy = vi.spyOn(window, "getComputedStyle");
|
|
641
|
+
spy.mockClear();
|
|
642
|
+
|
|
643
|
+
// Simulate Radix's scroll-lock: an inline style write on <body>, nothing
|
|
644
|
+
// to do with this field's own theme/density/decoration.
|
|
645
|
+
document.body.style.paddingRight = "17px";
|
|
646
|
+
await Promise.resolve();
|
|
647
|
+
await Promise.resolve();
|
|
648
|
+
expect(spy).not.toHaveBeenCalled();
|
|
649
|
+
document.body.style.removeProperty("padding-right");
|
|
650
|
+
|
|
651
|
+
// A real theme attribute flip on the scope the observer actually
|
|
652
|
+
// watches (<html>, the field's closest `[data-theme]` ancestor — there
|
|
653
|
+
// is none here, so it falls back to the document root) DOES trigger a
|
|
654
|
+
// re-measure.
|
|
655
|
+
try {
|
|
656
|
+
await act(async () => {
|
|
657
|
+
document.documentElement.setAttribute("data-theme", "dark");
|
|
658
|
+
await vi.waitFor(() => {
|
|
659
|
+
expect(spy).toHaveBeenCalled();
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
} finally {
|
|
663
|
+
document.documentElement.removeAttribute("data-theme");
|
|
664
|
+
spy.mockRestore();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
});
|
|
@@ -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;
|
|
@@ -190,6 +154,45 @@ const MIRRORED_PROPERTIES = [
|
|
|
190
154
|
"wordBreak",
|
|
191
155
|
] as const satisfies readonly (keyof CSSStyleDeclaration & string)[];
|
|
192
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Extra keys `readMirrorStyle` writes onto the mirror style object BESIDES
|
|
159
|
+
* `MIRRORED_PROPERTIES` (some of which — `paddingRight`, `whiteSpace` — it
|
|
160
|
+
* overrides rather than adds). Used by `stylesEqual` below so the "did
|
|
161
|
+
* anything actually change" check compares every key the object can carry,
|
|
162
|
+
* without re-deriving that list from an object literal on every call.
|
|
163
|
+
*/
|
|
164
|
+
const EXTRA_MIRROR_KEYS = [
|
|
165
|
+
"position",
|
|
166
|
+
"left",
|
|
167
|
+
"top",
|
|
168
|
+
"width",
|
|
169
|
+
"height",
|
|
170
|
+
"borderStyle",
|
|
171
|
+
"borderColor",
|
|
172
|
+
"overflow",
|
|
173
|
+
"margin",
|
|
174
|
+
] as const;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Field-by-field comparison of two mirror style snapshots (perf review §3.3:
|
|
178
|
+
* "two JSON.stringify per callback"). Both objects always carry the exact
|
|
179
|
+
* same key set (`MIRRORED_PROPERTIES` + `EXTRA_MIRROR_KEYS`), so a plain
|
|
180
|
+
* per-key `!==` walk is both cheaper and exact — `JSON.stringify` also
|
|
181
|
+
* silently drops `undefined`-valued keys and is sensitive to key order in a
|
|
182
|
+
* way this object's construction never guarantees.
|
|
183
|
+
*/
|
|
184
|
+
function stylesEqual(a: CSSProperties, b: CSSProperties): boolean {
|
|
185
|
+
const record = a as Record<string, unknown>;
|
|
186
|
+
const other = b as Record<string, unknown>;
|
|
187
|
+
for (const key of MIRRORED_PROPERTIES) {
|
|
188
|
+
if (record[key] !== other[key]) return false;
|
|
189
|
+
}
|
|
190
|
+
for (const key of EXTRA_MIRROR_KEYS) {
|
|
191
|
+
if (record[key] !== other[key]) return false;
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
193
196
|
function readMirrorStyle(textarea: HTMLTextAreaElement): CSSProperties {
|
|
194
197
|
const computed = getComputedStyle(textarea);
|
|
195
198
|
const style: Record<string, string | number> = {};
|
|
@@ -354,7 +357,13 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
|
|
|
354
357
|
// ------------------------------------------------------------------
|
|
355
358
|
// Query / open state
|
|
356
359
|
// ------------------------------------------------------------------
|
|
357
|
-
const queryInfo = useMemo(
|
|
360
|
+
const queryInfo = useMemo(
|
|
361
|
+
() =>
|
|
362
|
+
findTriggerQuery(value.text, caret, trigger, {
|
|
363
|
+
isTriggerConsumed: (start) => mentionAt(value, start, trigger) !== undefined,
|
|
364
|
+
}),
|
|
365
|
+
[value, caret, trigger],
|
|
366
|
+
);
|
|
358
367
|
|
|
359
368
|
// Escape (and an outside dismissal) suppress the popup for the run it was
|
|
360
369
|
// dismissed on, without touching the text — the query is still there, the
|
|
@@ -570,9 +579,7 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
|
|
|
570
579
|
const element = textareaRef.current;
|
|
571
580
|
if (!element) return;
|
|
572
581
|
const next = readMirrorStyle(element);
|
|
573
|
-
setMirrorStyle((previous) =>
|
|
574
|
-
previous && JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
|
|
575
|
-
);
|
|
582
|
+
setMirrorStyle((previous) => (previous && stylesEqual(previous, next) ? previous : next));
|
|
576
583
|
}, []);
|
|
577
584
|
|
|
578
585
|
useLayoutEffect(() => {
|
|
@@ -601,14 +608,27 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
|
|
|
601
608
|
|
|
602
609
|
// Theme / density / decoration switches re-resolve the INHERITED font and
|
|
603
610
|
// type scale, and a stale snapshot misaligns every chip on the surface.
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
611
|
+
//
|
|
612
|
+
// Scoped to the field's OWN nearest `[data-theme]` ancestor (falling
|
|
613
|
+
// back to `<html>` when the field isn't inside a scoped region), never
|
|
614
|
+
// the whole document — and filtered to only the three attributes a
|
|
615
|
+
// theme/density/decoration switch actually writes, never generic
|
|
616
|
+
// `class`/`style` on `<html>`/`<body>`. The broader, document-wide
|
|
617
|
+
// observer this replaced fired on every Radix `Sheet`/`Dialog`/`Popover`
|
|
618
|
+
// scroll-lock ANYWHERE in the app (it toggles inline `style` —
|
|
619
|
+
// `overflow`, `padding-right` — on `<body>` on every open/close), which
|
|
620
|
+
// has nothing to do with this field's own metrics (perf review §3.3).
|
|
621
|
+
const themeScope =
|
|
622
|
+
element.closest("[data-theme]") ??
|
|
623
|
+
(typeof document !== "undefined" ? document.documentElement : null);
|
|
624
|
+
if (themeScope) {
|
|
625
|
+
const themeObserver = new MutationObserver(remeasureMirror);
|
|
626
|
+
themeObserver.observe(themeScope, {
|
|
607
627
|
attributes: true,
|
|
608
|
-
attributeFilter: ["data-theme", "data-density", "data-decoration"
|
|
628
|
+
attributeFilter: ["data-theme", "data-density", "data-decoration"],
|
|
609
629
|
});
|
|
630
|
+
teardown.push(() => themeObserver.disconnect());
|
|
610
631
|
}
|
|
611
|
-
teardown.push(() => rootObserver.disconnect());
|
|
612
632
|
}
|
|
613
633
|
|
|
614
634
|
// A web font landing after first paint changes every advance width. This
|
|
@@ -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
|
|
|
@@ -25,6 +25,7 @@ export const Menubar = forwardRef<
|
|
|
25
25
|
return (
|
|
26
26
|
<MenubarPrimitive.Root
|
|
27
27
|
ref={ref}
|
|
28
|
+
data-slot="menubar"
|
|
28
29
|
className={cn("flex h-9 items-center gap-1 rounded-md bg-card p-1 shadow-ring-sm", className)}
|
|
29
30
|
{...props}
|
|
30
31
|
/>
|
|
@@ -38,8 +39,9 @@ export const MenubarTrigger = forwardRef<
|
|
|
38
39
|
return (
|
|
39
40
|
<MenubarPrimitive.Trigger
|
|
40
41
|
ref={ref}
|
|
42
|
+
data-slot="menubar-trigger"
|
|
41
43
|
className={cn(
|
|
42
|
-
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-
|
|
44
|
+
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-body font-medium outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
|
|
43
45
|
className,
|
|
44
46
|
)}
|
|
45
47
|
{...props}
|
|
@@ -58,6 +60,7 @@ export const MenubarContent = forwardRef<
|
|
|
58
60
|
<MenubarPrimitive.Portal>
|
|
59
61
|
<MenubarPrimitive.Content
|
|
60
62
|
ref={ref}
|
|
63
|
+
data-slot="menubar-content"
|
|
61
64
|
align={align}
|
|
62
65
|
alignOffset={alignOffset}
|
|
63
66
|
sideOffset={sideOffset}
|
|
@@ -80,8 +83,9 @@ export const MenubarItem = forwardRef<
|
|
|
80
83
|
return (
|
|
81
84
|
<MenubarPrimitive.Item
|
|
82
85
|
ref={ref}
|
|
86
|
+
data-slot="menubar-item"
|
|
83
87
|
className={cn(
|
|
84
|
-
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-
|
|
88
|
+
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
85
89
|
inset && "ps-8",
|
|
86
90
|
className,
|
|
87
91
|
)}
|
|
@@ -97,8 +101,9 @@ export const MenubarSubTrigger = forwardRef<
|
|
|
97
101
|
return (
|
|
98
102
|
<MenubarPrimitive.SubTrigger
|
|
99
103
|
ref={ref}
|
|
104
|
+
data-slot="menubar-sub-trigger"
|
|
100
105
|
className={cn(
|
|
101
|
-
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-
|
|
106
|
+
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
|
|
102
107
|
inset && "ps-8",
|
|
103
108
|
className,
|
|
104
109
|
)}
|
|
@@ -117,6 +122,7 @@ export const MenubarSubContent = forwardRef<
|
|
|
117
122
|
return (
|
|
118
123
|
<MenubarPrimitive.SubContent
|
|
119
124
|
ref={ref}
|
|
125
|
+
data-slot="menubar-sub-content"
|
|
120
126
|
className={cn(
|
|
121
127
|
"z-50 min-w-[8rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
|
|
122
128
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
|
@@ -135,9 +141,10 @@ export const MenubarCheckboxItem = forwardRef<
|
|
|
135
141
|
return (
|
|
136
142
|
<MenubarPrimitive.CheckboxItem
|
|
137
143
|
ref={ref}
|
|
144
|
+
data-slot="menubar-checkbox-item"
|
|
138
145
|
checked={checked}
|
|
139
146
|
className={cn(
|
|
140
|
-
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-
|
|
147
|
+
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
|
|
141
148
|
className,
|
|
142
149
|
)}
|
|
143
150
|
{...props}
|
|
@@ -159,8 +166,9 @@ export const MenubarRadioItem = forwardRef<
|
|
|
159
166
|
return (
|
|
160
167
|
<MenubarPrimitive.RadioItem
|
|
161
168
|
ref={ref}
|
|
169
|
+
data-slot="menubar-radio-item"
|
|
162
170
|
className={cn(
|
|
163
|
-
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-
|
|
171
|
+
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
|
|
164
172
|
className,
|
|
165
173
|
)}
|
|
166
174
|
{...props}
|
|
@@ -182,6 +190,7 @@ export const MenubarSeparator = forwardRef<
|
|
|
182
190
|
return (
|
|
183
191
|
<MenubarPrimitive.Separator
|
|
184
192
|
ref={ref}
|
|
193
|
+
data-slot="menubar-separator"
|
|
185
194
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
186
195
|
{...props}
|
|
187
196
|
/>
|
|
@@ -191,7 +200,8 @@ export const MenubarSeparator = forwardRef<
|
|
|
191
200
|
export function MenubarShortcut({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
|
192
201
|
return (
|
|
193
202
|
<span
|
|
194
|
-
|
|
203
|
+
data-slot="menubar-shortcut"
|
|
204
|
+
className={cn("ms-auto text-meta tracking-widest text-muted-foreground", className)}
|
|
195
205
|
{...props}
|
|
196
206
|
/>
|
|
197
207
|
);
|
|
@@ -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} />);
|