@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.
Files changed (576) hide show
  1. package/dist/components/Accordion/accordion.d.ts +1 -1
  2. package/dist/components/Accordion/accordion.d.ts.map +1 -1
  3. package/dist/components/Accordion/accordion.js +8 -3
  4. package/dist/components/Accordion/accordion.js.map +1 -1
  5. package/dist/components/AccountMenu/account-menu.d.ts.map +1 -1
  6. package/dist/components/AccountMenu/account-menu.js.map +1 -1
  7. package/dist/components/AgentPanel/agent-panel-fab.d.ts +7 -1
  8. package/dist/components/AgentPanel/agent-panel-fab.d.ts.map +1 -1
  9. package/dist/components/AgentPanel/agent-panel-fab.js +36 -14
  10. package/dist/components/AgentPanel/agent-panel-fab.js.map +1 -1
  11. package/dist/components/AgentPanel/agent-panel-logo.d.ts +3 -3
  12. package/dist/components/AgentPanel/agent-panel-logo.js.map +1 -1
  13. package/dist/components/AgentPanel/agent-panel.d.ts +1 -1
  14. package/dist/components/AgentPanel/agent-panel.d.ts.map +1 -1
  15. package/dist/components/AgentPanel/agent-panel.js +28 -22
  16. package/dist/components/AgentPanel/agent-panel.js.map +1 -1
  17. package/dist/components/AppShell/app-shell.d.ts +1 -1
  18. package/dist/components/AppShell/app-shell.js +1 -1
  19. package/dist/components/AppShell/app-shell.js.map +1 -1
  20. package/dist/components/Avatar/avatar.d.ts +95 -1
  21. package/dist/components/Avatar/avatar.d.ts.map +1 -1
  22. package/dist/components/Avatar/avatar.js +106 -15
  23. package/dist/components/Avatar/avatar.js.map +1 -1
  24. package/dist/components/Avatar/index.js +14 -2
  25. package/dist/components/Breadcrumb/breadcrumb.d.ts +4 -2
  26. package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -1
  27. package/dist/components/Breadcrumb/breadcrumb.js +2 -2
  28. package/dist/components/Breadcrumb/breadcrumb.js.map +1 -1
  29. package/dist/components/BulkActionBar/bulk-action-bar.d.ts.map +1 -1
  30. package/dist/components/BulkActionBar/bulk-action-bar.js +27 -2
  31. package/dist/components/BulkActionBar/bulk-action-bar.js.map +1 -1
  32. package/dist/components/Button/button.d.ts +2 -2
  33. package/dist/components/Button/button.d.ts.map +1 -1
  34. package/dist/components/Button/button.js +118 -22
  35. package/dist/components/Button/button.js.map +1 -1
  36. package/dist/components/Calendar/calendar.d.ts +34 -10
  37. package/dist/components/Calendar/calendar.d.ts.map +1 -1
  38. package/dist/components/Calendar/calendar.js +210 -36
  39. package/dist/components/Calendar/calendar.js.map +1 -1
  40. package/dist/components/Carousel/carousel.d.ts +1 -1
  41. package/dist/components/Carousel/carousel.d.ts.map +1 -1
  42. package/dist/components/Carousel/carousel.js +3 -2
  43. package/dist/components/Carousel/carousel.js.map +1 -1
  44. package/dist/components/Chart/chart.d.ts +1 -0
  45. package/dist/components/Chart/chart.d.ts.map +1 -1
  46. package/dist/components/Chart/chart.js +8 -0
  47. package/dist/components/Chart/chart.js.map +1 -1
  48. package/dist/components/Checkbox/checkbox.d.ts +1 -1
  49. package/dist/components/Checkbox/checkbox.d.ts.map +1 -1
  50. package/dist/components/Checkbox/checkbox.js +10 -2
  51. package/dist/components/Checkbox/checkbox.js.map +1 -1
  52. package/dist/components/Chip/chip.d.ts +1 -1
  53. package/dist/components/Chip/chip.d.ts.map +1 -1
  54. package/dist/components/Chip/chip.js +4 -2
  55. package/dist/components/Chip/chip.js.map +1 -1
  56. package/dist/components/Combobox/combobox.d.ts +1 -1
  57. package/dist/components/Combobox/combobox.d.ts.map +1 -1
  58. package/dist/components/Combobox/combobox.js +37 -11
  59. package/dist/components/Combobox/combobox.js.map +1 -1
  60. package/dist/components/Command/command.d.ts +1 -1
  61. package/dist/components/Command/command.d.ts.map +1 -1
  62. package/dist/components/Command/command.js +106 -8
  63. package/dist/components/Command/command.js.map +1 -1
  64. package/dist/components/DataTable/cell-registry.d.ts.map +1 -1
  65. package/dist/components/DataTable/cell-registry.js +3 -2
  66. package/dist/components/DataTable/cell-registry.js.map +1 -1
  67. package/dist/components/DataTable/data-table-column-visibility-panel.d.ts.map +1 -1
  68. package/dist/components/DataTable/data-table-column-visibility-panel.js +24 -24
  69. package/dist/components/DataTable/data-table-column-visibility-panel.js.map +1 -1
  70. package/dist/components/DataTable/data-table-interaction-layer.d.ts +2 -1
  71. package/dist/components/DataTable/data-table-interaction-layer.d.ts.map +1 -1
  72. package/dist/components/DataTable/data-table-interaction-layer.js +3 -4
  73. package/dist/components/DataTable/data-table-interaction-layer.js.map +1 -1
  74. package/dist/components/DataTable/data-table-sort-manager.d.ts.map +1 -1
  75. package/dist/components/DataTable/data-table-sort-manager.js +20 -20
  76. package/dist/components/DataTable/data-table-sort-manager.js.map +1 -1
  77. package/dist/components/DataTable/data-table.d.ts +8 -5
  78. package/dist/components/DataTable/data-table.d.ts.map +1 -1
  79. package/dist/components/DataTable/data-table.js +197 -71
  80. package/dist/components/DataTable/data-table.js.map +1 -1
  81. package/dist/components/DateGrid/date-grid.d.ts +1 -1
  82. package/dist/components/DateGrid/date-grid.d.ts.map +1 -1
  83. package/dist/components/DateGrid/date-grid.js +358 -225
  84. package/dist/components/DateGrid/date-grid.js.map +1 -1
  85. package/dist/components/DatePicker/date-picker.d.ts.map +1 -1
  86. package/dist/components/DatePicker/date-picker.js +16 -1
  87. package/dist/components/DatePicker/date-picker.js.map +1 -1
  88. package/dist/components/Dialog/dialog.d.ts.map +1 -1
  89. package/dist/components/Dialog/dialog.js +55 -43
  90. package/dist/components/Dialog/dialog.js.map +1 -1
  91. package/dist/components/DropdownMenu/dropdown-menu-keyboard.d.ts +86 -0
  92. package/dist/components/DropdownMenu/dropdown-menu-keyboard.d.ts.map +1 -0
  93. package/dist/components/DropdownMenu/dropdown-menu-keyboard.js +172 -0
  94. package/dist/components/DropdownMenu/dropdown-menu-keyboard.js.map +1 -0
  95. package/dist/components/DropdownMenu/dropdown-menu.d.ts +6 -1
  96. package/dist/components/DropdownMenu/dropdown-menu.d.ts.map +1 -1
  97. package/dist/components/DropdownMenu/dropdown-menu.js +50 -39
  98. package/dist/components/DropdownMenu/dropdown-menu.js.map +1 -1
  99. package/dist/components/Field/field-context.d.ts +4 -0
  100. package/dist/components/Field/field-context.d.ts.map +1 -1
  101. package/dist/components/Field/field-context.js +1 -2
  102. package/dist/components/Field/field-context.js.map +1 -1
  103. package/dist/components/Field/field-edit-keys.d.ts +8 -0
  104. package/dist/components/Field/field-edit-keys.d.ts.map +1 -1
  105. package/dist/components/Field/field-edit-keys.js +3 -0
  106. package/dist/components/Field/field-edit-keys.js.map +1 -1
  107. package/dist/components/Field/field-wrapper.d.ts +18 -1
  108. package/dist/components/Field/field-wrapper.d.ts.map +1 -1
  109. package/dist/components/Field/field-wrapper.js +39 -10
  110. package/dist/components/Field/field-wrapper.js.map +1 -1
  111. package/dist/components/Field/field.d.ts +3 -2
  112. package/dist/components/Field/field.d.ts.map +1 -1
  113. package/dist/components/Field/field.js +2 -1
  114. package/dist/components/Field/field.js.map +1 -1
  115. package/dist/components/FileItem/file-item.d.ts +26 -2
  116. package/dist/components/FileItem/file-item.d.ts.map +1 -1
  117. package/dist/components/FileItem/file-item.js +82 -25
  118. package/dist/components/FileItem/file-item.js.map +1 -1
  119. package/dist/components/FileItem/index.js +4 -2
  120. package/dist/components/FileUpload/file-upload.d.ts +1 -1
  121. package/dist/components/FileUpload/file-upload.d.ts.map +1 -1
  122. package/dist/components/FileUpload/file-upload.js +132 -55
  123. package/dist/components/FileUpload/file-upload.js.map +1 -1
  124. package/dist/components/FileViewer/file-viewer.d.ts +1 -1
  125. package/dist/components/FileViewer/file-viewer.d.ts.map +1 -1
  126. package/dist/components/FileViewer/file-viewer.js +9 -4
  127. package/dist/components/FileViewer/file-viewer.js.map +1 -1
  128. package/dist/components/InlineEdit/inline-edit.d.ts.map +1 -1
  129. package/dist/components/InlineEdit/inline-edit.js +1 -1
  130. package/dist/components/InlineEdit/inline-edit.js.map +1 -1
  131. package/dist/components/Input/input.d.ts.map +1 -1
  132. package/dist/components/Input/input.js +6 -1
  133. package/dist/components/Input/input.js.map +1 -1
  134. package/dist/components/LinkInput/link-input.d.ts.map +1 -1
  135. package/dist/components/LinkInput/link-input.js +20 -4
  136. package/dist/components/LinkInput/link-input.js.map +1 -1
  137. package/dist/components/Notice/notice.js +1 -1
  138. package/dist/components/Notice/notice.js.map +1 -1
  139. package/dist/components/NumberInput/number-input.d.ts.map +1 -1
  140. package/dist/components/NumberInput/number-input.js +6 -1
  141. package/dist/components/NumberInput/number-input.js.map +1 -1
  142. package/dist/components/OverflowIndicator/index.js +2 -1
  143. package/dist/components/OverflowIndicator/overflow-indicator.d.ts +19 -1
  144. package/dist/components/OverflowIndicator/overflow-indicator.d.ts.map +1 -1
  145. package/dist/components/OverflowIndicator/overflow-indicator.js +47 -27
  146. package/dist/components/OverflowIndicator/overflow-indicator.js.map +1 -1
  147. package/dist/components/Pagination/pagination.d.ts +1 -1
  148. package/dist/components/Pagination/pagination.js +1 -1
  149. package/dist/components/Pagination/pagination.js.map +1 -1
  150. package/dist/components/PeoplePicker/avatar-stack-overflow.d.ts +7 -6
  151. package/dist/components/PeoplePicker/avatar-stack-overflow.d.ts.map +1 -1
  152. package/dist/components/PeoplePicker/avatar-stack-overflow.js +6 -7
  153. package/dist/components/PeoplePicker/avatar-stack-overflow.js.map +1 -1
  154. package/dist/components/PeoplePicker/people-picker-helpers.d.ts.map +1 -1
  155. package/dist/components/PeoplePicker/people-picker-helpers.js +1 -1
  156. package/dist/components/PeoplePicker/people-picker-helpers.js.map +1 -1
  157. package/dist/components/PeoplePicker/people-picker.d.ts.map +1 -1
  158. package/dist/components/PeoplePicker/people-picker.js +2 -2
  159. package/dist/components/PeoplePicker/people-picker.js.map +1 -1
  160. package/dist/components/PeoplePicker/person-display.d.ts.map +1 -1
  161. package/dist/components/PeoplePicker/person-display.js +26 -28
  162. package/dist/components/PeoplePicker/person-display.js.map +1 -1
  163. package/dist/components/ProfileCard/profile-card.d.ts.map +1 -1
  164. package/dist/components/ProfileCard/profile-card.js.map +1 -1
  165. package/dist/components/RadioGroup/radio-group.d.ts +1 -1
  166. package/dist/components/RadioGroup/radio-group.d.ts.map +1 -1
  167. package/dist/components/RadioGroup/radio-group.js +10 -4
  168. package/dist/components/RadioGroup/radio-group.js.map +1 -1
  169. package/dist/components/Rating/rating.d.ts +28 -6
  170. package/dist/components/Rating/rating.d.ts.map +1 -1
  171. package/dist/components/Rating/rating.js +97 -80
  172. package/dist/components/Rating/rating.js.map +1 -1
  173. package/dist/components/ScrollArea/scroll-area.d.ts +1 -1
  174. package/dist/components/ScrollArea/scroll-area.js +1 -1
  175. package/dist/components/ScrollArea/scroll-area.js.map +1 -1
  176. package/dist/components/SegmentedControl/segmented-control.d.ts +1 -1
  177. package/dist/components/SegmentedControl/segmented-control.d.ts.map +1 -1
  178. package/dist/components/SegmentedControl/segmented-control.js +3 -2
  179. package/dist/components/SegmentedControl/segmented-control.js.map +1 -1
  180. package/dist/components/Select/select.d.ts.map +1 -1
  181. package/dist/components/Select/select.js +15 -8
  182. package/dist/components/Select/select.js.map +1 -1
  183. package/dist/components/SelectMenu/index.js +2 -1
  184. package/dist/components/SelectMenu/index.js.map +1 -1
  185. package/dist/components/SelectMenu/select-menu-keyboard.d.ts +59 -0
  186. package/dist/components/SelectMenu/select-menu-keyboard.d.ts.map +1 -0
  187. package/dist/components/SelectMenu/select-menu-keyboard.js +107 -0
  188. package/dist/components/SelectMenu/select-menu-keyboard.js.map +1 -0
  189. package/dist/components/SelectMenu/select-menu.d.ts +1 -22
  190. package/dist/components/SelectMenu/select-menu.d.ts.map +1 -1
  191. package/dist/components/SelectMenu/select-menu.js +18 -34
  192. package/dist/components/SelectMenu/select-menu.js.map +1 -1
  193. package/dist/components/SelectionControl/selection-item.d.ts.map +1 -1
  194. package/dist/components/SelectionControl/selection-item.js +1 -0
  195. package/dist/components/SelectionControl/selection-item.js.map +1 -1
  196. package/dist/components/Sidebar/sidebar.d.ts +7 -9
  197. package/dist/components/Sidebar/sidebar.d.ts.map +1 -1
  198. package/dist/components/Sidebar/sidebar.js +171 -50
  199. package/dist/components/Sidebar/sidebar.js.map +1 -1
  200. package/dist/components/Slider/slider.d.ts +1 -1
  201. package/dist/components/Slider/slider.d.ts.map +1 -1
  202. package/dist/components/Slider/slider.js +9 -4
  203. package/dist/components/Slider/slider.js.map +1 -1
  204. package/dist/components/Steps/steps.d.ts.map +1 -1
  205. package/dist/components/Steps/steps.js +54 -26
  206. package/dist/components/Steps/steps.js.map +1 -1
  207. package/dist/components/Switch/switch.d.ts +1 -1
  208. package/dist/components/Switch/switch.d.ts.map +1 -1
  209. package/dist/components/Switch/switch.js +7 -2
  210. package/dist/components/Switch/switch.js.map +1 -1
  211. package/dist/components/Tabs/tabs.d.ts +1 -1
  212. package/dist/components/Tabs/tabs.d.ts.map +1 -1
  213. package/dist/components/Tabs/tabs.js +39 -4
  214. package/dist/components/Tabs/tabs.js.map +1 -1
  215. package/dist/components/Tag/tag.d.ts +1 -1
  216. package/dist/components/Tag/tag.d.ts.map +1 -1
  217. package/dist/components/Tag/tag.js +3 -2
  218. package/dist/components/Tag/tag.js.map +1 -1
  219. package/dist/components/Textarea/textarea.d.ts.map +1 -1
  220. package/dist/components/Textarea/textarea.js +8 -2
  221. package/dist/components/Textarea/textarea.js.map +1 -1
  222. package/dist/components/TimePicker/time-columns.d.ts.map +1 -1
  223. package/dist/components/TimePicker/time-columns.js +8 -4
  224. package/dist/components/TimePicker/time-columns.js.map +1 -1
  225. package/dist/components/TimePicker/time-picker.d.ts +1 -1
  226. package/dist/components/TimePicker/time-picker.js +1 -1
  227. package/dist/components/TimePicker/time-picker.js.map +1 -1
  228. package/dist/components/TreeView/tree-view.d.ts +9 -5
  229. package/dist/components/TreeView/tree-view.d.ts.map +1 -1
  230. package/dist/components/TreeView/tree-view.js +266 -214
  231. package/dist/components/TreeView/tree-view.js.map +1 -1
  232. package/dist/hooks/use-input-modality.d.ts +12 -17
  233. package/dist/hooks/use-input-modality.d.ts.map +1 -1
  234. package/dist/hooks/use-input-modality.js +4 -34
  235. package/dist/hooks/use-input-modality.js.map +1 -1
  236. package/dist/hooks/use-overflow-items.d.ts.map +1 -1
  237. package/dist/hooks/use-overflow-items.js +2 -0
  238. package/dist/hooks/use-overflow-items.js.map +1 -1
  239. package/dist/hooks/use-truncated.d.ts.map +1 -1
  240. package/dist/hooks/use-truncated.js.map +1 -1
  241. package/dist/index.d.ts +3 -0
  242. package/dist/index.d.ts.map +1 -1
  243. package/dist/index.js +23 -3
  244. package/dist/index.js.map +1 -1
  245. package/dist/lib/drag-announcements.d.ts +32 -0
  246. package/dist/lib/drag-announcements.d.ts.map +1 -1
  247. package/dist/lib/drag-announcements.js +39 -1
  248. package/dist/lib/drag-announcements.js.map +1 -1
  249. package/dist/lib/focus-after-trigger.d.ts +45 -0
  250. package/dist/lib/focus-after-trigger.d.ts.map +1 -0
  251. package/dist/lib/focus-after-trigger.js +66 -0
  252. package/dist/lib/focus-after-trigger.js.map +1 -0
  253. package/dist/lib/overlay-coexistence.js +1 -1
  254. package/dist/lib/roving-list-keyboard.d.ts +208 -0
  255. package/dist/lib/roving-list-keyboard.d.ts.map +1 -0
  256. package/dist/lib/roving-list-keyboard.js +174 -0
  257. package/dist/lib/roving-list-keyboard.js.map +1 -0
  258. package/dist/lib/utils.d.ts.map +1 -1
  259. package/dist/lib/utils.js +6 -1
  260. package/dist/lib/utils.js.map +1 -1
  261. package/dist/patterns/element-anatomy/item-anatomy.d.ts +4 -4
  262. package/dist/patterns/element-anatomy/item-anatomy.d.ts.map +1 -1
  263. package/dist/patterns/element-anatomy/item-anatomy.js +14 -4
  264. package/dist/patterns/element-anatomy/item-anatomy.js.map +1 -1
  265. package/dist/patterns/horizontal-overflow/horizontal-overflow.d.ts.map +1 -1
  266. package/dist/patterns/horizontal-overflow/horizontal-overflow.js +25 -0
  267. package/dist/patterns/horizontal-overflow/horizontal-overflow.js.map +1 -1
  268. package/dist/patterns/overlay-surface/overlay-surface.d.ts.map +1 -1
  269. package/dist/patterns/overlay-surface/overlay-surface.js.map +1 -1
  270. package/dist/tokens/categorical-color.d.ts +6 -1
  271. package/dist/tokens/categorical-color.d.ts.map +1 -1
  272. package/dist/tokens/categorical-color.js.map +1 -1
  273. package/dist/tokens/uiSize/icon-size.d.ts +1 -1
  274. package/dist/tokens/uiSize/stack-gap.d.ts +18 -0
  275. package/dist/tokens/uiSize/stack-gap.d.ts.map +1 -0
  276. package/dist/tokens/uiSize/stack-gap.js +5 -0
  277. package/dist/tokens/uiSize/stack-gap.js.map +1 -0
  278. package/ds-canonical/fork/codex/agents/skills/performance-audit/SKILL.md +1 -1
  279. package/ds-canonical/fork/codex/hooks.json +1 -1
  280. package/ds-canonical/fork/consumer/lock.json +17 -14
  281. package/ds-canonical/fork/consumer/managed-files/423729840acdd43c812f17c245b871716c6b60f76e4d3e94864d0e34a815bf8d.blob +9 -0
  282. package/ds-canonical/fork/consumer/managed-files/46f10bc55cdfdf834d0749eebff42ea0bb8576f361b36d2b6ba1851354c50150.blob +47 -9
  283. package/ds-canonical/fork/consumer/managed-files/5a0d134a2645114c8caac20458070b00a7f0fd55b3e8805b7325cb4cf52fa15e.blob +110 -0
  284. package/ds-canonical/fork/consumer/managed-files/5af0a46760af338d51fd0f7950675f8e97f7d2f09386b8818fcd7a58e6654e75.blob +790 -0
  285. package/ds-canonical/fork/consumer/managed-files/68bfcddff9c4a16388c248cd34fb66163e9e64b3d809a48ef3984f6ae4ff6d12.blob +211 -0
  286. package/ds-canonical/fork/consumer/managed-files/7eb6cc60abfdabaf6617c336058fad1454ccde08889295767bfd9b7fd153cc6e.blob +45 -6
  287. package/ds-canonical/fork/consumer/managed-files/92e4828cdcb96ff0d01daa16f6885f24045a8e72820665650f2a1890dc3a446c.blob +42 -2
  288. package/ds-canonical/fork/consumer/managed-files/ab072e7f2eca51f0e18a5e18c4b333260ee5d219a12cc6df5bb2d683f0b2734d.blob +27 -1
  289. package/ds-canonical/fork/consumer/managed-files/e71c65f45c6af7ce12b6b3c0d8028f185da5b719e2050c9b2a6b243d41ae4878.blob +62 -14
  290. package/ds-canonical/fork/governance.lock +41 -17
  291. package/ds-canonical/fork/hooks/check_consumer_app_invariants.sh +16 -4
  292. package/ds-canonical/fork/hooks/check_consumer_code_quality.sh +3 -1
  293. package/ds-canonical/fork/hooks/check_pattern_invariants.sh +13 -9
  294. package/ds-canonical/fork/launchers/settings-hooks.json +1 -1
  295. package/ds-canonical/fork/manifest.json +17 -11
  296. package/ds-canonical/fork/providers/claude/skills/performance-audit/SKILL.md +1 -1
  297. package/ds-canonical/fork/providers/codex/hook-config.json +1 -1
  298. package/ds-canonical/fork/providers/codex/skills/performance-audit/SKILL.md +1 -1
  299. package/ds-canonical/fork/skills/performance-audit/SKILL.md +1 -1
  300. package/ds-canonical/hooks/check_consumer_app_invariants.sh +16 -4
  301. package/ds-canonical/hooks/check_consumer_code_quality.sh +3 -1
  302. package/ds-canonical/hooks/check_datatable_invariants.sh +4 -1
  303. package/ds-canonical/hooks/check_pattern_invariants.sh +13 -9
  304. package/ds-canonical/hooks/check_plugin_fork_health.sh +8 -1
  305. package/ds-canonical/hooks/check_propose_discipline.sh +8 -1
  306. package/ds-canonical/hooks/check_storybook_addon_packaging.sh +8 -1
  307. package/ds-canonical/hooks/check_substantive_edit_approval_preflight.sh +64 -2
  308. package/ds-canonical/hooks/lib/_overlay_handcraft.sh +5 -2
  309. package/ds-canonical/hooks/registrations.json +12 -7
  310. package/ds-canonical/hooks/session_start_governance_check.sh +16 -0
  311. package/ds-canonical/hooks/tests/test_check_consumer_app_invariants.sh +25 -0
  312. package/ds-canonical/hooks/tests/test_check_consumer_code_quality.sh +4 -0
  313. package/ds-canonical/hooks/tests/test_check_consumer_ds_primitive_misuse.sh +14 -0
  314. package/ds-canonical/hooks/tests/test_check_datatable_invariants.sh +24 -0
  315. package/ds-canonical/hooks/tests/test_check_overlay_handcraft.sh +32 -0
  316. package/ds-canonical/hooks/tests/test_check_pattern_invariants.sh +59 -0
  317. package/ds-canonical/hooks/tests/test_check_plugin_fork_health.sh +32 -1
  318. package/ds-canonical/hooks/tests/test_check_propose_discipline.sh +21 -0
  319. package/ds-canonical/hooks/tests/test_check_storybook_addon_packaging.sh +21 -0
  320. package/ds-canonical/hooks/tests/test_check_substantive_edit_approval_preflight.sh +97 -0
  321. package/ds-canonical/hooks/tests/test_session_start_governance_check.sh +49 -0
  322. package/ds-canonical/references/README.md +12 -6
  323. package/ds-canonical/references/drag-canonical.md +3 -1
  324. package/ds-canonical/references/focus-canonical.md +26 -13
  325. package/ds-canonical/references/governance-audit-coverage.md +1 -1
  326. package/ds-canonical/references/hit-area-canonical.md +361 -0
  327. package/ds-canonical/references/item-anatomy-recipe.md +4 -2
  328. package/ds-canonical/references/keyboard-model-canonical.md +559 -0
  329. package/ds-canonical/rules/meta-patterns.md +4 -4
  330. package/ds-canonical/rules/self-verify.md +4 -4
  331. package/ds-canonical/skills/design-system-audit/references/audit-prompts.md +1 -1
  332. package/ds-canonical/skills/design-system-audit/references/historical-bugs.md +96 -0
  333. package/ds-canonical/skills/knowledge-prune/SKILL.md +4 -1
  334. package/ds-canonical/skills/performance-audit/SKILL.md +1 -1
  335. package/ds-story-manifest.json +19 -7
  336. package/llms-full.txt +2 -2
  337. package/llms.txt +2 -2
  338. package/package.json +3 -1
  339. package/src/components/Accordion/accordion.anatomy.stories.tsx +3 -3
  340. package/src/components/Accordion/accordion.spec.md +1 -1
  341. package/src/components/Accordion/accordion.tsx +8 -3
  342. package/src/components/AccountMenu/account-menu.spec.md +2 -3
  343. package/src/components/AccountMenu/account-menu.tsx +0 -1
  344. package/src/components/AgentPanel/agent-decision-card.spec.md +88 -0
  345. package/src/components/AgentPanel/agent-panel-fab.spec.md +151 -0
  346. package/src/components/AgentPanel/agent-panel-fab.tsx +67 -24
  347. package/src/components/AgentPanel/agent-panel-logo.spec.md +87 -0
  348. package/src/components/AgentPanel/agent-panel-logo.tsx +3 -3
  349. package/src/components/AgentPanel/agent-panel.anatomy.stories.tsx +1 -1
  350. package/src/components/AgentPanel/agent-panel.principles.stories.tsx +1 -1
  351. package/src/components/AgentPanel/agent-panel.spec.md +30 -275
  352. package/src/components/AgentPanel/agent-panel.stories.tsx +113 -8
  353. package/src/components/AgentPanel/agent-panel.tsx +59 -22
  354. package/src/components/Alert/alert.anatomy.stories.tsx +1 -1
  355. package/src/components/AppShell/app-shell.tsx +1 -1
  356. package/src/components/AspectRatio/aspect-ratio.anatomy.stories.tsx +10 -15
  357. package/src/components/Avatar/avatar.anatomy.stories.tsx +23 -26
  358. package/src/components/Avatar/avatar.principles.stories.tsx +10 -10
  359. package/src/components/Avatar/avatar.spec.md +45 -6
  360. package/src/components/Avatar/avatar.stories.tsx +8 -8
  361. package/src/components/Avatar/avatar.tsx +227 -21
  362. package/src/components/Badge/badge.anatomy.stories.tsx +16 -21
  363. package/src/components/Breadcrumb/breadcrumb.anatomy.stories.tsx +3 -3
  364. package/src/components/Breadcrumb/breadcrumb.spec.md +3 -3
  365. package/src/components/Breadcrumb/breadcrumb.tsx +5 -3
  366. package/src/components/BulkActionBar/bulk-action-bar.anatomy.stories.tsx +1 -1
  367. package/src/components/BulkActionBar/bulk-action-bar.principles.stories.tsx +1 -1
  368. package/src/components/BulkActionBar/bulk-action-bar.spec.md +1 -0
  369. package/src/components/BulkActionBar/bulk-action-bar.stories.tsx +57 -2
  370. package/src/components/BulkActionBar/bulk-action-bar.tsx +32 -2
  371. package/src/components/Button/button.anatomy.stories.tsx +174 -33
  372. package/src/components/Button/button.spec.md +23 -8
  373. package/src/components/Button/button.tsx +116 -21
  374. package/src/components/Calendar/calendar.anatomy.stories.tsx +34 -11
  375. package/src/components/Calendar/calendar.principles.stories.tsx +44 -1
  376. package/src/components/Calendar/calendar.spec.md +97 -20
  377. package/src/components/Calendar/calendar.stories.tsx +101 -4
  378. package/src/components/Calendar/calendar.tsx +379 -74
  379. package/src/components/Carousel/carousel.anatomy.stories.tsx +16 -17
  380. package/src/components/Carousel/carousel.spec.md +48 -2
  381. package/src/components/Carousel/carousel.tsx +9 -3
  382. package/src/components/Chart/chart.anatomy.stories.tsx +7 -5
  383. package/src/components/Chart/chart.spec.md +32 -7
  384. package/src/components/Chart/chart.tsx +9 -0
  385. package/src/components/Checkbox/checkbox.anatomy.stories.tsx +21 -28
  386. package/src/components/Checkbox/checkbox.spec.md +4 -2
  387. package/src/components/Checkbox/checkbox.tsx +10 -2
  388. package/src/components/Chip/chip.anatomy.stories.tsx +1 -1
  389. package/src/components/Chip/chip.spec.md +2 -1
  390. package/src/components/Chip/chip.tsx +4 -2
  391. package/src/components/Combobox/combobox.anatomy.stories.tsx +30 -37
  392. package/src/components/Combobox/combobox.spec.md +11 -3
  393. package/src/components/Combobox/combobox.tsx +43 -12
  394. package/src/components/Command/command.spec.md +11 -0
  395. package/src/components/Command/command.tsx +143 -10
  396. package/src/components/DataTable/cell-registry.tsx +5 -1
  397. package/src/components/DataTable/data-table-column-visibility-panel.tsx +5 -6
  398. package/src/components/DataTable/data-table-interaction-layer.tsx +27 -14
  399. package/src/components/DataTable/data-table-known-defects.spec.md +72 -0
  400. package/src/components/DataTable/data-table-scroll-performance.spec.md +295 -0
  401. package/src/components/DataTable/data-table-sort-manager.tsx +5 -6
  402. package/src/components/DataTable/data-table.anatomy.stories.tsx +1 -1
  403. package/src/components/DataTable/data-table.css +64 -0
  404. package/src/components/DataTable/data-table.spec.md +70 -346
  405. package/src/components/DataTable/data-table.stories.tsx +56 -9
  406. package/src/components/DataTable/data-table.tsx +392 -103
  407. package/src/components/DateGrid/date-grid.anatomy.stories.tsx +2 -2
  408. package/src/components/DateGrid/date-grid.spec.md +163 -1
  409. package/src/components/DateGrid/date-grid.tsx +270 -7
  410. package/src/components/DatePicker/date-picker.anatomy.stories.tsx +18 -27
  411. package/src/components/DatePicker/date-picker.spec.md +6 -2
  412. package/src/components/DatePicker/date-picker.tsx +20 -1
  413. package/src/components/Dialog/dialog.spec.md +6 -2
  414. package/src/components/Dialog/dialog.stories.tsx +18 -9
  415. package/src/components/Dialog/dialog.tsx +83 -48
  416. package/src/components/DropdownMenu/dropdown-menu-keyboard.ts +255 -0
  417. package/src/components/DropdownMenu/dropdown-menu.anatomy.stories.tsx +32 -33
  418. package/src/components/DropdownMenu/dropdown-menu.spec.md +18 -5
  419. package/src/components/DropdownMenu/dropdown-menu.stories.tsx +58 -1
  420. package/src/components/DropdownMenu/dropdown-menu.tsx +70 -33
  421. package/src/components/Empty/empty.spec.md +1 -2
  422. package/src/components/Field/field-context.ts +9 -4
  423. package/src/components/Field/field-controls.spec.md +5 -2
  424. package/src/components/Field/field-edit-keys.ts +12 -0
  425. package/src/components/Field/field-wrapper.tsx +76 -10
  426. package/src/components/Field/field.anatomy.stories.tsx +1 -1
  427. package/src/components/Field/field.spec.md +11 -6
  428. package/src/components/Field/field.stories.tsx +24 -1
  429. package/src/components/Field/field.tsx +10 -4
  430. package/src/components/FileItem/file-item.anatomy.stories.tsx +20 -10
  431. package/src/components/FileItem/file-item.principles.stories.tsx +14 -7
  432. package/src/components/FileItem/file-item.spec.md +50 -48
  433. package/src/components/FileItem/file-item.stories.tsx +41 -28
  434. package/src/components/FileItem/file-item.tsx +184 -50
  435. package/src/components/FileUpload/file-upload.anatomy.stories.tsx +11 -15
  436. package/src/components/FileUpload/file-upload.spec.md +27 -1
  437. package/src/components/FileUpload/file-upload.stories.tsx +33 -0
  438. package/src/components/FileUpload/file-upload.tsx +173 -43
  439. package/src/components/FileViewer/file-viewer.tsx +9 -4
  440. package/src/components/HoverCard/hover-card.stories.tsx +2 -2
  441. package/src/components/InlineEdit/inline-edit.spec.md +2 -0
  442. package/src/components/InlineEdit/inline-edit.tsx +8 -3
  443. package/src/components/Input/input.anatomy.stories.tsx +23 -31
  444. package/src/components/Input/input.spec.md +11 -0
  445. package/src/components/Input/input.tsx +6 -1
  446. package/src/components/LinkInput/link-input.anatomy.stories.tsx +18 -21
  447. package/src/components/LinkInput/link-input.principles.stories.tsx +4 -4
  448. package/src/components/LinkInput/link-input.spec.md +12 -1
  449. package/src/components/LinkInput/link-input.tsx +37 -4
  450. package/src/components/Menu/menu-item.anatomy.stories.tsx +50 -56
  451. package/src/components/Menu/menu-item.spec.md +1 -1
  452. package/src/components/Menu/menu-item.stories.tsx +48 -42
  453. package/src/components/Notice/notice.anatomy.stories.tsx +8 -16
  454. package/src/components/Notice/notice.spec.md +1 -1
  455. package/src/components/Notice/notice.tsx +1 -1
  456. package/src/components/NumberInput/number-input.anatomy.stories.tsx +21 -28
  457. package/src/components/NumberInput/number-input.spec.md +2 -0
  458. package/src/components/NumberInput/number-input.tsx +8 -1
  459. package/src/components/OverflowIndicator/overflow-indicator.anatomy.stories.tsx +28 -48
  460. package/src/components/OverflowIndicator/overflow-indicator.spec.md +13 -1
  461. package/src/components/OverflowIndicator/overflow-indicator.stories.tsx +12 -9
  462. package/src/components/OverflowIndicator/overflow-indicator.tsx +32 -12
  463. package/src/components/Pagination/pagination.spec.md +1 -0
  464. package/src/components/Pagination/pagination.tsx +1 -1
  465. package/src/components/PeoplePicker/avatar-stack-overflow.ts +14 -13
  466. package/src/components/PeoplePicker/people-picker-helpers.ts +5 -2
  467. package/src/components/PeoplePicker/people-picker.anatomy.stories.tsx +1 -1
  468. package/src/components/PeoplePicker/people-picker.spec.md +4 -2
  469. package/src/components/PeoplePicker/people-picker.tsx +1 -4
  470. package/src/components/PeoplePicker/person-display.tsx +45 -28
  471. package/src/components/Popover/popover.anatomy.stories.tsx +1 -1
  472. package/src/components/Popover/popover.principles.stories.tsx +3 -12
  473. package/src/components/Popover/popover.spec.md +7 -3
  474. package/src/components/ProfileCard/profile-card.anatomy.stories.tsx +1 -1
  475. package/src/components/ProfileCard/profile-card.spec.md +1 -1
  476. package/src/components/ProfileCard/profile-card.tsx +0 -3
  477. package/src/components/ProgressBar/progress-bar.anatomy.stories.tsx +11 -21
  478. package/src/components/ProgressBar/progress-bar.spec.md +1 -1
  479. package/src/components/RadioGroup/radio-group.anatomy.stories.tsx +17 -24
  480. package/src/components/RadioGroup/radio-group.spec.md +1 -1
  481. package/src/components/RadioGroup/radio-group.tsx +22 -4
  482. package/src/components/Rating/rating.anatomy.stories.tsx +131 -124
  483. package/src/components/Rating/rating.principles.stories.tsx +69 -43
  484. package/src/components/Rating/rating.spec.md +142 -63
  485. package/src/components/Rating/rating.stories.tsx +11 -15
  486. package/src/components/Rating/rating.tsx +131 -117
  487. package/src/components/ScrollArea/scroll-area.anatomy.stories.tsx +1 -1
  488. package/src/components/ScrollArea/scroll-area.principles.stories.tsx +6 -3
  489. package/src/components/ScrollArea/scroll-area.spec.md +3 -1
  490. package/src/components/ScrollArea/scroll-area.stories.tsx +5 -2
  491. package/src/components/ScrollArea/scroll-area.tsx +1 -1
  492. package/src/components/SegmentedControl/segmented-control.anatomy.stories.tsx +1 -1
  493. package/src/components/SegmentedControl/segmented-control.spec.md +2 -2
  494. package/src/components/SegmentedControl/segmented-control.tsx +3 -2
  495. package/src/components/Select/select.anatomy.stories.tsx +26 -35
  496. package/src/components/Select/select.spec.md +4 -3
  497. package/src/components/Select/select.tsx +11 -4
  498. package/src/components/SelectMenu/select-menu-keyboard.ts +218 -0
  499. package/src/components/SelectMenu/select-menu.anatomy.stories.tsx +6 -17
  500. package/src/components/SelectMenu/select-menu.spec.md +12 -4
  501. package/src/components/SelectMenu/select-menu.tsx +21 -56
  502. package/src/components/SelectionControl/selection-item.anatomy.stories.tsx +9 -33
  503. package/src/components/SelectionControl/selection-item.spec.md +2 -2
  504. package/src/components/SelectionControl/selection-item.tsx +3 -1
  505. package/src/components/Separator/separator.stories.tsx +4 -4
  506. package/src/components/Sheet/sheet.principles.stories.tsx +31 -13
  507. package/src/components/Sidebar/sidebar.anatomy.stories.tsx +1 -1
  508. package/src/components/Sidebar/sidebar.spec.md +73 -6
  509. package/src/components/Sidebar/sidebar.stories.tsx +82 -4
  510. package/src/components/Sidebar/sidebar.tsx +281 -64
  511. package/src/components/Skeleton/skeleton.spec.md +1 -1
  512. package/src/components/Slider/slider.anatomy.stories.tsx +1 -1
  513. package/src/components/Slider/slider.spec.md +6 -5
  514. package/src/components/Slider/slider.tsx +13 -8
  515. package/src/components/Steps/steps-state-visuals.spec.md +157 -0
  516. package/src/components/Steps/steps.anatomy.stories.tsx +1 -0
  517. package/src/components/Steps/steps.spec.md +59 -142
  518. package/src/components/Steps/steps.stories.tsx +1 -1
  519. package/src/components/Steps/steps.tsx +125 -33
  520. package/src/components/Switch/switch.spec.md +3 -3
  521. package/src/components/Switch/switch.tsx +6 -1
  522. package/src/components/Tabs/tabs.spec.md +5 -1
  523. package/src/components/Tabs/tabs.tsx +42 -3
  524. package/src/components/Tag/tag.anatomy.stories.tsx +24 -28
  525. package/src/components/Tag/tag.spec.md +10 -11
  526. package/src/components/Tag/tag.tsx +3 -3
  527. package/src/components/Textarea/textarea.tsx +7 -1
  528. package/src/components/TimePicker/time-columns.tsx +9 -4
  529. package/src/components/TimePicker/time-picker.spec.md +4 -1
  530. package/src/components/TimePicker/time-picker.tsx +1 -1
  531. package/src/components/Tooltip/tooltip.anatomy.stories.tsx +6 -15
  532. package/src/components/Tooltip/tooltip.principles.stories.tsx +4 -16
  533. package/src/components/Tooltip/tooltip.spec.md +2 -1
  534. package/src/components/TreeView/tree-view.anatomy.stories.tsx +35 -13
  535. package/src/components/TreeView/tree-view.spec.md +67 -38
  536. package/src/components/TreeView/tree-view.stories.tsx +221 -12
  537. package/src/components/TreeView/tree-view.tsx +463 -356
  538. package/src/hooks/use-input-modality.ts +17 -46
  539. package/src/hooks/use-overflow-items.ts +4 -0
  540. package/src/hooks/use-truncated.ts +3 -2
  541. package/src/index.ts +17 -0
  542. package/src/lib/README.md +2 -0
  543. package/src/lib/drag-announcements.ts +78 -0
  544. package/src/lib/focus-after-trigger.ts +102 -0
  545. package/src/lib/roving-list-keyboard.ts +400 -0
  546. package/src/lib/utils.ts +5 -0
  547. package/src/patterns/action-bar/action-bar.spec.md +2 -0
  548. package/src/patterns/element-anatomy/inline-action.spec.md +16 -12
  549. package/src/patterns/element-anatomy/item-anatomy.spec.md +98 -6
  550. package/src/patterns/element-anatomy/item-anatomy.stories.tsx +75 -70
  551. package/src/patterns/element-anatomy/item-anatomy.tsx +17 -6
  552. package/src/patterns/element-anatomy/truncated-text.spec.md +1 -0
  553. package/src/patterns/header-canonical/header-canonical.spec.md +3 -2
  554. package/src/patterns/horizontal-overflow/horizontal-overflow.spec.md +2 -0
  555. package/src/patterns/horizontal-overflow/horizontal-overflow.stories.tsx +36 -0
  556. package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +28 -0
  557. package/src/patterns/overlay-surface/overlay-chrome-sizing.spec.md +180 -0
  558. package/src/patterns/overlay-surface/overlay-surface.spec.md +8 -162
  559. package/src/patterns/overlay-surface/overlay-surface.tsx +5 -3
  560. package/src/patterns/resize-handle/resize-handle.spec.md +3 -3
  561. package/src/styles/base.css +1 -1
  562. package/src/tokens/categorical-color.ts +6 -1
  563. package/src/tokens/color/color.spec.md +91 -24
  564. package/src/tokens/color/color.stories.tsx +50 -2
  565. package/src/tokens/color/primitives.css +41 -38
  566. package/src/tokens/color/semantic.css +105 -36
  567. package/src/tokens/layoutSpace/layoutSpace.spec.md +2 -0
  568. package/src/tokens/motion/motion.spec.md +31 -8
  569. package/src/tokens/orphan-tokens.spec.md +1 -1
  570. package/src/tokens/uiSize/icon-size.ts +1 -1
  571. package/src/tokens/uiSize/stack-gap.ts +17 -0
  572. package/src/tokens/uiSize/uiSize.css +12 -0
  573. package/src/tokens/uiSize/uiSize.spec.md +17 -16
  574. package/src/tokens/uiSize/uiSize.stories.tsx +1 -1
  575. package/dist/node_modules/aria-hidden/dist/es2015/index.js +0 -147
  576. 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 { createDragAnnouncements, type DragOutcome } from '@/design-system/lib/drag-announcements'
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
- /** Pointer activation 後把 DOM focus 還給 virtual-focus tree root。 */
184
- focusTree: () => void
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="tree" 名稱無法從子節點推導,缺 aria-label / aria-labelledby → SR 只讀「tree」。
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="tree" 缺 accessible name — 請傳 aria-label(直接字串)或 aria-labelledby(指向可見標題 id)其一。' +
318
- '兩者皆缺時螢幕閱讀器只讀出「tree」無法辨識用途(WAI-ARIA APG 要求 role="tree" 具 accessible name)。',
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 focusTree = React.useCallback(() => {
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
- // ── Virtual focus id prefix ──
374
- // DOM focus 永遠停在 role=tree 容器(單一 tab stop);目前 node 透過 aria-activedescendant
375
- // 告知 AT(對齊 DS 既有 cmdk virtual-focus canonical:SelectMenu / Command listbox)。
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 = `${activeDescendantPrefix}tree-reorder-instructions`
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
- const targetDepth = Number(targetEl.getAttribute('aria-level') ?? 1) - 1
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 groupEl = targetEl.closest('[role="group"]')
512
- const parentTreeItem = groupEl?.parentElement?.closest('[role="treeitem"]')
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(parentTreeItem?.getAttribute('aria-level') ?? 1) - 1
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
- const dndAnnouncements = React.useMemo(
551
- () => createDragAnnouncements({ getOutcome: () => dragOutcomeRef.current, kind: '項目' }),
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 — 其 activator 需可接收 DOM focus
616
- // (https://dndkit.com/api-documentation/sensors/keyboard),與 row tabIndex={-1} +
617
- // aria-activedescendant 虛擬焦點模型結構性不相容(2026-07-05 D4 拍板不翻案)。
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>(`[data-tree-row="${id}"]`)
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 反而複雜(同 handleKeyDown 先例)
698
+ // 拆 sub-fn 會跨 fn 傳 siblings / announce state 反而複雜(同 handleKeyDownCapture 先例)
632
699
  const handleKeyboardReorder = React.useCallback(
633
- (key: ReorderArrowKey) => {
700
+ (key: RovingReorderKey, sourceId: string) => {
634
701
  const tree = treeRef.current
635
- if (!tree || !focusedId) return
636
- const sourceId = focusedId
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.getAttribute('aria-disabled') === 'true') return
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>('[role="treeitem"]:not([hidden])')
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.getAttribute('aria-disabled') !== 'true'
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
- ?.querySelector<HTMLElement>(`[data-tree-id="${sourceId}"]`)
662
- ?.scrollIntoView({ block: 'nearest' })
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 parentEl = tree.querySelector<HTMLElement>(`[data-tree-id="${parentId}"]`)
759
- const level = Number(parentEl?.getAttribute('aria-level') ?? '1')
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
- [focusedId, expandedIds, toggleExpand, onDragEndProp, getNodeLabelText, isInSubtree, announcements]
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
- focusTree,
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
- focusTree,
878
+ focusRow,
879
+ onRowUnmount,
814
880
  registerNode,
815
881
  unregisterNode,
816
882
  getNodeInfo,
817
883
  ]
818
884
  )
819
885
 
820
- // ── Keyboard handler ──
821
- // 2026-09-08:mousedown 不再手動改模態 —— 由 useInputModality 的 document 監聽統一處理
822
- const handleMouseDown = React.useCallback(() => {}, [])
823
-
824
- // code-quality-allow: long-function — helper fn 結構緊密,拆 sub-fn 會跨 fn 傳 state 反而複雜
825
- const handleKeyDown = React.useCallback(
826
- (e: React.KeyboardEvent) => {
827
- // 2026-09-08:模態由 useInputModality 判定(document capture 早於此 handler)
828
- if (!treeRef.current) return
829
-
830
- // ── 互動 descendant 自理鍵盤(先於導覽 / 重排分支)──
831
- // 公開 inlineActions / inlineActionsSlot 經 <ItemInlineAction> 渲染的原生 <button>
832
- // (及 consumer slot 內的 link / field / role=button)是各自獨立的 tab stop(spec
833
- // A11y「單一 tab stop」段)。其 Enter/Space/方向鍵應由該控件自理——若冒泡到容器
834
- // handleKeyDown,下方 Enter/Space 分支的 e.preventDefault()+select() 會吃掉 button
835
- // 原生啟動並誤選 tree node。虛擬焦點模型下 tree 導覽/重排只在事件來自容器本身時處理
836
- // (DOM focus 停在 tree 容器;treeitem row / chevron / checkbox 皆 tabIndex=-1)。
837
- const keyTarget = e.target as HTMLElement | null
838
- if (keyTarget && keyTarget !== e.currentTarget) {
839
- const interactive = keyTarget.closest<HTMLElement>(
840
- 'a[href], button, input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])'
841
- )
842
- // interactive === e.currentTarget:唯一可聚焦祖先是 tree 容器(tabIndex=0 單一 tab
843
- // stop 進出點)→ 屬非互動 descendant,照常走 tree 導覽。
844
- if (interactive && interactive !== e.currentTarget) return
845
- }
846
-
847
- // ── 鍵盤重排:Cmd/Ctrl+Shift+方向鍵(2026-07-14 v1,詳 spec「鍵盤重排」)──
848
- // 必排在下方「無焦點時方向鍵先聚焦第一項」分支之前(該分支不分 modifier 攔 Arrow*);
849
- // 非 modifier 導覽路徑(下方 switch)完全不變。
850
- if ((e.metaKey || e.ctrlKey) && e.shiftKey && REORDER_ARROW_KEYS.includes(e.key)) {
851
- if (!draggable) return // 未啟用拖曳 → 整組 no-op(modifier 組合不落入導覽 switch)
852
- e.preventDefault() // 阻止瀏覽器原生行為(捲動 / 文字選取)
853
- if (draggingId !== null) return // pointer 拖曳進行中 → 互斥,忽略鍵盤重排
854
- handleKeyboardReorder(e.key as ReorderArrowKey)
855
- return
856
- }
857
-
858
- // 取得所有可見的 treeitem
859
- const items = Array.from(
860
- // a11y:排除 disabled 節點 → 鍵盤導覽/選取/展開全跳過(APG:disabled 不可鍵盤操作)
861
- treeRef.current.querySelectorAll<HTMLElement>('[role="treeitem"]:not([hidden]):not([aria-disabled="true"])')
862
- )
863
- const currentIndex = items.findIndex(
864
- (el) => el.dataset.treeId === focusedId
865
- )
866
- // a11y:沒焦點時任一方向鍵先聚焦第一個(含 ArrowLeft/Right,原漏 → AT 無 activedescendant 可讀)
867
- if (currentIndex < 0 && items.length > 0 && ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
868
- // 沒有焦點時,任何方向鍵先聚焦第一個
869
- setFocusedId(items[0].dataset.treeId ?? null)
870
- e.preventDefault()
871
- return
872
- }
873
-
874
- const currentEl = items[currentIndex]
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
- switch (e.key) {
877
- case 'ArrowDown': {
878
- e.preventDefault()
879
- const next = items[currentIndex + 1]
880
- if (next) setFocusedId(next.dataset.treeId ?? null)
881
- break
882
- }
883
- case 'ArrowUp': {
884
- e.preventDefault()
885
- const prev = items[currentIndex - 1]
886
- if (prev) setFocusedId(prev.dataset.treeId ?? null)
887
- break
888
- }
889
- case 'ArrowRight': {
890
- e.preventDefault()
891
- const id = currentEl?.dataset.treeId
892
- if (!id) break
893
- const isExpanded = expandedIds.has(id)
894
- const hasChildren = currentEl?.dataset.treeHasChildren === 'true'
895
- if (hasChildren && !isExpanded) {
896
- toggleExpand(id)
897
- } else if (hasChildren && isExpanded) {
898
- // 已展開 → 移到第一個 child
899
- const next = items[currentIndex + 1]
900
- if (next) setFocusedId(next.dataset.treeId ?? null)
901
- }
902
- break
903
- }
904
- case 'ArrowLeft': {
905
- e.preventDefault()
906
- const id = currentEl?.dataset.treeId
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)/onKeyDown/tabIndex 必須勝過 consumer
945
- // 誤傳(原 spread 在最後 → consumer style 會整組蓋掉 --tree-px、onKeyDown 蓋掉鍵盤導覽)
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
- role="tree"
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
- onKeyDown={handleKeyDown}
980
- onMouseDown={handleMouseDown}
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
- // 2026-09-07:Tab 進場時把鍵盤模式打開。
986
- // 原本 `isKeyboardRef` 只在**樹內** keydown 才變 true(:814),但 Tab 的 keydown
987
- // 發生在上一個元素上、根本不會傳到這裡 → 進場當下 `showRing`(:1174)恆為 false,
988
- // 於是 aria-activedescendant 已經指向某一列、那列卻沒有任何可見指示,
989
- // 違反 APG aria-activedescendant 模式(作者必須自己畫出目前節點)。
990
- // 判準用瀏覽器自己的 `:focus-visible` —— 它就是「這次聚焦該不該給可見指示」的權威答案:
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={{ announcements: dndAnnouncements }}
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="tree" 之外
1027
- (tree 的合法 children 只有 treeitem / group);dnd-kit DndContext 內建 live region
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`:虛擬游標(showRing → focus-ring-inset)也寫 outline,
1076
- // 兩者特異性同階,留著等於讓「誰贏」取決於 Tailwind 的排序。這一列本來就不可聚焦
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 僅是 treeitem selection 的視覺鏡像;有效 React element 會由 TreeItem
1107
- * 強制正規化為 `aria-hidden` + `tabIndex=-1`,不形成第二個 node tab stop。
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"`(預設):row hover 或鍵盤 focus(focus-visible)時才淡入
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
- focusTree,
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
- const showRing = isFocused && keyboardModality
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
- // ── Focus scroll into view ──
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 && itemRef.current) {
1233
- itemRef.current.scrollIntoView({ block: 'nearest' })
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
- // Virtual-focus contract:DOM focus 必留在 tree root。先 focus 再設定 clicked id,
1244
- // 避免 root onFocus 的「初次進入選第一項」初始化覆寫 pointer target。
1245
- focusTree()
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, focusTree, select, setFocusedId, expandOnSelect, hasChildren, toggleExpand]
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
- focusTree()
1353
+ // 滑鼠點展開箭頭時瀏覽器會把焦點給這顆(tabIndex=-1)按鈕 —— 還給列,鍵盤接著從這一列走
1354
+ focusRow(id)
1260
1355
  setFocusedId(id)
1261
1356
  toggleExpand(id)
1262
1357
  },
1263
- [id, disabled, focusTree, setFocusedId, toggleExpand]
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
- <button
1293
- type="button"
1395
+ <ItemInlineActionButton
1396
+ icon={ChevronRight}
1294
1397
  tabIndex={-1}
1398
+ aria-label={isExpanded ? '收合' : '展開'}
1399
+ aria-expanded={isExpanded}
1295
1400
  onClick={handleChevronClick}
1296
- className={cn(
1297
- 'flex items-center justify-center rounded-md',
1298
- 'text-fg-muted hover:text-fg-secondary hover:bg-neutral-hover',
1299
- // hover 底色瞬間切換,不做過渡(user 2026-09-10 拍板「第三題改成全部瞬間」;SSOT = tokens/motion/motion.spec.md「hover 回饋不做過渡」);只留展開箭頭的旋轉
1300
- 'transition-transform duration-150 motion-reduce:duration-0',
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
- // DOM id 供容器 aria-activedescendant 指向(virtual focus);與 data-tree-id 並存
1325
- // (data-tree-id 給內部 querySelector / drag,id 給 AT)。
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 同組後者勝)+ 鍵盤焦點深一階 -focus。
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-07 遷到 outline 通道(全 DS 兩種幾何的 SSOT,base.css @utility)。
1393
- // **不加 `focus-visible:`** —— 這一列永遠不是 DOM 焦點(焦點停在 role=tree 容器,
1394
- // 見 :369),加了變體會變成永不生效的死用法(TimePicker 就是這樣死的)。
1395
- // 順帶修掉 box-shadow 通道的既有缺陷:高對比模式下 box-shadow 被強制 none,
1396
- // 原本的 ring 在那個模式完全看不見;outline 會照畫。
1397
- showRing && 'focus-ring-inset',
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 污染 treeitem 語意、tabIndex=0
1412
- // 讓每 row 變 DOM tab stop 破壞單一 tab stop 虛擬焦點模型(DOM focus 在 row 上按
1413
- // Enter 會 bubble 到容器 handleKeyDown 但 handler 操作 state focusedId → 錯位);
1414
- // sensors 僅 PointerSensor(無 KeyboardSensor),這些 attrs 換不到鍵盤拖曳能力;
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
- {chevronSlot}
1421
-
1422
- {/* Checkbox 在 icon 前——消費 `<ItemPrefix>` 對齊第一行
1423
- * 2026-05-26 SSOT lock(user explicit「多選的方式應該也是要跟 menu 一樣是出現 checkbox」):
1424
- * - selectionMode='multiple' + 無 consumer checkbox prop → auto-render `<Checkbox>` reflect selectedIds
1425
- * (對齊 SelectMenu multi pattern;consumer 不用手寫 checkbox)
1426
- * - selectionMode='multiple' + consumer 傳 checkbox → 用 consumer 的(parent-child cascade 等 advanced)
1427
- * - selectionMode='single' / 'none' → 不 render checkbox(text-foreground + bg 雙信號表 selected)
1428
- * 對齊 cite:menu-item.tsx:194-195(MenuItem selected bg)+ select-menu.tsx:352-354(SelectMenu multi=checkbox) */}
1429
- {(checkbox || selectionMode === 'multiple') && (
1430
- <ItemPrefix className="pointer-events-none">
1431
- {/* 2026-07-05 D4 修:auto-render Checkbox 補 tabIndex={-1} — Radix Checkbox root
1432
- * 是原生 button(預設可聚焦),aria-hidden + 可聚焦 = axe aria-hidden-focus,且
1433
- * 破壞 tree 單一 tab stop 虛擬焦點模型(tree-view.spec.md「Focus」段);
1434
- * ItemPrefix pointer-events-none 只擋滑鼠不擋鍵盤。選取語意由 treeitem
1435
- * aria-selected 承載,checkbox 純視覺反映。 */}
1436
- {visualCheckbox || <Checkbox checked={isSelected} disabled={disabled} aria-hidden="true" tabIndex={-1} />}
1437
- </ItemPrefix>
1438
- )}
1439
-
1440
- {/* indicator 取代 icon 的位置;h-[1lh] 對齊第一行
1441
- indicator 是 escape hatch(stepper status dot 等客製內容),消費 `<ItemPrefix>` 鎖 chevron 槽寬;
1442
- Icon 走 canonical `<ItemIcon>` helper——自動標 data-prefix-type="icon",
1443
- 讓 SidebarProvider 的全域 :has() prefix-mix 偵測能命中。 */}
1444
- {indicator ? (
1445
- <ItemPrefix style={{ width: iconPx }}>
1446
- {indicator}
1447
- </ItemPrefix>
1448
- ) : Icon ? (
1449
- <ItemIcon icon={Icon} className={disabled ? 'text-fg-disabled' : undefined} />
1450
- ) : null}
1451
-
1452
- <span className={cn('flex-1 min-w-0 truncate', disabled && 'text-fg-disabled')}>
1453
- {label}
1454
- </span>
1455
-
1456
- {/* Suffix inline actions——宣告式 API,用 `<ItemInlineAction>` 渲染。
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"(預設):row hover 或 keyboard focus-visible 才顯示;
1554
+ actionsReveal="hover"(預設):列被滑過、或鍵盤焦點在這一列(列本身 / 列裡的按鈕)才顯示;
1459
1555
  actionsReveal=false:常駐顯示。跟 SidebarMenuButton 共用同一條規則,行為一致。
1460
- inlineActionsSlot escape hatch 優先(consumer 自控 JSX,reveal 一樣套外層 group)。 */}
1461
- {/* 2026-06-12 R2(同 sidebar.tsx 修):宿主 disabled 時 render 層擋 inline actions —
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
- {disabled ? null : inlineActionsSlot ? (
1465
- <ItemSuffix hoverReveal={actionsReveal === 'hover'} hoverGroup="tree-item">
1466
- {inlineActionsSlot}
1467
- </ItemSuffix>
1468
- ) : inlineActions && inlineActions.length > 0 ? (
1469
- <ItemSuffix hoverReveal={actionsReveal === 'hover'} hoverGroup="tree-item">
1470
- {inlineActions.map((action, i) => (
1471
- <ItemInlineAction key={action.label + i} action={action} />
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
- ) : null}
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 role="group" className="flex flex-col w-full">
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-ring'],
1640
+ ring: ['focus-ring-inset'],
1534
1641
  },
1535
1642
  defaultSize: 'md',
1536
1643
  } as const