@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
|
@@ -18,6 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../card";
|
|
|
18
18
|
import { Checkbox } from "../checkbox";
|
|
19
19
|
import { Input } from "../input";
|
|
20
20
|
import { ScrollArea } from "../scroll-area";
|
|
21
|
+
import { useLocale } from "../locale-provider";
|
|
21
22
|
|
|
22
23
|
// ─── Public types ─────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -87,6 +88,7 @@ function TransferPanel({
|
|
|
87
88
|
titleId,
|
|
88
89
|
rowIdBase,
|
|
89
90
|
}: PanelProps) {
|
|
91
|
+
const { t } = useLocale();
|
|
90
92
|
// When a search is active, filter visible items; move operations affect VISIBLE enabled items.
|
|
91
93
|
const visibleItems = useMemo(() => {
|
|
92
94
|
if (!searchQuery) return items;
|
|
@@ -109,10 +111,12 @@ function TransferPanel({
|
|
|
109
111
|
<Checkbox
|
|
110
112
|
checked={someChecked ? "indeterminate" : allChecked}
|
|
111
113
|
onCheckedChange={(v) => onCheckAll(v === true)}
|
|
112
|
-
aria-label={
|
|
114
|
+
aria-label={t("ui.transfer.selectAllIn", {
|
|
115
|
+
title: typeof title === "string" ? title : t("ui.transfer.panelFallback"),
|
|
116
|
+
})}
|
|
113
117
|
disabled={enabledVisible.length === 0}
|
|
114
118
|
/>
|
|
115
|
-
<CardTitle id={titleId} className="text-
|
|
119
|
+
<CardTitle id={titleId} className="text-body">
|
|
116
120
|
{title}
|
|
117
121
|
</CardTitle>
|
|
118
122
|
</div>
|
|
@@ -124,15 +128,17 @@ function TransferPanel({
|
|
|
124
128
|
{showSearch && (
|
|
125
129
|
<div className="px-3 pb-2">
|
|
126
130
|
<label htmlFor={searchInputId} className="sr-only">
|
|
127
|
-
|
|
131
|
+
{t("ui.transfer.searchIn", {
|
|
132
|
+
title: typeof title === "string" ? title : t("ui.transfer.itemsFallback"),
|
|
133
|
+
})}
|
|
128
134
|
</label>
|
|
129
135
|
<Input
|
|
130
136
|
id={searchInputId}
|
|
131
137
|
type="search"
|
|
132
|
-
placeholder="
|
|
138
|
+
placeholder={t("ui.transfer.searchPlaceholder")}
|
|
133
139
|
value={searchQuery}
|
|
134
140
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
135
|
-
className="h-7 text-
|
|
141
|
+
className="h-7 text-meta"
|
|
136
142
|
/>
|
|
137
143
|
</div>
|
|
138
144
|
)}
|
|
@@ -140,8 +146,8 @@ function TransferPanel({
|
|
|
140
146
|
<CardContent className="flex-1 overflow-hidden p-0">
|
|
141
147
|
<ScrollArea className="h-full min-h-[200px]">
|
|
142
148
|
{visibleItems.length === 0 ? (
|
|
143
|
-
<p className="flex h-[200px] items-center justify-center text-
|
|
144
|
-
|
|
149
|
+
<p className="flex h-[200px] items-center justify-center text-body text-muted-foreground">
|
|
150
|
+
{t("ui.transfer.empty")}
|
|
145
151
|
</p>
|
|
146
152
|
) : (
|
|
147
153
|
<ul role="list" className="flex flex-col py-1">
|
|
@@ -156,7 +162,7 @@ function TransferPanel({
|
|
|
156
162
|
<li
|
|
157
163
|
key={item.value}
|
|
158
164
|
className={cn(
|
|
159
|
-
"flex select-none items-center gap-2 px-3 py-1.5 text-
|
|
165
|
+
"flex select-none items-center gap-2 px-3 py-1.5 text-body",
|
|
160
166
|
"hover:bg-accent hover:text-accent-foreground",
|
|
161
167
|
item.disabled && "opacity-50 hover:bg-transparent hover:text-foreground",
|
|
162
168
|
)}
|
|
@@ -198,13 +204,18 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
198
204
|
defaultTargetKeys,
|
|
199
205
|
onChange,
|
|
200
206
|
showSearch = false,
|
|
201
|
-
titles
|
|
207
|
+
titles,
|
|
202
208
|
renderItem = (item) => item.label,
|
|
203
209
|
className,
|
|
204
210
|
...props
|
|
205
211
|
},
|
|
206
212
|
ref,
|
|
207
213
|
) {
|
|
214
|
+
const { t } = useLocale();
|
|
215
|
+
const resolvedTitles: [ReactNode, ReactNode] = titles ?? [
|
|
216
|
+
t("ui.transfer.defaultSourceTitle"),
|
|
217
|
+
t("ui.transfer.defaultTargetTitle"),
|
|
218
|
+
];
|
|
208
219
|
const isControlled = controlledTargetKeys !== undefined;
|
|
209
220
|
|
|
210
221
|
// Uncontrolled internal state
|
|
@@ -273,13 +284,13 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
273
284
|
);
|
|
274
285
|
if (toMove.size === 0) return;
|
|
275
286
|
applyMove([...targetKeys, ...toMove]);
|
|
276
|
-
setAnnouncement(
|
|
287
|
+
setAnnouncement(t("ui.transfer.movedToTarget", { count: toMove.size }));
|
|
277
288
|
setSourceChecked((prev) => {
|
|
278
289
|
const next = new Set(prev);
|
|
279
290
|
toMove.forEach((v) => next.delete(v));
|
|
280
291
|
return next;
|
|
281
292
|
});
|
|
282
|
-
}, [sourceChecked, dataSource, targetKeys, targetSet, applyMove]);
|
|
293
|
+
}, [sourceChecked, dataSource, targetKeys, targetSet, applyMove, t]);
|
|
283
294
|
|
|
284
295
|
/** Move ALL visible enabled source items → target */
|
|
285
296
|
const handleMoveAllRight = useCallback(() => {
|
|
@@ -290,11 +301,9 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
290
301
|
if (toAdd.length === 0) return;
|
|
291
302
|
const next = [...new Set([...targetKeys, ...toAdd])];
|
|
292
303
|
applyMove(next);
|
|
293
|
-
setAnnouncement(
|
|
294
|
-
`${toAdd.length} item${toAdd.length === 1 ? "" : "s"} moved to the target list.`,
|
|
295
|
-
);
|
|
304
|
+
setAnnouncement(t("ui.transfer.movedToTarget", { count: toAdd.length }));
|
|
296
305
|
setSourceChecked(new Set());
|
|
297
|
-
}, [visibleSourceEnabled, targetKeys, applyMove]);
|
|
306
|
+
}, [visibleSourceEnabled, targetKeys, applyMove, t]);
|
|
298
307
|
|
|
299
308
|
/** Move target-panel checked items → source */
|
|
300
309
|
const handleMoveCheckedLeft = useCallback(() => {
|
|
@@ -306,26 +315,22 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
306
315
|
);
|
|
307
316
|
if (toRemove.size === 0) return;
|
|
308
317
|
applyMove(targetKeys.filter((k) => !toRemove.has(k)));
|
|
309
|
-
setAnnouncement(
|
|
310
|
-
`${toRemove.size} item${toRemove.size === 1 ? "" : "s"} moved to the source list.`,
|
|
311
|
-
);
|
|
318
|
+
setAnnouncement(t("ui.transfer.movedToSource", { count: toRemove.size }));
|
|
312
319
|
setTargetChecked((prev) => {
|
|
313
320
|
const next = new Set(prev);
|
|
314
321
|
toRemove.forEach((v) => next.delete(v));
|
|
315
322
|
return next;
|
|
316
323
|
});
|
|
317
|
-
}, [targetChecked, dataSource, targetKeys, targetSet, applyMove]);
|
|
324
|
+
}, [targetChecked, dataSource, targetKeys, targetSet, applyMove, t]);
|
|
318
325
|
|
|
319
326
|
/** Move ALL visible enabled target items → source */
|
|
320
327
|
const handleMoveAllLeft = useCallback(() => {
|
|
321
328
|
const toRemove = new Set(visibleTargetEnabled.map((it) => it.value));
|
|
322
329
|
if (toRemove.size === 0) return;
|
|
323
330
|
applyMove(targetKeys.filter((k) => !toRemove.has(k)));
|
|
324
|
-
setAnnouncement(
|
|
325
|
-
`${toRemove.size} item${toRemove.size === 1 ? "" : "s"} moved to the source list.`,
|
|
326
|
-
);
|
|
331
|
+
setAnnouncement(t("ui.transfer.movedToSource", { count: toRemove.size }));
|
|
327
332
|
setTargetChecked(new Set());
|
|
328
|
-
}, [visibleTargetEnabled, targetKeys, applyMove]);
|
|
333
|
+
}, [visibleTargetEnabled, targetKeys, applyMove, t]);
|
|
329
334
|
|
|
330
335
|
// ── Per-panel check handlers ──────────────────────────────────────────────
|
|
331
336
|
|
|
@@ -402,7 +407,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
402
407
|
|
|
403
408
|
{/* ── Source panel ─────────────────────────────── */}
|
|
404
409
|
<TransferPanel
|
|
405
|
-
title={
|
|
410
|
+
title={resolvedTitles[0]}
|
|
406
411
|
items={sourceItems}
|
|
407
412
|
checked={sourceChecked}
|
|
408
413
|
onCheckItem={handleSourceCheckItem}
|
|
@@ -420,12 +425,12 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
420
425
|
<div
|
|
421
426
|
className="flex flex-col items-center justify-center gap-1"
|
|
422
427
|
role="group"
|
|
423
|
-
aria-label="
|
|
428
|
+
aria-label={t("ui.transfer.moveControls")}
|
|
424
429
|
>
|
|
425
430
|
<Button
|
|
426
431
|
variant="outline"
|
|
427
432
|
size="icon-sm"
|
|
428
|
-
aria-label="
|
|
433
|
+
aria-label={t("ui.transfer.moveSelectedRight")}
|
|
429
434
|
disabled={!canMoveCheckedRight}
|
|
430
435
|
onClick={handleMoveCheckedRight}
|
|
431
436
|
>
|
|
@@ -434,7 +439,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
434
439
|
<Button
|
|
435
440
|
variant="outline"
|
|
436
441
|
size="icon-sm"
|
|
437
|
-
aria-label="
|
|
442
|
+
aria-label={t("ui.transfer.moveAllRight")}
|
|
438
443
|
disabled={!canMoveAllRight}
|
|
439
444
|
onClick={handleMoveAllRight}
|
|
440
445
|
>
|
|
@@ -443,7 +448,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
443
448
|
<Button
|
|
444
449
|
variant="outline"
|
|
445
450
|
size="icon-sm"
|
|
446
|
-
aria-label="
|
|
451
|
+
aria-label={t("ui.transfer.moveSelectedLeft")}
|
|
447
452
|
disabled={!canMoveCheckedLeft}
|
|
448
453
|
onClick={handleMoveCheckedLeft}
|
|
449
454
|
className="mt-2"
|
|
@@ -453,7 +458,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
453
458
|
<Button
|
|
454
459
|
variant="outline"
|
|
455
460
|
size="icon-sm"
|
|
456
|
-
aria-label="
|
|
461
|
+
aria-label={t("ui.transfer.moveAllLeft")}
|
|
457
462
|
disabled={!canMoveAllLeft}
|
|
458
463
|
onClick={handleMoveAllLeft}
|
|
459
464
|
>
|
|
@@ -463,7 +468,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
|
|
|
463
468
|
|
|
464
469
|
{/* ── Target panel ─────────────────────────────── */}
|
|
465
470
|
<TransferPanel
|
|
466
|
-
title={
|
|
471
|
+
title={resolvedTitles[1]}
|
|
467
472
|
items={targetItems}
|
|
468
473
|
checked={targetChecked}
|
|
469
474
|
onCheckItem={handleTargetCheckItem}
|
|
@@ -483,7 +483,7 @@ function RevealSelectedNodeDemo() {
|
|
|
483
483
|
key={id}
|
|
484
484
|
type="button"
|
|
485
485
|
onClick={() => setTarget(id)}
|
|
486
|
-
className="rounded-md border border-border-strong bg-background px-3 py-1.5 text-body font-medium text-foreground hover:bg-surface-muted focus-
|
|
486
|
+
className="rounded-md border border-border-strong bg-background px-3 py-1.5 text-body font-medium text-foreground hover:bg-surface-muted focus-ring"
|
|
487
487
|
>
|
|
488
488
|
Navigate to Node {Number(id.split("-")[1]) + 1}
|
|
489
489
|
</button>
|
|
@@ -535,7 +535,7 @@ export const RevealSelectedNode: Story = {
|
|
|
535
535
|
* (like the Sidebar itself) perceivability comes from the foreground/weight shift
|
|
536
536
|
* plus `aria-selected`, not fill contrast alone.
|
|
537
537
|
*
|
|
538
|
-
* NOTE: cross-theme visual correctness (
|
|
538
|
+
* NOTE: cross-theme visual correctness (every shipped theme — light,
|
|
539
539
|
* dark) requires a real render and is owed to a human reviewer;
|
|
540
540
|
* the tokens are semantic so they adapt, but pixel-level confirmation cannot be
|
|
541
541
|
* automated in this environment.
|
|
@@ -141,6 +141,29 @@ describe("Tree reveal-into-view (scrollToId / scrollSelectionIntoView)", () => {
|
|
|
141
141
|
}
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
it("does not re-scroll on an unrelated re-render with a same-content controlled selectedIds array", async () => {
|
|
145
|
+
const scrollSpy = vi.fn();
|
|
146
|
+
const orig = Element.prototype.scrollIntoView;
|
|
147
|
+
Element.prototype.scrollIntoView = scrollSpy;
|
|
148
|
+
try {
|
|
149
|
+
const { rerender } = render(<Tree nodes={nodes} scrollSelectionIntoView selectedIds={[]} />);
|
|
150
|
+
rerender(<Tree nodes={nodes} scrollSelectionIntoView selectedIds={["carrot"]} />);
|
|
151
|
+
await waitFor(() => expect(screen.getByText("Carrot")).toBeInTheDocument());
|
|
152
|
+
const callsAfterInitialReveal = scrollSpy.mock.calls.length;
|
|
153
|
+
expect(callsAfterInitialReveal).toBeGreaterThan(0);
|
|
154
|
+
|
|
155
|
+
// Same ids, but a FRESH array reference — a common pattern (parent
|
|
156
|
+
// recomputes `selectedIds` inline every render). Previously the
|
|
157
|
+
// controlled Set was rebuilt with a new identity every render, so the
|
|
158
|
+
// reveal effect saw a "changed" selection and re-scrolled on every
|
|
159
|
+
// unrelated re-render (scroll-jump-while-scrolling, #reviewed 1.6).
|
|
160
|
+
rerender(<Tree nodes={nodes} scrollSelectionIntoView selectedIds={["carrot"]} />);
|
|
161
|
+
expect(scrollSpy.mock.calls.length).toBe(callsAfterInitialReveal);
|
|
162
|
+
} finally {
|
|
163
|
+
Element.prototype.scrollIntoView = orig;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
144
167
|
it("scrollToId requests ancestor expansion in virtualize mode (controlled)", async () => {
|
|
145
168
|
const onExpandedChange = vi.fn();
|
|
146
169
|
const { rerender } = render(
|
|
@@ -395,6 +418,24 @@ describe("Tree (lazy loading)", () => {
|
|
|
395
418
|
expect(screen.getByRole("treeitem", { name: /Category A/ })).not.toHaveAttribute("aria-busy");
|
|
396
419
|
});
|
|
397
420
|
|
|
421
|
+
it("does not drop lazily-loaded children when the parent re-renders with a fresh (same-content) nodes prop", async () => {
|
|
422
|
+
const user = userEvent.setup();
|
|
423
|
+
const loadChildren = vi.fn().mockResolvedValue([{ id: "child-1", label: "Child 1" }]);
|
|
424
|
+
|
|
425
|
+
const { rerender } = render(<Tree nodes={makeLazyNodes()} loadChildren={loadChildren} />);
|
|
426
|
+
await user.click(screen.getByRole("treeitem", { name: /Category A/ }));
|
|
427
|
+
await waitFor(() => {
|
|
428
|
+
expect(screen.getByRole("treeitem", { name: /Child 1/ })).toBeInTheDocument();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Parent re-renders with a brand-new `nodes` array literal carrying the
|
|
432
|
+
// SAME data (no `children` on root-a — the caller doesn't know about the
|
|
433
|
+
// lazy-loaded subtree). This must not wipe out what we already fetched.
|
|
434
|
+
rerender(<Tree nodes={makeLazyNodes()} loadChildren={loadChildren} />);
|
|
435
|
+
|
|
436
|
+
expect(screen.getByRole("treeitem", { name: /Child 1/ })).toBeInTheDocument();
|
|
437
|
+
});
|
|
438
|
+
|
|
398
439
|
it("retries successfully after error", async () => {
|
|
399
440
|
const user = userEvent.setup();
|
|
400
441
|
const loadChildren = vi
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useCallback,
|
|
7
7
|
useEffect,
|
|
8
8
|
useId,
|
|
9
|
+
useMemo,
|
|
9
10
|
useRef,
|
|
10
11
|
useState,
|
|
11
12
|
type HTMLAttributes,
|
|
@@ -15,6 +16,7 @@ import { ChevronRight, AlertCircle, RotateCcw } from "lucide-react";
|
|
|
15
16
|
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
|
|
16
17
|
import { cn } from "../../lib/cn";
|
|
17
18
|
import { Checkbox } from "../checkbox/checkbox";
|
|
19
|
+
import { useLocale } from "../locale-provider";
|
|
18
20
|
import { Skeleton } from "../skeleton/skeleton";
|
|
19
21
|
import { Spinner } from "../spinner/spinner";
|
|
20
22
|
import { useTreeKeyboard } from "./use-tree-keyboard";
|
|
@@ -148,7 +150,24 @@ function useControllableSet(
|
|
|
148
150
|
() => new Set(controlled ?? defaultValue ?? []),
|
|
149
151
|
);
|
|
150
152
|
|
|
151
|
-
|
|
153
|
+
// Stable controlled Set: build a new Set only when the controlled array's
|
|
154
|
+
// CONTENT actually changed, not merely its reference (many callers pass a
|
|
155
|
+
// freshly-computed array with the same ids on every render). Without this,
|
|
156
|
+
// `selectedIds`/`expandedIds` changed identity on every render, which made
|
|
157
|
+
// effects keyed on them (the scrollToId/scrollSelectionIntoView reveal
|
|
158
|
+
// effect) fire on every re-render instead of on a real selection change —
|
|
159
|
+
// the scroll-jump-while-scrolling bug.
|
|
160
|
+
const controlledSetRef = useRef<Set<string> | null>(null);
|
|
161
|
+
let value: Set<string>;
|
|
162
|
+
if (isControlled) {
|
|
163
|
+
const prev = controlledSetRef.current;
|
|
164
|
+
const sameContent =
|
|
165
|
+
!!prev && prev.size === controlled!.length && controlled!.every((id) => prev.has(id));
|
|
166
|
+
value = sameContent ? prev! : new Set(controlled);
|
|
167
|
+
controlledSetRef.current = value;
|
|
168
|
+
} else {
|
|
169
|
+
value = internal;
|
|
170
|
+
}
|
|
152
171
|
|
|
153
172
|
const setValue = useCallback(
|
|
154
173
|
(next: Set<string>) => {
|
|
@@ -161,6 +180,30 @@ function useControllableSet(
|
|
|
161
180
|
return [value, setValue];
|
|
162
181
|
}
|
|
163
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Reconcile an incoming `nodes` prop against the previous internal tree,
|
|
185
|
+
* preserving any children merged in locally via `loadChildren` that the new
|
|
186
|
+
* prop doesn't (yet) carry. Without this, any parent re-render that hands
|
|
187
|
+
* down a new (even structurally-identical) `nodes` array wipes out every
|
|
188
|
+
* lazily-loaded subtree that isn't also present upstream.
|
|
189
|
+
*/
|
|
190
|
+
function reconcileNodes<T>(propNodes: TreeNode<T>[], prevNodes: TreeNode<T>[]): TreeNode<T>[] {
|
|
191
|
+
const prevById = new Map(prevNodes.map((n) => [n.id, n] as const));
|
|
192
|
+
return propNodes.map((n) => {
|
|
193
|
+
const prev = prevById.get(n.id);
|
|
194
|
+
if (!prev) return n;
|
|
195
|
+
if (!n.children?.length && prev.children?.length) {
|
|
196
|
+
// The prop still marks this node as lazily-expandable but doesn't (yet)
|
|
197
|
+
// carry the children we already fetched — keep what we loaded.
|
|
198
|
+
return { ...n, children: prev.children, hasChildren: n.hasChildren };
|
|
199
|
+
}
|
|
200
|
+
if (n.children?.length && prev.children?.length) {
|
|
201
|
+
return { ...n, children: reconcileNodes(n.children, prev.children) };
|
|
202
|
+
}
|
|
203
|
+
return n;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
164
207
|
/** Merge resolved children into a tree, returning a new tree. */
|
|
165
208
|
function mergeChildren<T>(
|
|
166
209
|
nodes: TreeNode<T>[],
|
|
@@ -222,6 +265,7 @@ function VirtualFlatRow<T>({
|
|
|
222
265
|
registerNodeRef,
|
|
223
266
|
setActiveId,
|
|
224
267
|
}: VirtualFlatRowProps<T>) {
|
|
268
|
+
const { t } = useLocale();
|
|
225
269
|
const { node, level, setSize, posInSet } = row;
|
|
226
270
|
const isLoading = lazyState === "loading";
|
|
227
271
|
const isError = lazyState === "error";
|
|
@@ -262,7 +306,7 @@ function VirtualFlatRow<T>({
|
|
|
262
306
|
"flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-body outline-none",
|
|
263
307
|
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
264
308
|
"hover:bg-accent hover:text-accent-foreground",
|
|
265
|
-
"focus-
|
|
309
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
266
310
|
isSelected &&
|
|
267
311
|
selectionMode !== "none" &&
|
|
268
312
|
(surface === "sidebar"
|
|
@@ -319,12 +363,14 @@ function VirtualFlatRow<T>({
|
|
|
319
363
|
|
|
320
364
|
{/* label */}
|
|
321
365
|
{isError ? (
|
|
322
|
-
|
|
366
|
+
// #124: running text ("Failed to load…"), not a mark — the ink rung
|
|
367
|
+
// clears >=4.5:1 and the wrapped icon inherits it fine (>=3:1 trivially).
|
|
368
|
+
<span id={labelId} className="min-w-0 flex items-center gap-1 text-destructive-text">
|
|
323
369
|
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
|
324
|
-
<span className="truncate">
|
|
370
|
+
<span className="truncate">{t("ui.tree.failedToLoad")}</span>
|
|
325
371
|
<button
|
|
326
372
|
type="button"
|
|
327
|
-
aria-label="
|
|
373
|
+
aria-label={t("ui.tree.retryLoadingChildren")}
|
|
328
374
|
onClick={(e) => {
|
|
329
375
|
e.stopPropagation();
|
|
330
376
|
onRetry(node.id);
|
|
@@ -332,11 +378,11 @@ function VirtualFlatRow<T>({
|
|
|
332
378
|
className={cn(
|
|
333
379
|
"ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-caption",
|
|
334
380
|
"text-muted-foreground hover:text-foreground",
|
|
335
|
-
"focus-
|
|
381
|
+
"focus-ring",
|
|
336
382
|
)}
|
|
337
383
|
>
|
|
338
384
|
<RotateCcw className="size-3" aria-hidden="true" />
|
|
339
|
-
|
|
385
|
+
{t("ui.tree.retry")}
|
|
340
386
|
</button>
|
|
341
387
|
</span>
|
|
342
388
|
) : (
|
|
@@ -375,11 +421,12 @@ function VirtualFlatRow<T>({
|
|
|
375
421
|
// ---------------------------------------------------------------------------
|
|
376
422
|
|
|
377
423
|
function LoadingRow({ level }: { level: number }) {
|
|
424
|
+
const { t } = useLocale();
|
|
378
425
|
const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
|
|
379
426
|
return (
|
|
380
427
|
<div
|
|
381
428
|
role="treeitem"
|
|
382
|
-
aria-label="
|
|
429
|
+
aria-label={t("loading")}
|
|
383
430
|
aria-disabled="true"
|
|
384
431
|
tabIndex={-1}
|
|
385
432
|
className="flex min-w-0 items-center gap-1.5 px-2 py-1.5"
|
|
@@ -392,19 +439,20 @@ function LoadingRow({ level }: { level: number }) {
|
|
|
392
439
|
}
|
|
393
440
|
|
|
394
441
|
function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
|
|
442
|
+
const { t } = useLocale();
|
|
395
443
|
const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
|
|
396
444
|
return (
|
|
397
445
|
<div
|
|
398
446
|
role="treeitem"
|
|
399
|
-
aria-label="
|
|
447
|
+
aria-label={t("ui.tree.failedToLoad")}
|
|
400
448
|
aria-disabled="true"
|
|
401
449
|
tabIndex={-1}
|
|
402
|
-
className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive"
|
|
450
|
+
className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive-text"
|
|
403
451
|
style={indentStyle}
|
|
404
452
|
>
|
|
405
453
|
<span aria-hidden="true" className="size-4 shrink-0" />
|
|
406
454
|
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
|
407
|
-
<span className="truncate">
|
|
455
|
+
<span className="truncate">{t("ui.tree.failedToLoad")}</span>
|
|
408
456
|
<button
|
|
409
457
|
type="button"
|
|
410
458
|
onClick={(e) => {
|
|
@@ -412,13 +460,13 @@ function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
|
|
|
412
460
|
onRetry();
|
|
413
461
|
}}
|
|
414
462
|
className={cn(
|
|
415
|
-
"ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-
|
|
463
|
+
"ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-meta",
|
|
416
464
|
"text-muted-foreground hover:text-foreground",
|
|
417
|
-
"focus-
|
|
465
|
+
"focus-ring",
|
|
418
466
|
)}
|
|
419
467
|
>
|
|
420
468
|
<RotateCcw className="size-3" aria-hidden="true" />
|
|
421
|
-
|
|
469
|
+
{t("ui.tree.retry")}
|
|
422
470
|
</button>
|
|
423
471
|
</div>
|
|
424
472
|
);
|
|
@@ -541,7 +589,7 @@ function TreeItem<T>({
|
|
|
541
589
|
"flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-body outline-none",
|
|
542
590
|
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
543
591
|
"hover:bg-accent hover:text-accent-foreground",
|
|
544
|
-
"focus-
|
|
592
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
545
593
|
isSelected &&
|
|
546
594
|
selectionMode !== "none" &&
|
|
547
595
|
(surface === "sidebar"
|
|
@@ -766,9 +814,10 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
766
814
|
// Internal mutable tree (supports lazy-loaded children merge)
|
|
767
815
|
const [nodes, setNodes] = useState<TreeNode[]>(() => nodesProp as TreeNode[]);
|
|
768
816
|
|
|
769
|
-
// Keep internal tree in sync with nodesProp if it changes externally
|
|
817
|
+
// Keep internal tree in sync with nodesProp if it changes externally,
|
|
818
|
+
// preserving any children merged in locally via lazy `loadChildren`.
|
|
770
819
|
useEffect(() => {
|
|
771
|
-
setNodes(nodesProp as TreeNode[]);
|
|
820
|
+
setNodes((prev) => reconcileNodes(nodesProp as TreeNode[], prev));
|
|
772
821
|
}, [nodesProp]);
|
|
773
822
|
|
|
774
823
|
const [expandedIds, setExpandedIds] = useControllableSet(
|
|
@@ -820,8 +869,18 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
820
869
|
// Flattened visible rows (for virtualized path and keyboard hook)
|
|
821
870
|
// ---------------------------------------------------------------------------
|
|
822
871
|
|
|
823
|
-
const flatRows =
|
|
824
|
-
|
|
872
|
+
const flatRows = useMemo(
|
|
873
|
+
() => flattenVisibleWithMeta(nodes, expandedIds, lazyState),
|
|
874
|
+
[nodes, expandedIds, lazyState],
|
|
875
|
+
);
|
|
876
|
+
const flatNodeIds = useMemo(() => flatRows.map((r) => r.node.id), [flatRows]);
|
|
877
|
+
// O(1) row lookup by id — `handleToggleExpand`/`handleSelectVirtual` run on
|
|
878
|
+
// every click/keypress and previously did an O(n) `Array.find` each time.
|
|
879
|
+
const flatRowIndexById = useMemo(() => {
|
|
880
|
+
const map = new Map<string, number>();
|
|
881
|
+
flatRows.forEach((r, i) => map.set(r.node.id, i));
|
|
882
|
+
return map;
|
|
883
|
+
}, [flatRows]);
|
|
825
884
|
|
|
826
885
|
// ---------------------------------------------------------------------------
|
|
827
886
|
// Keyboard hook
|
|
@@ -857,9 +916,10 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
857
916
|
|
|
858
917
|
const handleToggleExpand = useCallback(
|
|
859
918
|
(nodeId: string) => {
|
|
860
|
-
const
|
|
861
|
-
if (
|
|
862
|
-
const node =
|
|
919
|
+
const index = flatRowIndexById.get(nodeId);
|
|
920
|
+
if (index === undefined) return;
|
|
921
|
+
const node = flatRows[index]?.node;
|
|
922
|
+
if (!node) return;
|
|
863
923
|
const hasLoadedChildren = !!node.children?.length;
|
|
864
924
|
const next = new Set(expandedIds);
|
|
865
925
|
if (expandedIds.has(nodeId)) {
|
|
@@ -872,15 +932,15 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
872
932
|
}
|
|
873
933
|
setExpandedIds(next);
|
|
874
934
|
},
|
|
875
|
-
[expandedIds, flatRows, setExpandedIds, loadChildren, handleExpandLazy],
|
|
935
|
+
[expandedIds, flatRows, flatRowIndexById, setExpandedIds, loadChildren, handleExpandLazy],
|
|
876
936
|
);
|
|
877
937
|
|
|
878
938
|
const handleSelectVirtual = useCallback(
|
|
879
939
|
(nodeId: string) => {
|
|
880
|
-
const
|
|
881
|
-
if (
|
|
882
|
-
const node =
|
|
883
|
-
if (node.disabled || selectionMode === "none") return;
|
|
940
|
+
const index = flatRowIndexById.get(nodeId);
|
|
941
|
+
if (index === undefined) return;
|
|
942
|
+
const node = flatRows[index]?.node;
|
|
943
|
+
if (!node || node.disabled || selectionMode === "none") return;
|
|
884
944
|
if (selectionMode === "single") {
|
|
885
945
|
setSelectedIds(new Set([nodeId]));
|
|
886
946
|
} else {
|
|
@@ -893,7 +953,7 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
893
953
|
setSelectedIds(next);
|
|
894
954
|
}
|
|
895
955
|
},
|
|
896
|
-
[flatRows, selectionMode, selectedIds, setSelectedIds],
|
|
956
|
+
[flatRows, flatRowIndexById, selectionMode, selectedIds, setSelectedIds],
|
|
897
957
|
);
|
|
898
958
|
|
|
899
959
|
// ---------------------------------------------------------------------------
|
|
@@ -955,8 +1015,8 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
955
1015
|
// node (registered via registerAndFlush in both paths).
|
|
956
1016
|
const scrollNodeIntoView = useCallback(
|
|
957
1017
|
(id: string): boolean => {
|
|
958
|
-
const index =
|
|
959
|
-
if (index
|
|
1018
|
+
const index = flatRowIndexById.get(id);
|
|
1019
|
+
if (index === undefined) return false;
|
|
960
1020
|
if (virtualize) {
|
|
961
1021
|
virtualizerRef.current?.scrollToIndex(index, { align: "center" });
|
|
962
1022
|
} else {
|
|
@@ -965,7 +1025,7 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
965
1025
|
}
|
|
966
1026
|
return true;
|
|
967
1027
|
},
|
|
968
|
-
[
|
|
1028
|
+
[flatRowIndexById, virtualize],
|
|
969
1029
|
);
|
|
970
1030
|
|
|
971
1031
|
// Reveal `id`: scroll it now if visible, else expand its (loaded) ancestors and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import type { TreeNode } from "./tree";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -125,6 +125,31 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
125
125
|
}
|
|
126
126
|
}, []);
|
|
127
127
|
|
|
128
|
+
// Memoised O(1) lookups — rebuilt only when the tree shape / expansion
|
|
129
|
+
// actually changes, not on every keypress (a full re-flatten + `indexOf`/
|
|
130
|
+
// `findNode` walk per arrow key is O(n) on a large tree).
|
|
131
|
+
const visible = useMemo(
|
|
132
|
+
() => (virtualize && flatNodeIds ? flatNodeIds : flattenVisible(nodes, expandedIds)),
|
|
133
|
+
[virtualize, flatNodeIds, nodes, expandedIds],
|
|
134
|
+
);
|
|
135
|
+
const visibleIndexById = useMemo(() => {
|
|
136
|
+
const map = new Map<string, number>();
|
|
137
|
+
visible.forEach((id, i) => map.set(id, i));
|
|
138
|
+
return map;
|
|
139
|
+
}, [visible]);
|
|
140
|
+
const nodeById = useMemo(() => {
|
|
141
|
+
const map = new Map<string, TreeNode<T>>();
|
|
142
|
+
function walk(list: TreeNode<T>[]) {
|
|
143
|
+
for (const node of list) {
|
|
144
|
+
map.set(node.id, node);
|
|
145
|
+
if (node.children?.length) walk(node.children);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
walk(nodes);
|
|
149
|
+
return map;
|
|
150
|
+
}, [nodes]);
|
|
151
|
+
const parentMapMemo = useMemo(() => buildParentMap(nodes), [nodes]);
|
|
152
|
+
|
|
128
153
|
// Typeahead buffer
|
|
129
154
|
const typeaheadBuffer = useRef<string>("");
|
|
130
155
|
const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -156,8 +181,7 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
156
181
|
|
|
157
182
|
// Element not mounted — happens when it's scrolled out of the virtual window.
|
|
158
183
|
if (virtualize && scrollToVirtualIndex && pendingFocusIdRef) {
|
|
159
|
-
const
|
|
160
|
-
const index = ids.indexOf(id);
|
|
184
|
+
const index = visibleIndexById.get(id) ?? -1;
|
|
161
185
|
if (index >= 0) {
|
|
162
186
|
pendingFocusIdRef.current = id;
|
|
163
187
|
scrollToVirtualIndex(index);
|
|
@@ -165,18 +189,16 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
165
189
|
}
|
|
166
190
|
}
|
|
167
191
|
},
|
|
168
|
-
[virtualize,
|
|
192
|
+
[virtualize, visibleIndexById, pendingFocusIdRef, scrollToVirtualIndex],
|
|
169
193
|
);
|
|
170
194
|
|
|
171
195
|
const onKeyDown = useCallback(
|
|
172
196
|
(e: React.KeyboardEvent<HTMLElement>) => {
|
|
173
197
|
if (!activeId) return;
|
|
174
198
|
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const currentNode = findNode(nodes, activeId);
|
|
179
|
-
const parentMap = buildParentMap(nodes);
|
|
199
|
+
const currentIndex = visibleIndexById.get(activeId) ?? -1;
|
|
200
|
+
const currentNode = nodeById.get(activeId);
|
|
201
|
+
const parentMap = parentMapMemo;
|
|
180
202
|
|
|
181
203
|
const moveTo = (id: string) => {
|
|
182
204
|
e.preventDefault();
|
|
@@ -185,7 +207,7 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
185
207
|
|
|
186
208
|
const selectNode = (id: string) => {
|
|
187
209
|
if (selectionMode === "none") return;
|
|
188
|
-
const node =
|
|
210
|
+
const node = nodeById.get(id);
|
|
189
211
|
if (node?.disabled) return;
|
|
190
212
|
if (selectionMode === "single") {
|
|
191
213
|
onSelectionChange(new Set([id]));
|
|
@@ -282,7 +304,7 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
282
304
|
...visible.slice(0, currentIndex + 1),
|
|
283
305
|
];
|
|
284
306
|
for (const id of searchList) {
|
|
285
|
-
const node =
|
|
307
|
+
const node = nodeById.get(id);
|
|
286
308
|
if (!node) continue;
|
|
287
309
|
// only string labels are searchable
|
|
288
310
|
if (typeof node.label !== "string") continue;
|
|
@@ -298,15 +320,16 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
298
320
|
},
|
|
299
321
|
[
|
|
300
322
|
activeId,
|
|
301
|
-
|
|
323
|
+
visible,
|
|
324
|
+
visibleIndexById,
|
|
325
|
+
nodeById,
|
|
326
|
+
parentMapMemo,
|
|
302
327
|
expandedIds,
|
|
303
328
|
onExpandedChange,
|
|
304
329
|
selectionMode,
|
|
305
330
|
selectedIds,
|
|
306
331
|
onSelectionChange,
|
|
307
332
|
setActiveId,
|
|
308
|
-
virtualize,
|
|
309
|
-
flatNodeIds,
|
|
310
333
|
],
|
|
311
334
|
);
|
|
312
335
|
|