@qijenchen/design-system 0.1.0-beta.145 → 0.1.0-beta.147
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/dist/components/Accordion/accordion.d.ts +1 -1
- package/dist/components/Accordion/accordion.d.ts.map +1 -1
- package/dist/components/Accordion/accordion.js +8 -3
- package/dist/components/Accordion/accordion.js.map +1 -1
- package/dist/components/AccountMenu/account-menu.d.ts.map +1 -1
- package/dist/components/AccountMenu/account-menu.js.map +1 -1
- package/dist/components/AgentPanel/agent-panel-fab.d.ts +7 -1
- package/dist/components/AgentPanel/agent-panel-fab.d.ts.map +1 -1
- package/dist/components/AgentPanel/agent-panel-fab.js +36 -14
- package/dist/components/AgentPanel/agent-panel-fab.js.map +1 -1
- package/dist/components/AgentPanel/agent-panel-logo.d.ts +3 -3
- package/dist/components/AgentPanel/agent-panel-logo.js.map +1 -1
- package/dist/components/AgentPanel/agent-panel.d.ts +1 -1
- package/dist/components/AgentPanel/agent-panel.d.ts.map +1 -1
- package/dist/components/AgentPanel/agent-panel.js +28 -22
- package/dist/components/AgentPanel/agent-panel.js.map +1 -1
- package/dist/components/AppShell/app-shell.d.ts +1 -1
- package/dist/components/AppShell/app-shell.js +1 -1
- package/dist/components/AppShell/app-shell.js.map +1 -1
- package/dist/components/Avatar/avatar.d.ts +95 -1
- package/dist/components/Avatar/avatar.d.ts.map +1 -1
- package/dist/components/Avatar/avatar.js +106 -15
- package/dist/components/Avatar/avatar.js.map +1 -1
- package/dist/components/Avatar/index.js +14 -2
- package/dist/components/Breadcrumb/breadcrumb.d.ts +4 -2
- package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -1
- package/dist/components/Breadcrumb/breadcrumb.js +2 -2
- package/dist/components/Breadcrumb/breadcrumb.js.map +1 -1
- package/dist/components/BulkActionBar/bulk-action-bar.d.ts.map +1 -1
- package/dist/components/BulkActionBar/bulk-action-bar.js +27 -2
- package/dist/components/BulkActionBar/bulk-action-bar.js.map +1 -1
- package/dist/components/Button/button.d.ts +2 -2
- package/dist/components/Button/button.d.ts.map +1 -1
- package/dist/components/Button/button.js +118 -22
- package/dist/components/Button/button.js.map +1 -1
- package/dist/components/Calendar/calendar.d.ts +34 -10
- package/dist/components/Calendar/calendar.d.ts.map +1 -1
- package/dist/components/Calendar/calendar.js +210 -36
- package/dist/components/Calendar/calendar.js.map +1 -1
- package/dist/components/Carousel/carousel.d.ts +1 -1
- package/dist/components/Carousel/carousel.d.ts.map +1 -1
- package/dist/components/Carousel/carousel.js +3 -2
- package/dist/components/Carousel/carousel.js.map +1 -1
- package/dist/components/Chart/chart.d.ts +1 -0
- package/dist/components/Chart/chart.d.ts.map +1 -1
- package/dist/components/Chart/chart.js +8 -0
- package/dist/components/Chart/chart.js.map +1 -1
- package/dist/components/Checkbox/checkbox.d.ts +1 -1
- package/dist/components/Checkbox/checkbox.d.ts.map +1 -1
- package/dist/components/Checkbox/checkbox.js +10 -2
- package/dist/components/Checkbox/checkbox.js.map +1 -1
- package/dist/components/Chip/chip.d.ts +1 -1
- package/dist/components/Chip/chip.d.ts.map +1 -1
- package/dist/components/Chip/chip.js +4 -2
- package/dist/components/Chip/chip.js.map +1 -1
- package/dist/components/Combobox/combobox.d.ts +1 -1
- package/dist/components/Combobox/combobox.d.ts.map +1 -1
- package/dist/components/Combobox/combobox.js +37 -11
- package/dist/components/Combobox/combobox.js.map +1 -1
- package/dist/components/Command/command.d.ts +1 -1
- package/dist/components/Command/command.d.ts.map +1 -1
- package/dist/components/Command/command.js +106 -8
- package/dist/components/Command/command.js.map +1 -1
- package/dist/components/DataTable/cell-registry.d.ts.map +1 -1
- package/dist/components/DataTable/cell-registry.js +3 -2
- package/dist/components/DataTable/cell-registry.js.map +1 -1
- package/dist/components/DataTable/data-table-column-visibility-panel.d.ts.map +1 -1
- package/dist/components/DataTable/data-table-column-visibility-panel.js +24 -24
- package/dist/components/DataTable/data-table-column-visibility-panel.js.map +1 -1
- package/dist/components/DataTable/data-table-interaction-layer.d.ts +2 -1
- package/dist/components/DataTable/data-table-interaction-layer.d.ts.map +1 -1
- package/dist/components/DataTable/data-table-interaction-layer.js +3 -4
- package/dist/components/DataTable/data-table-interaction-layer.js.map +1 -1
- package/dist/components/DataTable/data-table-sort-manager.d.ts.map +1 -1
- package/dist/components/DataTable/data-table-sort-manager.js +20 -20
- package/dist/components/DataTable/data-table-sort-manager.js.map +1 -1
- package/dist/components/DataTable/data-table.d.ts +8 -5
- package/dist/components/DataTable/data-table.d.ts.map +1 -1
- package/dist/components/DataTable/data-table.js +197 -71
- package/dist/components/DataTable/data-table.js.map +1 -1
- package/dist/components/DateGrid/date-grid.d.ts +1 -1
- package/dist/components/DateGrid/date-grid.d.ts.map +1 -1
- package/dist/components/DateGrid/date-grid.js +358 -225
- package/dist/components/DateGrid/date-grid.js.map +1 -1
- package/dist/components/DatePicker/date-picker.d.ts.map +1 -1
- package/dist/components/DatePicker/date-picker.js +16 -1
- package/dist/components/DatePicker/date-picker.js.map +1 -1
- package/dist/components/Dialog/dialog.d.ts.map +1 -1
- package/dist/components/Dialog/dialog.js +55 -43
- package/dist/components/Dialog/dialog.js.map +1 -1
- package/dist/components/DropdownMenu/dropdown-menu-keyboard.d.ts +86 -0
- package/dist/components/DropdownMenu/dropdown-menu-keyboard.d.ts.map +1 -0
- package/dist/components/DropdownMenu/dropdown-menu-keyboard.js +172 -0
- package/dist/components/DropdownMenu/dropdown-menu-keyboard.js.map +1 -0
- package/dist/components/DropdownMenu/dropdown-menu.d.ts +6 -1
- package/dist/components/DropdownMenu/dropdown-menu.d.ts.map +1 -1
- package/dist/components/DropdownMenu/dropdown-menu.js +50 -39
- package/dist/components/DropdownMenu/dropdown-menu.js.map +1 -1
- package/dist/components/Field/field-context.d.ts +4 -0
- package/dist/components/Field/field-context.d.ts.map +1 -1
- package/dist/components/Field/field-context.js +1 -2
- package/dist/components/Field/field-context.js.map +1 -1
- package/dist/components/Field/field-edit-keys.d.ts +8 -0
- package/dist/components/Field/field-edit-keys.d.ts.map +1 -1
- package/dist/components/Field/field-edit-keys.js +3 -0
- package/dist/components/Field/field-edit-keys.js.map +1 -1
- package/dist/components/Field/field-wrapper.d.ts +18 -1
- package/dist/components/Field/field-wrapper.d.ts.map +1 -1
- package/dist/components/Field/field-wrapper.js +39 -10
- package/dist/components/Field/field-wrapper.js.map +1 -1
- package/dist/components/Field/field.d.ts +3 -2
- package/dist/components/Field/field.d.ts.map +1 -1
- package/dist/components/Field/field.js +2 -1
- package/dist/components/Field/field.js.map +1 -1
- package/dist/components/FileItem/file-item.d.ts +26 -2
- package/dist/components/FileItem/file-item.d.ts.map +1 -1
- package/dist/components/FileItem/file-item.js +82 -25
- package/dist/components/FileItem/file-item.js.map +1 -1
- package/dist/components/FileItem/index.js +4 -2
- package/dist/components/FileUpload/file-upload.d.ts +1 -1
- package/dist/components/FileUpload/file-upload.d.ts.map +1 -1
- package/dist/components/FileUpload/file-upload.js +132 -55
- package/dist/components/FileUpload/file-upload.js.map +1 -1
- package/dist/components/FileViewer/file-viewer.d.ts +1 -1
- package/dist/components/FileViewer/file-viewer.d.ts.map +1 -1
- package/dist/components/FileViewer/file-viewer.js +9 -4
- package/dist/components/FileViewer/file-viewer.js.map +1 -1
- package/dist/components/InlineEdit/inline-edit.d.ts.map +1 -1
- package/dist/components/InlineEdit/inline-edit.js +1 -1
- package/dist/components/InlineEdit/inline-edit.js.map +1 -1
- package/dist/components/Input/input.d.ts.map +1 -1
- package/dist/components/Input/input.js +6 -1
- package/dist/components/Input/input.js.map +1 -1
- package/dist/components/LinkInput/link-input.d.ts.map +1 -1
- package/dist/components/LinkInput/link-input.js +20 -4
- package/dist/components/LinkInput/link-input.js.map +1 -1
- package/dist/components/Notice/notice.js +1 -1
- package/dist/components/Notice/notice.js.map +1 -1
- package/dist/components/NumberInput/number-input.d.ts.map +1 -1
- package/dist/components/NumberInput/number-input.js +6 -1
- package/dist/components/NumberInput/number-input.js.map +1 -1
- package/dist/components/OverflowIndicator/index.js +2 -1
- package/dist/components/OverflowIndicator/overflow-indicator.d.ts +19 -1
- package/dist/components/OverflowIndicator/overflow-indicator.d.ts.map +1 -1
- package/dist/components/OverflowIndicator/overflow-indicator.js +47 -27
- package/dist/components/OverflowIndicator/overflow-indicator.js.map +1 -1
- package/dist/components/Pagination/pagination.d.ts +1 -1
- package/dist/components/Pagination/pagination.js +1 -1
- package/dist/components/Pagination/pagination.js.map +1 -1
- package/dist/components/PeoplePicker/avatar-stack-overflow.d.ts +7 -6
- package/dist/components/PeoplePicker/avatar-stack-overflow.d.ts.map +1 -1
- package/dist/components/PeoplePicker/avatar-stack-overflow.js +6 -7
- package/dist/components/PeoplePicker/avatar-stack-overflow.js.map +1 -1
- package/dist/components/PeoplePicker/people-picker-helpers.d.ts.map +1 -1
- package/dist/components/PeoplePicker/people-picker-helpers.js +1 -1
- package/dist/components/PeoplePicker/people-picker-helpers.js.map +1 -1
- package/dist/components/PeoplePicker/people-picker.d.ts.map +1 -1
- package/dist/components/PeoplePicker/people-picker.js +2 -2
- package/dist/components/PeoplePicker/people-picker.js.map +1 -1
- package/dist/components/PeoplePicker/person-display.d.ts.map +1 -1
- package/dist/components/PeoplePicker/person-display.js +26 -28
- package/dist/components/PeoplePicker/person-display.js.map +1 -1
- package/dist/components/ProfileCard/profile-card.d.ts.map +1 -1
- package/dist/components/ProfileCard/profile-card.js.map +1 -1
- package/dist/components/RadioGroup/radio-group.d.ts +1 -1
- package/dist/components/RadioGroup/radio-group.d.ts.map +1 -1
- package/dist/components/RadioGroup/radio-group.js +10 -4
- package/dist/components/RadioGroup/radio-group.js.map +1 -1
- package/dist/components/Rating/rating.d.ts +28 -6
- package/dist/components/Rating/rating.d.ts.map +1 -1
- package/dist/components/Rating/rating.js +97 -80
- package/dist/components/Rating/rating.js.map +1 -1
- package/dist/components/ScrollArea/scroll-area.d.ts +1 -1
- package/dist/components/ScrollArea/scroll-area.js +1 -1
- package/dist/components/ScrollArea/scroll-area.js.map +1 -1
- package/dist/components/SegmentedControl/segmented-control.d.ts +1 -1
- package/dist/components/SegmentedControl/segmented-control.d.ts.map +1 -1
- package/dist/components/SegmentedControl/segmented-control.js +3 -2
- package/dist/components/SegmentedControl/segmented-control.js.map +1 -1
- package/dist/components/Select/select.d.ts.map +1 -1
- package/dist/components/Select/select.js +15 -8
- package/dist/components/Select/select.js.map +1 -1
- package/dist/components/SelectMenu/index.js +2 -1
- package/dist/components/SelectMenu/index.js.map +1 -1
- package/dist/components/SelectMenu/select-menu-keyboard.d.ts +59 -0
- package/dist/components/SelectMenu/select-menu-keyboard.d.ts.map +1 -0
- package/dist/components/SelectMenu/select-menu-keyboard.js +107 -0
- package/dist/components/SelectMenu/select-menu-keyboard.js.map +1 -0
- package/dist/components/SelectMenu/select-menu.d.ts +1 -22
- package/dist/components/SelectMenu/select-menu.d.ts.map +1 -1
- package/dist/components/SelectMenu/select-menu.js +18 -34
- package/dist/components/SelectMenu/select-menu.js.map +1 -1
- package/dist/components/SelectionControl/selection-item.d.ts.map +1 -1
- package/dist/components/SelectionControl/selection-item.js +1 -0
- package/dist/components/SelectionControl/selection-item.js.map +1 -1
- package/dist/components/Sidebar/sidebar.d.ts +7 -9
- package/dist/components/Sidebar/sidebar.d.ts.map +1 -1
- package/dist/components/Sidebar/sidebar.js +171 -50
- package/dist/components/Sidebar/sidebar.js.map +1 -1
- package/dist/components/Slider/slider.d.ts +1 -1
- package/dist/components/Slider/slider.d.ts.map +1 -1
- package/dist/components/Slider/slider.js +9 -4
- package/dist/components/Slider/slider.js.map +1 -1
- package/dist/components/Steps/steps.d.ts.map +1 -1
- package/dist/components/Steps/steps.js +54 -26
- package/dist/components/Steps/steps.js.map +1 -1
- package/dist/components/Switch/switch.d.ts +1 -1
- package/dist/components/Switch/switch.d.ts.map +1 -1
- package/dist/components/Switch/switch.js +7 -2
- package/dist/components/Switch/switch.js.map +1 -1
- package/dist/components/Tabs/tabs.d.ts +1 -1
- package/dist/components/Tabs/tabs.d.ts.map +1 -1
- package/dist/components/Tabs/tabs.js +39 -4
- package/dist/components/Tabs/tabs.js.map +1 -1
- package/dist/components/Tag/tag.d.ts +1 -1
- package/dist/components/Tag/tag.d.ts.map +1 -1
- package/dist/components/Tag/tag.js +3 -2
- package/dist/components/Tag/tag.js.map +1 -1
- package/dist/components/Textarea/textarea.d.ts.map +1 -1
- package/dist/components/Textarea/textarea.js +8 -2
- package/dist/components/Textarea/textarea.js.map +1 -1
- package/dist/components/TimePicker/time-columns.d.ts.map +1 -1
- package/dist/components/TimePicker/time-columns.js +8 -4
- package/dist/components/TimePicker/time-columns.js.map +1 -1
- package/dist/components/TimePicker/time-picker.d.ts +1 -1
- package/dist/components/TimePicker/time-picker.js +1 -1
- package/dist/components/TimePicker/time-picker.js.map +1 -1
- package/dist/components/TreeView/tree-view.d.ts +9 -5
- package/dist/components/TreeView/tree-view.d.ts.map +1 -1
- package/dist/components/TreeView/tree-view.js +266 -214
- package/dist/components/TreeView/tree-view.js.map +1 -1
- package/dist/hooks/use-input-modality.d.ts +12 -17
- package/dist/hooks/use-input-modality.d.ts.map +1 -1
- package/dist/hooks/use-input-modality.js +4 -34
- package/dist/hooks/use-input-modality.js.map +1 -1
- package/dist/hooks/use-overflow-items.d.ts.map +1 -1
- package/dist/hooks/use-overflow-items.js +2 -0
- package/dist/hooks/use-overflow-items.js.map +1 -1
- package/dist/hooks/use-truncated.d.ts.map +1 -1
- package/dist/hooks/use-truncated.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/drag-announcements.d.ts +32 -0
- package/dist/lib/drag-announcements.d.ts.map +1 -1
- package/dist/lib/drag-announcements.js +39 -1
- package/dist/lib/drag-announcements.js.map +1 -1
- package/dist/lib/focus-after-trigger.d.ts +45 -0
- package/dist/lib/focus-after-trigger.d.ts.map +1 -0
- package/dist/lib/focus-after-trigger.js +66 -0
- package/dist/lib/focus-after-trigger.js.map +1 -0
- package/dist/lib/overlay-coexistence.js +1 -1
- package/dist/lib/roving-list-keyboard.d.ts +208 -0
- package/dist/lib/roving-list-keyboard.d.ts.map +1 -0
- package/dist/lib/roving-list-keyboard.js +174 -0
- package/dist/lib/roving-list-keyboard.js.map +1 -0
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +6 -1
- package/dist/lib/utils.js.map +1 -1
- package/dist/patterns/element-anatomy/item-anatomy.d.ts +4 -4
- package/dist/patterns/element-anatomy/item-anatomy.d.ts.map +1 -1
- package/dist/patterns/element-anatomy/item-anatomy.js +14 -4
- package/dist/patterns/element-anatomy/item-anatomy.js.map +1 -1
- package/dist/patterns/horizontal-overflow/horizontal-overflow.d.ts.map +1 -1
- package/dist/patterns/horizontal-overflow/horizontal-overflow.js +25 -0
- package/dist/patterns/horizontal-overflow/horizontal-overflow.js.map +1 -1
- package/dist/patterns/overlay-surface/overlay-surface.d.ts.map +1 -1
- package/dist/patterns/overlay-surface/overlay-surface.js.map +1 -1
- package/dist/tokens/categorical-color.d.ts +6 -1
- package/dist/tokens/categorical-color.d.ts.map +1 -1
- package/dist/tokens/categorical-color.js.map +1 -1
- package/dist/tokens/uiSize/icon-size.d.ts +1 -1
- package/dist/tokens/uiSize/stack-gap.d.ts +18 -0
- package/dist/tokens/uiSize/stack-gap.d.ts.map +1 -0
- package/dist/tokens/uiSize/stack-gap.js +5 -0
- package/dist/tokens/uiSize/stack-gap.js.map +1 -0
- package/ds-canonical/fork/codex/agents/skills/performance-audit/SKILL.md +1 -1
- package/ds-canonical/fork/codex/hooks.json +1 -1
- package/ds-canonical/fork/consumer/lock.json +17 -14
- package/ds-canonical/fork/consumer/managed-files/423729840acdd43c812f17c245b871716c6b60f76e4d3e94864d0e34a815bf8d.blob +9 -0
- package/ds-canonical/fork/consumer/managed-files/46f10bc55cdfdf834d0749eebff42ea0bb8576f361b36d2b6ba1851354c50150.blob +47 -9
- package/ds-canonical/fork/consumer/managed-files/5a0d134a2645114c8caac20458070b00a7f0fd55b3e8805b7325cb4cf52fa15e.blob +110 -0
- package/ds-canonical/fork/consumer/managed-files/5af0a46760af338d51fd0f7950675f8e97f7d2f09386b8818fcd7a58e6654e75.blob +790 -0
- package/ds-canonical/fork/consumer/managed-files/68bfcddff9c4a16388c248cd34fb66163e9e64b3d809a48ef3984f6ae4ff6d12.blob +211 -0
- package/ds-canonical/fork/consumer/managed-files/7eb6cc60abfdabaf6617c336058fad1454ccde08889295767bfd9b7fd153cc6e.blob +45 -6
- package/ds-canonical/fork/consumer/managed-files/92e4828cdcb96ff0d01daa16f6885f24045a8e72820665650f2a1890dc3a446c.blob +42 -2
- package/ds-canonical/fork/consumer/managed-files/ab072e7f2eca51f0e18a5e18c4b333260ee5d219a12cc6df5bb2d683f0b2734d.blob +27 -1
- package/ds-canonical/fork/consumer/managed-files/e71c65f45c6af7ce12b6b3c0d8028f185da5b719e2050c9b2a6b243d41ae4878.blob +62 -14
- package/ds-canonical/fork/governance.lock +41 -17
- package/ds-canonical/fork/hooks/check_consumer_app_invariants.sh +16 -4
- package/ds-canonical/fork/hooks/check_consumer_code_quality.sh +3 -1
- package/ds-canonical/fork/hooks/check_pattern_invariants.sh +13 -9
- package/ds-canonical/fork/launchers/settings-hooks.json +1 -1
- package/ds-canonical/fork/manifest.json +17 -11
- package/ds-canonical/fork/providers/claude/skills/performance-audit/SKILL.md +1 -1
- package/ds-canonical/fork/providers/codex/hook-config.json +1 -1
- package/ds-canonical/fork/providers/codex/skills/performance-audit/SKILL.md +1 -1
- package/ds-canonical/fork/skills/performance-audit/SKILL.md +1 -1
- package/ds-canonical/hooks/check_consumer_app_invariants.sh +16 -4
- package/ds-canonical/hooks/check_consumer_code_quality.sh +3 -1
- package/ds-canonical/hooks/check_datatable_invariants.sh +4 -1
- package/ds-canonical/hooks/check_pattern_invariants.sh +13 -9
- package/ds-canonical/hooks/check_plugin_fork_health.sh +8 -1
- package/ds-canonical/hooks/check_propose_discipline.sh +8 -1
- package/ds-canonical/hooks/check_storybook_addon_packaging.sh +8 -1
- package/ds-canonical/hooks/check_substantive_edit_approval_preflight.sh +64 -2
- package/ds-canonical/hooks/lib/_overlay_handcraft.sh +5 -2
- package/ds-canonical/hooks/registrations.json +12 -7
- package/ds-canonical/hooks/session_start_governance_check.sh +16 -0
- package/ds-canonical/hooks/tests/test_check_consumer_app_invariants.sh +25 -0
- package/ds-canonical/hooks/tests/test_check_consumer_code_quality.sh +4 -0
- package/ds-canonical/hooks/tests/test_check_consumer_ds_primitive_misuse.sh +14 -0
- package/ds-canonical/hooks/tests/test_check_datatable_invariants.sh +24 -0
- package/ds-canonical/hooks/tests/test_check_overlay_handcraft.sh +32 -0
- package/ds-canonical/hooks/tests/test_check_pattern_invariants.sh +59 -0
- package/ds-canonical/hooks/tests/test_check_plugin_fork_health.sh +32 -1
- package/ds-canonical/hooks/tests/test_check_propose_discipline.sh +21 -0
- package/ds-canonical/hooks/tests/test_check_storybook_addon_packaging.sh +21 -0
- package/ds-canonical/hooks/tests/test_check_substantive_edit_approval_preflight.sh +97 -0
- package/ds-canonical/hooks/tests/test_session_start_governance_check.sh +49 -0
- package/ds-canonical/references/README.md +12 -6
- package/ds-canonical/references/drag-canonical.md +3 -1
- package/ds-canonical/references/focus-canonical.md +26 -13
- package/ds-canonical/references/governance-audit-coverage.md +1 -1
- package/ds-canonical/references/hit-area-canonical.md +361 -0
- package/ds-canonical/references/item-anatomy-recipe.md +4 -2
- package/ds-canonical/references/keyboard-model-canonical.md +559 -0
- package/ds-canonical/rules/meta-patterns.md +4 -4
- package/ds-canonical/rules/self-verify.md +4 -4
- package/ds-canonical/skills/design-system-audit/references/audit-prompts.md +1 -1
- package/ds-canonical/skills/design-system-audit/references/historical-bugs.md +96 -0
- package/ds-canonical/skills/knowledge-prune/SKILL.md +4 -1
- package/ds-canonical/skills/performance-audit/SKILL.md +1 -1
- package/ds-story-manifest.json +19 -7
- package/llms-full.txt +2 -2
- package/llms.txt +2 -2
- package/package.json +3 -1
- package/src/components/Accordion/accordion.anatomy.stories.tsx +3 -3
- package/src/components/Accordion/accordion.spec.md +1 -1
- package/src/components/Accordion/accordion.tsx +8 -3
- package/src/components/AccountMenu/account-menu.spec.md +2 -3
- package/src/components/AccountMenu/account-menu.tsx +0 -1
- package/src/components/AgentPanel/agent-decision-card.spec.md +88 -0
- package/src/components/AgentPanel/agent-panel-fab.spec.md +151 -0
- package/src/components/AgentPanel/agent-panel-fab.tsx +67 -24
- package/src/components/AgentPanel/agent-panel-logo.spec.md +87 -0
- package/src/components/AgentPanel/agent-panel-logo.tsx +3 -3
- package/src/components/AgentPanel/agent-panel.anatomy.stories.tsx +1 -1
- package/src/components/AgentPanel/agent-panel.principles.stories.tsx +1 -1
- package/src/components/AgentPanel/agent-panel.spec.md +30 -275
- package/src/components/AgentPanel/agent-panel.stories.tsx +113 -8
- package/src/components/AgentPanel/agent-panel.tsx +59 -22
- package/src/components/Alert/alert.anatomy.stories.tsx +1 -1
- package/src/components/AppShell/app-shell.tsx +1 -1
- package/src/components/AspectRatio/aspect-ratio.anatomy.stories.tsx +10 -15
- package/src/components/Avatar/avatar.anatomy.stories.tsx +23 -26
- package/src/components/Avatar/avatar.principles.stories.tsx +10 -10
- package/src/components/Avatar/avatar.spec.md +45 -6
- package/src/components/Avatar/avatar.stories.tsx +8 -8
- package/src/components/Avatar/avatar.tsx +227 -21
- package/src/components/Badge/badge.anatomy.stories.tsx +16 -21
- package/src/components/Breadcrumb/breadcrumb.anatomy.stories.tsx +3 -3
- package/src/components/Breadcrumb/breadcrumb.spec.md +3 -3
- package/src/components/Breadcrumb/breadcrumb.tsx +5 -3
- package/src/components/BulkActionBar/bulk-action-bar.anatomy.stories.tsx +1 -1
- package/src/components/BulkActionBar/bulk-action-bar.principles.stories.tsx +1 -1
- package/src/components/BulkActionBar/bulk-action-bar.spec.md +1 -0
- package/src/components/BulkActionBar/bulk-action-bar.stories.tsx +57 -2
- package/src/components/BulkActionBar/bulk-action-bar.tsx +32 -2
- package/src/components/Button/button.anatomy.stories.tsx +174 -33
- package/src/components/Button/button.spec.md +23 -8
- package/src/components/Button/button.tsx +116 -21
- package/src/components/Calendar/calendar.anatomy.stories.tsx +34 -11
- package/src/components/Calendar/calendar.principles.stories.tsx +44 -1
- package/src/components/Calendar/calendar.spec.md +97 -20
- package/src/components/Calendar/calendar.stories.tsx +101 -4
- package/src/components/Calendar/calendar.tsx +379 -74
- package/src/components/Carousel/carousel.anatomy.stories.tsx +16 -17
- package/src/components/Carousel/carousel.spec.md +48 -2
- package/src/components/Carousel/carousel.tsx +9 -3
- package/src/components/Chart/chart.anatomy.stories.tsx +7 -5
- package/src/components/Chart/chart.spec.md +32 -7
- package/src/components/Chart/chart.tsx +9 -0
- package/src/components/Checkbox/checkbox.anatomy.stories.tsx +21 -28
- package/src/components/Checkbox/checkbox.spec.md +4 -2
- package/src/components/Checkbox/checkbox.tsx +10 -2
- package/src/components/Chip/chip.anatomy.stories.tsx +1 -1
- package/src/components/Chip/chip.spec.md +2 -1
- package/src/components/Chip/chip.tsx +4 -2
- package/src/components/Combobox/combobox.anatomy.stories.tsx +30 -37
- package/src/components/Combobox/combobox.spec.md +11 -3
- package/src/components/Combobox/combobox.tsx +43 -12
- package/src/components/Command/command.spec.md +11 -0
- package/src/components/Command/command.tsx +143 -10
- package/src/components/DataTable/cell-registry.tsx +5 -1
- package/src/components/DataTable/data-table-column-visibility-panel.tsx +5 -6
- package/src/components/DataTable/data-table-interaction-layer.tsx +27 -14
- package/src/components/DataTable/data-table-known-defects.spec.md +72 -0
- package/src/components/DataTable/data-table-scroll-performance.spec.md +295 -0
- package/src/components/DataTable/data-table-sort-manager.tsx +5 -6
- package/src/components/DataTable/data-table.anatomy.stories.tsx +1 -1
- package/src/components/DataTable/data-table.css +64 -0
- package/src/components/DataTable/data-table.spec.md +70 -346
- package/src/components/DataTable/data-table.stories.tsx +56 -9
- package/src/components/DataTable/data-table.tsx +392 -103
- package/src/components/DateGrid/date-grid.anatomy.stories.tsx +2 -2
- package/src/components/DateGrid/date-grid.spec.md +163 -1
- package/src/components/DateGrid/date-grid.tsx +270 -7
- package/src/components/DatePicker/date-picker.anatomy.stories.tsx +18 -27
- package/src/components/DatePicker/date-picker.spec.md +6 -2
- package/src/components/DatePicker/date-picker.tsx +20 -1
- package/src/components/Dialog/dialog.spec.md +6 -2
- package/src/components/Dialog/dialog.stories.tsx +18 -9
- package/src/components/Dialog/dialog.tsx +83 -48
- package/src/components/DropdownMenu/dropdown-menu-keyboard.ts +255 -0
- package/src/components/DropdownMenu/dropdown-menu.anatomy.stories.tsx +32 -33
- package/src/components/DropdownMenu/dropdown-menu.spec.md +18 -5
- package/src/components/DropdownMenu/dropdown-menu.stories.tsx +58 -1
- package/src/components/DropdownMenu/dropdown-menu.tsx +70 -33
- package/src/components/Empty/empty.spec.md +1 -2
- package/src/components/Field/field-context.ts +9 -4
- package/src/components/Field/field-controls.spec.md +5 -2
- package/src/components/Field/field-edit-keys.ts +12 -0
- package/src/components/Field/field-wrapper.tsx +76 -10
- package/src/components/Field/field.anatomy.stories.tsx +1 -1
- package/src/components/Field/field.spec.md +11 -6
- package/src/components/Field/field.stories.tsx +24 -1
- package/src/components/Field/field.tsx +10 -4
- package/src/components/FileItem/file-item.anatomy.stories.tsx +20 -10
- package/src/components/FileItem/file-item.principles.stories.tsx +14 -7
- package/src/components/FileItem/file-item.spec.md +50 -48
- package/src/components/FileItem/file-item.stories.tsx +41 -28
- package/src/components/FileItem/file-item.tsx +184 -50
- package/src/components/FileUpload/file-upload.anatomy.stories.tsx +11 -15
- package/src/components/FileUpload/file-upload.spec.md +27 -1
- package/src/components/FileUpload/file-upload.stories.tsx +33 -0
- package/src/components/FileUpload/file-upload.tsx +173 -43
- package/src/components/FileViewer/file-viewer.tsx +9 -4
- package/src/components/HoverCard/hover-card.stories.tsx +2 -2
- package/src/components/InlineEdit/inline-edit.spec.md +2 -0
- package/src/components/InlineEdit/inline-edit.tsx +8 -3
- package/src/components/Input/input.anatomy.stories.tsx +23 -31
- package/src/components/Input/input.spec.md +11 -0
- package/src/components/Input/input.tsx +6 -1
- package/src/components/LinkInput/link-input.anatomy.stories.tsx +18 -21
- package/src/components/LinkInput/link-input.principles.stories.tsx +4 -4
- package/src/components/LinkInput/link-input.spec.md +12 -1
- package/src/components/LinkInput/link-input.tsx +37 -4
- package/src/components/Menu/menu-item.anatomy.stories.tsx +50 -56
- package/src/components/Menu/menu-item.spec.md +1 -1
- package/src/components/Menu/menu-item.stories.tsx +48 -42
- package/src/components/Notice/notice.anatomy.stories.tsx +8 -16
- package/src/components/Notice/notice.spec.md +1 -1
- package/src/components/Notice/notice.tsx +1 -1
- package/src/components/NumberInput/number-input.anatomy.stories.tsx +21 -28
- package/src/components/NumberInput/number-input.spec.md +2 -0
- package/src/components/NumberInput/number-input.tsx +8 -1
- package/src/components/OverflowIndicator/overflow-indicator.anatomy.stories.tsx +28 -48
- package/src/components/OverflowIndicator/overflow-indicator.spec.md +13 -1
- package/src/components/OverflowIndicator/overflow-indicator.stories.tsx +12 -9
- package/src/components/OverflowIndicator/overflow-indicator.tsx +32 -12
- package/src/components/Pagination/pagination.spec.md +1 -0
- package/src/components/Pagination/pagination.tsx +1 -1
- package/src/components/PeoplePicker/avatar-stack-overflow.ts +14 -13
- package/src/components/PeoplePicker/people-picker-helpers.ts +5 -2
- package/src/components/PeoplePicker/people-picker.anatomy.stories.tsx +1 -1
- package/src/components/PeoplePicker/people-picker.spec.md +4 -2
- package/src/components/PeoplePicker/people-picker.tsx +1 -4
- package/src/components/PeoplePicker/person-display.tsx +45 -28
- package/src/components/Popover/popover.anatomy.stories.tsx +1 -1
- package/src/components/Popover/popover.principles.stories.tsx +3 -12
- package/src/components/Popover/popover.spec.md +7 -3
- package/src/components/ProfileCard/profile-card.anatomy.stories.tsx +1 -1
- package/src/components/ProfileCard/profile-card.spec.md +1 -1
- package/src/components/ProfileCard/profile-card.tsx +0 -3
- package/src/components/ProgressBar/progress-bar.anatomy.stories.tsx +11 -21
- package/src/components/ProgressBar/progress-bar.spec.md +1 -1
- package/src/components/RadioGroup/radio-group.anatomy.stories.tsx +17 -24
- package/src/components/RadioGroup/radio-group.spec.md +1 -1
- package/src/components/RadioGroup/radio-group.tsx +22 -4
- package/src/components/Rating/rating.anatomy.stories.tsx +131 -124
- package/src/components/Rating/rating.principles.stories.tsx +69 -43
- package/src/components/Rating/rating.spec.md +142 -63
- package/src/components/Rating/rating.stories.tsx +11 -15
- package/src/components/Rating/rating.tsx +131 -117
- package/src/components/ScrollArea/scroll-area.anatomy.stories.tsx +1 -1
- package/src/components/ScrollArea/scroll-area.principles.stories.tsx +6 -3
- package/src/components/ScrollArea/scroll-area.spec.md +3 -1
- package/src/components/ScrollArea/scroll-area.stories.tsx +5 -2
- package/src/components/ScrollArea/scroll-area.tsx +1 -1
- package/src/components/SegmentedControl/segmented-control.anatomy.stories.tsx +1 -1
- package/src/components/SegmentedControl/segmented-control.spec.md +2 -2
- package/src/components/SegmentedControl/segmented-control.tsx +3 -2
- package/src/components/Select/select.anatomy.stories.tsx +26 -35
- package/src/components/Select/select.spec.md +4 -3
- package/src/components/Select/select.tsx +11 -4
- package/src/components/SelectMenu/select-menu-keyboard.ts +218 -0
- package/src/components/SelectMenu/select-menu.anatomy.stories.tsx +6 -17
- package/src/components/SelectMenu/select-menu.spec.md +12 -4
- package/src/components/SelectMenu/select-menu.tsx +21 -56
- package/src/components/SelectionControl/selection-item.anatomy.stories.tsx +9 -33
- package/src/components/SelectionControl/selection-item.spec.md +2 -2
- package/src/components/SelectionControl/selection-item.tsx +3 -1
- package/src/components/Separator/separator.stories.tsx +4 -4
- package/src/components/Sheet/sheet.principles.stories.tsx +31 -13
- package/src/components/Sidebar/sidebar.anatomy.stories.tsx +1 -1
- package/src/components/Sidebar/sidebar.spec.md +73 -6
- package/src/components/Sidebar/sidebar.stories.tsx +82 -4
- package/src/components/Sidebar/sidebar.tsx +281 -64
- package/src/components/Skeleton/skeleton.spec.md +1 -1
- package/src/components/Slider/slider.anatomy.stories.tsx +1 -1
- package/src/components/Slider/slider.spec.md +6 -5
- package/src/components/Slider/slider.tsx +13 -8
- package/src/components/Steps/steps-state-visuals.spec.md +157 -0
- package/src/components/Steps/steps.anatomy.stories.tsx +1 -0
- package/src/components/Steps/steps.spec.md +59 -142
- package/src/components/Steps/steps.stories.tsx +1 -1
- package/src/components/Steps/steps.tsx +125 -33
- package/src/components/Switch/switch.spec.md +3 -3
- package/src/components/Switch/switch.tsx +6 -1
- package/src/components/Tabs/tabs.spec.md +5 -1
- package/src/components/Tabs/tabs.tsx +42 -3
- package/src/components/Tag/tag.anatomy.stories.tsx +24 -28
- package/src/components/Tag/tag.spec.md +10 -11
- package/src/components/Tag/tag.tsx +3 -3
- package/src/components/Textarea/textarea.tsx +7 -1
- package/src/components/TimePicker/time-columns.tsx +9 -4
- package/src/components/TimePicker/time-picker.spec.md +4 -1
- package/src/components/TimePicker/time-picker.tsx +1 -1
- package/src/components/Tooltip/tooltip.anatomy.stories.tsx +6 -15
- package/src/components/Tooltip/tooltip.principles.stories.tsx +4 -16
- package/src/components/Tooltip/tooltip.spec.md +2 -1
- package/src/components/TreeView/tree-view.anatomy.stories.tsx +35 -13
- package/src/components/TreeView/tree-view.spec.md +67 -38
- package/src/components/TreeView/tree-view.stories.tsx +221 -12
- package/src/components/TreeView/tree-view.tsx +463 -356
- package/src/hooks/use-input-modality.ts +17 -46
- package/src/hooks/use-overflow-items.ts +4 -0
- package/src/hooks/use-truncated.ts +3 -2
- package/src/index.ts +17 -0
- package/src/lib/README.md +2 -0
- package/src/lib/drag-announcements.ts +78 -0
- package/src/lib/focus-after-trigger.ts +102 -0
- package/src/lib/roving-list-keyboard.ts +400 -0
- package/src/lib/utils.ts +5 -0
- package/src/patterns/action-bar/action-bar.spec.md +2 -0
- package/src/patterns/element-anatomy/inline-action.spec.md +16 -12
- package/src/patterns/element-anatomy/item-anatomy.spec.md +98 -6
- package/src/patterns/element-anatomy/item-anatomy.stories.tsx +75 -70
- package/src/patterns/element-anatomy/item-anatomy.tsx +17 -6
- package/src/patterns/element-anatomy/truncated-text.spec.md +1 -0
- package/src/patterns/header-canonical/header-canonical.spec.md +3 -2
- package/src/patterns/horizontal-overflow/horizontal-overflow.spec.md +2 -0
- package/src/patterns/horizontal-overflow/horizontal-overflow.stories.tsx +36 -0
- package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +28 -0
- package/src/patterns/overlay-surface/overlay-chrome-sizing.spec.md +180 -0
- package/src/patterns/overlay-surface/overlay-surface.spec.md +8 -162
- package/src/patterns/overlay-surface/overlay-surface.tsx +5 -3
- package/src/patterns/resize-handle/resize-handle.spec.md +3 -3
- package/src/styles/base.css +1 -1
- package/src/tokens/categorical-color.ts +6 -1
- package/src/tokens/color/color.spec.md +91 -24
- package/src/tokens/color/color.stories.tsx +50 -2
- package/src/tokens/color/primitives.css +41 -38
- package/src/tokens/color/semantic.css +105 -36
- package/src/tokens/layoutSpace/layoutSpace.spec.md +2 -0
- package/src/tokens/motion/motion.spec.md +31 -8
- package/src/tokens/orphan-tokens.spec.md +1 -1
- package/src/tokens/uiSize/icon-size.ts +1 -1
- package/src/tokens/uiSize/stack-gap.ts +17 -0
- package/src/tokens/uiSize/uiSize.css +12 -0
- package/src/tokens/uiSize/uiSize.spec.md +17 -16
- package/src/tokens/uiSize/uiSize.stories.tsx +1 -1
- package/dist/node_modules/aria-hidden/dist/es2015/index.js +0 -147
- package/dist/node_modules/aria-hidden/dist/es2015/index.js.map +0 -1
|
@@ -17,10 +17,21 @@ import { ChevronRight } from 'lucide-react'
|
|
|
17
17
|
import { cva } from 'class-variance-authority'
|
|
18
18
|
import type { LucideIcon } from 'lucide-react'
|
|
19
19
|
import { dragSourceClass, dropIndicatorRow, dropIndicatorInside, DRAG_ACTIVATION_DISTANCE_PX } from '@/design-system/lib/drag-visual'
|
|
20
|
-
import {
|
|
20
|
+
import { type DragOutcome, useDragAccessibility } from '@/design-system/lib/drag-announcements'
|
|
21
21
|
import { cn } from '@/lib/utils'
|
|
22
|
-
import { useInputModality } from '@/design-system/hooks/use-input-modality'
|
|
23
22
|
import { Checkbox } from '@/design-system/components/Checkbox/checkbox'
|
|
23
|
+
// 「列上有小按鈕的一串」鍵盤路線的唯一判定與執行(與 Sidebar / FileUpload / Command 共用;判定表 scripts/test-roving-list-keyboard.mjs
|
|
24
|
+
// + 樹專屬格 scripts/test-tree-keyboard-route.mjs;SSOT tree-view.spec.md「鍵盤導覽」,總帳 B9 + 〇節「按鍵規則合併」)
|
|
25
|
+
import {
|
|
26
|
+
ROVING_CONTROL_SELECTOR,
|
|
27
|
+
applyRovingAction,
|
|
28
|
+
isTextEntryElement,
|
|
29
|
+
listRovingControls,
|
|
30
|
+
pickRovingTabStop,
|
|
31
|
+
removeRovingControlsFromTabOrder,
|
|
32
|
+
resolveRovingKey,
|
|
33
|
+
type RovingReorderKey,
|
|
34
|
+
} from '@/design-system/lib/roving-list-keyboard'
|
|
24
35
|
// Row primitive 共用常數——單一 source of truth
|
|
25
36
|
import {
|
|
26
37
|
ICON_SIZE,
|
|
@@ -29,6 +40,7 @@ import {
|
|
|
29
40
|
ItemPrefix,
|
|
30
41
|
ItemSuffix,
|
|
31
42
|
ItemInlineAction,
|
|
43
|
+
ItemInlineActionButton,
|
|
32
44
|
ROW_PADDING_BY_SIZE,
|
|
33
45
|
type InlineActionConfig,
|
|
34
46
|
} from '@/design-system/patterns/element-anatomy/item-anatomy'
|
|
@@ -42,7 +54,7 @@ import {
|
|
|
42
54
|
* TreeView 負責:
|
|
43
55
|
* 1. 遞迴渲染 + indent
|
|
44
56
|
* 2. 展開/收合狀態管理
|
|
45
|
-
* 3. 鍵盤導覽 + ARIA tree
|
|
57
|
+
* 3. 鍵盤導覽 + ARIA 樹狀表格(treegrid;2026-09-25 由 tree 改,總帳 B9)
|
|
46
58
|
*
|
|
47
59
|
* 它不管 node 裡面長什麼樣——icon、badge、status indicator 等
|
|
48
60
|
* 由 consumer 透過 props / slots 決定。
|
|
@@ -120,13 +132,6 @@ export interface TreeReorderAnnouncements {
|
|
|
120
132
|
// 則走 CSS token 的 Tailwind class(跨元件視覺一致)。結構對齊:子 chevron 對齊父 icon,子 icon 對齊父 label。
|
|
121
133
|
const INDENT_STEP: Record<SizeKey, number> = { sm: 24, md: 24, lg: 28 }
|
|
122
134
|
|
|
123
|
-
// ── 鍵盤重排(keyboard reorder,2026-07-14 v1)常數 ──
|
|
124
|
-
// 鍵位 = Cmd/Ctrl+Shift+方向鍵,對齊 Notion 移動 block 鍵位(https://www.notion.com/help/keyboard-shortcuts,
|
|
125
|
-
// WebFetch 驗證紀錄 .claude/logs/treeview-keyboard-dnd-design.md#L25);
|
|
126
|
-
// modifier 層與 APG tree 導覽鍵(無 modifier 的 ↑↓→←)完全正交,零衝突。
|
|
127
|
-
const REORDER_ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
|
|
128
|
-
type ReorderArrowKey = 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'
|
|
129
|
-
|
|
130
135
|
// 鍵盤重排 SR 播報預設文案(zh-TW;consumer 經 `reorderAnnouncements` prop per-key 覆寫)。
|
|
131
136
|
// 格式 =「結果 + 序數位置」— 非視覺使用者靠「第 n 項,共 m 項」建立位置模型
|
|
132
137
|
// (世界級 cite 詳 tree-view.spec.md「鍵盤重排」段,M22)。
|
|
@@ -151,6 +156,42 @@ const DEFAULT_REORDER_ANNOUNCEMENTS: Required<TreeReorderAnnouncements> = {
|
|
|
151
156
|
instructions: '按 Cmd(Ctrl)+Shift+方向鍵可重新排列項目',
|
|
152
157
|
}
|
|
153
158
|
|
|
159
|
+
// ── 樹狀表格的 DOM 查詢(2026-09-25 總帳 B9:由虛擬焦點改為列上的 roving tabindex)──
|
|
160
|
+
// 每個 node = 外層 wrapper(`data-tree-id`,無角色)+ 直屬子節點 `role="row"`(`data-tree-row`)
|
|
161
|
+
// + 子項容器(`data-tree-children`,Radix Collapsible Content)。列不得包住子列(treegrid 的 row 只能擁有格),
|
|
162
|
+
// 所以 aria-level / aria-expanded / aria-selected 都住在 row 上,wrapper 只是結構。
|
|
163
|
+
const ROW_SELECTOR = '[data-tree-row]'
|
|
164
|
+
/** 打字跳位:兩次按鍵相隔在這之內算同一串(AI 推導;見 TreeView 內 typeahead) */
|
|
165
|
+
const TYPEAHEAD_WINDOW_MS = 1000
|
|
166
|
+
const cssEscape = (value: string) =>
|
|
167
|
+
typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(value) : value.replace(/["\\]/g, '\\$&')
|
|
168
|
+
const rowSelector = (id: string) => `[data-tree-row="${cssEscape(id)}"]`
|
|
169
|
+
/** node wrapper 的那一列(直屬子節點) */
|
|
170
|
+
const rowOf = (wrapper: Element | null | undefined) => wrapper?.querySelector<HTMLElement>(':scope > [data-tree-row]') ?? null
|
|
171
|
+
/**
|
|
172
|
+
* 看得到 = 不在收合中的子樹裡。Radix Collapsible 關閉動畫期間 Content 仍掛著、`data-state="closed"`,
|
|
173
|
+
* 那段時間裡的列不能再被方向鍵走到(舊查詢 `:not([hidden])` 對列本身永遠成立,擋不到)。
|
|
174
|
+
*/
|
|
175
|
+
const isInOpenBranch = (el: Element) => !el.closest('[data-tree-children][data-state="closed"], [hidden]')
|
|
176
|
+
const isRowEnabled = (row: Element) => row.getAttribute('aria-disabled') !== 'true'
|
|
177
|
+
/**
|
|
178
|
+
* 動作格裡可以走到的東西 —— 全部 tabIndex=-1,只用 → / ← 走到(總帳 B9;tree-view.spec.md「鍵盤導覽」)。
|
|
179
|
+
* 候選與過濾 = lib/roving-list-keyboard.ts(四個宿主同一份);動作格裡的輸入框 / 可編輯區保留自己的方向鍵,
|
|
180
|
+
* 那一格由共用判定的 `controlIsTextEntry` 處理(2026-09-26 前這裡另有一份 isButtonLike)。
|
|
181
|
+
*/
|
|
182
|
+
const getRowActions = (row: Element): HTMLElement[] => {
|
|
183
|
+
const cell = row.querySelector<HTMLElement>(':scope > [data-tree-actions]')
|
|
184
|
+
return cell ? listRovingControls(cell) : []
|
|
185
|
+
}
|
|
186
|
+
const hasAccessibleName = (el: Element) =>
|
|
187
|
+
Boolean(
|
|
188
|
+
el.getAttribute('aria-label')?.trim() ||
|
|
189
|
+
el.getAttribute('aria-labelledby')?.trim() ||
|
|
190
|
+
el.getAttribute('title')?.trim() ||
|
|
191
|
+
el.textContent?.trim(),
|
|
192
|
+
)
|
|
193
|
+
const warnedUnnamedAction = new WeakSet<Element>()
|
|
194
|
+
|
|
154
195
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
155
196
|
// Context
|
|
156
197
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -161,18 +202,12 @@ interface TreeViewContextValue {
|
|
|
161
202
|
selectionMode: SelectionMode
|
|
162
203
|
expandOnSelect: boolean
|
|
163
204
|
draggable: boolean
|
|
164
|
-
isKeyboardRef: React.RefObject<boolean>
|
|
165
|
-
/** 最近一次輸入是鍵盤(布林,隨模態變化觸發列 re-render;ref 身分不變不會) */
|
|
166
|
-
keyboardModality: boolean
|
|
167
|
-
/**
|
|
168
|
-
* Per-tree instance 前綴(React.useId),用來組每個 treeitem 的 DOM `id`
|
|
169
|
-
* (`${prefix}treeitem-${nodeId}`),讓容器的 `aria-activedescendant` 能指向目前 focused node。
|
|
170
|
-
* 多棵 TreeView 同頁 / node id 跨樹重複時不會撞 DOM id。
|
|
171
|
-
*/
|
|
172
|
-
activeDescendantPrefix: string
|
|
173
205
|
expandedIds: Set<string>
|
|
174
206
|
selectedIds: Set<string>
|
|
207
|
+
/** 焦點最後停在哪一列(真 DOM 焦點,由容器 onFocus 同步) */
|
|
175
208
|
focusedId: string | null
|
|
209
|
+
/** 整棵樹在 Tab 路上唯一的那一站(roving tabindex;總帳 B9) */
|
|
210
|
+
tabStopId: string | null
|
|
176
211
|
/** 目前拖曳中的 node id(null = 沒在拖) */
|
|
177
212
|
draggingId: string | null
|
|
178
213
|
/** 目前 drop indicator 的位置 + depth(用於 line indent) */
|
|
@@ -180,8 +215,10 @@ interface TreeViewContextValue {
|
|
|
180
215
|
toggleExpand: (id: string) => void
|
|
181
216
|
select: (id: string) => void
|
|
182
217
|
setFocusedId: (id: string | null) => void
|
|
183
|
-
/**
|
|
184
|
-
|
|
218
|
+
/** 把 DOM 焦點放到某一列(不捲動;捲動由該列的 isFocused effect 以 block:nearest 處理) */
|
|
219
|
+
focusRow: (id: string) => void
|
|
220
|
+
/** 列卸載時通知(它若是 Tab 停靠點或焦點所在,要重新選一站,否則整棵樹 Tab 不到) */
|
|
221
|
+
onRowUnmount: (id: string) => void
|
|
185
222
|
registerNode: (id: string, parentId: string | null, hasChildren: boolean, label?: React.ReactNode, icon?: LucideIcon) => void
|
|
186
223
|
getNodeInfo: (id: string) => NodeInfo | undefined
|
|
187
224
|
unregisterNode: (id: string) => void
|
|
@@ -303,7 +340,7 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
303
340
|
) => {
|
|
304
341
|
// ── Accessible name dev-warn(2026-07-18:兌現 spec「Accessible name 必填契約」段承諾 —
|
|
305
342
|
// 原 spec 宣稱有此 console.warn 但 code 缺,spec-code drift;補上使兩者一致)──
|
|
306
|
-
// role="
|
|
343
|
+
// role="treegrid" 名稱無法從子節點推導,缺 aria-label / aria-labelledby → SR 只讀出角色名。
|
|
307
344
|
// 對齊 Button / Tag 的 dev-only 誤用警告 idiom;production 不觸發。
|
|
308
345
|
const ariaLabel = props['aria-label']
|
|
309
346
|
const ariaLabelledby = props['aria-labelledby']
|
|
@@ -314,8 +351,8 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
314
351
|
ariaLabelledby == null
|
|
315
352
|
) {
|
|
316
353
|
console.warn(
|
|
317
|
-
'[DS] TreeView:role="
|
|
318
|
-
'
|
|
354
|
+
'[DS] TreeView:role="treegrid" 缺 accessible name — 請傳 aria-label(直接字串)或 aria-labelledby(指向可見標題 id)其一。' +
|
|
355
|
+
'兩者皆缺時螢幕閱讀器只讀出角色名、無法辨識用途(WAI-ARIA 要求 treegrid 具 accessible name)。',
|
|
319
356
|
)
|
|
320
357
|
}
|
|
321
358
|
}, [ariaLabel, ariaLabelledby])
|
|
@@ -362,19 +399,60 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
362
399
|
[controlledSelected, onSelectedChange]
|
|
363
400
|
)
|
|
364
401
|
|
|
365
|
-
// ── Focus state ──
|
|
402
|
+
// ── Focus state(2026-09-25 總帳 B9:虛擬焦點 → 列上的 roving tabindex)──
|
|
403
|
+
// 真 DOM 焦點落在列(role="row")或列裡的按鈕上;focusedId 由容器 onFocus 同步。
|
|
404
|
+
// 為何改:樹狀表格要讓 → 把焦點**真的**交給列裡的按鈕、← 再交回列 —— 這一步本來就是真焦點,
|
|
405
|
+
// 列若維持虛擬焦點,同一棵樹就有兩種焦點模型。W3C 樹狀表格範例與 Adobe 的樹都是列上真焦點
|
|
406
|
+
// (來源見 tree-view.spec.md「A11y 預設」)。
|
|
366
407
|
const [focusedId, setFocusedId] = React.useState<string | null>(null)
|
|
367
408
|
const treeRef = React.useRef<HTMLDivElement>(null)
|
|
409
|
+
// 打字跳位的累積字串(見下方 typeahead)
|
|
410
|
+
const typeaheadRef = React.useRef({ buffer: '', at: 0 })
|
|
368
411
|
React.useImperativeHandle(ref, () => treeRef.current!)
|
|
369
|
-
const
|
|
370
|
-
treeRef.current?.focus({ preventScroll: true })
|
|
412
|
+
const focusRow = React.useCallback((id: string) => {
|
|
413
|
+
treeRef.current?.querySelector<HTMLElement>(rowSelector(id))?.focus({ preventScroll: true })
|
|
414
|
+
}, [])
|
|
415
|
+
|
|
416
|
+
// ── 唯一的 Tab 停靠點(roving tabindex)──
|
|
417
|
+
// 順序:焦點最後停的那一列(仍看得到)→ 選中的第一列 → 第一個可用列
|
|
418
|
+
// (W3C keyboard-interface「進入組合元件時落在選中項,沒有就第一項」;來源見 spec)。
|
|
419
|
+
// 在 layout 階段從真 DOM 重算,相同就不 setState,不會迴圈:答案只看列的存在 / 開合 / aria-selected / aria-disabled 與 focusedId,
|
|
420
|
+
// 不看 tabStopId 本身(它只影響列的 tabIndex),所以 set 之後那一次重算必然相等。
|
|
421
|
+
const [tabStopId, setTabStopId] = React.useState<string | null>(null)
|
|
422
|
+
// 值本身不讀,只當下方 layout effect 的相依:列卸載時 TreeView 自己不一定 re-render,靠它讓 effect 重算停靠點
|
|
423
|
+
const [rowRepairTick, setRowRepairTick] = React.useState(0)
|
|
424
|
+
const tabStopRef = React.useRef<string | null>(null)
|
|
425
|
+
tabStopRef.current = tabStopId
|
|
426
|
+
const focusedIdRef = React.useRef<string | null>(null)
|
|
427
|
+
focusedIdRef.current = focusedId
|
|
428
|
+
React.useLayoutEffect(() => {
|
|
429
|
+
const tree = treeRef.current
|
|
430
|
+
if (!tree) return
|
|
431
|
+
const rows = Array.from(tree.querySelectorAll<HTMLElement>(ROW_SELECTOR)).filter(isInOpenBranch)
|
|
432
|
+
// 判定與 Sidebar / FileUpload 同一份(X1;lib/roving-list-keyboard.ts `pickRovingTabStop`)。
|
|
433
|
+
// 焦點停過的列即使停用也還拿得住焦點(← 回上一層可能落在停用列),所以 canHoldFocus 恆真;「選中」「第一列」只算可用列。
|
|
434
|
+
const stop = pickRovingTabStop(rows, {
|
|
435
|
+
remembered: focusedId != null ? rows.find((row) => row.dataset.treeRow === focusedId) ?? null : null,
|
|
436
|
+
canHoldFocus: () => true,
|
|
437
|
+
isNavigable: isRowEnabled,
|
|
438
|
+
isCurrent: (row) => row.getAttribute('aria-selected') === 'true',
|
|
439
|
+
})
|
|
440
|
+
const next = stop?.dataset.treeRow ?? null
|
|
441
|
+
if (next !== tabStopId) setTabStopId(next)
|
|
442
|
+
// 相依 = 會改變「哪些列存在 / 在開著的分支裡 / aria-selected / aria-disabled」的 render 輸入,加上 remembered(focusedId)與比對用的 tabStopId:
|
|
443
|
+
// children(節點增刪 / disabled)、expandedIds(子樹掛載與開合)、selectedIds + selectionMode(aria-selected)、rowRepairTick(列卸載通知)。
|
|
444
|
+
// 2026-09-29 前沒有相依清單(每次 render 都跑);列 DOM 只會因這幾個輸入而變,其餘 render(拖曳落點 / 播報文字 / 無關 prop)重算結果必相同,
|
|
445
|
+
// 跳過不改行為。日後新增會影響列 DOM 的輸入時要一併列進來。
|
|
446
|
+
}, [children, expandedIds, selectedIds, selectionMode, focusedId, tabStopId, rowRepairTick])
|
|
447
|
+
// 列被卸載(例:consumer 收合了焦點所在列的上一層,關閉動畫結束後子樹才卸載 —— 那一刻 TreeView 本身不會 re-render)
|
|
448
|
+
// → 若它是停靠點或焦點所在,觸發一次重算,否則整棵樹會從 Tab 路上消失。
|
|
449
|
+
const onRowUnmount = React.useCallback((id: string) => {
|
|
450
|
+
if (id === tabStopRef.current || id === focusedIdRef.current) setRowRepairTick((tick) => tick + 1)
|
|
371
451
|
}, [])
|
|
372
452
|
|
|
373
|
-
// ──
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
// useId 確保多棵 TreeView 同頁 / node id 跨樹重複時 DOM id 不撞。
|
|
377
|
-
const activeDescendantPrefix = React.useId()
|
|
453
|
+
// ── DOM id prefix(重排操作說明節點的 id)──
|
|
454
|
+
// useId 確保多棵 TreeView 同頁時 DOM id 不撞。
|
|
455
|
+
const idPrefix = React.useId()
|
|
378
456
|
|
|
379
457
|
// ── 鍵盤重排 SR 播報(2026-07-14 v1;詳 spec「鍵盤重排」)──
|
|
380
458
|
// 單一 polite live region 覆寫式更新(textContent 替換非 append → 快速連按天然只播最新)。
|
|
@@ -386,16 +464,7 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
386
464
|
[reorderAnnouncementsProp]
|
|
387
465
|
)
|
|
388
466
|
// sr-only 操作說明節點 id(tree 容器 aria-describedby 指向;僅 draggable 渲染)
|
|
389
|
-
const reorderInstructionsId = `${
|
|
390
|
-
|
|
391
|
-
// ── Keyboard vs mouse detection ──
|
|
392
|
-
// focus ring 只在鍵盤操作時顯示,滑鼠點擊用 bg-neutral-selected 表達選中,不顯示 ring。
|
|
393
|
-
// 2026-09-08:判斷來源改為共用的 useInputModality(SelectMenu / DropdownMenu / AgentPanel 同款,
|
|
394
|
-
// 原本四處各自實作 = 四份 SSOT)。ref 保留給 context 消費端讀,每次 render 由 hook 餵值;
|
|
395
|
-
// 模態一變 hook 觸發 root re-render,子項在 render 期讀到的就是新值。
|
|
396
|
-
const isKeyboardRef = React.useRef(false)
|
|
397
|
-
const keyboardModality = useInputModality() === 'keyboard'
|
|
398
|
-
isKeyboardRef.current = keyboardModality
|
|
467
|
+
const reorderInstructionsId = `${idPrefix}tree-reorder-instructions`
|
|
399
468
|
|
|
400
469
|
// ── Drag state ──
|
|
401
470
|
const [draggingId, setDraggingId] = React.useState<string | null>(null)
|
|
@@ -476,7 +545,8 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
476
545
|
const ratio = Math.max(0, Math.min(1, offsetY / height))
|
|
477
546
|
|
|
478
547
|
const hasChildren = targetEl.dataset.treeHasChildren === 'true'
|
|
479
|
-
|
|
548
|
+
// 2026-09-25:aria-level 從 wrapper 搬到列(role="row")上(樹狀表格的層級屬性住在列)
|
|
549
|
+
const targetDepth = Number(rowEl.getAttribute('aria-level') ?? 1) - 1
|
|
480
550
|
|
|
481
551
|
// ── X 軸:計算指標在哪個 indent level ──
|
|
482
552
|
const treeEl = treeRef.current
|
|
@@ -507,12 +577,11 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
507
577
|
// X 軸:如果指標在比 target 更淺的層級,提升 drop depth
|
|
508
578
|
// 例:Contact(depth 1)的 after,如果滑鼠在 depth 0 → 變成「after Pages」
|
|
509
579
|
if (pointerIndentLevel < targetDepth) {
|
|
510
|
-
// 找 parent 來放
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
const parentId = parentTreeItem?.getAttribute('data-tree-id')
|
|
580
|
+
// 找 parent 來放(2026-09-25:原本沿 role="group" / role="treeitem" 上溯,treegrid 沒有這兩個角色 → 改讀 wrapper 記的 parent id)
|
|
581
|
+
const parentId = targetEl.dataset.treeParentId || null
|
|
582
|
+
const parentRow = parentId ? treeEl?.querySelector<HTMLElement>(rowSelector(parentId)) ?? null : null
|
|
514
583
|
if (parentId && parentId !== String(active.id)) {
|
|
515
|
-
const parentDepth = Number(
|
|
584
|
+
const parentDepth = Number(parentRow?.getAttribute('aria-level') ?? 1) - 1
|
|
516
585
|
finalDepth = parentDepth
|
|
517
586
|
setDropTarget(prev => prev && prev.id === parentId && prev.position === 'after' && prev.depth === parentDepth ? prev : { id: parentId, position: 'after', depth: parentDepth })
|
|
518
587
|
return
|
|
@@ -537,7 +606,7 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
537
606
|
} else {
|
|
538
607
|
if (autoExpandTimerRef.current) { clearTimeout(autoExpandTimerRef.current); autoExpandTimerRef.current = null }
|
|
539
608
|
}
|
|
540
|
-
}, [expandedIds, isInSubtree])
|
|
609
|
+
}, [expandedIds, isInSubtree, size])
|
|
541
610
|
|
|
542
611
|
const dropTargetRef = React.useRef(dropTarget)
|
|
543
612
|
dropTargetRef.current = dropTarget
|
|
@@ -547,10 +616,8 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
547
616
|
// 這裡把指標路徑也接上,並誠實回報結果:守衛擋下(不合法 target / 子樹內)
|
|
548
617
|
// 就播「未變更」而不是假的成功。共用 SSOT 見 `lib/drag-announcements.ts`。
|
|
549
618
|
const dragOutcomeRef = React.useRef<DragOutcome | null>(null)
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
[],
|
|
553
|
-
)
|
|
619
|
+
// 指標拖曳的播報走 polite 區域 + 繁中操作說明(2026-09-27 OE10;先前交給 dnd-kit 寫死的 assertive 區域)
|
|
620
|
+
const drag = useDragAccessibility({ getOutcome: () => dragOutcomeRef.current, kind: '項目' })
|
|
554
621
|
|
|
555
622
|
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
|
556
623
|
dragOutcomeRef.current = null
|
|
@@ -612,42 +679,41 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
612
679
|
// onDragEnd prop + registry(consumer API 零改動);每按一下立即 commit(無 grab-mode /
|
|
613
680
|
// 無預覽,對齊 Notion Cmd/Ctrl+Shift+Arrow 移動 block:https://www.notion.com/help/keyboard-shortcuts,
|
|
614
681
|
// WebFetch 驗證紀錄 .claude/logs/treeview-keyboard-dnd-design.md#L25)。
|
|
615
|
-
// 不用 dnd-kit KeyboardSensor
|
|
616
|
-
//
|
|
617
|
-
//
|
|
682
|
+
// 不用 dnd-kit KeyboardSensor:它是「按住 → 方向鍵搬 → 放開」的抓取模式(https://dndkit.com/api-documentation/sensors/keyboard),
|
|
683
|
+
// 啟動鍵是 Enter / 空白鍵 —— 本元件那兩個鍵是「選取目前列」;每按即 commit 的語意也與抓取模式不同。
|
|
684
|
+
// (2026-09-25 更正:舊理由「列不可聚焦、與虛擬焦點結構性不相容」在列改為真焦點後已不成立。)
|
|
618
685
|
|
|
619
686
|
// 播報用 label 文字:registry label 是 ReactNode — string 直接用,否則退 DOM row textContent。
|
|
620
687
|
const getNodeLabelText = React.useCallback(
|
|
621
688
|
(id: string): string => {
|
|
622
689
|
const info = getNodeInfo(id)
|
|
623
690
|
if (typeof info?.label === 'string') return info.label
|
|
624
|
-
const rowEl = treeRef.current?.querySelector<HTMLElement>(
|
|
691
|
+
const rowEl = treeRef.current?.querySelector<HTMLElement>(rowSelector(id))
|
|
625
692
|
return rowEl?.textContent?.trim() || id
|
|
626
693
|
},
|
|
627
694
|
[getNodeInfo]
|
|
628
695
|
)
|
|
629
696
|
|
|
630
697
|
// code-quality-allow: long-function — 四鍵語意(同層上下 / 移入 / 移出)+ 邊界播報結構緊密,
|
|
631
|
-
// 拆 sub-fn 會跨 fn 傳 siblings / announce state 反而複雜(同
|
|
698
|
+
// 拆 sub-fn 會跨 fn 傳 siblings / announce state 反而複雜(同 handleKeyDownCapture 先例)
|
|
632
699
|
const handleKeyboardReorder = React.useCallback(
|
|
633
|
-
(key:
|
|
700
|
+
(key: RovingReorderKey, sourceId: string) => {
|
|
634
701
|
const tree = treeRef.current
|
|
635
|
-
if (!tree
|
|
636
|
-
const
|
|
637
|
-
const sourceEl = tree.querySelector<HTMLElement>(`[data-tree-id="${sourceId}"]`)
|
|
702
|
+
if (!tree) return
|
|
703
|
+
const sourceEl = tree.querySelector<HTMLElement>(`[data-tree-id="${cssEscape(sourceId)}"]`)
|
|
638
704
|
// disabled node 不可移(鍵盤導覽本就跳過 disabled,此為防禦 guard)
|
|
639
|
-
if (!sourceEl || sourceEl
|
|
705
|
+
if (!sourceEl || !isRowEnabled(rowOf(sourceEl) ?? sourceEl)) return
|
|
640
706
|
|
|
641
707
|
const label = getNodeLabelText(sourceId)
|
|
642
708
|
// 同層 siblings(DOM 序)。focused node 可見 ⇒ 其 parent 已展開 ⇒ 全 siblings 已 mount。
|
|
643
709
|
const parentId = sourceEl.dataset.treeParentId || null
|
|
644
710
|
const visibleItems = Array.from(
|
|
645
|
-
tree.querySelectorAll<HTMLElement>('[
|
|
646
|
-
)
|
|
711
|
+
tree.querySelectorAll<HTMLElement>('[data-tree-id]')
|
|
712
|
+
).filter(isInOpenBranch)
|
|
647
713
|
const siblings = visibleItems.filter((el) => (el.dataset.treeParentId || null) === parentId)
|
|
648
714
|
const sourceIndex = siblings.findIndex((el) => el.dataset.treeId === sourceId)
|
|
649
715
|
if (sourceIndex < 0) return
|
|
650
|
-
const isEnabled = (el: HTMLElement) => el
|
|
716
|
+
const isEnabled = (el: HTMLElement) => isRowEnabled(rowOf(el) ?? el)
|
|
651
717
|
|
|
652
718
|
// 發出與 pointer 路徑同型的 TreeDragEndEvent,並排定 re-render 後 scrollIntoView
|
|
653
719
|
// (既有 effect 只在 isFocused 變化時跑;同 id 移動不 re-fire,故此處補)。
|
|
@@ -657,9 +723,11 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
657
723
|
if (targetId === sourceId || isInSubtree(targetId, sourceId)) return false
|
|
658
724
|
onDragEndProp?.({ sourceId, targetId, position })
|
|
659
725
|
requestAnimationFrame(() => {
|
|
660
|
-
treeRef.current
|
|
661
|
-
|
|
662
|
-
|
|
726
|
+
const movedRow = treeRef.current?.querySelector<HTMLElement>(rowSelector(sourceId))
|
|
727
|
+
movedRow?.scrollIntoView({ block: 'nearest' })
|
|
728
|
+
// 2026-09-25(總帳 B9 改真焦點後):React 搬動 / 重掛那一列時,瀏覽器會把焦點丟到 body ——
|
|
729
|
+
// 鍵盤使用者按完一下就失去位置。焦點還給被移動的那一列(虛擬焦點時代靠 aria-activedescendant 自動跟上)。
|
|
730
|
+
if (movedRow && document.activeElement !== movedRow) movedRow.focus({ preventScroll: true })
|
|
663
731
|
})
|
|
664
732
|
return true
|
|
665
733
|
}
|
|
@@ -755,8 +823,8 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
755
823
|
setReorderAnnouncement(announcements.blocked({ reason: 'root', label }))
|
|
756
824
|
return
|
|
757
825
|
}
|
|
758
|
-
const
|
|
759
|
-
const level = Number(
|
|
826
|
+
const parentRow = tree.querySelector<HTMLElement>(rowSelector(parentId))
|
|
827
|
+
const level = Number(parentRow?.getAttribute('aria-level') ?? '1')
|
|
760
828
|
if (commit(parentId, 'after')) {
|
|
761
829
|
setReorderAnnouncement(
|
|
762
830
|
announcements.movedOut({ label, parentLabel: getNodeLabelText(parentId), level })
|
|
@@ -766,7 +834,7 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
766
834
|
}
|
|
767
835
|
}
|
|
768
836
|
},
|
|
769
|
-
[
|
|
837
|
+
[expandedIds, toggleExpand, onDragEndProp, getNodeLabelText, isInSubtree, announcements]
|
|
770
838
|
)
|
|
771
839
|
|
|
772
840
|
// ── Context value ──
|
|
@@ -777,18 +845,17 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
777
845
|
selectionMode,
|
|
778
846
|
expandOnSelect,
|
|
779
847
|
draggable,
|
|
780
|
-
isKeyboardRef,
|
|
781
|
-
keyboardModality,
|
|
782
|
-
activeDescendantPrefix,
|
|
783
848
|
draggingId,
|
|
784
849
|
dropTarget,
|
|
785
850
|
expandedIds,
|
|
786
851
|
selectedIds,
|
|
787
852
|
focusedId,
|
|
853
|
+
tabStopId,
|
|
788
854
|
toggleExpand,
|
|
789
855
|
select,
|
|
790
856
|
setFocusedId,
|
|
791
|
-
|
|
857
|
+
focusRow,
|
|
858
|
+
onRowUnmount,
|
|
792
859
|
registerNode,
|
|
793
860
|
unregisterNode,
|
|
794
861
|
getNodeInfo,
|
|
@@ -799,157 +866,129 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
799
866
|
selectionMode,
|
|
800
867
|
expandOnSelect,
|
|
801
868
|
draggable,
|
|
802
|
-
isKeyboardRef,
|
|
803
|
-
keyboardModality,
|
|
804
|
-
activeDescendantPrefix,
|
|
805
869
|
draggingId,
|
|
806
870
|
dropTarget,
|
|
807
871
|
expandedIds,
|
|
808
872
|
selectedIds,
|
|
809
873
|
focusedId,
|
|
874
|
+
tabStopId,
|
|
810
875
|
toggleExpand,
|
|
811
876
|
select,
|
|
812
877
|
setFocusedId,
|
|
813
|
-
|
|
878
|
+
focusRow,
|
|
879
|
+
onRowUnmount,
|
|
814
880
|
registerNode,
|
|
815
881
|
unregisterNode,
|
|
816
882
|
getNodeInfo,
|
|
817
883
|
]
|
|
818
884
|
)
|
|
819
885
|
|
|
820
|
-
// ── Keyboard handler ──
|
|
821
|
-
//
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
)
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
886
|
+
// ── Keyboard handler(2026-09-25 總帳 B9:樹狀表格路線;2026-09-26 判定與執行併入共用零件)──
|
|
887
|
+
// 判定 = lib/roving-list-keyboard.ts `resolveRovingKey`(與 Sidebar / FileUpload / Command 同一份按鍵表),
|
|
888
|
+
// 執行 = 同檔 `applyRovingAction`;這裡只讀樹自己的 DOM 狀態(哪一列、展開與否、上一層),以及重排。
|
|
889
|
+
// 用 capture(同 Sidebar):方向鍵整串歸樹,搶在列上按鈕自己的處理之前 —— 列上的選單鈕按 ↓ 換下一列、
|
|
890
|
+
// 不開選單(X6「統一成側欄做法」;開選單用 Enter / 空白鍵)。2026-09-26 之前在冒泡階段,選單鈕的 ↓ 讓給 Radix 開選單。
|
|
891
|
+
// 不包 useCallback:它只掛在一個 DOM 節點上,每次 render 讀最新的 props.onKeyDownCapture / expandedIds(同下方 onFocus)
|
|
892
|
+
const handleKeyDownCapture = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
893
|
+
;(props as React.HTMLAttributes<HTMLDivElement>).onKeyDownCapture?.(e)
|
|
894
|
+
const tree = treeRef.current
|
|
895
|
+
if (!tree) return
|
|
896
|
+
const target = e.target as HTMLElement
|
|
897
|
+
// React 合成事件會穿過 portal 傳遞(例:列上選單鈕打開的選單),DOM 上不在這棵樹裡的一律不管
|
|
898
|
+
if (!tree.contains(target)) return
|
|
899
|
+
const row = target.closest<HTMLElement>(ROW_SELECTOR)
|
|
900
|
+
const id = row?.dataset.treeRow
|
|
901
|
+
if (!row || !id) return
|
|
902
|
+
const onRow = target === row
|
|
903
|
+
const actions = getRowActions(row)
|
|
904
|
+
// 列裡不在動作格的東西(展開箭頭、勾選框:都是 tabIndex=-1 的視覺件):交給它自己
|
|
905
|
+
if (!onRow && !actions.includes(target)) return
|
|
906
|
+
|
|
907
|
+
const wrapper = row.closest<HTMLElement>('[data-tree-id]')
|
|
908
|
+
const parentId = wrapper?.dataset.treeParentId || null
|
|
909
|
+
const action = resolveRovingKey({
|
|
910
|
+
key: e.key,
|
|
911
|
+
focus: onRow ? 'item' : 'control',
|
|
912
|
+
controlCount: actions.length,
|
|
913
|
+
controlIndex: onRow ? -1 : actions.indexOf(target),
|
|
914
|
+
controlIsTextEntry: !onRow && isTextEntryElement(target),
|
|
915
|
+
tree: { hasChildren: wrapper?.dataset.treeHasChildren === 'true', expanded: expandedIds.has(id), hasParent: parentId != null },
|
|
916
|
+
// 打字跳位只在列上(按鈕上打字沒有意義;動作格裡的輸入框由 controlIsTextEntry 保住自己的打字)
|
|
917
|
+
typeahead: onRow,
|
|
918
|
+
// 未啟用拖曳 → 重排組合整組 no-op(modifier 組合不落入導覽)
|
|
919
|
+
reorderable: draggable,
|
|
920
|
+
defaultPrevented: e.defaultPrevented,
|
|
921
|
+
metaKey: e.metaKey,
|
|
922
|
+
ctrlKey: e.ctrlKey,
|
|
923
|
+
altKey: e.altKey,
|
|
924
|
+
shiftKey: e.shiftKey,
|
|
925
|
+
})
|
|
926
|
+
if (action.type === 'reorder') {
|
|
927
|
+
e.preventDefault() // 阻止瀏覽器原生行為(捲動 / 文字選取)
|
|
928
|
+
if (draggingId !== null) return // pointer 拖曳進行中 → 互斥,忽略鍵盤重排
|
|
929
|
+
handleKeyboardReorder(action.key, id)
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
// 看得到的列(DOM 序)。停用列不是方向鍵的落點(既有規則),但焦點可能經 ← 停在停用的上一層,
|
|
933
|
+
// 所以相鄰列以 DOM 序從目前這一列往前 / 往後找第一個可用列(pickRovingTarget)。
|
|
934
|
+
applyRovingAction(action, {
|
|
935
|
+
event: e,
|
|
936
|
+
item: row,
|
|
937
|
+
controls: actions,
|
|
938
|
+
items: Array.from(tree.querySelectorAll<HTMLElement>(ROW_SELECTOR)).filter(isInOpenBranch),
|
|
939
|
+
isNavigable: isRowEnabled,
|
|
940
|
+
focusOptions: { preventScroll: true },
|
|
941
|
+
onExpand: () => toggleExpand(id),
|
|
942
|
+
onCollapse: () => toggleExpand(id),
|
|
943
|
+
onParent: () => { if (parentId) focusRow(parentId) },
|
|
944
|
+
onActivateItem: () => select(id),
|
|
945
|
+
onTypeahead: (char) => typeahead(char, row),
|
|
946
|
+
})
|
|
947
|
+
}
|
|
875
948
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if (!id) break
|
|
908
|
-
const isExpanded = expandedIds.has(id)
|
|
909
|
-
const hasChildren = currentEl?.dataset.treeHasChildren === 'true'
|
|
910
|
-
if (hasChildren && isExpanded) {
|
|
911
|
-
toggleExpand(id)
|
|
912
|
-
} else {
|
|
913
|
-
// 收合狀態或 leaf → 移到 parent
|
|
914
|
-
const parentId = currentEl?.dataset.treeParentId
|
|
915
|
-
if (parentId) setFocusedId(parentId)
|
|
916
|
-
}
|
|
917
|
-
break
|
|
918
|
-
}
|
|
919
|
-
case 'Home': {
|
|
920
|
-
e.preventDefault()
|
|
921
|
-
if (items[0]) setFocusedId(items[0].dataset.treeId ?? null)
|
|
922
|
-
break
|
|
923
|
-
}
|
|
924
|
-
case 'End': {
|
|
925
|
-
e.preventDefault()
|
|
926
|
-
const last = items[items.length - 1]
|
|
927
|
-
if (last) setFocusedId(last.dataset.treeId ?? null)
|
|
928
|
-
break
|
|
929
|
-
}
|
|
930
|
-
case 'Enter':
|
|
931
|
-
case ' ': {
|
|
932
|
-
e.preventDefault()
|
|
933
|
-
const id = currentEl?.dataset.treeId
|
|
934
|
-
if (id) select(id)
|
|
935
|
-
break
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
},
|
|
939
|
-
[focusedId, expandedIds, toggleExpand, select, setFocusedId, draggable, draggingId, handleKeyboardReorder]
|
|
940
|
-
)
|
|
949
|
+
// ── 打字跳位(typeahead;2026-09-29 待辦總帳 N46)──
|
|
950
|
+
// keyboard-model-canonical.md 五條判準第 2 條把「打字前導」列為樹必備的鍵盤模型;規格 tree-view.spec.md「鍵盤導覽」。
|
|
951
|
+
// W3C APG Tree View 逐字:"Type a character: focus moves to the next node with a name that starts with the typed character." /
|
|
952
|
+
// "Type multiple characters in rapid succession: focus moves to the next node with a name that starts with the string of
|
|
953
|
+
// characters typed."(https://www.w3.org/WAI/ARIA/apg/patterns/treeview/)。
|
|
954
|
+
// 名字 = 列的 accessible name(aria-labelledby 指到的 label 文字),比對折疊大小寫;候選 = 看得到、沒停用的列,
|
|
955
|
+
// 從目前這一列的**下一列**往後找、到底再從頭(繞回一圈,最後才是自己 → 只有自己符合就不動)。
|
|
956
|
+
// 連打同一個字母(「bb」)而沒有名字以「bb」開頭時退回單字循環(連按同一鍵 = 在同字母的列之間輪流)。
|
|
957
|
+
// 字串累積窗 1000ms(AI 推導,無外部出處;超過就當重新開始)。判定在 lib/roving-list-keyboard.ts(`typeahead` 格),
|
|
958
|
+
// 這裡只做「找哪一列、搬焦點」。
|
|
959
|
+
const typeahead = (char: string, row: HTMLElement) => {
|
|
960
|
+
const tree = treeRef.current
|
|
961
|
+
if (!tree) return
|
|
962
|
+
const now = Date.now()
|
|
963
|
+
const state = typeaheadRef.current
|
|
964
|
+
const buffer = now - state.at <= TYPEAHEAD_WINDOW_MS ? state.buffer + char : char
|
|
965
|
+
typeaheadRef.current = { buffer, at: now }
|
|
966
|
+
const rows = Array.from(tree.querySelectorAll<HTMLElement>(ROW_SELECTOR)).filter((r) => isInOpenBranch(r) && isRowEnabled(r))
|
|
967
|
+
const nameOf = (r: HTMLElement) => {
|
|
968
|
+
const labelledBy = r.getAttribute('aria-labelledby')
|
|
969
|
+
const label = labelledBy ? document.getElementById(labelledBy) : null
|
|
970
|
+
return (label?.textContent ?? '').trim().toLocaleLowerCase()
|
|
971
|
+
}
|
|
972
|
+
const start = rows.indexOf(row)
|
|
973
|
+
const ordered = [...rows.slice(start + 1), ...rows.slice(0, start + 1)]
|
|
974
|
+
const find = (prefix: string) => ordered.find((r) => nameOf(r).startsWith(prefix))
|
|
975
|
+
const lower = buffer.toLocaleLowerCase()
|
|
976
|
+
const sameChar = lower.length > 1 && [...lower].every((c) => c === lower[0])
|
|
977
|
+
const target = find(lower) ?? (sameChar ? find(lower[0]) : undefined)
|
|
978
|
+
target?.focus({ preventScroll: true })
|
|
979
|
+
}
|
|
941
980
|
|
|
942
981
|
const treeEl = (
|
|
943
982
|
<div
|
|
944
|
-
// {...props} 在最前:內部 role/style(--tree-px)/
|
|
945
|
-
// 誤傳(原 spread 在最後 → consumer style 會整組蓋掉 --tree-px
|
|
983
|
+
// {...props} 在最前:內部 role/style(--tree-px)/onKeyDownCapture 必須勝過 consumer
|
|
984
|
+
// 誤傳(原 spread 在最後 → consumer style 會整組蓋掉 --tree-px、鍵盤 handler 蓋掉鍵盤導覽);
|
|
985
|
+
// consumer 的 onKeyDownCapture 由 handleKeyDownCapture 先呼叫再接手(同 SidebarMenu)
|
|
946
986
|
{...props}
|
|
947
987
|
ref={treeRef}
|
|
948
|
-
|
|
988
|
+
// 2026-09-25 總帳 B9:tree → treegrid。W3C 只在樹狀表格定義了「列上的按鈕」怎麼走
|
|
989
|
+
// (來源見 tree-view.spec.md「A11y 預設」);容器本身不再可聚焦,唯一的 Tab 停靠點是 tabStopId 那一列。
|
|
990
|
+
role="treegrid"
|
|
949
991
|
aria-multiselectable={selectionMode === 'multiple' || undefined}
|
|
950
|
-
// Virtual focus:DOM focus 停在容器(單一 tab stop),aria-activedescendant 指向目前 node
|
|
951
|
-
// 的 DOM id,讓 AT 朗讀目前焦點 node(對齊 WAI-ARIA TreeView APG aria-activedescendant 模式)。
|
|
952
|
-
aria-activedescendant={focusedId ? `${activeDescendantPrefix}treeitem-${focusedId}` : undefined}
|
|
953
992
|
// 鍵盤重排操作說明(sr-only,僅 draggable;與 consumer 傳入的 aria-describedby 合併)
|
|
954
993
|
aria-describedby={
|
|
955
994
|
[props['aria-describedby'], draggable ? reorderInstructionsId : undefined]
|
|
@@ -962,43 +1001,26 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
962
1001
|
// - 在 DropdownMenuContent 內: content py-2 提供
|
|
963
1002
|
// - 獨立使用(story demo): consumer 自己加 py-2
|
|
964
1003
|
// 這樣才能跟 DropdownMenu / MenuGroup 的結構一致(group 是容器,row 是內容)。
|
|
1004
|
+
// 2026-09-25:原本這裡的「有 aria-activedescendant 才抑制容器外框」(@focus-suppress A)隨虛擬焦點一起拿掉 ——
|
|
1005
|
+
// 容器已不可聚焦,框由拿到真焦點的那一列自己畫(TreeItem 列的 focus-visible:focus-ring-inset)。
|
|
965
1006
|
'flex flex-col',
|
|
966
|
-
// 2026-09-07 H1f:DOM 焦點永遠停在這個 role=tree 容器(:369),視覺指示器畫在
|
|
967
|
-
// aria-activedescendant 指到的那一列上。若不抑制,全域 base.css:44-47 會再給容器
|
|
968
|
-
// 畫一圈 +2px 外框 → 同一次互動兩個焦點指示。
|
|
969
|
-
// 條件寫成「有 aria-activedescendant 才抑制」:空樹(找不到任何 treeitem → 該屬性不渲染,
|
|
970
|
-
// 見 :939)時全域框仍會畫,不會變成「聚焦了卻完全沒有指示」。
|
|
971
|
-
// @focus-suppress A — A 虛擬游標;承擔者:指示器畫在 aria-activedescendant 指到的那一列(showRing → focus-ring-inset,:1381)
|
|
972
|
-
'[&[aria-activedescendant]:focus-visible]:outline-none',
|
|
973
1007
|
className,
|
|
974
1008
|
)}
|
|
975
1009
|
style={{
|
|
976
1010
|
['--tree-px' as string]: CONTEXT_PX_VAR[context],
|
|
977
1011
|
...props.style,
|
|
978
1012
|
} as React.CSSProperties}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
// a11y APG:Tab 進入時初始化 virtual focus(優先 selected,其次第一個可用節點);
|
|
982
|
-
// 原本無 onFocus init → focusedId=null → aria-activedescendant undefined,AT 讀不到目前節點
|
|
1013
|
+
onKeyDownCapture={handleKeyDownCapture}
|
|
1014
|
+
// 焦點落在任何一列或列裡的按鈕 → 記下是哪一列(它就是下一次 Tab 進來的落點;總帳 B9)
|
|
983
1015
|
onFocus={(e) => {
|
|
984
1016
|
;(props as React.HTMLAttributes<HTMLDivElement>).onFocus?.(e)
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
//
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
// 滑鼠按下進場時它不成立(且 mousedown 已先把 ref 設回 false),鍵盤進場才成立。
|
|
992
|
-
// 2026-09-08:Tab 進場的鍵盤模態改由 useInputModality 判定(Tab 的 keydown 在 document
|
|
993
|
-
// capture 就被記成鍵盤,不再依賴 :focus-visible 補位)。
|
|
994
|
-
if (e.target === e.currentTarget && !focusedId && treeRef.current) {
|
|
995
|
-
const first =
|
|
996
|
-
treeRef.current.querySelector<HTMLElement>('[role="treeitem"][aria-selected="true"]:not([hidden]):not([aria-disabled="true"])') ??
|
|
997
|
-
treeRef.current.querySelector<HTMLElement>('[role="treeitem"]:not([hidden]):not([aria-disabled="true"])')
|
|
998
|
-
if (first) setFocusedId(first.dataset.treeId ?? null)
|
|
999
|
-
}
|
|
1017
|
+
const tree = treeRef.current
|
|
1018
|
+
const target = e.target as HTMLElement
|
|
1019
|
+
// React 合成的 focus 事件也會穿過 portal 冒泡;只認 DOM 上在這棵樹裡的
|
|
1020
|
+
if (!tree || !tree.contains(target)) return
|
|
1021
|
+
const id = target.closest<HTMLElement>(ROW_SELECTOR)?.dataset.treeRow
|
|
1022
|
+
if (id && id !== focusedIdRef.current) setFocusedId(id)
|
|
1000
1023
|
}}
|
|
1001
|
-
tabIndex={0}
|
|
1002
1024
|
>
|
|
1003
1025
|
{children}
|
|
1004
1026
|
</div>
|
|
@@ -1017,14 +1039,16 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
|
1017
1039
|
onDragOver={handleDragOver}
|
|
1018
1040
|
onDragEnd={handleDragEnd}
|
|
1019
1041
|
onDragCancel={handleDragCancel}
|
|
1020
|
-
accessibility={
|
|
1042
|
+
accessibility={drag.accessibility}
|
|
1021
1043
|
>
|
|
1022
1044
|
{treeEl}
|
|
1045
|
+
{/* 指標拖曳播報的 polite 區域(sr-only;lib/drag-announcements.ts useDragAccessibility);鍵盤重排另有下方自有區域 */}
|
|
1046
|
+
{drag.liveRegion}
|
|
1023
1047
|
{draggable && (
|
|
1024
1048
|
<>
|
|
1025
1049
|
{/* 鍵盤重排 SR 播報 — TreeView 自有 sr-only polite live region(單一節點覆寫式更新;
|
|
1026
|
-
消費 select-menu.tsx SelectMenuLiveStatus 先例)。渲染在 role="
|
|
1027
|
-
(
|
|
1050
|
+
消費 select-menu.tsx SelectMenuLiveStatus 先例)。渲染在 role="treegrid" 之外
|
|
1051
|
+
(treegrid 的合法 children 只有 row / rowgroup);dnd-kit DndContext 內建 live region
|
|
1028
1052
|
只播 dnd-kit drag session,自建鍵盤路徑觸不到,故必須自有。 */}
|
|
1029
1053
|
<div role="status" aria-live="polite" className="sr-only">
|
|
1030
1054
|
{reorderAnnouncement}
|
|
@@ -1072,9 +1096,8 @@ const treeItemVariants = cva(
|
|
|
1072
1096
|
'flex items-start gap-2 w-full',
|
|
1073
1097
|
'cursor-pointer select-none',
|
|
1074
1098
|
// hover 底色瞬間切換,不做過渡(user 2026-09-10 拍板「第三題改成全部瞬間」;SSOT = tokens/motion/motion.spec.md「hover 回饋不做過渡」)
|
|
1075
|
-
// 2026-09-07 刪 `outline-none
|
|
1076
|
-
//
|
|
1077
|
-
// (tabIndex 在 li 上且為 -1),不需要防禦性抑制。
|
|
1099
|
+
// 2026-09-07 刪 `outline-none`。2026-09-25 起這一列就是真焦點(roving tabindex,總帳 B9),
|
|
1100
|
+
// 焦點框由下方 TreeItem 的 `focus-visible:focus-ring-inset` 畫,更不能加 outline-none。
|
|
1078
1101
|
// Label 字重 500(跟 SidebarMenuButton 一致)
|
|
1079
1102
|
'font-medium',
|
|
1080
1103
|
],
|
|
@@ -1103,8 +1126,8 @@ export interface TreeItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>
|
|
|
1103
1126
|
/**
|
|
1104
1127
|
* Checkbox(多選模式,label 前方)。傳入 ReactNode(Checkbox 元件)。
|
|
1105
1128
|
* 位置:在 chevron 之後、indicator/icon 之前。
|
|
1106
|
-
* 此 slot
|
|
1107
|
-
* 強制正規化為 `aria-hidden` + `tabIndex=-1`,不形成第二個
|
|
1129
|
+
* 此 slot 僅是列 selection 的視覺鏡像;有效 React element 會由 TreeItem
|
|
1130
|
+
* 強制正規化為 `aria-hidden` + `tabIndex=-1`,不形成第二個 Tab 停靠點。
|
|
1108
1131
|
* 單選模式通常不需要(用 bg-neutral-selected 表達選中)。
|
|
1109
1132
|
*/
|
|
1110
1133
|
checkbox?: React.ReactNode
|
|
@@ -1117,6 +1140,7 @@ export interface TreeItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>
|
|
|
1117
1140
|
* - Icon 尺寸 = `ICON_SIZE[treeViewSize]`(自動)
|
|
1118
1141
|
* - Hover bg、tooltip、aria-label、cursor-pointer 自動處理
|
|
1119
1142
|
* - **不可以**手刻 button JSX(canonical 實作在 `patterns/element-anatomy/item-anatomy.tsx` `ItemInlineAction`)
|
|
1143
|
+
* - 鍵盤:按鈕不在 Tab 路上;焦點在列上按 → 進第一顆、→ / ← 在按鈕間走、第一顆再 ← 回列(tree-view.spec.md「鍵盤導覽」)
|
|
1120
1144
|
*
|
|
1121
1145
|
* ```tsx
|
|
1122
1146
|
* <TreeItem
|
|
@@ -1142,6 +1166,8 @@ export interface TreeItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>
|
|
|
1142
1166
|
* (如 DropdownMenu trigger / 自訂 popover / 多 tier 動作)。
|
|
1143
1167
|
*
|
|
1144
1168
|
* 跟 `inlineActions` 互斥(同時傳 `inlineActionsSlot` 會優先,`inlineActions` 被忽略)。
|
|
1169
|
+
* slot 裡每一個可聚焦的元素都會被設成 `tabIndex=-1`、改由 → / ← 走到,而且**每一個都必須有可讀名稱**
|
|
1170
|
+
* (aria-label / 可見文字;缺的話 dev 模式 console.warn)。
|
|
1145
1171
|
*
|
|
1146
1172
|
* 規則對齊 Input.endSlot canonical:90% case 用 `inlineActions` 宣告式 API,
|
|
1147
1173
|
* 10% config 表達不出時走 slot。視覺一致性由 consumer 負責(可使用 host 內部 helper
|
|
@@ -1150,7 +1176,8 @@ export interface TreeItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>
|
|
|
1150
1176
|
inlineActionsSlot?: React.ReactNode
|
|
1151
1177
|
/**
|
|
1152
1178
|
* Inline actions 的顯示模式:
|
|
1153
|
-
* - `"hover"`(預設)
|
|
1179
|
+
* - `"hover"`(預設):列被滑過、或鍵盤焦點在這一列(列本身或列裡的按鈕)時才出現,瞬間出現、不淡入
|
|
1180
|
+
* (待辦總帳 L9;規則住 ItemSuffix `hoverReveal`,見 tree-view.spec.md「Hover-only Inline Actions」)
|
|
1154
1181
|
* - `false`:常駐顯示
|
|
1155
1182
|
*
|
|
1156
1183
|
* 對齊 `SidebarMenuButton.actionsReveal`,同一套規則。
|
|
@@ -1180,25 +1207,31 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1180
1207
|
expandedIds,
|
|
1181
1208
|
selectedIds,
|
|
1182
1209
|
focusedId,
|
|
1210
|
+
tabStopId,
|
|
1183
1211
|
draggingId,
|
|
1184
1212
|
dropTarget,
|
|
1185
1213
|
toggleExpand,
|
|
1186
1214
|
select,
|
|
1187
1215
|
setFocusedId,
|
|
1188
|
-
|
|
1216
|
+
focusRow,
|
|
1217
|
+
onRowUnmount,
|
|
1189
1218
|
registerNode,
|
|
1190
1219
|
unregisterNode,
|
|
1191
|
-
keyboardModality,
|
|
1192
|
-
activeDescendantPrefix,
|
|
1193
1220
|
} = ctx
|
|
1194
1221
|
|
|
1195
1222
|
const hasChildren = React.Children.count(children) > 0
|
|
1196
1223
|
const isExpanded = expandedIds.has(id)
|
|
1197
1224
|
const isSelected = selectedIds.has(id)
|
|
1198
1225
|
const isFocused = focusedId === id
|
|
1199
|
-
|
|
1226
|
+
// 整棵樹唯一的 Tab 停靠點(roving tabindex;總帳 B9)
|
|
1227
|
+
const isTabStop = tabStopId === id
|
|
1200
1228
|
const isDragging = draggingId === id
|
|
1201
1229
|
const isDropTarget = dropTarget?.id === id
|
|
1230
|
+
// 列名只取 label(aria-labelledby 指過來)。用 useId 而非 node id 組:node id 可能含空白,會把 IDREF 清單拆開
|
|
1231
|
+
const labelId = React.useId()
|
|
1232
|
+
// 沿用舊的真值判斷(slot 傳 false / '' 視同沒傳,改走 inlineActions)
|
|
1233
|
+
const hasSlot = Boolean(inlineActionsSlot)
|
|
1234
|
+
const hasActionCell = !disabled && (hasSlot || (inlineActions?.length ?? 0) > 0)
|
|
1202
1235
|
const visualCheckbox = checkbox && React.isValidElement<Record<string, unknown>>(checkbox)
|
|
1203
1236
|
? React.cloneElement(checkbox, { 'aria-hidden': true, tabIndex: -1 })
|
|
1204
1237
|
: checkbox
|
|
@@ -1224,43 +1257,105 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1224
1257
|
return () => unregisterNode(id)
|
|
1225
1258
|
}, [id, parentId, hasChildren, label, Icon, registerNode, unregisterNode])
|
|
1226
1259
|
|
|
1227
|
-
//
|
|
1260
|
+
// 卸載時通知 TreeView 重選 Tab 停靠點(總帳 B9;見 TreeView onRowUnmount)
|
|
1261
|
+
React.useEffect(() => () => onRowUnmount(id), [id, onRowUnmount])
|
|
1262
|
+
|
|
1263
|
+
// ── Refs ──
|
|
1228
1264
|
const itemRef = React.useRef<HTMLDivElement>(null)
|
|
1229
1265
|
React.useImperativeHandle(ref, () => itemRef.current!)
|
|
1266
|
+
const rowRef = React.useRef<HTMLDivElement | null>(null)
|
|
1267
|
+
const actionsRef = React.useRef<HTMLSpanElement | null>(null)
|
|
1230
1268
|
|
|
1269
|
+
// ── Focus scroll into view ──(捲的是列本身;原本捲 wrapper,展開的資料夾 wrapper 含整個子樹)
|
|
1231
1270
|
React.useEffect(() => {
|
|
1232
|
-
if (isFocused &&
|
|
1233
|
-
|
|
1271
|
+
if (isFocused && rowRef.current) {
|
|
1272
|
+
rowRef.current.scrollIntoView({ block: 'nearest' })
|
|
1234
1273
|
}
|
|
1235
1274
|
}, [isFocused])
|
|
1236
1275
|
|
|
1276
|
+
// ── 列被刪掉時焦點去下一列(2026-09-29 待辦總帳 N46「刪除 / 消失後焦點去下一項」)──
|
|
1277
|
+
// consumer 從資料裡拿掉這個節點時,焦點若在這一列或它的按鈕上,搬到**下一列**;沒有下一列 → **上一列**(上一列可能就是父節點)。
|
|
1278
|
+
// 落點順序同 FileUpload focusAfterFileRemoval(下一個 → 上一個 → 擁有者)。用 layout effect 的 cleanup:它在 React 把這棵子樹從 DOM
|
|
1279
|
+
// 拆掉之前跑,鄰列都還在、焦點還在列上;passive effect 的 cleanup 跑的時候節點已不在 DOM、焦點早掉到 body。真正搬焦點放到 microtask
|
|
1280
|
+
//(commit 同步結束之後):那時列真的不在 DOM 了才搬 —— StrictMode 開發模式會多跑一次 cleanup 再 re-mount,那一次列還在,不搬。
|
|
1281
|
+
// 收合中的分支(Collapsible 關閉動畫結束後才卸載、期間 data-state="closed")不算候選:整段子樹都在拆,落點是上一列(= 收合的父節點)。
|
|
1282
|
+
// 整棵樹一起卸載時候選全空 / 目標也已離線,focus() 是 no-op。
|
|
1283
|
+
React.useLayoutEffect(() => () => {
|
|
1284
|
+
const row = rowRef.current
|
|
1285
|
+
const active = document.activeElement
|
|
1286
|
+
if (!row || !active || !row.contains(active)) return
|
|
1287
|
+
const tree = row.closest<HTMLElement>('[role="treegrid"]')
|
|
1288
|
+
const wrapper = row.closest<HTMLElement>('[data-tree-id]')
|
|
1289
|
+
if (!tree || !wrapper) return
|
|
1290
|
+
const rows = Array.from(tree.querySelectorAll<HTMLElement>(ROW_SELECTOR))
|
|
1291
|
+
.filter((r) => !wrapper.contains(r) && isInOpenBranch(r) && isRowEnabled(r))
|
|
1292
|
+
const after = rows.find((r) => (wrapper.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0)
|
|
1293
|
+
const before = [...rows].reverse().find((r) => (wrapper.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_PRECEDING) !== 0)
|
|
1294
|
+
const target = after ?? before
|
|
1295
|
+
if (!target) return
|
|
1296
|
+
queueMicrotask(() => {
|
|
1297
|
+
if (row.isConnected) return
|
|
1298
|
+
const now = document.activeElement
|
|
1299
|
+
if (now && now !== document.body && now.isConnected) return // 有人(consumer 的對話框等)已經接走焦點
|
|
1300
|
+
target.focus({ preventScroll: true })
|
|
1301
|
+
})
|
|
1302
|
+
}, [])
|
|
1303
|
+
|
|
1304
|
+
// ── 列上的按鈕不在 Tab 路上(總帳 B9;tree-view.spec.md「鍵盤導覽」)──
|
|
1305
|
+
// 動作格裡每一個可聚焦的東西都設 tabIndex=-1,改由 → / ← 走到。用 DOM 設而不是逐一傳 prop:
|
|
1306
|
+
// inlineActionsSlot 是 consumer 的任意 ReactNode(選單觸發鈕、連結…),拿不到它們的 props;
|
|
1307
|
+
// MutationObserver 接住 slot 內容之後才掛上 / 自己改回 tabindex 的情況。
|
|
1308
|
+
React.useLayoutEffect(() => {
|
|
1309
|
+
const cell = actionsRef.current
|
|
1310
|
+
if (!cell) return
|
|
1311
|
+
const enforce = () => {
|
|
1312
|
+
const controls = cell.querySelectorAll<HTMLElement>(ROVING_CONTROL_SELECTOR)
|
|
1313
|
+
removeRovingControlsFromTabOrder(controls)
|
|
1314
|
+
// 每顆列上按鈕都要有可讀名稱(總帳 B9 驗收;inlineActions 由 ItemInlineAction 的 aria-label 保證,這裡管 slot)
|
|
1315
|
+
if (process.env.NODE_ENV === 'production') return
|
|
1316
|
+
controls.forEach((el) => {
|
|
1317
|
+
if (!hasAccessibleName(el) && !warnedUnnamedAction.has(el)) {
|
|
1318
|
+
warnedUnnamedAction.add(el)
|
|
1319
|
+
console.warn(`[DS] TreeItem「${id}」:inlineActionsSlot 裡有一個可聚焦元素沒有可讀名稱 —— 請加 aria-label 或可見文字。`, el)
|
|
1320
|
+
}
|
|
1321
|
+
})
|
|
1322
|
+
}
|
|
1323
|
+
enforce()
|
|
1324
|
+
const observer = new MutationObserver(enforce)
|
|
1325
|
+
observer.observe(cell, { subtree: true, childList: true, attributes: true, attributeFilter: ['tabindex'] })
|
|
1326
|
+
return () => observer.disconnect()
|
|
1327
|
+
}, [hasActionCell, id])
|
|
1328
|
+
|
|
1237
1329
|
// ── Handlers ──
|
|
1238
1330
|
const handleRowClick = React.useCallback(
|
|
1239
1331
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
1240
1332
|
onClick?.(e)
|
|
1241
1333
|
if (e.defaultPrevented || disabled) return
|
|
1242
1334
|
e.stopPropagation()
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1335
|
+
// 焦點:點在列上 → 列拿到焦點(roving tabindex 的停靠點跟著搬);
|
|
1336
|
+
// 點在列上的按鈕(或對它按 Enter 合成的 click)→ 焦點留在那顆按鈕上,不搶(總帳 B9)。
|
|
1337
|
+
// 選取行為沿用既有(點到按鈕也會選取該列,本次不改;見 spec「展開/收合」)。
|
|
1338
|
+
const fromActionCell = (e.target as HTMLElement).closest('[data-tree-actions]')
|
|
1339
|
+
if (!fromActionCell) focusRow(id)
|
|
1246
1340
|
setFocusedId(id)
|
|
1247
1341
|
select(id)
|
|
1248
1342
|
if (expandOnSelect && hasChildren) {
|
|
1249
1343
|
toggleExpand(id)
|
|
1250
1344
|
}
|
|
1251
1345
|
},
|
|
1252
|
-
[id, disabled, onClick,
|
|
1346
|
+
[id, disabled, onClick, focusRow, select, setFocusedId, expandOnSelect, hasChildren, toggleExpand]
|
|
1253
1347
|
)
|
|
1254
1348
|
|
|
1255
1349
|
const handleChevronClick = React.useCallback(
|
|
1256
1350
|
(e: React.MouseEvent) => {
|
|
1257
1351
|
e.stopPropagation()
|
|
1258
1352
|
if (disabled) return
|
|
1259
|
-
|
|
1353
|
+
// 滑鼠點展開箭頭時瀏覽器會把焦點給這顆(tabIndex=-1)按鈕 —— 還給列,鍵盤接著從這一列走
|
|
1354
|
+
focusRow(id)
|
|
1260
1355
|
setFocusedId(id)
|
|
1261
1356
|
toggleExpand(id)
|
|
1262
1357
|
},
|
|
1263
|
-
[id, disabled,
|
|
1358
|
+
[id, disabled, focusRow, setFocusedId, toggleExpand]
|
|
1264
1359
|
)
|
|
1265
1360
|
|
|
1266
1361
|
// dnd-kit PointerSensor listener 與 consumer DOM callback 必安全 compose:consumer 先收到
|
|
@@ -1285,27 +1380,29 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1285
1380
|
|
|
1286
1381
|
// ── Chevron(永遠存在:expandable = 旋轉箭頭;leaf = placeholder 佔位) ──
|
|
1287
1382
|
// 消費 `<ItemPrefix>` SSOT — 永遠 h-[1lh] 對齊 label 第一行中線(item-anatomy 對應)。
|
|
1288
|
-
// forced width 透過 style 鎖 chevron 槽寬,讓 sibling label 起點水平對齊(無 chevron leaf
|
|
1383
|
+
// forced width 透過 style 鎖 chevron 槽寬,讓 sibling label 起點水平對齊(無 chevron leaf 佔位同寬;縮排不變)。
|
|
1384
|
+
// 箭頭本身 = 共用行內小按鈕 `ItemInlineActionButton`(2026-09-26,待辦總帳 L5,user:「是改成inline action對吧?」;
|
|
1385
|
+
// 依據 inline-action.spec.md「尺寸對照」TreeItem 列 + hit-area-canonical.md:滑過底色 18(lg 22)= 點得到的範圍,
|
|
1386
|
+
// 盒與排版佔位仍是圖示尺寸 16 / 20,多出的 1px 靠溢出;按下多一階 neutral-active)。與 DataTable 巢狀列的展開箭頭同一顆
|
|
1387
|
+
//(data-table.tsx nestedPrefix)。2026-09-26 之前這裡手刻一顆 16×16 的 <button>,滑過底色與點擊範圍都只有 16。
|
|
1388
|
+
// 鍵盤用 → / ← 展開收合(列上的 aria-expanded),箭頭本身不在 Tab 路上(tabIndex -1),**但保留名字與角色、不 aria-hidden**
|
|
1389
|
+
//(2026-09-29 待辦總帳 N46):語音控制使用者喊「展開」才按得到、讀屏在列裡也叫得到它;與 DataTable 巢狀列的展開箭頭同一種寫法
|
|
1390
|
+
//(data-table.tsx nestedPrefix:aria-label 展開 / 收合 + aria-expanded)。2026-09-29 前寫 aria-hidden,一顆能聚焦的按鈕對讀屏隱藏
|
|
1391
|
+
// 也是 axe aria-hidden-focus 那一族。
|
|
1289
1392
|
const chevronSlot = (
|
|
1290
1393
|
<ItemPrefix style={{ width: iconPx }}>
|
|
1291
1394
|
{hasChildren ? (
|
|
1292
|
-
<
|
|
1293
|
-
|
|
1395
|
+
<ItemInlineActionButton
|
|
1396
|
+
icon={ChevronRight}
|
|
1294
1397
|
tabIndex={-1}
|
|
1398
|
+
aria-label={isExpanded ? '收合' : '展開'}
|
|
1399
|
+
aria-expanded={isExpanded}
|
|
1295
1400
|
onClick={handleChevronClick}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
isExpanded && 'rotate-90',
|
|
1302
|
-
disabled && 'text-fg-disabled pointer-events-none',
|
|
1303
|
-
)}
|
|
1304
|
-
style={{ width: iconPx, height: iconPx }}
|
|
1305
|
-
aria-hidden
|
|
1306
|
-
>
|
|
1307
|
-
<ChevronRight size={iconPx} />
|
|
1308
|
-
</button>
|
|
1401
|
+
// 展開 / 收合的旋轉是狀態切換的動畫,不是滑過回饋 → 保留(motion.spec.md「hover 回饋不做過渡」只管滑過;
|
|
1402
|
+
// 滑過底色與圖示色由 ItemInlineActionButton 瞬間切換)
|
|
1403
|
+
iconClassName={cn('transition-transform duration-150 motion-reduce:duration-0', isExpanded && 'rotate-90')}
|
|
1404
|
+
className={disabled ? 'text-fg-disabled pointer-events-none' : undefined}
|
|
1405
|
+
/>
|
|
1309
1406
|
) : (
|
|
1310
1407
|
// Leaf placeholder
|
|
1311
1408
|
<span style={{ width: iconPx }} aria-hidden />
|
|
@@ -1321,21 +1418,11 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1321
1418
|
if (typeof ref === 'function') ref(node)
|
|
1322
1419
|
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
1323
1420
|
}}
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
id={`${activeDescendantPrefix}treeitem-${id}`}
|
|
1327
|
-
role="treeitem"
|
|
1328
|
-
aria-expanded={hasChildren ? isExpanded : undefined}
|
|
1329
|
-
aria-selected={selectionMode !== 'none' ? isSelected : undefined}
|
|
1330
|
-
aria-level={depth + 1}
|
|
1331
|
-
aria-disabled={disabled || undefined}
|
|
1332
|
-
// 2026-07-05 D4 修:aria-roledescription 移到 treeitem(有 role 的元素才合法)—
|
|
1333
|
-
// 供 AT 提示此 node 可拖曳;值消費 dnd-kit attributes SSOT(預設 'draggable')。
|
|
1334
|
-
aria-roledescription={draggable && !disabled ? dragAttrs['aria-roledescription'] : undefined}
|
|
1421
|
+
// node wrapper(2026-09-25 總帳 B9):無角色 —— 樹狀表格的列不得包住子列,角色與 aria-* 都搬到下面的 row。
|
|
1422
|
+
// data-tree-* 留在這裡給內部查詢 / 拖曳 / 閘使用。
|
|
1335
1423
|
data-tree-id={id}
|
|
1336
1424
|
data-tree-parent-id={parentId ?? ''}
|
|
1337
1425
|
data-tree-has-children={hasChildren}
|
|
1338
|
-
tabIndex={-1}
|
|
1339
1426
|
className={cn('w-full min-w-0 relative', isDragging && dragSourceClass)}
|
|
1340
1427
|
>
|
|
1341
1428
|
{/* Drop indicator — before:水平 2px primary line(指 SSOT drag-visual.ts);
|
|
@@ -1351,10 +1438,25 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1351
1438
|
<div
|
|
1352
1439
|
{...props}
|
|
1353
1440
|
ref={(node) => {
|
|
1354
|
-
// 合併 drag + drop ref 到同一個 element
|
|
1441
|
+
// 合併 drag + drop + 本地 ref 到同一個 element
|
|
1442
|
+
rowRef.current = node
|
|
1355
1443
|
if (draggable) setDragRef(node)
|
|
1356
1444
|
setDropRef(node)
|
|
1357
1445
|
}}
|
|
1446
|
+
// ── 樹狀表格的列(2026-09-25 總帳 B9)──
|
|
1447
|
+
// role / aria-* / tabIndex 放在 {...props} 之後:內部語意必須勝過 consumer 誤傳。
|
|
1448
|
+
role="row"
|
|
1449
|
+
aria-level={depth + 1}
|
|
1450
|
+
aria-expanded={hasChildren ? isExpanded : undefined}
|
|
1451
|
+
aria-selected={selectionMode !== 'none' ? isSelected : undefined}
|
|
1452
|
+
aria-disabled={disabled || undefined}
|
|
1453
|
+
// 列的名稱只取 label —— 不讓動作按鈕的 aria-label 併進列名(否則念成「Alice 重新命名 刪除」)
|
|
1454
|
+
aria-labelledby={labelId}
|
|
1455
|
+
// 2026-07-05 D4 修:aria-roledescription 放在有角色的元素上(原 treeitem,現 row)—
|
|
1456
|
+
// 供 AT 提示此 node 可拖曳;值消費 dnd-kit attributes SSOT(預設 'draggable')。
|
|
1457
|
+
aria-roledescription={draggable && !disabled ? dragAttrs['aria-roledescription'] : undefined}
|
|
1458
|
+
// roving tabindex:整棵樹只有停靠點那一列是 0,其餘列與所有列上按鈕都是 -1(總帳 B9)
|
|
1459
|
+
tabIndex={isTabStop ? 0 : -1}
|
|
1358
1460
|
data-tree-row={id}
|
|
1359
1461
|
className={cn(
|
|
1360
1462
|
'group/tree-item',
|
|
@@ -1373,13 +1475,7 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1373
1475
|
!disabled && 'hover:bg-neutral-hover hover:text-foreground',
|
|
1374
1476
|
// 2026-08-11 修偏移(SSOT = item-anatomy「選中 × 互動疊加」):先前 hover:bg-neutral-hover
|
|
1375
1477
|
//(0,2,0)蓋掉無修飾的 bg-neutral-selected(0,1,0)→ 選中列 hover 反而變淺 = bug。
|
|
1376
|
-
// 釘住 hover 不變(twMerge 同組後者勝)
|
|
1377
|
-
// 2026-09-06:移除 `focus-visible:bg-neutral-selected-focus`。TreeView 是**虛擬焦點**元件
|
|
1378
|
-
//(tree 根 tabIndex=0 :959 / treeitem tabIndex=-1 :1292 / 本 row div 無 tabIndex,
|
|
1379
|
-
// 全 DS 131 處 <TreeItem> 用法 0 處傳 tabIndex),該列永遠不是 DOM 焦點,
|
|
1380
|
-
// `:focus-visible` 恆不 match —— 那行自 2026-08-11 加入起從未生效。
|
|
1381
|
-
// 而且本元件的鍵盤游標**已經由下一行的 ring 表達**(showRing,:1154),再深一階是多餘的。
|
|
1382
|
-
// 判準見 item-anatomy.spec.md「虛擬焦點以 not-hover: 分流、真焦點用 focus-visible:」。
|
|
1478
|
+
// 釘住 hover 不變(twMerge 同組後者勝)。鍵盤焦點不改底色,只畫框(focus-canonical 規則二)。
|
|
1383
1479
|
!disabled && isSelected && selectionMode === 'single' && 'bg-neutral-selected hover:bg-neutral-selected',
|
|
1384
1480
|
// 落點底色排在所有其他 `bg-*` 之後 —— twMerge 同組後者勝。
|
|
1385
1481
|
// 2026-09-06 修:原本排在 `hover:bg-neutral-hover` 與 `bg-neutral-selected` **之前**,
|
|
@@ -1389,12 +1485,12 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1389
1485
|
// 游標又必然停在該列上,落點底色一樣看不到。`dropIndicatorInside` 自帶 `hover:` 同色治 (2),
|
|
1390
1486
|
// 這行的位置治 (1)。
|
|
1391
1487
|
isDropTarget && dropTarget?.position === 'inside' && dropIndicatorInside,
|
|
1392
|
-
// 2026-09-
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
|
|
1488
|
+
// 焦點框(2026-09-25 總帳 B9:虛擬游標 showRing → 真焦點 focus-visible):
|
|
1489
|
+
// 這一列現在是真 DOM 焦點,「這次要不要畫」交給瀏覽器的 :focus-visible(滑鼠點 → 不畫、鍵盤 → 畫),
|
|
1490
|
+
// 與 Sidebar 選單鈕同一種寫法(focus-canonical「真焦點用 focus-visible:」)。
|
|
1491
|
+
// 往內畫(outline 通道,base.css @utility):列撐滿容器寬,往外會被裁。
|
|
1492
|
+
// 只在列**本身**是焦點時畫;焦點在列上的按鈕時由那顆按鈕自己畫框,列不再多畫一個。
|
|
1493
|
+
'focus-visible:focus-ring-inset',
|
|
1398
1494
|
disabled && 'pointer-events-none text-fg-disabled cursor-default',
|
|
1399
1495
|
className,
|
|
1400
1496
|
)}
|
|
@@ -1408,70 +1504,78 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1408
1504
|
}}
|
|
1409
1505
|
// 2026-07-05 D4 修:只 spread listeners,不 spread dnd-kit attributes —
|
|
1410
1506
|
// useDraggable 預設 attributes 注入 role="button" + tabIndex=0 + aria-pressed +
|
|
1411
|
-
// aria-describedby(鍵盤拖曳指示):role=button
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1415
|
-
// 鍵盤重排(2026-07-14 v1)走容器 handleKeyDown 的 Cmd/Ctrl+Shift+Arrow 分支,非 dnd-kit sensor。
|
|
1416
|
-
// aria-roledescription 保留在上方 treeitem 元素(有 role 才合法)。
|
|
1507
|
+
// aria-describedby(鍵盤拖曳指示):role=button 會蓋掉列的語意、tabIndex=0 會讓每一列都變成
|
|
1508
|
+
// Tab 停靠點(破壞整棵樹只佔一站);sensors 僅 PointerSensor(無 KeyboardSensor),這些 attrs 換不到鍵盤拖曳能力;
|
|
1509
|
+
// 鍵盤重排(2026-07-14 v1)走容器 handleKeyDownCapture 的 Cmd/Ctrl+Shift+Arrow 分支,非 dnd-kit sensor。
|
|
1510
|
+
// aria-roledescription 保留在上方列元素(有 role 才合法)。
|
|
1417
1511
|
{...(draggable ? composedDragListeners : {})}
|
|
1418
1512
|
onClick={handleRowClick}
|
|
1419
1513
|
>
|
|
1420
|
-
{
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
{
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
{
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1514
|
+
{/* 第一格:chevron / checkbox / indicator 或 icon / label(樹狀表格 row 只能擁有格,2026-09-25 總帳 B9) */}
|
|
1515
|
+
<div role="gridcell" className="flex flex-1 min-w-0 items-start gap-2">
|
|
1516
|
+
{chevronSlot}
|
|
1517
|
+
|
|
1518
|
+
{/* Checkbox 在 icon 前——消費 `<ItemPrefix>` 對齊第一行
|
|
1519
|
+
* 2026-05-26 SSOT lock(user explicit「多選的方式應該也是要跟 menu 一樣是出現 checkbox」):
|
|
1520
|
+
* - selectionMode='multiple' + 無 consumer checkbox prop → auto-render `<Checkbox>` reflect selectedIds
|
|
1521
|
+
* (對齊 SelectMenu multi pattern;consumer 不用手寫 checkbox)
|
|
1522
|
+
* - selectionMode='multiple' + consumer 傳 checkbox → 用 consumer 的(parent-child cascade 等 advanced)
|
|
1523
|
+
* - selectionMode='single' / 'none' → 不 render checkbox(text-foreground + bg 雙信號表 selected)
|
|
1524
|
+
* 對齊 cite:menu-item.tsx:194-195(MenuItem selected bg)+ select-menu.tsx:352-354(SelectMenu multi=checkbox) */}
|
|
1525
|
+
{(checkbox || selectionMode === 'multiple') && (
|
|
1526
|
+
<ItemPrefix className="pointer-events-none">
|
|
1527
|
+
{/* 2026-07-05 D4 修:auto-render Checkbox 補 tabIndex={-1} — 勾選框的 root 是原生 button
|
|
1528
|
+
* (預設可聚焦),aria-hidden + 可聚焦 = axe aria-hidden-focus,且會多出 Tab 停靠點
|
|
1529
|
+
* (tree-view.spec.md「A11y 預設」Focus 段);ItemPrefix pointer-events-none 只擋滑鼠不擋鍵盤。
|
|
1530
|
+
* 選取語意由列的 aria-selected 承載,checkbox 純視覺反映。 */}
|
|
1531
|
+
{visualCheckbox || <Checkbox checked={isSelected} disabled={disabled} aria-hidden="true" tabIndex={-1} />}
|
|
1532
|
+
</ItemPrefix>
|
|
1533
|
+
)}
|
|
1534
|
+
|
|
1535
|
+
{/* indicator 取代 icon 的位置;h-[1lh] 對齊第一行
|
|
1536
|
+
indicator 是 escape hatch(stepper status dot 等客製內容),消費 `<ItemPrefix>` 鎖 chevron 槽寬;
|
|
1537
|
+
Icon 走 canonical `<ItemIcon>` helper——自動標 data-prefix-type="icon",
|
|
1538
|
+
讓 SidebarProvider 的全域 :has() prefix-mix 偵測能命中。 */}
|
|
1539
|
+
{indicator ? (
|
|
1540
|
+
<ItemPrefix style={{ width: iconPx }}>
|
|
1541
|
+
{indicator}
|
|
1542
|
+
</ItemPrefix>
|
|
1543
|
+
) : Icon ? (
|
|
1544
|
+
<ItemIcon icon={Icon} className={disabled ? 'text-fg-disabled' : undefined} />
|
|
1545
|
+
) : null}
|
|
1546
|
+
|
|
1547
|
+
<span id={labelId} className={cn('flex-1 min-w-0 truncate', disabled && 'text-fg-disabled')}>
|
|
1548
|
+
{label}
|
|
1549
|
+
</span>
|
|
1550
|
+
</div>
|
|
1551
|
+
|
|
1552
|
+
{/* 第二格:Suffix inline actions——宣告式 API,用 `<ItemInlineAction>` 渲染。
|
|
1457
1553
|
消費 `<ItemSuffix hoverReveal hoverGroup="tree-item">` SSOT(2026-05-05 v8 group selector 參數化後)。
|
|
1458
|
-
actionsReveal="hover"(預設)
|
|
1554
|
+
actionsReveal="hover"(預設):列被滑過、或鍵盤焦點在這一列(列本身 / 列裡的按鈕)才顯示;
|
|
1459
1555
|
actionsReveal=false:常駐顯示。跟 SidebarMenuButton 共用同一條規則,行為一致。
|
|
1460
|
-
inlineActionsSlot escape hatch 優先(consumer 自控 JSX,reveal 一樣套外層 group)。
|
|
1461
|
-
|
|
1556
|
+
inlineActionsSlot escape hatch 優先(consumer 自控 JSX,reveal 一樣套外層 group)。
|
|
1557
|
+
2026-06-12 R2(同 sidebar.tsx 修):宿主 disabled 時 render 層擋 inline actions —
|
|
1462
1558
|
inline-action.spec.md「宿主 disabled | 不渲染」;row pointer-events 蓋不住
|
|
1463
1559
|
actionsReveal=false 常駐顯示的視覺暗示,必須 render 層 guard。 */}
|
|
1464
|
-
{
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1560
|
+
{/* 動作格的 hover-reveal 另加 `group-focus-visible/tree-item`:ItemSuffix 內建的
|
|
1561
|
+
group-has-[:focus-visible] 只看**子孫**,列自己拿到鍵盤焦點時抓不到(總帳 B9:
|
|
1562
|
+
滑過才出現的按鈕,焦點在這一列裡時必須看得到)。 */}
|
|
1563
|
+
{hasActionCell && (
|
|
1564
|
+
<ItemSuffix
|
|
1565
|
+
ref={actionsRef}
|
|
1566
|
+
role="gridcell"
|
|
1567
|
+
data-tree-actions=""
|
|
1568
|
+
hoverReveal={actionsReveal === 'hover'}
|
|
1569
|
+
hoverGroup="tree-item"
|
|
1570
|
+
className={actionsReveal === 'hover' ? 'group-focus-visible/tree-item:opacity-100' : undefined}
|
|
1571
|
+
>
|
|
1572
|
+
{hasSlot
|
|
1573
|
+
? inlineActionsSlot
|
|
1574
|
+
: inlineActions!.map((action, i) => (
|
|
1575
|
+
<ItemInlineAction key={action.label + i} action={action} />
|
|
1576
|
+
))}
|
|
1473
1577
|
</ItemSuffix>
|
|
1474
|
-
)
|
|
1578
|
+
)}
|
|
1475
1579
|
</div>
|
|
1476
1580
|
|
|
1477
1581
|
{/* Drop indicator — after:同 before mirror 到 bottom edge(SSOT drag-visual.ts)*/}
|
|
@@ -1482,14 +1586,17 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
|
|
1482
1586
|
/>
|
|
1483
1587
|
)}
|
|
1484
1588
|
|
|
1485
|
-
{/* Children: Collapsible
|
|
1589
|
+
{/* Children: Collapsible 展開/收合。
|
|
1590
|
+
2026-09-25:子項容器不再是 role="group"(樹狀表格裡 row 之間不允許 group);
|
|
1591
|
+
data-tree-children 讓鍵盤查詢認得「關閉動畫中(data-state=closed)的子樹」並跳過。 */}
|
|
1486
1592
|
{hasChildren && (
|
|
1487
1593
|
<CollapsiblePrimitive.Root open={isExpanded}>
|
|
1488
1594
|
<CollapsiblePrimitive.Content
|
|
1595
|
+
data-tree-children=""
|
|
1489
1596
|
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none"
|
|
1490
1597
|
>
|
|
1491
1598
|
<DepthContext.Provider value={depth + 1}>
|
|
1492
|
-
<div
|
|
1599
|
+
<div className="flex flex-col w-full">
|
|
1493
1600
|
{children}
|
|
1494
1601
|
</div>
|
|
1495
1602
|
</DepthContext.Provider>
|
|
@@ -1530,7 +1637,7 @@ export const treeViewMeta = {
|
|
|
1530
1637
|
tokens: {
|
|
1531
1638
|
bg: ['bg-neutral-hover', 'bg-neutral-selected', 'bg-surface'],
|
|
1532
1639
|
fg: ['text-fg-disabled', 'text-fg-muted', 'text-fg-secondary', 'text-foreground'],
|
|
1533
|
-
ring: ['ring-
|
|
1640
|
+
ring: ['focus-ring-inset'],
|
|
1534
1641
|
},
|
|
1535
1642
|
defaultSize: 'md',
|
|
1536
1643
|
} as const
|