@elabs-ai/components-ui 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -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-within:outline-none focus-within:ring-2 focus-within:ring-ring hover:-translate-y-0.5 hover:border-ring/40 hover:shadow-md motion-reduce:hover:translate-y-0",
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}
@@ -5,4 +5,9 @@ export {
5
5
  type LocaleProviderProps,
6
6
  type Messages,
7
7
  } from "./locale-provider";
8
- export { DEFAULT_MESSAGES } from "./messages";
8
+ export {
9
+ DEFAULT_MESSAGES,
10
+ type MessageValue,
11
+ type PluralCategory,
12
+ type PluralMessage,
13
+ } from "./messages";
@@ -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: "Providers/LocaleProvider",
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
+ };