@elabs-ai/components-ui 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useId, useMemo, useState, type ComponentProps } from "react";
|
|
4
|
+
import { ChevronDown, Search } from "lucide-react";
|
|
5
|
+
import { cn } from "../../lib/cn";
|
|
6
|
+
import { Badge } from "../badge";
|
|
7
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../collapsible";
|
|
8
|
+
import { Input } from "../input";
|
|
9
|
+
import { Kbd } from "../kbd";
|
|
10
|
+
import { useLocale } from "../locale-provider";
|
|
11
|
+
import { StatePanel } from "../state-panel";
|
|
12
|
+
|
|
13
|
+
/** One keyboard shortcut: the action it performs and its ordered key tokens. */
|
|
14
|
+
export interface Shortcut {
|
|
15
|
+
action: string;
|
|
16
|
+
/** Ordered key tokens, e.g. `["⌘", "K"]`. Rendered via {@link Kbd}. */
|
|
17
|
+
keys: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A named group of shortcuts. There is deliberately no `count` field — the
|
|
22
|
+
* displayed count is always derived from `items.length`, never supplied by
|
|
23
|
+
* the caller (see issue #113: brainless, the upstream this was adapted from,
|
|
24
|
+
* shipped a `count` with no `items`, an honestly-unverifiable number).
|
|
25
|
+
*/
|
|
26
|
+
export interface ShortcutGroup {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
items: Shortcut[];
|
|
30
|
+
/** Whether this group starts expanded when search is inactive. @default false */
|
|
31
|
+
defaultOpen?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface KeyboardShortcutsProps extends Omit<ComponentProps<"div">, "onChange"> {
|
|
35
|
+
groups: ShortcutGroup[];
|
|
36
|
+
/** Show the built-in search field. @default true */
|
|
37
|
+
searchable?: boolean;
|
|
38
|
+
/** Controlled search query, if the app owns it. */
|
|
39
|
+
query?: string;
|
|
40
|
+
onQueryChange?: (query: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matchesQuery(shortcut: Shortcut, normalizedQuery: string): boolean {
|
|
44
|
+
if (shortcut.action.toLowerCase().includes(normalizedQuery)) return true;
|
|
45
|
+
return shortcut.keys.some((key) => key.toLowerCase().includes(normalizedQuery));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface FilteredGroup {
|
|
49
|
+
group: ShortcutGroup;
|
|
50
|
+
items: Shortcut[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A grouped, searchable presentation of a shortcut set. Renders **content
|
|
55
|
+
* only** — the app supplies the `Dialog`/`Sheet` shell, so the same
|
|
56
|
+
* component works in a modal, a settings page or a sidebar.
|
|
57
|
+
*
|
|
58
|
+
* Group counts are always derived from `items.length` (or the number of
|
|
59
|
+
* matching items while a search is active) — never a value passed in.
|
|
60
|
+
*/
|
|
61
|
+
export const KeyboardShortcuts = forwardRef<HTMLDivElement, KeyboardShortcutsProps>(
|
|
62
|
+
function KeyboardShortcuts(
|
|
63
|
+
{ groups, searchable = true, query: queryProp, onQueryChange, className, ...props },
|
|
64
|
+
ref,
|
|
65
|
+
) {
|
|
66
|
+
const isControlled = queryProp !== undefined;
|
|
67
|
+
const [internalQuery, setInternalQuery] = useState("");
|
|
68
|
+
const query = isControlled ? queryProp : internalQuery;
|
|
69
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
70
|
+
const isFiltering = normalizedQuery.length > 0;
|
|
71
|
+
const { t } = useLocale();
|
|
72
|
+
const searchId = useId();
|
|
73
|
+
|
|
74
|
+
const setQuery = (next: string) => {
|
|
75
|
+
if (!isControlled) setInternalQuery(next);
|
|
76
|
+
onQueryChange?.(next);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const filteredGroups = useMemo<FilteredGroup[]>(() => {
|
|
80
|
+
return groups
|
|
81
|
+
.map((group) => ({
|
|
82
|
+
group,
|
|
83
|
+
items: isFiltering
|
|
84
|
+
? group.items.filter((item) => matchesQuery(item, normalizedQuery))
|
|
85
|
+
: group.items,
|
|
86
|
+
}))
|
|
87
|
+
.filter(({ items }) => items.length > 0);
|
|
88
|
+
}, [groups, isFiltering, normalizedQuery]);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
ref={ref}
|
|
93
|
+
data-slot="keyboard-shortcuts"
|
|
94
|
+
className={cn("flex flex-col gap-4", className)}
|
|
95
|
+
{...props}
|
|
96
|
+
>
|
|
97
|
+
{searchable && (
|
|
98
|
+
<div data-slot="keyboard-shortcuts-search" className="relative">
|
|
99
|
+
<label htmlFor={searchId} className="sr-only">
|
|
100
|
+
Search shortcuts
|
|
101
|
+
</label>
|
|
102
|
+
<Search
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
|
105
|
+
/>
|
|
106
|
+
<Input
|
|
107
|
+
id={searchId}
|
|
108
|
+
type="search"
|
|
109
|
+
autoComplete="off"
|
|
110
|
+
placeholder={t("ui.keyboardShortcuts.searchPlaceholder")}
|
|
111
|
+
value={query}
|
|
112
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
113
|
+
className="pl-8"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{filteredGroups.length === 0 ? (
|
|
119
|
+
<div data-slot="keyboard-shortcuts-empty">
|
|
120
|
+
<StatePanel
|
|
121
|
+
kind="empty"
|
|
122
|
+
title={t("ui.keyboardShortcuts.emptyTitle")}
|
|
123
|
+
description={
|
|
124
|
+
isFiltering
|
|
125
|
+
? t("ui.keyboardShortcuts.emptyFiltered", { query: query.trim() })
|
|
126
|
+
: t("ui.keyboardShortcuts.empty")
|
|
127
|
+
}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<div data-slot="keyboard-shortcuts-groups" className="flex flex-col gap-2">
|
|
132
|
+
{filteredGroups.map(({ group, items }) => (
|
|
133
|
+
<ShortcutGroupItem
|
|
134
|
+
key={group.id}
|
|
135
|
+
group={group}
|
|
136
|
+
items={items}
|
|
137
|
+
forceOpen={isFiltering}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
interface ShortcutGroupItemProps {
|
|
148
|
+
group: ShortcutGroup;
|
|
149
|
+
/** Items to display — the full group, or the subset matching the active search. */
|
|
150
|
+
items: Shortcut[];
|
|
151
|
+
/** Force the group open (search is active) without discarding the user's manual toggle. */
|
|
152
|
+
forceOpen: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function ShortcutGroupItem({ group, items, forceOpen }: ShortcutGroupItemProps) {
|
|
156
|
+
const [open, setOpen] = useState(group.defaultOpen ?? false);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<Collapsible
|
|
160
|
+
data-slot="keyboard-shortcuts-group"
|
|
161
|
+
open={forceOpen || open}
|
|
162
|
+
onOpenChange={setOpen}
|
|
163
|
+
className="rounded-md border"
|
|
164
|
+
>
|
|
165
|
+
<CollapsibleTrigger
|
|
166
|
+
data-slot="keyboard-shortcuts-group-trigger"
|
|
167
|
+
className={cn(
|
|
168
|
+
"flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-start",
|
|
169
|
+
"focus-ring",
|
|
170
|
+
"[&[data-state=open]>svg]:rotate-180",
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
<span className="flex items-center gap-2">
|
|
174
|
+
<span className="text-body font-medium text-foreground">{group.label}</span>
|
|
175
|
+
{/* Derived from items.length — never a caller-supplied count (see #113). */}
|
|
176
|
+
<Badge variant="secondary">{items.length}</Badge>
|
|
177
|
+
</span>
|
|
178
|
+
<ChevronDown
|
|
179
|
+
aria-hidden="true"
|
|
180
|
+
className="size-4 shrink-0 text-muted-foreground transition-transform duration-base ease-standard"
|
|
181
|
+
/>
|
|
182
|
+
</CollapsibleTrigger>
|
|
183
|
+
<CollapsibleContent data-slot="keyboard-shortcuts-group-content">
|
|
184
|
+
<ul className="flex flex-col gap-1 px-3 pb-3">
|
|
185
|
+
{items.map((item, index) => (
|
|
186
|
+
<li
|
|
187
|
+
key={`${item.action}-${index}`}
|
|
188
|
+
data-slot="keyboard-shortcuts-item"
|
|
189
|
+
className="flex items-center justify-between gap-4 py-1"
|
|
190
|
+
>
|
|
191
|
+
<span className="text-body text-muted-foreground">{item.action}</span>
|
|
192
|
+
<span className="flex items-center gap-1">
|
|
193
|
+
{item.keys.map((key, keyIndex) => (
|
|
194
|
+
<Kbd key={`${key}-${keyIndex}`}>{key}</Kbd>
|
|
195
|
+
))}
|
|
196
|
+
</span>
|
|
197
|
+
</li>
|
|
198
|
+
))}
|
|
199
|
+
</ul>
|
|
200
|
+
</CollapsibleContent>
|
|
201
|
+
</Collapsible>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -115,7 +115,7 @@ export const LinkPreviewCard = forwardRef<HTMLDivElement, LinkPreviewCardProps>(
|
|
|
115
115
|
// When `href` is present: stretched-link focus ring (via focus-within on
|
|
116
116
|
// the container, not the <a>) + hover lift + motion-tokened transition.
|
|
117
117
|
href &&
|
|
118
|
-
"cursor-pointer transition-[translate,box-shadow,border-color] duration-fast ease-standard focus-
|
|
118
|
+
"cursor-pointer transition-[translate,box-shadow,border-color] duration-fast ease-standard focus-ring-within hover:-translate-y-0.5 hover:border-ring/40 hover:shadow-md motion-reduce:hover:translate-y-0",
|
|
119
119
|
className,
|
|
120
120
|
)}
|
|
121
121
|
{...props}
|
|
@@ -2,9 +2,23 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
|
2
2
|
import { LocaleProvider, useLocale } from "./locale-provider";
|
|
3
3
|
|
|
4
4
|
const meta = {
|
|
5
|
-
title: "
|
|
5
|
+
title: "Foundations/Localization",
|
|
6
6
|
component: LocaleProvider,
|
|
7
7
|
tags: ["autodocs"],
|
|
8
|
+
parameters: {
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
"Root i18n provider — one of the two providers every app mounts once, at the root. " +
|
|
13
|
+
'Wrap it around your app inside `ThemeProvider`: `<ThemeProvider><LocaleProvider locale="en-US">…' +
|
|
14
|
+
"</LocaleProvider></ThemeProvider>`. Every component's microcopy, number formatting and " +
|
|
15
|
+
"date formatting then reads through `useLocale()`. Full resolution chain (`translate` → " +
|
|
16
|
+
"`messages` → shipped English defaults → raw key), cardinal plurals and bridging to your " +
|
|
17
|
+
"own i18n runtime (next-intl, react-intl, i18next, …): `docs/I18N.md`. See " +
|
|
18
|
+
"[Foundations / Theming](?path=/docs/foundations-theming--docs) for the other root provider.",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
8
22
|
} satisfies Meta<typeof LocaleProvider>;
|
|
9
23
|
|
|
10
24
|
export default meta;
|
|
@@ -52,6 +66,24 @@ function LocaleDemo() {
|
|
|
52
66
|
);
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Demo component: cardinal-plural microcopy via `Intl.PluralRules`
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function PluralDemo() {
|
|
74
|
+
const { t } = useLocale();
|
|
75
|
+
return (
|
|
76
|
+
<div className="space-y-1 rounded-md border border-border p-4 text-body">
|
|
77
|
+
{[0, 1, 2, 5].map((count) => (
|
|
78
|
+
<p key={count}>
|
|
79
|
+
<span className="font-medium text-muted-foreground">count={count}: </span>
|
|
80
|
+
{t("itemsSelected", { count })}
|
|
81
|
+
</p>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
55
87
|
// ---------------------------------------------------------------------------
|
|
56
88
|
// Stories
|
|
57
89
|
// ---------------------------------------------------------------------------
|
|
@@ -81,3 +113,59 @@ export const RtlArabic: Story = {
|
|
|
81
113
|
export const NoProvider: Story = {
|
|
82
114
|
render: () => <LocaleDemo />,
|
|
83
115
|
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The `translate` resolver bridge (#19) — stands in for an app's own i18n
|
|
119
|
+
* runtime (next-intl, react-intl, i18next, …). It takes precedence over both
|
|
120
|
+
* `messages` and the shipped English defaults; returning `undefined` for a
|
|
121
|
+
* key falls through to the normal chain. See `docs/I18N.md` for a worked
|
|
122
|
+
* next-intl example.
|
|
123
|
+
*/
|
|
124
|
+
export const ExternalTranslateResolver: Story = {
|
|
125
|
+
render: () => (
|
|
126
|
+
<LocaleProvider
|
|
127
|
+
locale="de-DE"
|
|
128
|
+
messages={{ close: "Schließen (via messages)" }}
|
|
129
|
+
translate={(key) => (key === "close" ? "Schließen (via translate)" : undefined)}
|
|
130
|
+
>
|
|
131
|
+
<LocaleDemo />
|
|
132
|
+
</LocaleProvider>
|
|
133
|
+
),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cardinal-plural microcopy (#19) — `t("itemsSelected", { count })` selects
|
|
138
|
+
* the right English form ("1 item selected" vs "N items selected") from a
|
|
139
|
+
* `PluralMessage` map as `count` changes, via `Intl.PluralRules`.
|
|
140
|
+
*/
|
|
141
|
+
export const PluralForms: Story = {
|
|
142
|
+
render: () => (
|
|
143
|
+
<LocaleProvider locale="en-US">
|
|
144
|
+
<PluralDemo />
|
|
145
|
+
</LocaleProvider>
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The same plural key resolved for Polish (`pl-PL`), which has FOUR cardinal
|
|
151
|
+
* categories (one/few/many/other) with different count boundaries than
|
|
152
|
+
* English's two (one/other) — the case a plural design that only handles
|
|
153
|
+
* English silently gets wrong. Count 1 → "one", 2 → "few", 5 → "many".
|
|
154
|
+
*/
|
|
155
|
+
export const PluralFormsPolish: Story = {
|
|
156
|
+
render: () => (
|
|
157
|
+
<LocaleProvider
|
|
158
|
+
locale="pl-PL"
|
|
159
|
+
messages={{
|
|
160
|
+
itemsSelected: {
|
|
161
|
+
one: "{count} plik zaznaczony",
|
|
162
|
+
few: "{count} pliki zaznaczone",
|
|
163
|
+
many: "{count} plików zaznaczonych",
|
|
164
|
+
other: "{count} pliku zaznaczonego",
|
|
165
|
+
},
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
<PluralDemo />
|
|
169
|
+
</LocaleProvider>
|
|
170
|
+
),
|
|
171
|
+
};
|