@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
|
@@ -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 start-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="ps-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
|
+
}
|
|
@@ -10,7 +10,7 @@ export const Label = forwardRef<
|
|
|
10
10
|
<LabelPrimitive.Root
|
|
11
11
|
ref={ref}
|
|
12
12
|
className={cn(
|
|
13
|
-
"text-
|
|
13
|
+
"text-body font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
14
14
|
className,
|
|
15
15
|
)}
|
|
16
16
|
{...props}
|
|
@@ -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}
|
|
@@ -69,6 +69,30 @@ describe("ListEditor", () => {
|
|
|
69
69
|
expect(onValueChange).toHaveBeenCalledWith(["b", "a"]);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
it("keeps keyboard focus on the moved row's own move-down button after a non-boundary move", async () => {
|
|
73
|
+
render(<ListEditor defaultValue={["a", "b", "c", "d"]} />);
|
|
74
|
+
const moveDown = screen.getByRole("button", { name: "Move item 2 down" });
|
|
75
|
+
moveDown.focus();
|
|
76
|
+
await userEvent.keyboard("{Enter}");
|
|
77
|
+
// Row "b" moved from position 2 to position 3 — index-keyed rows would
|
|
78
|
+
// leave focus sitting on whatever the DOM slot at the old position now
|
|
79
|
+
// renders (the row that got swapped INTO it), not on the row the user
|
|
80
|
+
// actually moved.
|
|
81
|
+
expect(screen.getByRole("button", { name: "Move item 3 down" })).toHaveFocus();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("redirects focus to the complementary button when a move disables the one just pressed", async () => {
|
|
85
|
+
render(<ListEditor defaultValue={["a", "b", "c"]} />);
|
|
86
|
+
const moveUp = screen.getByRole("button", { name: "Move item 2 up" });
|
|
87
|
+
moveUp.focus();
|
|
88
|
+
await userEvent.keyboard("{Enter}");
|
|
89
|
+
// "b" is now row 1 (the top) — its own "move up" is now disabled, and a
|
|
90
|
+
// browser blurs a focused element the moment it goes disabled. Focus
|
|
91
|
+
// must land on that same row's still-enabled "move down" button instead
|
|
92
|
+
// of falling through to <body>.
|
|
93
|
+
expect(screen.getByRole("button", { name: "Move item 1 down" })).toHaveFocus();
|
|
94
|
+
});
|
|
95
|
+
|
|
72
96
|
it("hides reorder buttons when reorderable=false", () => {
|
|
73
97
|
render(<ListEditor defaultValue={["a", "b"]} reorderable={false} />);
|
|
74
98
|
// Anchored at the start — "Remove item 1" contains the substring "move
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import {
|
|
4
4
|
forwardRef,
|
|
5
5
|
useCallback,
|
|
6
|
+
useEffect,
|
|
6
7
|
useMemo,
|
|
8
|
+
useRef,
|
|
7
9
|
useState,
|
|
8
10
|
type HTMLAttributes,
|
|
9
11
|
type ReactNode,
|
|
@@ -77,10 +79,48 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
|
|
|
77
79
|
[isControlled, onValueChange],
|
|
78
80
|
);
|
|
79
81
|
|
|
82
|
+
// `rows` is a bare `string[]` with no identity of its own — keying each
|
|
83
|
+
// row's DOM by its ARRAY INDEX means a reorder swaps which row's data a
|
|
84
|
+
// given DOM node (and its focused "Move up" button) renders, not which
|
|
85
|
+
// node holds the moved row's data. A stable per-row id, generated once and
|
|
86
|
+
// carried through add/remove/move in lockstep with `rows`, lets React
|
|
87
|
+
// (and thus keyboard focus) follow the actual row instead of the slot.
|
|
88
|
+
const idCounterRef = useRef(0);
|
|
89
|
+
const makeId = useCallback(() => `list-editor-row-${idCounterRef.current++}`, []);
|
|
90
|
+
const [rowIds, setRowIds] = useState<string[]>(() => rows.map(() => makeId()));
|
|
91
|
+
const lastRowsRef = useRef(rows);
|
|
92
|
+
const upButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
93
|
+
const downButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
94
|
+
const pendingFocusRef = useRef<{ id: string; want: "up" | "down" } | null>(null);
|
|
95
|
+
|
|
96
|
+
// Safety net for a `rows` change that did NOT come from add/remove/move
|
|
97
|
+
// below (e.g. a controlled `value` reset by the app) — keeps `rowIds` the
|
|
98
|
+
// same length as `rows` without guessing at a diff.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (rows === lastRowsRef.current) return;
|
|
101
|
+
lastRowsRef.current = rows;
|
|
102
|
+
setRowIds((prev) => {
|
|
103
|
+
if (prev.length === rows.length) return prev;
|
|
104
|
+
if (rows.length > prev.length) {
|
|
105
|
+
return [...prev, ...Array.from({ length: rows.length - prev.length }, () => makeId())];
|
|
106
|
+
}
|
|
107
|
+
return prev.slice(0, rows.length);
|
|
108
|
+
});
|
|
109
|
+
}, [rows, makeId]);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const pending = pendingFocusRef.current;
|
|
113
|
+
if (!pending) return;
|
|
114
|
+
pendingFocusRef.current = null;
|
|
115
|
+
const map = pending.want === "up" ? upButtonRefs.current : downButtonRefs.current;
|
|
116
|
+
map.get(pending.id)?.focus();
|
|
117
|
+
}, [rowIds]);
|
|
118
|
+
|
|
80
119
|
const addRow = useCallback(() => {
|
|
81
120
|
if (max !== undefined && rows.length >= max) return;
|
|
82
121
|
commit([...rows, ""]);
|
|
83
|
-
|
|
122
|
+
setRowIds((ids) => [...ids, makeId()]);
|
|
123
|
+
}, [rows, max, commit, makeId]);
|
|
84
124
|
|
|
85
125
|
const updateRow = useCallback(
|
|
86
126
|
(index: number, next: string) => {
|
|
@@ -92,6 +132,7 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
|
|
|
92
132
|
const removeRow = useCallback(
|
|
93
133
|
(index: number) => {
|
|
94
134
|
commit(rows.filter((_, i) => i !== index));
|
|
135
|
+
setRowIds((ids) => ids.filter((_, i) => i !== index));
|
|
95
136
|
},
|
|
96
137
|
[rows, commit],
|
|
97
138
|
);
|
|
@@ -103,9 +144,31 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
|
|
|
103
144
|
const next = [...rows];
|
|
104
145
|
const [moved] = next.splice(index, 1);
|
|
105
146
|
next.splice(target, 0, moved!);
|
|
147
|
+
const movedId = rowIds[index]!;
|
|
106
148
|
commit(next);
|
|
149
|
+
setRowIds((ids) => {
|
|
150
|
+
const nextIds = [...ids];
|
|
151
|
+
const [id] = nextIds.splice(index, 1);
|
|
152
|
+
nextIds.splice(target, 0, id!);
|
|
153
|
+
return nextIds;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// The button the user just pressed keeps focus by default (same id ⇒
|
|
157
|
+
// same DOM node, just repositioned). The one case that needs help: the
|
|
158
|
+
// row landed at the top/bottom, so THIS direction's button is now
|
|
159
|
+
// `disabled` — a browser blurs a focused element the instant it goes
|
|
160
|
+
// disabled, dropping focus to <body>. Redirect to the row's other
|
|
161
|
+
// reorder button instead, which stays enabled.
|
|
162
|
+
const activated: "up" | "down" = direction === -1 ? "up" : "down";
|
|
163
|
+
const willDisable =
|
|
164
|
+
(activated === "up" && target === 0) ||
|
|
165
|
+
(activated === "down" && target === rows.length - 1);
|
|
166
|
+
pendingFocusRef.current = {
|
|
167
|
+
id: movedId,
|
|
168
|
+
want: willDisable ? (activated === "up" ? "down" : "up") : activated,
|
|
169
|
+
};
|
|
107
170
|
},
|
|
108
|
-
[rows, commit],
|
|
171
|
+
[rows, rowIds, commit],
|
|
109
172
|
);
|
|
110
173
|
|
|
111
174
|
const atMax = max !== undefined && rows.length >= max;
|
|
@@ -123,56 +186,67 @@ export const ListEditor = forwardRef<HTMLDivElement, ListEditorProps>(function L
|
|
|
123
186
|
</p>
|
|
124
187
|
) : (
|
|
125
188
|
<div className="flex flex-col gap-2">
|
|
126
|
-
{rows.map((row, index) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
189
|
+
{rows.map((row, index) => {
|
|
190
|
+
const rowId = rowIds[index] ?? String(index);
|
|
191
|
+
return (
|
|
192
|
+
<div key={rowId} data-slot="list-editor-row" className="flex items-center gap-1">
|
|
193
|
+
<Input
|
|
194
|
+
data-slot="list-editor-item"
|
|
195
|
+
value={row}
|
|
196
|
+
placeholder={placeholder}
|
|
197
|
+
disabled={disabled}
|
|
198
|
+
aria-label={t("ui.listEditor.itemLabel", { n: index + 1 })}
|
|
199
|
+
onChange={(event) => updateRow(index, event.target.value)}
|
|
200
|
+
className="flex-1"
|
|
201
|
+
/>
|
|
202
|
+
{reorderable && (
|
|
203
|
+
<>
|
|
204
|
+
<Button
|
|
205
|
+
ref={(el) => {
|
|
206
|
+
if (el) upButtonRefs.current.set(rowId, el);
|
|
207
|
+
else upButtonRefs.current.delete(rowId);
|
|
208
|
+
}}
|
|
209
|
+
data-slot="list-editor-move-up"
|
|
210
|
+
type="button"
|
|
211
|
+
variant="ghost"
|
|
212
|
+
size="icon-sm"
|
|
213
|
+
disabled={disabled || index === 0}
|
|
214
|
+
aria-label={t("ui.listEditor.moveUp", { n: index + 1 })}
|
|
215
|
+
onClick={() => moveRow(index, -1)}
|
|
216
|
+
>
|
|
217
|
+
<ChevronUp aria-hidden="true" />
|
|
218
|
+
</Button>
|
|
219
|
+
<Button
|
|
220
|
+
ref={(el) => {
|
|
221
|
+
if (el) downButtonRefs.current.set(rowId, el);
|
|
222
|
+
else downButtonRefs.current.delete(rowId);
|
|
223
|
+
}}
|
|
224
|
+
data-slot="list-editor-move-down"
|
|
225
|
+
type="button"
|
|
226
|
+
variant="ghost"
|
|
227
|
+
size="icon-sm"
|
|
228
|
+
disabled={disabled || index === rows.length - 1}
|
|
229
|
+
aria-label={t("ui.listEditor.moveDown", { n: index + 1 })}
|
|
230
|
+
onClick={() => moveRow(index, 1)}
|
|
231
|
+
>
|
|
232
|
+
<ChevronDown aria-hidden="true" />
|
|
233
|
+
</Button>
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
<Button
|
|
237
|
+
data-slot="list-editor-remove"
|
|
238
|
+
type="button"
|
|
239
|
+
variant="ghost"
|
|
240
|
+
size="icon-sm"
|
|
241
|
+
disabled={disabled}
|
|
242
|
+
aria-label={t("ui.listEditor.removeItem", { n: index + 1 })}
|
|
243
|
+
onClick={() => removeRow(index)}
|
|
244
|
+
>
|
|
245
|
+
<X aria-hidden="true" />
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
176
250
|
</div>
|
|
177
251
|
)}
|
|
178
252
|
<Button
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { DEFAULT_MESSAGES } from "./messages";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Guards `t("some.key")` call sites across every package against the shipped
|
|
9
|
+
// English dictionary — a key used in source but never registered in
|
|
10
|
+
// `DEFAULT_MESSAGES` silently renders the RAW KEY to end users (t()'s last
|
|
11
|
+
// fallback rung), which is exactly what shipped when a i18n sweep added
|
|
12
|
+
// `t()` call sites but the matching messages.ts entries lagged behind
|
|
13
|
+
// (#4 i18n review). Source is the one true list of "keys a component can
|
|
14
|
+
// render" (see docs/I18N.md); this test keeps that list honest without
|
|
15
|
+
// hand-maintaining a duplicate inventory.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
// packages/ui/src/components/locale-provider -> repo root
|
|
20
|
+
const REPO_ROOT = join(HERE, "../../../../..");
|
|
21
|
+
|
|
22
|
+
/** Source `.tsx`/`.ts` files under a package's `src/`, excluding tests/stories. */
|
|
23
|
+
function sourceFiles(dir: string, acc: string[] = []): string[] {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return acc;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const full = join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
34
|
+
sourceFiles(full, acc);
|
|
35
|
+
} else if (/\.tsx?$/.test(entry.name) && !/\.(test|spec|stories)\.tsx?$/.test(entry.name)) {
|
|
36
|
+
acc.push(full);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return acc;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Strip `//` and `/* */` comments so example code in comments never counts as a call site. */
|
|
43
|
+
function stripComments(source: string): string {
|
|
44
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract every string-literal key passed as (or inside a ternary forming)
|
|
49
|
+
* the FIRST argument of a `t(...)` call in `source`. Handles the shapes this
|
|
50
|
+
* codebase actually uses:
|
|
51
|
+
* t("ui.foo.bar")
|
|
52
|
+
* t("ui.foo.bar", { count })
|
|
53
|
+
* t(cond ? "ui.foo.a" : "ui.foo.b", { name })
|
|
54
|
+
* t(SOME_MAP[status]) — dynamic; no literal to extract, skipped
|
|
55
|
+
*
|
|
56
|
+
* A literal only counts as a candidate KEY when it stands as a whole ternary
|
|
57
|
+
* branch or the whole argument — i.e. bounded (modulo whitespace) by the
|
|
58
|
+
* start of the argument, `?`, `:`, or the argument's end — so a literal used
|
|
59
|
+
* mid-condition (`zoom === "fit-width" ? a : b`) is correctly NOT treated as
|
|
60
|
+
* a translation key.
|
|
61
|
+
*/
|
|
62
|
+
export function extractTranslationKeys(source: string): string[] {
|
|
63
|
+
const clean = stripComments(source);
|
|
64
|
+
const keys: string[] = [];
|
|
65
|
+
const callRe = /(?<![A-Za-z0-9_.])t\(/g;
|
|
66
|
+
let match: RegExpExecArray | null;
|
|
67
|
+
|
|
68
|
+
while ((match = callRe.exec(clean))) {
|
|
69
|
+
const start = match.index + match[0].length;
|
|
70
|
+
let depth = 0;
|
|
71
|
+
let end = start;
|
|
72
|
+
// Walk to the first top-level comma or closing paren — the boundary of
|
|
73
|
+
// the first argument expression.
|
|
74
|
+
for (; end < clean.length; end++) {
|
|
75
|
+
const ch = clean[end];
|
|
76
|
+
if (ch === "(" || ch === "{" || ch === "[") depth++;
|
|
77
|
+
else if (ch === ")" || ch === "}" || ch === "]") {
|
|
78
|
+
if (depth === 0) break;
|
|
79
|
+
depth--;
|
|
80
|
+
} else if (ch === "," && depth === 0) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const firstArg = clean.slice(start, end);
|
|
85
|
+
const literalRe = /(?:^|[?:(,])\s*(["'])((?:(?!\1).)*)\1\s*(?=$|[):,])/g;
|
|
86
|
+
for (const lit of firstArg.matchAll(literalRe)) {
|
|
87
|
+
if (lit[2]) keys.push(lit[2]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return keys;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface Finding {
|
|
94
|
+
file: string;
|
|
95
|
+
key: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function collectMissingKeys(): Finding[] {
|
|
99
|
+
const missing: Finding[] = [];
|
|
100
|
+
const pkgsDir = join(REPO_ROOT, "packages");
|
|
101
|
+
for (const pkg of readdirSync(pkgsDir, { withFileTypes: true })) {
|
|
102
|
+
if (!pkg.isDirectory()) continue;
|
|
103
|
+
const src = join(pkgsDir, pkg.name, "src");
|
|
104
|
+
try {
|
|
105
|
+
if (!statSync(src).isDirectory()) continue;
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const file of sourceFiles(src)) {
|
|
110
|
+
const source = readFileSync(file, "utf8");
|
|
111
|
+
for (const key of extractTranslationKeys(source)) {
|
|
112
|
+
if (!(key in DEFAULT_MESSAGES)) {
|
|
113
|
+
missing.push({ file: relative(REPO_ROOT, file), key });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return missing;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("t() call sites vs. DEFAULT_MESSAGES", () => {
|
|
122
|
+
it("every literal key passed to t() across every package exists in the shipped dictionary", () => {
|
|
123
|
+
const missing = collectMissingKeys();
|
|
124
|
+
if (missing.length > 0) {
|
|
125
|
+
const report = missing.map((m) => ` ${m.file}: t("${m.key}")`).join("\n");
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Found ${missing.length} t() call(s) whose key has no entry in ` +
|
|
128
|
+
`packages/ui/src/components/locale-provider/messages.ts — this key would ` +
|
|
129
|
+
`render as the raw key string to end users instead of English copy:\n${report}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
expect(missing).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
});
|