@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
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
2
3
|
import { Input } from "./input";
|
|
3
4
|
|
|
4
5
|
const meta = {
|
|
5
6
|
title: "Core/Input",
|
|
6
7
|
component: Input,
|
|
7
8
|
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The plain text field. A search box with a leading icon and a clear button is `Data/SearchInput`, which is built on this one; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
8
17
|
args: { placeholder: "you@example.com" },
|
|
9
18
|
argTypes: {
|
|
10
19
|
type: {
|
|
@@ -42,3 +51,115 @@ type Story = StoryObj<typeof meta>;
|
|
|
42
51
|
export const Default: Story = {};
|
|
43
52
|
export const Invalid: Story = { args: { "aria-invalid": true, defaultValue: "not-an-email" } };
|
|
44
53
|
export const Disabled: Story = { args: { disabled: true, value: "Disabled" } };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The element whose `data-theme` actually GOVERNS `el` — the nearest ancestor
|
|
57
|
+
* carrying the attribute, falling back to the document element.
|
|
58
|
+
*
|
|
59
|
+
* The earlier version of this helper guessed between `document.body` and
|
|
60
|
+
* `document.documentElement`. When the decorator wrote `data-theme` onto a
|
|
61
|
+
* wrapper closer to the story than either of those, flipping the far ancestor
|
|
62
|
+
* was a silent no-op: the nearer attribute still won the cascade, so BOTH
|
|
63
|
+
* branches of the loop below asserted in whatever theme the run had already
|
|
64
|
+
* chosen, and the docblock's "checked in both themes" claim was false. That
|
|
65
|
+
* stayed invisible until the suite was run under `STORYBOOK_THEME=dark`, where
|
|
66
|
+
* the same two branches both asserted in dark and the guard fired. Resolving
|
|
67
|
+
* the governing element from the input itself cannot drift that way.
|
|
68
|
+
*/
|
|
69
|
+
function themeHost(el: Element): HTMLElement {
|
|
70
|
+
return (el.closest("[data-theme]") as HTMLElement | null) ?? document.documentElement;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Regression lock for #49: `Input` must set its OWN text colour rather than
|
|
75
|
+
* inherit whatever ambient ink its container declares. Nested inside a
|
|
76
|
+
* `text-sidebar-foreground` wrapper (the `sidebar-04` mail search field's real
|
|
77
|
+
* shape), the input's resolved `color` must still match an un-nested `Input`'s
|
|
78
|
+
* — not the wrapper's near-invisible-on-`--background` sidebar ink. Runs in
|
|
79
|
+
* the Storybook browser project so `getComputedStyle` reflects the real
|
|
80
|
+
* Tailwind/token cascade (a jsdom unit test has none — `css: false` in
|
|
81
|
+
* `vitest.config.ts` — so it can only assert the class list, which would stay
|
|
82
|
+
* green even if a later change kept the class string but lost the cascade).
|
|
83
|
+
* Checked in BOTH themes via an in-place `data-theme` flip (no remount), so
|
|
84
|
+
* the fix is verified theme-safe rather than merely assumed to be from the
|
|
85
|
+
* class being additive.
|
|
86
|
+
*/
|
|
87
|
+
export const InAmbientTextContainer: Story = {
|
|
88
|
+
render: () => (
|
|
89
|
+
<div className="flex flex-col gap-4">
|
|
90
|
+
<Input aria-label="reference input" placeholder="reference (no ambient wrapper)" />
|
|
91
|
+
<div className="text-sidebar-foreground">
|
|
92
|
+
<Input aria-label="nested input" placeholder="nested under text-sidebar-foreground" />
|
|
93
|
+
</div>
|
|
94
|
+
<div className="text-muted-foreground">
|
|
95
|
+
<Input aria-label="muted input" placeholder="nested under text-muted-foreground" />
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
),
|
|
99
|
+
play: async ({ canvas, userEvent }) => {
|
|
100
|
+
const reference = canvas.getByLabelText<HTMLInputElement>("reference input");
|
|
101
|
+
const nested = canvas.getByLabelText<HTMLInputElement>("nested input");
|
|
102
|
+
const muted = canvas.getByLabelText<HTMLInputElement>("muted input");
|
|
103
|
+
await userEvent.type(nested, "search query");
|
|
104
|
+
|
|
105
|
+
const assertMatchesReference = async () => {
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(getComputedStyle(nested).color).toBe(getComputedStyle(reference).color);
|
|
108
|
+
expect(getComputedStyle(muted).color).toBe(getComputedStyle(reference).color);
|
|
109
|
+
});
|
|
110
|
+
/*
|
|
111
|
+
* Guard against a vacuous pass: an ambient wrapper's ink must actually
|
|
112
|
+
* differ from the input's own, or this lock could never fail.
|
|
113
|
+
*
|
|
114
|
+
* The guard rides on the `text-muted-foreground` wrapper, NOT the
|
|
115
|
+
* sidebar one. `--sidebar-foreground` and `--foreground` are separate
|
|
116
|
+
* roles that happen to carry the same literal in the dark reference
|
|
117
|
+
* theme (both `oklch(0.95 0.004 257)`) — a legitimate coincidence, not
|
|
118
|
+
* an undeclared alias: light's sidebar ink is near-white because that
|
|
119
|
+
* theme's sidebar ground is DARK, and dark's is near-white because
|
|
120
|
+
* everything is. So in dark the sidebar wrapper cannot express a
|
|
121
|
+
* difference, and asserting one there is unsatisfiable.
|
|
122
|
+
* `--muted-foreground` differs from `--foreground` in both reference
|
|
123
|
+
* themes (light 0.5 vs 0.3, dark 0.72 vs 0.95), so it can carry the
|
|
124
|
+
* guard in either. The sidebar wrapper stays because it is the real
|
|
125
|
+
* shape this locks (the `sidebar-04` mail search field).
|
|
126
|
+
*/
|
|
127
|
+
await expect(getComputedStyle(muted).color).not.toBe(
|
|
128
|
+
getComputedStyle(muted.parentElement!).color,
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const host = themeHost(nested);
|
|
133
|
+
const original = host.getAttribute("data-theme");
|
|
134
|
+
/*
|
|
135
|
+
* Prove the flip actually took, rather than assuming it. A flip that lands
|
|
136
|
+
* on an element the cascade ignores turns the whole cross-theme claim into
|
|
137
|
+
* a no-op, which is precisely how this story passed for two themes while
|
|
138
|
+
* only ever measuring one.
|
|
139
|
+
*/
|
|
140
|
+
const assertThemeApplied = async (expected: string) => {
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(getComputedStyle(host).getPropertyValue("--foreground").trim()).not.toBe("");
|
|
143
|
+
});
|
|
144
|
+
expect(host.getAttribute("data-theme")).toBe(expected);
|
|
145
|
+
expect(nested.closest("[data-theme]")).toBe(host);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
host.setAttribute("data-theme", "light");
|
|
150
|
+
await assertThemeApplied("light");
|
|
151
|
+
const lightInk = getComputedStyle(nested).color;
|
|
152
|
+
await assertMatchesReference();
|
|
153
|
+
|
|
154
|
+
host.setAttribute("data-theme", "dark");
|
|
155
|
+
await assertThemeApplied("dark");
|
|
156
|
+
await assertMatchesReference();
|
|
157
|
+
// The two themes must actually resolve to different ink, or "checked in
|
|
158
|
+
// both themes" is a claim about an attribute nobody read.
|
|
159
|
+
expect(getComputedStyle(nested).color).not.toBe(lightInk);
|
|
160
|
+
} finally {
|
|
161
|
+
if (original === null) host.removeAttribute("data-theme");
|
|
162
|
+
else host.setAttribute("data-theme", original);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
};
|
|
@@ -16,4 +16,17 @@ describe("Input", () => {
|
|
|
16
16
|
const el = screen.getByLabelText<HTMLInputElement>("email");
|
|
17
17
|
expect(el).toBeDisabled();
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
it("applies text-foreground class to ensure text color is not inherited from parent", () => {
|
|
21
|
+
// Render Input inside a container with a different text color (simulating sidebar)
|
|
22
|
+
render(
|
|
23
|
+
<div className="text-sidebar-foreground">
|
|
24
|
+
<Input aria-label="search" />
|
|
25
|
+
</div>,
|
|
26
|
+
);
|
|
27
|
+
const el = screen.getByLabelText<HTMLInputElement>("search");
|
|
28
|
+
// Verify that the Input element has the text-foreground class
|
|
29
|
+
// This ensures its text color is explicitly set and not inherited
|
|
30
|
+
expect(el.className).toContain("text-foreground");
|
|
31
|
+
});
|
|
19
32
|
});
|
|
@@ -12,12 +12,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
12
12
|
ref={ref}
|
|
13
13
|
type={type}
|
|
14
14
|
className={cn(
|
|
15
|
-
"flex h-
|
|
15
|
+
"flex h-control w-full rounded-control border border-input bg-input-background text-foreground px-3 py-1 text-body shadow-input transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
|
|
16
16
|
"placeholder:text-muted-foreground",
|
|
17
|
-
"focus-
|
|
18
|
-
|
|
17
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
18
|
+
// One disabled mechanism (opacity fade), matching every `Button`
|
|
19
|
+
// variant — `disabled:bg-muted` used to also fire here, stacking a
|
|
20
|
+
// fill swap on top of the fade (#286); `disabled:border-border` stays,
|
|
21
|
+
// it is the separate/subtle "not editable" edge, not a fill.
|
|
22
|
+
"disabled:cursor-not-allowed disabled:opacity-50 disabled:border-border",
|
|
19
23
|
"aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
|
|
20
|
-
"file:border-0 file:bg-transparent file:text-
|
|
24
|
+
"file:border-0 file:bg-transparent file:text-body file:font-medium",
|
|
21
25
|
className,
|
|
22
26
|
)}
|
|
23
27
|
{...props}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
2
3
|
import { Search } from "lucide-react";
|
|
3
4
|
import {
|
|
4
5
|
InputGroup,
|
|
@@ -85,3 +86,76 @@ export const Card: Story = {
|
|
|
85
86
|
</div>
|
|
86
87
|
),
|
|
87
88
|
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Regression lock for #67 (fix round 2) — the compound focus indicator must be
|
|
92
|
+
* ONE mark on ONE element.
|
|
93
|
+
*
|
|
94
|
+
* `InputGroup` delegates its indicator to the wrapper, because the control is
|
|
95
|
+
* borderless and full-bleed and a ring around IT would be drawn inside the
|
|
96
|
+
* well. Before this lock the delegation was only half done and the indicator
|
|
97
|
+
* split across two elements: the wrapper painted the brand ring and no contour
|
|
98
|
+
* (its own base `outline-none` set `--tw-outline-style: none`, and a following
|
|
99
|
+
* `outline-1` re-declared `outline-style` from that variable), while the inner
|
|
100
|
+
* control painted the contour and no ring, ~10px inside, at a different radius.
|
|
101
|
+
* On light that read as a stray dark rectangle across the composer, and the
|
|
102
|
+
* well kept the 1.23:1 un-contoured lime ring #67 exists to remove.
|
|
103
|
+
*
|
|
104
|
+
* THE ASSERTION IS ON COMPUTED STYLE, NOT ON THE CLASS STRING. The class string
|
|
105
|
+
* was already "correct" while the bug shipped — `has-[…]:focus-ring-static` was
|
|
106
|
+
* present and cancelled by a sibling utility — so a class-name assertion (or a
|
|
107
|
+
* jsdom unit test, which has no CSS at all: `css: false` in `vitest.config.ts`)
|
|
108
|
+
* stays green through the exact regression this locks. It therefore lives in
|
|
109
|
+
* the Storybook browser project, where `getComputedStyle` reflects the real
|
|
110
|
+
* Tailwind cascade.
|
|
111
|
+
*/
|
|
112
|
+
export const CompoundFocusIndicator: Story = {
|
|
113
|
+
tags: ["!autodocs"],
|
|
114
|
+
render: () => (
|
|
115
|
+
<InputGroup className="max-w-sm">
|
|
116
|
+
<InputGroupAddon>
|
|
117
|
+
<Search />
|
|
118
|
+
</InputGroupAddon>
|
|
119
|
+
<InputGroupInput aria-label="locked control" placeholder="Search…" />
|
|
120
|
+
</InputGroup>
|
|
121
|
+
),
|
|
122
|
+
play: async ({ canvas, userEvent }) => {
|
|
123
|
+
const control = canvas.getByLabelText<HTMLInputElement>("locked control");
|
|
124
|
+
const wrapper = control.closest<HTMLElement>("[data-slot=input-group]")!;
|
|
125
|
+
await userEvent.tab();
|
|
126
|
+
await expect(control).toHaveFocus();
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* Settle past `transition-colors`: reading immediately after focus catches
|
|
130
|
+
* `outline-color` mid-transition and reports the element's own ink instead
|
|
131
|
+
* of the contour token. That artefact is why the utility now declares
|
|
132
|
+
* `outline-color` at rest (themes.css); the wait keeps this lock honest
|
|
133
|
+
* about which property it is measuring.
|
|
134
|
+
*/
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
const w = getComputedStyle(wrapper);
|
|
137
|
+
// The wrapper owns BOTH layers.
|
|
138
|
+
expect(w.outlineStyle).not.toBe("none");
|
|
139
|
+
expect(w.outlineWidth).toBe("1px");
|
|
140
|
+
expect(ringSpread(w)).toBeGreaterThan(0);
|
|
141
|
+
// …and they are flush: the contour sits exactly on the ring's outer edge.
|
|
142
|
+
expect(parseFloat(w.outlineOffset)).toBe(ringSpread(w));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The control paints NEITHER layer — no second mark inside the well.
|
|
146
|
+
const c = getComputedStyle(control);
|
|
147
|
+
await expect(c.outlineStyle).toBe("none");
|
|
148
|
+
await expect(ringSpread(c)).toBe(0);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `--tw-ring-shadow` is a `0 0 0 calc(<offset> + <width>) <colour>` string; the
|
|
154
|
+
* painted reach is that `calc`. Reading it (rather than `boxShadow`) keeps the
|
|
155
|
+
* measurement independent of whatever elevation rung the element also carries.
|
|
156
|
+
*/
|
|
157
|
+
function ringSpread(style: CSSStyleDeclaration): number {
|
|
158
|
+
const shadow = style.getPropertyValue("--tw-ring-shadow");
|
|
159
|
+
const match = /calc\(\s*([\d.]+)px\s*\+\s*([\d.]+)px\s*\)/.exec(shadow);
|
|
160
|
+
return match ? parseFloat(match[1]) + parseFloat(match[2]) : 0;
|
|
161
|
+
}
|
|
@@ -7,12 +7,21 @@ import { Textarea } from "../textarea";
|
|
|
7
7
|
|
|
8
8
|
export const inputGroupVariants = cva(
|
|
9
9
|
cn(
|
|
10
|
-
"group/input-group relative flex w-full flex-wrap items-center rounded-
|
|
10
|
+
"group/input-group relative flex w-full flex-wrap items-center rounded-control transition-[color,box-shadow] duration-fast ease-standard",
|
|
11
11
|
// Grow to fit a textarea even when it's nested (e.g. wrapped in a
|
|
12
12
|
// `display:contents` body like PromptInputBody). `:has(>textarea)`
|
|
13
13
|
// misses through that wrapper, so match any descendant textarea.
|
|
14
|
-
"h-
|
|
15
|
-
|
|
14
|
+
"h-control has-[textarea]:h-auto",
|
|
15
|
+
// The compound indicator is the wrapper's, not the control's — the control
|
|
16
|
+
// is borderless and full-bleed, so a ring around IT would draw inside the
|
|
17
|
+
// well. `focus-ring-static` paints BOTH layers; the only geometry knob is
|
|
18
|
+
// `ring-offset-1`, which `outline-offset: calc(--tw-ring-offset-width + 2px)`
|
|
19
|
+
// tracks so ring and contour stay flush at the same radius. Do NOT add an
|
|
20
|
+
// `outline-*` utility here: `outline-1` re-declares
|
|
21
|
+
// `outline-style: var(--tw-outline-style)`, which `outline-none` on this
|
|
22
|
+
// same element used to pin to `none` — that pairing shipped, and it cancelled
|
|
23
|
+
// the contour on every composer, NumberInput and TagInput (#67 fix round 2).
|
|
24
|
+
"has-[[data-slot=input-group-control]:focus-visible]:focus-ring-static has-[[data-slot=input-group-control]:focus-visible]:ring-offset-1 has-[[data-slot=input-group-control]:focus-visible]:ring-offset-background",
|
|
16
25
|
"has-[[data-slot=input-group-control][aria-invalid=true]]:ring-2 has-[[data-slot=input-group-control][aria-invalid=true]]:ring-destructive",
|
|
17
26
|
),
|
|
18
27
|
{
|
|
@@ -20,7 +29,7 @@ export const inputGroupVariants = cva(
|
|
|
20
29
|
variant: {
|
|
21
30
|
// The form-field look: the strong `--input` boundary is the field's
|
|
22
31
|
// sole structural cue, so it stays on the strong rung.
|
|
23
|
-
outline: "border border-input shadow-
|
|
32
|
+
outline: "border border-input shadow-input",
|
|
24
33
|
// The composer look (#194, research 08 §C.1/§D): a soft fill with the
|
|
25
34
|
// focus-within ring carrying focus — NO hard border. Separation comes
|
|
26
35
|
// from the fill (the chat footer already draws its own `border-t`).
|
|
@@ -59,7 +68,7 @@ export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(function I
|
|
|
59
68
|
});
|
|
60
69
|
|
|
61
70
|
export const inputGroupAddonVariants = cva(
|
|
62
|
-
"flex h-auto cursor-text items-center gap-2 py-1.5 text-
|
|
71
|
+
"flex h-auto cursor-text items-center gap-2 py-1.5 text-body font-medium text-muted-foreground select-none [&>svg:not([class*='size-'])]:size-4",
|
|
63
72
|
{
|
|
64
73
|
variants: {
|
|
65
74
|
align: {
|
|
@@ -89,7 +98,7 @@ export const InputGroupAddon = forwardRef<
|
|
|
89
98
|
);
|
|
90
99
|
});
|
|
91
100
|
|
|
92
|
-
const inputGroupButtonVariants = cva("flex items-center gap-2 text-
|
|
101
|
+
const inputGroupButtonVariants = cva("flex items-center gap-2 text-body shadow-none", {
|
|
93
102
|
variants: {
|
|
94
103
|
size: {
|
|
95
104
|
xs: "h-6 gap-1 rounded-sm px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
|
@@ -128,7 +137,7 @@ export const InputGroupText = forwardRef<HTMLSpanElement, ComponentProps<"span">
|
|
|
128
137
|
<span
|
|
129
138
|
ref={ref}
|
|
130
139
|
className={cn(
|
|
131
|
-
"flex items-center gap-2 text-
|
|
140
|
+
"flex items-center gap-2 text-body text-muted-foreground [&_svg:not([class*='size-'])]:size-4",
|
|
132
141
|
className,
|
|
133
142
|
)}
|
|
134
143
|
{...props}
|
|
@@ -144,7 +153,7 @@ export const InputGroupInput = forwardRef<HTMLInputElement, ComponentProps<typeo
|
|
|
144
153
|
ref={ref}
|
|
145
154
|
data-slot="input-group-control"
|
|
146
155
|
className={cn(
|
|
147
|
-
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
156
|
+
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
148
157
|
className,
|
|
149
158
|
)}
|
|
150
159
|
{...props}
|
|
@@ -160,7 +169,7 @@ export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, ComponentProps
|
|
|
160
169
|
ref={ref}
|
|
161
170
|
data-slot="input-group-control"
|
|
162
171
|
className={cn(
|
|
163
|
-
"flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
172
|
+
"flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
164
173
|
className,
|
|
165
174
|
)}
|
|
166
175
|
{...props}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import { forwardRef, useContext, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
|
2
4
|
import { OTPInput, OTPInputContext } from "input-otp";
|
|
3
5
|
import { Minus } from "lucide-react";
|
|
@@ -41,8 +43,12 @@ export const InputOTPSlot = forwardRef<
|
|
|
41
43
|
<div
|
|
42
44
|
ref={ref}
|
|
43
45
|
className={cn(
|
|
44
|
-
"relative flex size-9 items-center justify-center border-y border-e border-input text-
|
|
45
|
-
|
|
46
|
+
"relative flex size-9 items-center justify-center border-y border-e border-input text-body shadow-sm transition-[color,border-color,box-shadow] duration-fast ease-standard first:rounded-s-md first:border-s last:rounded-e-md",
|
|
47
|
+
// The OTP slot IS the field's focus indicator: the real focused element is
|
|
48
|
+
// InputOTP's visually-hidden <input>, and `isActive` is the caret's slot. It
|
|
49
|
+
// therefore carries the compound indicator (#67) — the state is JS-driven, so
|
|
50
|
+
// it is the `-static` flavour rather than a `:focus-visible` one.
|
|
51
|
+
isActive && "z-10 focus-ring-static",
|
|
46
52
|
className,
|
|
47
53
|
)}
|
|
48
54
|
{...props}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect } from "storybook/test";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { KeyboardShortcuts, type ShortcutGroup } from "./keyboard-shortcuts";
|
|
5
|
+
|
|
6
|
+
const groups: ShortcutGroup[] = [
|
|
7
|
+
{
|
|
8
|
+
id: "navigation",
|
|
9
|
+
label: "Navigation",
|
|
10
|
+
defaultOpen: true,
|
|
11
|
+
items: [
|
|
12
|
+
{ action: "Open command palette", keys: ["⌘", "K"] },
|
|
13
|
+
{ action: "Go to file", keys: ["⌘", "P"] },
|
|
14
|
+
{ action: "Go to line", keys: ["⌘", "G"] },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "general",
|
|
19
|
+
label: "General",
|
|
20
|
+
items: [
|
|
21
|
+
{ action: "Close tab", keys: ["⌘", "W"] },
|
|
22
|
+
{ action: "Save", keys: ["⌘", "S"] },
|
|
23
|
+
{ action: "New window", keys: ["⌘", "⇧", "N"] },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "editing",
|
|
28
|
+
label: "Editing",
|
|
29
|
+
items: [
|
|
30
|
+
{ action: "Duplicate line", keys: ["⌘", "⇧", "D"] },
|
|
31
|
+
{ action: "Comment line", keys: ["⌘", "/"] },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: "Disclosure/KeyboardShortcuts",
|
|
38
|
+
component: KeyboardShortcuts,
|
|
39
|
+
tags: ["autodocs"],
|
|
40
|
+
parameters: {
|
|
41
|
+
docs: {
|
|
42
|
+
description: {
|
|
43
|
+
component:
|
|
44
|
+
"A grouped, searchable presentation of a shortcut set. Renders content only — " +
|
|
45
|
+
"the app supplies the Dialog/Sheet shell, so the same component works in a modal, " +
|
|
46
|
+
"a settings page or a sidebar. Group counts are always derived from `items.length` " +
|
|
47
|
+
"(or the number of matching items while filtering), never passed in.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
argTypes: {
|
|
52
|
+
groups: {
|
|
53
|
+
description:
|
|
54
|
+
"Shortcut groups (`ShortcutGroup[]`). Each group's displayed count is derived from `items.length`.",
|
|
55
|
+
control: false,
|
|
56
|
+
table: { category: "Content" },
|
|
57
|
+
},
|
|
58
|
+
searchable: {
|
|
59
|
+
description: "Show the built-in search field.",
|
|
60
|
+
control: "boolean",
|
|
61
|
+
table: { category: "Behavior" },
|
|
62
|
+
},
|
|
63
|
+
query: {
|
|
64
|
+
description: "Controlled search query, if the app owns it.",
|
|
65
|
+
control: "text",
|
|
66
|
+
table: { category: "State" },
|
|
67
|
+
},
|
|
68
|
+
onQueryChange: {
|
|
69
|
+
description: "Callback fired when the search query changes.",
|
|
70
|
+
control: false,
|
|
71
|
+
table: { category: "Behavior" },
|
|
72
|
+
},
|
|
73
|
+
className: {
|
|
74
|
+
description: "Extra Tailwind classes merged via cn().",
|
|
75
|
+
control: "text",
|
|
76
|
+
table: { category: "Appearance" },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
args: {
|
|
80
|
+
groups,
|
|
81
|
+
},
|
|
82
|
+
} satisfies Meta<typeof KeyboardShortcuts>;
|
|
83
|
+
export default meta;
|
|
84
|
+
type Story = StoryObj<typeof meta>;
|
|
85
|
+
|
|
86
|
+
export const Default: Story = {
|
|
87
|
+
args: {
|
|
88
|
+
groups,
|
|
89
|
+
},
|
|
90
|
+
render: (args) => (
|
|
91
|
+
<div className="w-96">
|
|
92
|
+
<KeyboardShortcuts {...args} />
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Typing a query hides non-matching groups and re-derives the visible group's count. */
|
|
98
|
+
export const Filtered: Story = {
|
|
99
|
+
args: {
|
|
100
|
+
groups,
|
|
101
|
+
},
|
|
102
|
+
render: (args) => (
|
|
103
|
+
<div className="w-96">
|
|
104
|
+
<KeyboardShortcuts {...args} query="close" />
|
|
105
|
+
</div>
|
|
106
|
+
),
|
|
107
|
+
play: async ({ canvas }) => {
|
|
108
|
+
// Only "General" contains a "Close tab" action — the other two groups hide entirely.
|
|
109
|
+
await expect(canvas.getByText("General")).toBeInTheDocument();
|
|
110
|
+
await expect(canvas.queryByText("Navigation")).not.toBeInTheDocument();
|
|
111
|
+
await expect(canvas.queryByText("Editing")).not.toBeInTheDocument();
|
|
112
|
+
// The visible group's badge reflects the single matching item, not its total (3).
|
|
113
|
+
await expect(canvas.getByText("1")).toBeInTheDocument();
|
|
114
|
+
await expect(canvas.getByText("Close tab")).toBeInTheDocument();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** A query that matches nothing renders a designed empty message, not a blank area. */
|
|
119
|
+
export const NoMatches: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
groups,
|
|
122
|
+
},
|
|
123
|
+
render: (args) => (
|
|
124
|
+
<div className="w-96">
|
|
125
|
+
<KeyboardShortcuts {...args} query="xyz-nonexistent" />
|
|
126
|
+
</div>
|
|
127
|
+
),
|
|
128
|
+
play: async ({ canvas }) => {
|
|
129
|
+
await expect(canvas.getByText("No shortcuts found")).toBeInTheDocument();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Uncontrolled search — the app doesn't own the query; the component manages it internally. */
|
|
134
|
+
export const Uncontrolled: Story = {
|
|
135
|
+
args: {
|
|
136
|
+
groups,
|
|
137
|
+
},
|
|
138
|
+
render: (args) => (
|
|
139
|
+
<div className="w-96">
|
|
140
|
+
<KeyboardShortcuts {...args} />
|
|
141
|
+
</div>
|
|
142
|
+
),
|
|
143
|
+
play: async ({ canvas, userEvent }) => {
|
|
144
|
+
const input = canvas.getByLabelText("Search shortcuts");
|
|
145
|
+
await userEvent.type(input, "save");
|
|
146
|
+
await expect(canvas.getByText("General")).toBeInTheDocument();
|
|
147
|
+
await expect(canvas.queryByText("Navigation")).not.toBeInTheDocument();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** The app owns the query — e.g. it lives alongside a `Dialog` header search field. */
|
|
152
|
+
export const ControlledQuery: Story = {
|
|
153
|
+
render: () => {
|
|
154
|
+
function Demo() {
|
|
155
|
+
const [query, setQuery] = useState("");
|
|
156
|
+
return (
|
|
157
|
+
<div className="w-96">
|
|
158
|
+
<KeyboardShortcuts groups={groups} query={query} onQueryChange={setQuery} />
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return <Demo />;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** No search field — just the grouped, collapsible list. */
|
|
167
|
+
export const NotSearchable: Story = {
|
|
168
|
+
args: {
|
|
169
|
+
groups,
|
|
170
|
+
searchable: false,
|
|
171
|
+
},
|
|
172
|
+
render: (args) => (
|
|
173
|
+
<div className="w-96">
|
|
174
|
+
<KeyboardShortcuts {...args} />
|
|
175
|
+
</div>
|
|
176
|
+
),
|
|
177
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { KeyboardShortcuts, type ShortcutGroup } from "./keyboard-shortcuts";
|
|
4
|
+
|
|
5
|
+
const groups: ShortcutGroup[] = [
|
|
6
|
+
{
|
|
7
|
+
id: "navigation",
|
|
8
|
+
label: "Navigation",
|
|
9
|
+
items: [
|
|
10
|
+
{ action: "Open command palette", keys: ["⌘", "K"] },
|
|
11
|
+
{ action: "Go to file", keys: ["⌘", "P"] },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "general",
|
|
16
|
+
label: "General",
|
|
17
|
+
items: [
|
|
18
|
+
{ action: "Close tab", keys: ["⌘", "W"] },
|
|
19
|
+
{ action: "Save", keys: ["⌘", "S"] },
|
|
20
|
+
{ action: "New window", keys: ["⌘", "⇧", "N"] },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe("KeyboardShortcuts", () => {
|
|
26
|
+
it("renders every group with a count derived from items.length", () => {
|
|
27
|
+
render(<KeyboardShortcuts groups={groups} searchable={false} />);
|
|
28
|
+
expect(screen.getByText("Navigation")).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByText("General")).toBeInTheDocument();
|
|
30
|
+
// "Navigation" has 2 items, "General" has 3 — neither count was passed in.
|
|
31
|
+
expect(screen.getByText("2")).toBeInTheDocument();
|
|
32
|
+
expect(screen.getByText("3")).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("a query matching only one group hides the others, and derives the visible group's count from the matches", () => {
|
|
36
|
+
render(<KeyboardShortcuts groups={groups} query="close" />);
|
|
37
|
+
|
|
38
|
+
// Only "General" has an item matching "close" — "Navigation" is hidden entirely,
|
|
39
|
+
// not rendered empty.
|
|
40
|
+
expect(screen.getByText("General")).toBeInTheDocument();
|
|
41
|
+
expect(screen.queryByText("Navigation")).not.toBeInTheDocument();
|
|
42
|
+
|
|
43
|
+
// The visible group's displayed count is the number of MATCHING items (1),
|
|
44
|
+
// never its total item count (3) — this is the "count is derived" lock.
|
|
45
|
+
expect(screen.getByText("1")).toBeInTheDocument();
|
|
46
|
+
expect(screen.queryByText("3")).not.toBeInTheDocument();
|
|
47
|
+
expect(screen.getByText("Close tab")).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("matches on key tokens as well as action text", () => {
|
|
51
|
+
render(<KeyboardShortcuts groups={groups} query="⇧" />);
|
|
52
|
+
expect(screen.getByText("General")).toBeInTheDocument();
|
|
53
|
+
expect(screen.queryByText("Navigation")).not.toBeInTheDocument();
|
|
54
|
+
expect(screen.getByText("New window")).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders a designed empty message, not a blank area, when nothing matches", () => {
|
|
58
|
+
render(<KeyboardShortcuts groups={groups} query="does-not-exist" />);
|
|
59
|
+
expect(screen.queryByText("Navigation")).not.toBeInTheDocument();
|
|
60
|
+
expect(screen.queryByText("General")).not.toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText("No shortcuts found")).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("supports an uncontrolled query without a query prop", () => {
|
|
65
|
+
render(<KeyboardShortcuts groups={groups} />);
|
|
66
|
+
expect(screen.getByLabelText("Search shortcuts")).toHaveValue("");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders no search field when searchable is false", () => {
|
|
70
|
+
render(<KeyboardShortcuts groups={groups} searchable={false} />);
|
|
71
|
+
expect(screen.queryByLabelText("Search shortcuts")).not.toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
});
|