@qijenchen/design-system 0.1.0-beta.146 → 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 (257) hide show
  1. package/dist/components/Accordion/accordion.d.ts.map +1 -1
  2. package/dist/components/Accordion/accordion.js +4 -1
  3. package/dist/components/Accordion/accordion.js.map +1 -1
  4. package/dist/components/AccountMenu/account-menu.d.ts.map +1 -1
  5. package/dist/components/AccountMenu/account-menu.js.map +1 -1
  6. package/dist/components/AgentPanel/agent-panel-fab.d.ts +7 -1
  7. package/dist/components/AgentPanel/agent-panel-fab.d.ts.map +1 -1
  8. package/dist/components/AgentPanel/agent-panel-fab.js +13 -9
  9. package/dist/components/AgentPanel/agent-panel-fab.js.map +1 -1
  10. package/dist/components/AgentPanel/agent-panel-logo.d.ts +3 -3
  11. package/dist/components/AgentPanel/agent-panel-logo.js.map +1 -1
  12. package/dist/components/AgentPanel/agent-panel.d.ts.map +1 -1
  13. package/dist/components/AgentPanel/agent-panel.js +6 -1
  14. package/dist/components/AgentPanel/agent-panel.js.map +1 -1
  15. package/dist/components/Breadcrumb/breadcrumb.d.ts +3 -1
  16. package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -1
  17. package/dist/components/Breadcrumb/breadcrumb.js.map +1 -1
  18. package/dist/components/BulkActionBar/bulk-action-bar.d.ts.map +1 -1
  19. package/dist/components/BulkActionBar/bulk-action-bar.js +27 -2
  20. package/dist/components/BulkActionBar/bulk-action-bar.js.map +1 -1
  21. package/dist/components/Button/button.d.ts.map +1 -1
  22. package/dist/components/Button/button.js +56 -9
  23. package/dist/components/Button/button.js.map +1 -1
  24. package/dist/components/Calendar/calendar.d.ts +8 -3
  25. package/dist/components/Calendar/calendar.d.ts.map +1 -1
  26. package/dist/components/Calendar/calendar.js +22 -8
  27. package/dist/components/Calendar/calendar.js.map +1 -1
  28. package/dist/components/Carousel/carousel.d.ts.map +1 -1
  29. package/dist/components/Carousel/carousel.js +2 -0
  30. package/dist/components/Carousel/carousel.js.map +1 -1
  31. package/dist/components/Combobox/combobox.d.ts.map +1 -1
  32. package/dist/components/Combobox/combobox.js +10 -4
  33. package/dist/components/Combobox/combobox.js.map +1 -1
  34. package/dist/components/Command/command.d.ts.map +1 -1
  35. package/dist/components/Command/command.js +9 -5
  36. package/dist/components/Command/command.js.map +1 -1
  37. package/dist/components/DataTable/cell-registry.d.ts.map +1 -1
  38. package/dist/components/DataTable/cell-registry.js +2 -1
  39. package/dist/components/DataTable/cell-registry.js.map +1 -1
  40. package/dist/components/DataTable/data-table-column-visibility-panel.d.ts.map +1 -1
  41. package/dist/components/DataTable/data-table-column-visibility-panel.js +24 -24
  42. package/dist/components/DataTable/data-table-column-visibility-panel.js.map +1 -1
  43. package/dist/components/DataTable/data-table-sort-manager.d.ts.map +1 -1
  44. package/dist/components/DataTable/data-table-sort-manager.js +20 -20
  45. package/dist/components/DataTable/data-table-sort-manager.js.map +1 -1
  46. package/dist/components/DataTable/data-table.d.ts.map +1 -1
  47. package/dist/components/DataTable/data-table.js +104 -40
  48. package/dist/components/DataTable/data-table.js.map +1 -1
  49. package/dist/components/Dialog/dialog.d.ts.map +1 -1
  50. package/dist/components/Dialog/dialog.js +55 -43
  51. package/dist/components/Dialog/dialog.js.map +1 -1
  52. package/dist/components/Field/field-context.d.ts +4 -0
  53. package/dist/components/Field/field-context.d.ts.map +1 -1
  54. package/dist/components/Field/field-context.js +1 -2
  55. package/dist/components/Field/field-context.js.map +1 -1
  56. package/dist/components/Field/field-edit-keys.d.ts +8 -0
  57. package/dist/components/Field/field-edit-keys.d.ts.map +1 -1
  58. package/dist/components/Field/field-edit-keys.js +3 -0
  59. package/dist/components/Field/field-edit-keys.js.map +1 -1
  60. package/dist/components/Field/field.d.ts.map +1 -1
  61. package/dist/components/Field/field.js +1 -0
  62. package/dist/components/Field/field.js.map +1 -1
  63. package/dist/components/FileViewer/file-viewer.d.ts.map +1 -1
  64. package/dist/components/FileViewer/file-viewer.js +5 -2
  65. package/dist/components/FileViewer/file-viewer.js.map +1 -1
  66. package/dist/components/LinkInput/link-input.d.ts.map +1 -1
  67. package/dist/components/LinkInput/link-input.js +1 -0
  68. package/dist/components/LinkInput/link-input.js.map +1 -1
  69. package/dist/components/Notice/notice.js +1 -1
  70. package/dist/components/Notice/notice.js.map +1 -1
  71. package/dist/components/NumberInput/number-input.d.ts.map +1 -1
  72. package/dist/components/NumberInput/number-input.js +1 -0
  73. package/dist/components/NumberInput/number-input.js.map +1 -1
  74. package/dist/components/PeoplePicker/people-picker.d.ts.map +1 -1
  75. package/dist/components/PeoplePicker/people-picker.js.map +1 -1
  76. package/dist/components/ProfileCard/profile-card.d.ts.map +1 -1
  77. package/dist/components/ProfileCard/profile-card.js.map +1 -1
  78. package/dist/components/Rating/rating.d.ts.map +1 -1
  79. package/dist/components/Rating/rating.js +6 -3
  80. package/dist/components/Rating/rating.js.map +1 -1
  81. package/dist/components/Select/select.d.ts.map +1 -1
  82. package/dist/components/Select/select.js +13 -7
  83. package/dist/components/Select/select.js.map +1 -1
  84. package/dist/components/SelectionControl/selection-item.d.ts.map +1 -1
  85. package/dist/components/SelectionControl/selection-item.js +1 -0
  86. package/dist/components/SelectionControl/selection-item.js.map +1 -1
  87. package/dist/components/Sidebar/sidebar.d.ts.map +1 -1
  88. package/dist/components/Sidebar/sidebar.js +6 -1
  89. package/dist/components/Sidebar/sidebar.js.map +1 -1
  90. package/dist/components/Steps/steps.d.ts.map +1 -1
  91. package/dist/components/Steps/steps.js +13 -3
  92. package/dist/components/Steps/steps.js.map +1 -1
  93. package/dist/components/Tag/tag.d.ts.map +1 -1
  94. package/dist/components/Tag/tag.js +2 -1
  95. package/dist/components/Tag/tag.js.map +1 -1
  96. package/dist/components/Textarea/textarea.d.ts.map +1 -1
  97. package/dist/components/Textarea/textarea.js +4 -0
  98. package/dist/components/Textarea/textarea.js.map +1 -1
  99. package/dist/components/TimePicker/time-columns.d.ts.map +1 -1
  100. package/dist/components/TimePicker/time-columns.js +3 -3
  101. package/dist/components/TimePicker/time-columns.js.map +1 -1
  102. package/dist/components/TreeView/tree-view.d.ts.map +1 -1
  103. package/dist/components/TreeView/tree-view.js +54 -10
  104. package/dist/components/TreeView/tree-view.js.map +1 -1
  105. package/dist/hooks/use-overflow-items.d.ts.map +1 -1
  106. package/dist/hooks/use-overflow-items.js +2 -0
  107. package/dist/hooks/use-overflow-items.js.map +1 -1
  108. package/dist/hooks/use-truncated.d.ts.map +1 -1
  109. package/dist/hooks/use-truncated.js.map +1 -1
  110. package/dist/index.js +3 -1
  111. package/dist/lib/drag-announcements.d.ts +32 -0
  112. package/dist/lib/drag-announcements.d.ts.map +1 -1
  113. package/dist/lib/drag-announcements.js +39 -1
  114. package/dist/lib/drag-announcements.js.map +1 -1
  115. package/dist/lib/overlay-coexistence.js +1 -1
  116. package/dist/lib/roving-list-keyboard.d.ts +15 -0
  117. package/dist/lib/roving-list-keyboard.d.ts.map +1 -1
  118. package/dist/lib/roving-list-keyboard.js +6 -0
  119. package/dist/lib/roving-list-keyboard.js.map +1 -1
  120. package/dist/lib/utils.js +1 -1
  121. package/dist/lib/utils.js.map +1 -1
  122. package/dist/patterns/horizontal-overflow/horizontal-overflow.d.ts.map +1 -1
  123. package/dist/patterns/horizontal-overflow/horizontal-overflow.js +25 -0
  124. package/dist/patterns/horizontal-overflow/horizontal-overflow.js.map +1 -1
  125. package/dist/patterns/overlay-surface/overlay-surface.js.map +1 -1
  126. package/ds-canonical/fork/codex/agents/skills/performance-audit/SKILL.md +1 -1
  127. package/ds-canonical/fork/consumer/lock.json +11 -11
  128. package/ds-canonical/fork/consumer/managed-files/46f10bc55cdfdf834d0749eebff42ea0bb8576f361b36d2b6ba1851354c50150.blob +47 -9
  129. package/ds-canonical/fork/consumer/managed-files/5af0a46760af338d51fd0f7950675f8e97f7d2f09386b8818fcd7a58e6654e75.blob +71 -1
  130. package/ds-canonical/fork/consumer/managed-files/7eb6cc60abfdabaf6617c336058fad1454ccde08889295767bfd9b7fd153cc6e.blob +45 -6
  131. package/ds-canonical/fork/consumer/managed-files/ab072e7f2eca51f0e18a5e18c4b333260ee5d219a12cc6df5bb2d683f0b2734d.blob +27 -1
  132. package/ds-canonical/fork/governance.lock +9 -9
  133. package/ds-canonical/fork/manifest.json +10 -10
  134. package/ds-canonical/fork/providers/claude/skills/performance-audit/SKILL.md +1 -1
  135. package/ds-canonical/fork/providers/codex/skills/performance-audit/SKILL.md +1 -1
  136. package/ds-canonical/fork/skills/performance-audit/SKILL.md +1 -1
  137. package/ds-canonical/hooks/check_substantive_edit_approval_preflight.sh +29 -8
  138. package/ds-canonical/hooks/tests/test_check_substantive_edit_approval_preflight.sh +16 -6
  139. package/ds-canonical/references/focus-canonical.md +1 -1
  140. package/ds-canonical/references/governance-audit-coverage.md +1 -1
  141. package/ds-canonical/references/hit-area-canonical.md +4 -2
  142. package/ds-canonical/references/keyboard-model-canonical.md +4 -4
  143. package/ds-canonical/rules/meta-patterns.md +1 -1
  144. package/ds-canonical/rules/self-verify.md +4 -4
  145. package/ds-canonical/skills/design-system-audit/references/historical-bugs.md +27 -0
  146. package/ds-canonical/skills/performance-audit/SKILL.md +1 -1
  147. package/ds-story-manifest.json +11 -5
  148. package/llms-full.txt +2 -2
  149. package/llms.txt +2 -2
  150. package/package.json +3 -1
  151. package/src/components/Accordion/accordion.tsx +4 -1
  152. package/src/components/AccountMenu/account-menu.spec.md +1 -2
  153. package/src/components/AccountMenu/account-menu.tsx +0 -1
  154. package/src/components/AgentPanel/agent-decision-card.spec.md +88 -0
  155. package/src/components/AgentPanel/agent-panel-fab.spec.md +151 -0
  156. package/src/components/AgentPanel/agent-panel-fab.tsx +41 -17
  157. package/src/components/AgentPanel/agent-panel-logo.spec.md +87 -0
  158. package/src/components/AgentPanel/agent-panel-logo.tsx +3 -3
  159. package/src/components/AgentPanel/agent-panel.principles.stories.tsx +1 -1
  160. package/src/components/AgentPanel/agent-panel.spec.md +24 -284
  161. package/src/components/AgentPanel/agent-panel.stories.tsx +112 -7
  162. package/src/components/AgentPanel/agent-panel.tsx +9 -4
  163. package/src/components/Breadcrumb/breadcrumb.tsx +3 -1
  164. package/src/components/BulkActionBar/bulk-action-bar.spec.md +1 -0
  165. package/src/components/BulkActionBar/bulk-action-bar.stories.tsx +53 -0
  166. package/src/components/BulkActionBar/bulk-action-bar.tsx +32 -2
  167. package/src/components/Button/button.anatomy.stories.tsx +60 -31
  168. package/src/components/Button/button.spec.md +6 -4
  169. package/src/components/Button/button.tsx +52 -8
  170. package/src/components/Calendar/calendar.anatomy.stories.tsx +10 -7
  171. package/src/components/Calendar/calendar.principles.stories.tsx +8 -3
  172. package/src/components/Calendar/calendar.spec.md +12 -10
  173. package/src/components/Calendar/calendar.stories.tsx +76 -0
  174. package/src/components/Calendar/calendar.tsx +39 -14
  175. package/src/components/Carousel/carousel.spec.md +1 -1
  176. package/src/components/Carousel/carousel.tsx +5 -0
  177. package/src/components/Chip/chip.spec.md +1 -0
  178. package/src/components/Combobox/combobox.anatomy.stories.tsx +2 -1
  179. package/src/components/Combobox/combobox.tsx +19 -5
  180. package/src/components/Command/command.tsx +9 -5
  181. package/src/components/DataTable/cell-registry.tsx +3 -0
  182. package/src/components/DataTable/data-table-column-visibility-panel.tsx +5 -6
  183. package/src/components/DataTable/data-table-known-defects.spec.md +72 -0
  184. package/src/components/DataTable/data-table-scroll-performance.spec.md +295 -0
  185. package/src/components/DataTable/data-table-sort-manager.tsx +5 -6
  186. package/src/components/DataTable/data-table.spec.md +9 -341
  187. package/src/components/DataTable/data-table.stories.tsx +1 -1
  188. package/src/components/DataTable/data-table.tsx +152 -51
  189. package/src/components/DatePicker/date-picker.spec.md +1 -1
  190. package/src/components/Dialog/dialog.spec.md +5 -1
  191. package/src/components/Dialog/dialog.stories.tsx +9 -4
  192. package/src/components/Dialog/dialog.tsx +80 -47
  193. package/src/components/DropdownMenu/dropdown-menu.anatomy.stories.tsx +7 -0
  194. package/src/components/Empty/empty.spec.md +1 -2
  195. package/src/components/Field/field-context.ts +5 -2
  196. package/src/components/Field/field-controls.spec.md +3 -0
  197. package/src/components/Field/field-edit-keys.ts +12 -0
  198. package/src/components/Field/field.spec.md +1 -1
  199. package/src/components/Field/field.tsx +3 -0
  200. package/src/components/FileViewer/file-viewer.tsx +5 -2
  201. package/src/components/LinkInput/link-input.tsx +3 -0
  202. package/src/components/Notice/notice.tsx +1 -1
  203. package/src/components/NumberInput/number-input.tsx +3 -0
  204. package/src/components/Pagination/pagination.spec.md +1 -0
  205. package/src/components/PeoplePicker/people-picker.tsx +0 -3
  206. package/src/components/Popover/popover.spec.md +2 -0
  207. package/src/components/ProfileCard/profile-card.tsx +0 -3
  208. package/src/components/Rating/rating.anatomy.stories.tsx +1 -1
  209. package/src/components/Rating/rating.spec.md +3 -3
  210. package/src/components/Rating/rating.tsx +7 -3
  211. package/src/components/ScrollArea/scroll-area.spec.md +2 -0
  212. package/src/components/Select/select.spec.md +1 -1
  213. package/src/components/Select/select.tsx +11 -4
  214. package/src/components/SelectMenu/select-menu.spec.md +4 -0
  215. package/src/components/SelectionControl/selection-item.tsx +3 -1
  216. package/src/components/Sidebar/sidebar.spec.md +3 -2
  217. package/src/components/Sidebar/sidebar.stories.tsx +79 -0
  218. package/src/components/Sidebar/sidebar.tsx +10 -2
  219. package/src/components/Skeleton/skeleton.spec.md +1 -1
  220. package/src/components/Slider/slider.spec.md +1 -0
  221. package/src/components/Steps/steps-state-visuals.spec.md +157 -0
  222. package/src/components/Steps/steps.spec.md +13 -144
  223. package/src/components/Steps/steps.tsx +29 -5
  224. package/src/components/Tag/tag.spec.md +9 -10
  225. package/src/components/Tag/tag.tsx +2 -2
  226. package/src/components/Textarea/textarea.tsx +4 -0
  227. package/src/components/TimePicker/time-columns.tsx +4 -3
  228. package/src/components/TimePicker/time-picker.spec.md +1 -1
  229. package/src/components/Tooltip/tooltip.spec.md +1 -0
  230. package/src/components/TreeView/tree-view.spec.md +4 -1
  231. package/src/components/TreeView/tree-view.stories.tsx +87 -3
  232. package/src/components/TreeView/tree-view.tsx +88 -12
  233. package/src/hooks/use-overflow-items.ts +4 -0
  234. package/src/hooks/use-truncated.ts +3 -2
  235. package/src/lib/drag-announcements.ts +78 -0
  236. package/src/lib/roving-list-keyboard.ts +20 -0
  237. package/src/lib/utils.ts +1 -1
  238. package/src/patterns/action-bar/action-bar.spec.md +2 -0
  239. package/src/patterns/element-anatomy/inline-action.spec.md +2 -2
  240. package/src/patterns/element-anatomy/item-anatomy.spec.md +5 -3
  241. package/src/patterns/element-anatomy/truncated-text.spec.md +1 -0
  242. package/src/patterns/header-canonical/header-canonical.spec.md +2 -1
  243. package/src/patterns/horizontal-overflow/horizontal-overflow.spec.md +2 -0
  244. package/src/patterns/horizontal-overflow/horizontal-overflow.stories.tsx +36 -0
  245. package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +28 -0
  246. package/src/patterns/overlay-surface/overlay-chrome-sizing.spec.md +180 -0
  247. package/src/patterns/overlay-surface/overlay-surface.spec.md +5 -160
  248. package/src/patterns/overlay-surface/overlay-surface.tsx +1 -1
  249. package/src/patterns/resize-handle/resize-handle.spec.md +2 -2
  250. package/src/tokens/color/color.spec.md +14 -6
  251. package/src/tokens/color/semantic.css +13 -0
  252. package/src/tokens/layoutSpace/layoutSpace.spec.md +2 -0
  253. package/src/tokens/motion/motion.spec.md +5 -0
  254. package/src/tokens/orphan-tokens.spec.md +1 -1
  255. package/src/tokens/uiSize/uiSize.spec.md +1 -1
  256. package/dist/node_modules/aria-hidden/dist/es2015/index.js +0 -147
  257. package/dist/node_modules/aria-hidden/dist/es2015/index.js.map +0 -1
@@ -283,16 +283,6 @@ Tag 是**純視覺 indicator**(非互動 control,互動版本是 Chip),預設 AR
283
283
  - `../Chip/chip.spec.md` — 可互動 filter(Tag 的互動版本)
284
284
  - `../Combobox/combobox.spec.md` — 多選場景會渲染 Tag 陣列
285
285
  - `../ProfileCard/profile-card.spec.md` — 人員資訊顯示
286
-
287
- ## 被引用(auto-maintained,Dim 3 reciprocal audit)
288
-
289
- > 本節由 `scripts/add-reciprocal-pointers.mjs` 自動維護,列出在 SSOT 語境下指向本 spec 的其他 spec。若要手動補充,寫在本節之前。
290
-
291
- - `avatar.spec.md`
292
- - `badge.spec.md`
293
- - `inline-action.spec.md`
294
- - `overflow-indicator.spec.md`
295
-
296
286
  ## 截斷量測不加快取(2026-09-14 退回紀錄)
297
287
 
298
288
  Tag 用 Canvas `measureText` 判截斷(flex 內 `scrollWidth` 不可靠)。一次量測做三件事:`getComputedStyle`(逼出樣式重算)、
@@ -304,3 +294,12 @@ CPU 剖析實測:一次 6,000px/s 手勢裡這個 measure 自身時間 142–232
304
294
  曾加兩層快取(字型 + 文字寬,`document.fonts` `loadingdone` 時整個清掉)。同機交錯 CPU 剖析(dpr2 + CPU×6):
305
295
  main 223ms / 加快取後 380ms / 再把快取鍵最佳化後 412ms —— 每次呼叫要組兩個字串當鍵(其中一個含 Tag 的 Tailwind class,數百字元)再各查一次 Map,
306
296
  組字串加雜湊比它想省的那兩個瀏覽器呼叫還貴。要再嘗試必先重跑同一組對照剖析,拿到比 main 低的數字才可回來。
297
+
298
+ ## 被引用(auto-maintained,Dim 3 reciprocal audit)
299
+
300
+ > 本節由 `scripts/add-reciprocal-pointers.mjs` 自動維護,列出在 SSOT 語境下指向本 spec 的其他 spec。若要手動補充,寫在本節之前。
301
+
302
+ - `avatar.spec.md`
303
+ - `badge.spec.md`
304
+ - `inline-action.spec.md`
305
+ - `overflow-indicator.spec.md`
@@ -33,7 +33,8 @@ function getMeasureCtx() {
33
33
  export const TAG_HEIGHT_PX = { sm: 20, md: 24, lg: 24 } as const
34
34
 
35
35
  const tagVariants = cva(
36
- "inline-flex items-center rounded-md border border-transparent transition-colors cursor-text",
36
+ // 停用(consumer 掛 aria-disabled;Combobox 的停用示範就是)→ 禁止符號蓋掉 cursor-text(hit-area-canonical.md 滑過原則一-3「停用的用禁止符號」,2026-09-29 全站掃出)
37
+ "inline-flex items-center rounded-md border border-transparent transition-colors cursor-text aria-disabled:cursor-not-allowed",
37
38
  {
38
39
  variants: {
39
40
  // color:categorical 色相(裝飾性分類,非語意狀態)。**消費 categorical-color SSOT**——
@@ -189,7 +190,6 @@ function TagInner(
189
190
  // 內部結構;原僅 jsdoc/spec 約束會靜默通過 → 2026-07-05 對齊 Button overlayBadge dev-warn
190
191
  // 先例(button.tsx「Dev-mode warning」段)補齊家族一致 runtime 防線。
191
192
  if (process.env.NODE_ENV !== 'production' && Icon && avatar) {
192
- // eslint-disable-next-line no-console
193
193
  console.warn(
194
194
  '[DS Tag] `icon` 與 `avatar` 互斥(同為 prefix slot),同時傳會並列渲染破壞 Tag 內部結構,請只擇一。SSOT:tag.spec.md Props 表 + tag.principles.stories.tsx IconRule。'
195
195
  )
@@ -43,6 +43,10 @@ const textareaVariants = cva(
43
43
  // K10 fix(2026-05-04):disabled 時 placeholder + text 切 fg-disabled(parallel 到 bareInputStyles)
44
44
  // Textarea 自身 `<textarea disabled>` 帶 disabled HTML attribute,用 `disabled:` variant 直接命中
45
45
  'disabled:placeholder:text-fg-disabled disabled:text-fg-disabled',
46
+ // 停用游標:外框(fieldWrapperStyles disabled compound)是 cursor-not-allowed,但 <textarea disabled> 自己的 UA 樣式
47
+ // 是 cursor: default,指到文字區就變回箭頭 —— 同 Input(input.tsx 停用分支)在控件本身補上禁止符號
48
+ //(field-controls.spec.md「游標指引」disabled → cursor-not-allowed;hit-area-canonical.md 滑過原則一-3「停用的用禁止符號」)。
49
+ 'disabled:cursor-not-allowed',
46
50
  'px-[var(--field-px)]',
47
51
  // 不寫 transition-colors:hover 外框一律瞬間(tokens/motion/motion.spec.md「hover 回饋不做過渡」;2026-09-26 由底色延伸到外框,待辦總帳 L9 / N4(3))。
48
52
  // 外框規則來自 field-wrapper.tsx 的 fieldDefaultChromeCompounds —— 單行 wrapper(fieldWrapperStyles)、
@@ -263,7 +263,8 @@ export function TimeColumns({
263
263
  values={hourValues}
264
264
  selected={safeValue.hours}
265
265
  disabledSet={disabledSets.hours}
266
- label="hours"
266
+ // 讀屏文字中文(全庫 SR label 中文,2026-07-04;同元件的「清除時間」「選擇時間」早已是中文,只有三欄的 listbox 名稱漏了)
267
+ label="時"
267
268
  onSelect={(h) => onChange({ ...safeValue, hours: h })}
268
269
  withDivider
269
270
  />
@@ -271,7 +272,7 @@ export function TimeColumns({
271
272
  values={minuteValues}
272
273
  selected={safeValue.minutes}
273
274
  disabledSet={disabledSets.minutes}
274
- label="minutes"
275
+ label="分"
275
276
  onSelect={(m) => onChange({ ...safeValue, minutes: m })}
276
277
  withDivider={showSeconds}
277
278
  />
@@ -280,7 +281,7 @@ export function TimeColumns({
280
281
  values={secondValues}
281
282
  selected={safeValue.seconds}
282
283
  disabledSet={disabledSets.seconds}
283
- label="seconds"
284
+ label="秒"
284
285
  onSelect={(s) => onChange({ ...safeValue, seconds: s })}
285
286
  />
286
287
  )}
@@ -215,7 +215,7 @@ N/A — TimePicker 是純同步輸入,無 async 狀態。
215
215
  - trigger `role="combobox"`,在 `<Field>` 內時以 `aria-labelledby` 指向 field label(`fieldCtx.labelId`)
216
216
  - **Standalone(無 `<Field>` 包裹)accessible name**:trigger 無預設 `aria-label`,`<Field>` 外使用時 consumer **必須**自帶 `aria-label` 或 `aria-labelledby`,否則 `role="combobox"` 成為無名稱控件(對齊 Select / Combobox / DatePicker 同家族 standalone 約定)。DS 不為 trigger 塞泛用 fallback name(如「時間」),避免遮蔽 consumer 的具體語意(如「會議開始時間」);Field 內則自動接線無需 consumer 介入。Panel 本體(Popover dialog)另有本 DS 提供的 `aria-label="選擇時間"`,與 trigger name 職責分離
217
217
  - Panel 開啟時 trigger `aria-expanded="true"` + `aria-haspopup="dialog"`
218
- - 每欄 `role="listbox"`(`aria-label` 為 `hours` / `minutes` / `seconds`),item `role="option" aria-selected`
218
+ - 每欄 `role="listbox"`(`aria-label` 為 `時` / `分` / `秒`;讀屏文字中文,2026-09-29 由 `hours` / `minutes` / `seconds` 改 —— 全庫 SR label 中文 2026-07-04,同元件的「清除時間」「選擇時間」早已是中文),item `role="option" aria-selected`
219
219
  - Screen reader 經 trigger 的 label + 內層可見值文字朗讀目前選取時間(目前值以可見 `<span>` 呈現,無額外 `aria-valuetext`)
220
220
 
221
221
  ---
@@ -141,6 +141,7 @@ Tooltip 是**單一職責 hover 提示 primitive**(一句話補充),刻意無變
141
141
 
142
142
  > 本節由 `scripts/add-reciprocal-pointers.mjs` 自動維護,列出在 SSOT 語境下指向本 spec 的其他 spec。若要手動補充,寫在本節之前。
143
143
 
144
+ - `agent-panel.spec.md`
144
145
  - `calendar.spec.md`
145
146
  - `coachmark.spec.md`
146
147
  - `date-picker.spec.md`
@@ -92,7 +92,7 @@ TreeView 本身只負責三件事:
92
92
 
93
93
  Chevron 是**展開/收合控件**,不是 prefix icon:`fg-muted`(指示色,hover 時 `fg-secondary`,只前進一階)/ 位置在 indent 之後 icon 之前 / 點擊只 toggle expand 不觸發 selection / `rotate-90` transition。
94
94
 
95
- **消費共用行內小按鈕**(2026-09-26,待辦總帳 L5,user:「是改成inline action對吧?」):箭頭本身就是 `ItemInlineActionButton`(`../../patterns/element-anatomy/item-anatomy.tsx`),尺寸照 `../../patterns/element-anatomy/inline-action.spec.md`「尺寸對照」TreeItem 列 —— 圖示與排版佔位 16(lg 20)、**滑過底色 18(lg 22)= 點得到的範圍**(`../../../ds-canonical/references/hit-area-canonical.md`)、按下多一階 `neutral-active`。與 DataTable 巢狀列的展開箭頭同一顆(`../DataTable/data-table.tsx` nestedPrefix)。縮排不變:箭頭仍住 `ItemPrefix` 鎖寬的槽,多出的 1px 靠溢出不吃版位。2026-09-26 之前這裡手刻一顆 16×16 的按鈕,滑過底色與點擊範圍都只有 16、也沒有按下態。箭頭 `tabIndex=-1`、對讀屏隱藏(展開 / 收合的鍵盤與語意在列上:`→` / `←` 與 `aria-expanded`)。
95
+ **消費共用行內小按鈕**(2026-09-26,待辦總帳 L5,user:「是改成inline action對吧?」):箭頭本身就是 `ItemInlineActionButton`(`../../patterns/element-anatomy/item-anatomy.tsx`),尺寸照 `../../patterns/element-anatomy/inline-action.spec.md`「尺寸對照」TreeItem 列 —— 圖示與排版佔位 16(lg 20)、**滑過底色 18(lg 22)= 點得到的範圍**(`../../../ds-canonical/references/hit-area-canonical.md`)、按下多一階 `neutral-active`。與 DataTable 巢狀列的展開箭頭同一顆(`../DataTable/data-table.tsx` nestedPrefix)。縮排不變:箭頭仍住 `ItemPrefix` 鎖寬的槽,多出的 1px 靠溢出不吃版位。2026-09-26 之前這裡手刻一顆 16×16 的按鈕,滑過底色與點擊範圍都只有 16、也沒有按下態。箭頭 `tabIndex=-1`(不佔 Tab 停靠點;展開 / 收合的鍵盤在列上:`→` / `←`),**但保留名字與角色、不 `aria-hidden`**(2026-09-29 更正,待辦總帳 N46):`aria-label` 展開 / 收合 + `aria-expanded`,語音控制使用者喊「展開」才按得到、讀屏在列裡也叫得到它;與 DataTable 巢狀列的展開箭頭同一種寫法(`../DataTable/data-table.tsx` nestedPrefix)。2026-09-29 前寫 `aria-hidden`,一顆能聚焦(`tabIndex=-1` 仍可被程式與滑鼠聚焦)的按鈕對讀屏隱藏是 axe `aria-hidden-focus` 那一族。
96
96
 
97
97
  ### 佔位規則(chevron + icon)
98
98
 
@@ -175,10 +175,13 @@ Chevron 是**展開/收合控件**,不是 prefix icon:`fg-muted`(指示色,hover
175
175
  | `←` | 展開的資料夾 → 收合;收著的資料夾或葉節點 → 回上一層;最外層 → 不動 |
176
176
  | `Enter` / `Space` | 選取(等同點擊 label;`expandOnSelect` 的連帶展開僅限指標點擊) |
177
177
  | `Tab` / `Shift+Tab` | **一下就離開整棵樹**(別列、本列的按鈕都不在 Tab 路上) |
178
+ | 打字(可見字元) | **打字跳位**(2026-09-29,待辦總帳 N46;`ds-canonical/references/keyboard-model-canonical.md` 五條判準第 2 條把「打字前導」列為樹必備):焦點移到**下一個**名字以該字開頭的可見列(從目前這一列的下一列往後找,到底繞回第一列;折疊大小寫);1 秒內連打多個字 = 以整串比對(「fr」→ Frontend);連打同一個字母而沒有名字以「bb」開頭時退回單字循環(在同字母的列之間輪流)。`Space` 不算(它是選取)。一手:W3C APG Tree View 逐字 "Type a character: focus moves to the next node with a name that starts with the typed character." / "Type multiple characters in rapid succession: focus moves to the next node with a name that starts with the string of characters typed."(<https://www.w3.org/WAI/ARIA/apg/patterns/treeview/>);1 秒的累積窗與同字母退回是 AI 推導。閘:`scripts/tree-view-keyboard-route-invariant.mjs` K1(真按鍵)+ `scripts/test-tree-keyboard-route.mjs`(判定表) |
178
179
  | `Cmd/Ctrl+Shift+↑` / `↓` | 重排:同層上移 / 下移(僅 `draggable` 時;詳「Drag and Drop → 鍵盤重排」) |
179
180
  | `Cmd/Ctrl+Shift+→` | 重排:移入上一個 sibling(需為 folder;收合時自動展開) |
180
181
  | `Cmd/Ctrl+Shift+←` | 重排:移出,成為 parent 的下一個 sibling |
181
182
 
183
+ **列被刪掉時焦點接力**(2026-09-29,待辦總帳 N46「刪除 / 消失後焦點去下一項」):consumer 從資料裡拿掉節點時,焦點若在那一列(或它的按鈕)上,接力到畫面順序的**下一個可見列**(可能是下一個頂層節點);沒有下一列 → **上一個可見列**(可能就是父節點);禁止掉到 `body`。落點順序同 `../FileUpload/file-upload.spec.md`「移除焦點」(下一個 → 上一個 → 擁有者)。收合中的分支不算候選(整段子樹一起卸載,落點是收合的那個父節點)。實作 = `tree-view.tsx` TreeItem 的 layout-effect cleanup(節點拆掉前鄰列都還在);驗證 = `tree-view.stories.tsx`「刪除後焦點接力」(play 實刪四次)。
184
+
182
185
  焦點在**列上的按鈕**上:
183
186
 
184
187
  | 按鍵 | 行為 |
@@ -1,10 +1,10 @@
1
1
  // @story-history: hasInteractiveStates trait — TreeView interactive states(hover / focus / selected / disabled)由 TreeItem 內部處理(spec.md state machine + anatomy StateBehavior story 已 cover),showcase 層 manual Disabled/States story retired per F migration(2026-05-15)— anatomy.stories.tsx auto-compile owns StateBehavior 6-canonical。AllSizes 同理 retired(SizeMatrix anatomy auto-compile owns)。
2
2
  import * as React from 'react'
3
3
  import type { Meta, StoryObj } from '@storybook/react'
4
- import { expect, userEvent, within } from '@storybook/test'
4
+ import { expect, userEvent, waitFor, within } from '@storybook/test'
5
5
  import {
6
6
  Folder, FileText, FileCode, Image, Settings,
7
- CheckCircle2, Circle, Minus, MoreVertical, Plus,
7
+ CheckCircle2, Circle, Minus, MoreVertical, Plus, Trash2,
8
8
  type LucideIcon,
9
9
  } from 'lucide-react'
10
10
  import { TreeView, TreeItem } from './tree-view'
@@ -67,8 +67,9 @@ export const ActionHoverState: Story = {
67
67
  </div>
68
68
  ),
69
69
  play: async ({ canvasElement }) => {
70
+ // 展開箭頭 2026-09-29 起保留名字(aria-label 展開 / 收合)、不再 aria-hidden(tree-view.spec.md「展開 / 收合 chevron」;待辦總帳 N46)
70
71
  const action = canvasElement.querySelector<HTMLElement>(
71
- '[data-tree-id="product-roadmap"] button[aria-hidden="true"]',
72
+ '[data-tree-row="product-roadmap"] button[aria-label="展開"]',
72
73
  )
73
74
  if (!action) throw new Error('TreeView 展開動作不存在')
74
75
  action.setAttribute('data-visual-hover-target', '')
@@ -353,6 +354,89 @@ export const RowActions: Story = {
353
354
  },
354
355
  }
355
356
 
357
+ // ── 刪除節點後焦點接力(2026-09-29 待辦總帳 N46「刪除 / 消失後焦點去下一項」)──────────
358
+ // 真實情境:知識庫的頁面樹,列上的「刪除」把節點從資料裡拿掉。焦點在被刪的那一列(或它的按鈕)上時,
359
+ // 接力到畫面順序的**下一個可見列**(可能是下一個頂層節點);沒有下一列 → **上一個可見列**(可能就是父節點);禁止掉到 body。
360
+ // 落點順序同 FileUpload 的移除鈕(下一個 → 上一個 → 擁有者)。
361
+ type WikiNode = { id: string; label: string; children?: WikiNode[] }
362
+ const dropNode = (nodes: WikiNode[], id: string): WikiNode[] =>
363
+ nodes.filter((n) => n.id !== id).map((n) => (n.children ? { ...n, children: dropNode(n.children, id) } : n))
364
+ const DeleteHandoffTree = () => {
365
+ const [nodes, setNodes] = React.useState<WikiNode[]>([
366
+ { id: 'handbook', label: '員工手冊', children: [
367
+ { id: 'onboarding', label: '到職第一週' },
368
+ { id: 'expense', label: '報帳流程' },
369
+ { id: 'leave', label: '請假規定' },
370
+ ] },
371
+ { id: 'archive', label: '封存' },
372
+ ])
373
+ const remove = (id: string) => ({ icon: Trash2, label: '刪除', onClick: () => setNodes((prev) => dropNode(prev, id)) })
374
+ const renderNodes = (list: WikiNode[]) => list.map((n) => (
375
+ <TreeItem key={n.id} id={n.id} icon={n.children ? Folder : FileText} label={n.label} inlineActions={[remove(n.id)]}>
376
+ {n.children ? renderNodes(n.children) : undefined}
377
+ </TreeItem>
378
+ ))
379
+ return (
380
+ <div className="flex flex-col gap-2">
381
+ <button type="button" className="sr-only">樹狀清單之前</button>
382
+ <div className="w-[320px] border border-divider rounded-lg bg-surface overflow-hidden py-2">
383
+ <TreeView aria-label="知識庫" defaultExpandedIds={['handbook']}>{renderNodes(nodes)}</TreeView>
384
+ </div>
385
+ <button type="button" className="sr-only">樹狀清單之後</button>
386
+ </div>
387
+ )
388
+ }
389
+
390
+ export const DeleteHandoff: Story = {
391
+ name: '刪除後焦點接力',
392
+ parameters: {
393
+ demoFocus: 'keep',
394
+ docs: {
395
+ description: {
396
+ story:
397
+ '列上的「刪除」把節點從資料裡拿掉時,鍵盤焦點不會掉到頁面外:接力到畫面順序的下一個可見列(可能是下一個頂層節點);' +
398
+ '已是最後一列 → 上一個可見列(可能就是父節點)。這則的 play 用鍵盤實際刪四次驗證(同層下一列 / 下一個頂層節點 / 上一列 / 父節點)。',
399
+ },
400
+ },
401
+ },
402
+ render: () => <DeleteHandoffTree />,
403
+ play: async ({ canvasElement }) => {
404
+ const canvas = within(canvasElement)
405
+ const before = canvas.getByRole('button', { name: '樹狀清單之前' })
406
+ const row = (id: string) => {
407
+ const el = canvasElement.querySelector<HTMLElement>(`[data-tree-row="${id}"]`)
408
+ if (!el) throw new Error(`找不到列 ${id}`)
409
+ return el
410
+ }
411
+ // Tab 進樹落在第一列(員工手冊),↓↓ 到「報帳流程」,→ 進它的「刪除」,Enter 刪掉 → 焦點接力到下一列「請假規定」
412
+ before.focus()
413
+ await userEvent.tab()
414
+ await expect(row('handbook')).toHaveFocus()
415
+ await userEvent.keyboard('{ArrowDown}{ArrowDown}')
416
+ await expect(row('expense')).toHaveFocus()
417
+ await userEvent.keyboard('{ArrowRight}')
418
+ await expect(row('expense').querySelector('[data-tree-actions] button')).toHaveFocus()
419
+ await userEvent.keyboard('{Enter}')
420
+ await waitFor(() => expect(canvasElement.querySelector('[data-tree-row="expense"]')).toBeNull())
421
+ await waitFor(() => expect(row('leave')).toHaveFocus())
422
+ // 刪掉「請假規定」(員工手冊的最後一個子節點):畫面順序的下一個可見列是下一個頂層節點「封存」
423
+ await userEvent.keyboard('{ArrowRight}{Enter}')
424
+ await waitFor(() => expect(canvasElement.querySelector('[data-tree-row="leave"]')).toBeNull())
425
+ await waitFor(() => expect(row('archive')).toHaveFocus())
426
+ // 刪掉「封存」(整棵樹最後一列):沒有下一列 → 上一個可見列「到職第一週」
427
+ await userEvent.keyboard('{ArrowRight}{Enter}')
428
+ await waitFor(() => expect(canvasElement.querySelector('[data-tree-row="archive"]')).toBeNull())
429
+ await waitFor(() => expect(row('onboarding')).toHaveFocus())
430
+ // 再刪「到職第一週」(唯一剩下的子節點):上一列就是父節點「員工手冊」;整棵樹的 Tab 停靠點跟著落在它
431
+ await userEvent.keyboard('{ArrowRight}{Enter}')
432
+ await waitFor(() => expect(canvasElement.querySelector('[data-tree-row="onboarding"]')).toBeNull())
433
+ await waitFor(() => expect(row('handbook')).toHaveFocus())
434
+ await expect(row('handbook')).toHaveAttribute('tabindex', '0')
435
+ // 收尾:焦點放回樹外
436
+ canvas.getByRole('button', { name: '樹狀清單之後' }).focus()
437
+ },
438
+ }
439
+
356
440
  // ── Long label (wrap test) ──────────────────────────────────────────────
357
441
 
358
442
  export const LongLabel: Story = {
@@ -17,7 +17,7 @@ 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
22
  import { Checkbox } from '@/design-system/components/Checkbox/checkbox'
23
23
  // 「列上有小按鈕的一串」鍵盤路線的唯一判定與執行(與 Sidebar / FileUpload / Command 共用;判定表 scripts/test-roving-list-keyboard.mjs
@@ -161,6 +161,8 @@ const DEFAULT_REORDER_ANNOUNCEMENTS: Required<TreeReorderAnnouncements> = {
161
161
  // + 子項容器(`data-tree-children`,Radix Collapsible Content)。列不得包住子列(treegrid 的 row 只能擁有格),
162
162
  // 所以 aria-level / aria-expanded / aria-selected 都住在 row 上,wrapper 只是結構。
163
163
  const ROW_SELECTOR = '[data-tree-row]'
164
+ /** 打字跳位:兩次按鍵相隔在這之內算同一串(AI 推導;見 TreeView 內 typeahead) */
165
+ const TYPEAHEAD_WINDOW_MS = 1000
164
166
  const cssEscape = (value: string) =>
165
167
  typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(value) : value.replace(/["\\]/g, '\\$&')
166
168
  const rowSelector = (id: string) => `[data-tree-row="${cssEscape(id)}"]`
@@ -404,6 +406,8 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
404
406
  // (來源見 tree-view.spec.md「A11y 預設」)。
405
407
  const [focusedId, setFocusedId] = React.useState<string | null>(null)
406
408
  const treeRef = React.useRef<HTMLDivElement>(null)
409
+ // 打字跳位的累積字串(見下方 typeahead)
410
+ const typeaheadRef = React.useRef({ buffer: '', at: 0 })
407
411
  React.useImperativeHandle(ref, () => treeRef.current!)
408
412
  const focusRow = React.useCallback((id: string) => {
409
413
  treeRef.current?.querySelector<HTMLElement>(rowSelector(id))?.focus({ preventScroll: true })
@@ -412,10 +416,11 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
412
416
  // ── 唯一的 Tab 停靠點(roving tabindex)──
413
417
  // 順序:焦點最後停的那一列(仍看得到)→ 選中的第一列 → 第一個可用列
414
418
  // (W3C keyboard-interface「進入組合元件時落在選中項,沒有就第一項」;來源見 spec)。
415
- // 每次 render 後在 layout 階段重算(列的展開 / 選取 / 卸載都會改變答案),相同就不 setState,不會迴圈。
419
+ // 在 layout 階段從真 DOM 重算,相同就不 setState,不會迴圈:答案只看列的存在 / 開合 / aria-selected / aria-disabled 與 focusedId,
420
+ // 不看 tabStopId 本身(它只影響列的 tabIndex),所以 set 之後那一次重算必然相等。
416
421
  const [tabStopId, setTabStopId] = React.useState<string | null>(null)
417
- // 只用來觸發一次 re-render(值本身不讀):列卸載後讓下方 layout effect 重算停靠點
418
- const [, setRowRepairTick] = React.useState(0)
422
+ // 值本身不讀,只當下方 layout effect 的相依:列卸載時 TreeView 自己不一定 re-render,靠它讓 effect 重算停靠點
423
+ const [rowRepairTick, setRowRepairTick] = React.useState(0)
419
424
  const tabStopRef = React.useRef<string | null>(null)
420
425
  tabStopRef.current = tabStopId
421
426
  const focusedIdRef = React.useRef<string | null>(null)
@@ -434,7 +439,11 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
434
439
  })
435
440
  const next = stop?.dataset.treeRow ?? null
436
441
  if (next !== tabStopId) setTabStopId(next)
437
- })
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])
438
447
  // 列被卸載(例:consumer 收合了焦點所在列的上一層,關閉動畫結束後子樹才卸載 —— 那一刻 TreeView 本身不會 re-render)
439
448
  // → 若它是停靠點或焦點所在,觸發一次重算,否則整棵樹會從 Tab 路上消失。
440
449
  const onRowUnmount = React.useCallback((id: string) => {
@@ -607,10 +616,8 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
607
616
  // 這裡把指標路徑也接上,並誠實回報結果:守衛擋下(不合法 target / 子樹內)
608
617
  // 就播「未變更」而不是假的成功。共用 SSOT 見 `lib/drag-announcements.ts`。
609
618
  const dragOutcomeRef = React.useRef<DragOutcome | null>(null)
610
- const dndAnnouncements = React.useMemo(
611
- () => createDragAnnouncements({ getOutcome: () => dragOutcomeRef.current, kind: '項目' }),
612
- [],
613
- )
619
+ // 指標拖曳的播報走 polite 區域 + 繁中操作說明(2026-09-27 OE10;先前交給 dnd-kit 寫死的 assertive 區域)
620
+ const drag = useDragAccessibility({ getOutcome: () => dragOutcomeRef.current, kind: '項目' })
614
621
 
615
622
  const handleDragEnd = React.useCallback((event: DragEndEvent) => {
616
623
  dragOutcomeRef.current = null
@@ -906,6 +913,8 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
906
913
  controlIndex: onRow ? -1 : actions.indexOf(target),
907
914
  controlIsTextEntry: !onRow && isTextEntryElement(target),
908
915
  tree: { hasChildren: wrapper?.dataset.treeHasChildren === 'true', expanded: expandedIds.has(id), hasParent: parentId != null },
916
+ // 打字跳位只在列上(按鈕上打字沒有意義;動作格裡的輸入框由 controlIsTextEntry 保住自己的打字)
917
+ typeahead: onRow,
909
918
  // 未啟用拖曳 → 重排組合整組 no-op(modifier 組合不落入導覽)
910
919
  reorderable: draggable,
911
920
  defaultPrevented: e.defaultPrevented,
@@ -933,9 +942,42 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
933
942
  onCollapse: () => toggleExpand(id),
934
943
  onParent: () => { if (parentId) focusRow(parentId) },
935
944
  onActivateItem: () => select(id),
945
+ onTypeahead: (char) => typeahead(char, row),
936
946
  })
937
947
  }
938
948
 
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
+ }
980
+
939
981
  const treeEl = (
940
982
  <div
941
983
  // {...props} 在最前:內部 role/style(--tree-px)/onKeyDownCapture 必須勝過 consumer
@@ -997,9 +1039,11 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
997
1039
  onDragOver={handleDragOver}
998
1040
  onDragEnd={handleDragEnd}
999
1041
  onDragCancel={handleDragCancel}
1000
- accessibility={{ announcements: dndAnnouncements }}
1042
+ accessibility={drag.accessibility}
1001
1043
  >
1002
1044
  {treeEl}
1045
+ {/* 指標拖曳播報的 polite 區域(sr-only;lib/drag-announcements.ts useDragAccessibility);鍵盤重排另有下方自有區域 */}
1046
+ {drag.liveRegion}
1003
1047
  {draggable && (
1004
1048
  <>
1005
1049
  {/* 鍵盤重排 SR 播報 — TreeView 自有 sr-only polite live region(單一節點覆寫式更新;
@@ -1229,6 +1273,34 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
1229
1273
  }
1230
1274
  }, [isFocused])
1231
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
+
1232
1304
  // ── 列上的按鈕不在 Tab 路上(總帳 B9;tree-view.spec.md「鍵盤導覽」)──
1233
1305
  // 動作格裡每一個可聚焦的東西都設 tabIndex=-1,改由 → / ← 走到。用 DOM 設而不是逐一傳 prop:
1234
1306
  // inlineActionsSlot 是 consumer 的任意 ReactNode(選單觸發鈕、連結…),拿不到它們的 props;
@@ -1313,14 +1385,18 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
1313
1385
  // 依據 inline-action.spec.md「尺寸對照」TreeItem 列 + hit-area-canonical.md:滑過底色 18(lg 22)= 點得到的範圍,
1314
1386
  // 盒與排版佔位仍是圖示尺寸 16 / 20,多出的 1px 靠溢出;按下多一階 neutral-active)。與 DataTable 巢狀列的展開箭頭同一顆
1315
1387
  //(data-table.tsx nestedPrefix)。2026-09-26 之前這裡手刻一顆 16×16 的 <button>,滑過底色與點擊範圍都只有 16。
1316
- // 鍵盤用 → / ← 展開收合(列上的 aria-expanded),箭頭本身不在 Tab 路上、對讀屏隱藏。
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 那一族。
1317
1392
  const chevronSlot = (
1318
1393
  <ItemPrefix style={{ width: iconPx }}>
1319
1394
  {hasChildren ? (
1320
1395
  <ItemInlineActionButton
1321
1396
  icon={ChevronRight}
1322
1397
  tabIndex={-1}
1323
- aria-hidden
1398
+ aria-label={isExpanded ? '收合' : '展開'}
1399
+ aria-expanded={isExpanded}
1324
1400
  onClick={handleChevronClick}
1325
1401
  // 展開 / 收合的旋轉是狀態切換的動畫,不是滑過回饋 → 保留(motion.spec.md「hover 回饋不做過渡」只管滑過;
1326
1402
  // 滑過底色與圖示色由 ItemInlineActionButton 瞬間切換)
@@ -88,11 +88,15 @@ export function useScrollEdges<T extends HTMLElement = HTMLElement>(): UseScroll
88
88
  // 監聽 children 變化(items 增減、字體載入)
89
89
  const mo = new MutationObserver(update)
90
90
  mo.observe(el, { childList: true, subtree: true, characterData: true })
91
+ // 標記捲動容器(2026-09-29,待辦總帳 OE26):OverflowScrollArrow 卸載時若焦點在它身上、另一側箭頭也不在,
92
+ // 就把焦點交給這個容器 —— 靠這個屬性找到它,消費者不用多傳 ref。
93
+ el.setAttribute('data-overflow-scroller', '')
91
94
 
92
95
  return () => {
93
96
  el.removeEventListener('scroll', update)
94
97
  ro.disconnect()
95
98
  mo.disconnect()
99
+ el.removeAttribute('data-overflow-scroller')
96
100
  }
97
101
  }, [])
98
102
 
@@ -103,8 +103,9 @@ export function useTruncated<E extends HTMLElement = HTMLElement>(
103
103
  if (t) clearTimeout(t)
104
104
  cleanup()
105
105
  }
106
- // deps 由 consumer 決定(Tag: [children];其餘: []);measure 走 ref 不需進 deps。
107
- // eslint-disable-next-line react-hooks/exhaustive-deps
106
+ // deps 由 consumer 決定(預設 [] = mount-once;Tag / Calendar / Select 等傳文字來源);measure 走 ref 不需進 deps;
107
+ // recheckAfterPaint / timing 是 call-site 固定字面值,只在訂閱當下讀一次。exhaustive-deps 只認 useEffect /
108
+ // useLayoutEffect 等已知名字(config 未設 additionalHooks),不檢查 `useIsoEffect` 別名 → 這裡不掛 eslint-disable。
108
109
  }, deps)
109
110
 
110
111
  return { ref, isTruncated }
@@ -25,6 +25,25 @@
25
25
  // handler 靜默 `return prev`,而螢幕閱讀器仍聽到「已移動」。
26
26
  // 2026-09-07 錨:ColumnReorder story 的 columnOrder 漏了 `seller`(畫面 7 欄、state 6 個),
27
27
  // 拖到該欄時就是這個情況;已修 story,並由 `scripts/drag-runtime-contract.mjs` 守著。
28
+ //
29
+ // **播報的禮貌等級 = polite,不是 assertive**(2026-09-27,待辦總帳 OE10):
30
+ // dnd-kit 6.3.1 自己的 live region 寫死 `aria-live="assertive"`
31
+ // (`@dnd-kit/accessibility` LiveRegion 的 `ariaLiveType = "assertive"`,`DndContext` 沒有任何 prop 能改),
32
+ // 拖曳中每移到一個落點就打斷螢幕閱讀器正在唸的句子。WAI-ARIA 1.2 對 `assertive` 的定義:
33
+ // "Indicates that updates to the region have the highest priority and should be presented to the user immediately."
34
+ // 並明文 "don't use the assertive value unless the interruption is imperative"
35
+ // (<https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live>,
36
+ // 轉述 <https://www.w3.org/TR/wai-aria-1.2/#aria-live>)。拖曳的落點回饋不是「必須打斷」的事 ——
37
+ // DS 內自己的先例也是這麼判的:TreeView 鍵盤重排的區域是 polite(tree-view.tsx)、Toast 只有 error / warning
38
+ // 才 assertive(toast.tsx)、Alert 同(alert.tsx)。所以 `useDragAccessibility` 把文字寫進**本檔自己的 polite 區域**,
39
+ // 交給 dnd-kit 的那份永遠是空字串(它的 assertive 區域因此一個字都不會唸)。
40
+ // 世界級並非一致(react-beautiful-dnd 的 announcer 是 assertive),這裡依 DS 內既有 canonical(M23)取 polite。
41
+ //
42
+ // **操作說明也一併改成繁中**:dnd-kit 預設的 `screenReaderInstructions.draggable`
43
+ // ("To pick up a draggable item, press the space bar…")會被掛到每一顆把手的 `aria-describedby`,
44
+ // 四個 DndContext 先前全部唸英文。
45
+
46
+ import * as React from 'react'
28
47
 
29
48
  /** 拖曳結果。`null` = 沒有真的重排(被守衛擋下 / 使用者放在原位)。 */
30
49
  export interface DragOutcome {
@@ -85,3 +104,62 @@ export function createDragAnnouncements({ getOutcome, kind }: DragAnnouncementAr
85
104
  `已取消移動${kindOf(active)}『${String(active.id)}』,回到原位`,
86
105
  }
87
106
  }
107
+
108
+ /**
109
+ * dnd-kit 掛在每顆把手 `aria-describedby` 上的操作說明(繁中;取代 dnd-kit 的英文預設)。
110
+ * 句型沿用 TreeView 既有的鍵盤重排說明(tree-view.tsx `DEFAULT_REORDER_ANNOUNCEMENTS.instructions`)。
111
+ */
112
+ export const DRAG_SCREEN_READER_INSTRUCTIONS = {
113
+ draggable: '按空白鍵提起,用方向鍵移動,再按空白鍵放下;按 Esc 取消。', // i18n-allow: DS 預設文案
114
+ }
115
+
116
+ type DragAnnouncements = ReturnType<typeof createDragAnnouncements>
117
+
118
+ /**
119
+ * 四個 `DndContext` 共用的無障礙接線:**繁中播報 + polite 區域 + 繁中操作說明**。
120
+ *
121
+ * 用法:
122
+ * ```tsx
123
+ * const drag = useDragAccessibility({ getOutcome: () => outcomeRef.current, kind: '列' })
124
+ * <DndContext sensors={…} onDragEnd={…} accessibility={drag.accessibility}>
125
+ * {children}
126
+ * {drag.liveRegion}
127
+ * </DndContext>
128
+ * ```
129
+ * `accessibility.announcements` 的四個函式把句子寫進 `liveRegion`(role=status、aria-live=polite)後
130
+ * **回傳空字串**:dnd-kit 只在回傳值非 null 時寫進它自己的 assertive 區域,空字串寫進去也沒有東西可唸。
131
+ * `liveRegion` 必須渲染在 DOM 裡(放在 DndContext 內外都可以);沒渲染 = 螢幕閱讀器整趟拖曳無聲,
132
+ * 靜態閘 `scripts/drag-announcement-invariant.mjs` 會抓。
133
+ */
134
+ export function useDragAccessibility(args: DragAnnouncementArgs): {
135
+ accessibility: { announcements: DragAnnouncements; screenReaderInstructions: typeof DRAG_SCREEN_READER_INSTRUCTIONS }
136
+ liveRegion: React.ReactElement
137
+ } {
138
+ const [text, setText] = React.useState('')
139
+ // 呼叫端幾乎都傳 inline 箭頭函式;拿它們當 memo 依賴會每次 render 重建 announcements、DndContext 也跟著拿到新物件。
140
+ // 所以 announcements 只建一份,讀取時再經 ref 取最新的 getOutcome / kind。
141
+ const argsRef = React.useRef(args)
142
+ argsRef.current = args
143
+ const accessibility = React.useMemo(() => {
144
+ const inner = createDragAnnouncements({
145
+ getOutcome: () => argsRef.current.getOutcome(),
146
+ kind: (active) => { const k = argsRef.current.kind; return typeof k === 'function' ? k(active) : k },
147
+ })
148
+ // 同一句連續兩次(例:拖曳中一直停在同一個落點)不重寫 —— 文字沒變,polite 區域本來就不會再唸;
149
+ // 換成別句再回來則會唸(狀態真的變了)。
150
+ const speak = (s: string) => { setText(s); return '' }
151
+ const announcements: DragAnnouncements = {
152
+ onDragStart: (e) => speak(inner.onDragStart(e)),
153
+ onDragOver: (e) => speak(inner.onDragOver(e)),
154
+ onDragEnd: () => speak(inner.onDragEnd()),
155
+ onDragCancel: (e) => speak(inner.onDragCancel(e)),
156
+ }
157
+ return { announcements, screenReaderInstructions: DRAG_SCREEN_READER_INSTRUCTIONS }
158
+ }, [])
159
+ const liveRegion = React.createElement(
160
+ 'div',
161
+ { role: 'status', 'aria-live': 'polite', 'aria-atomic': true, className: 'sr-only', 'data-drag-live-region': '' },
162
+ text,
163
+ )
164
+ return { accessibility, liveRegion }
165
+ }
@@ -59,6 +59,13 @@ export interface RovingKeyInput {
59
59
  tree?: RovingTreeState
60
60
  /** 可以用 Cmd / Ctrl + Shift + 方向鍵重排(只有 draggable 的樹) */
61
61
  reorderable?: boolean
62
+ /**
63
+ * 這一串有「打字跳位」(只有樹:keyboard-model-canonical.md 五條判準第 2 條「需要 tree 那一整套鍵盤模型(打字前導、…)」)。
64
+ * 焦點在項目上打一個可見字元 → 回 `typeahead`,由宿主找下一個名字以該字開頭的項目;平面清單(側欄 / 檔案清單)不傳 = 不處理。
65
+ * 出處:W3C APG Tree View 逐字 "Type a character: focus moves to the next node with a name that starts with the typed character."
66
+ * (<https://www.w3.org/WAI/ARIA/apg/patterns/treeview/>)。
67
+ */
68
+ typeahead?: boolean
62
69
  /** 事件已被更外層的捕獲處理程式 preventDefault */
63
70
  defaultPrevented: boolean
64
71
  metaKey: boolean
@@ -92,6 +99,8 @@ export type RovingKeyAction =
92
99
  | { type: 'activate-control' }
93
100
  /** 鍵盤重排(樹;修飾鍵層,與導覽鍵正交) */
94
101
  | { type: 'reorder'; key: RovingReorderKey }
102
+ /** 打字跳位(只有 `typeahead: true` 的宿主):焦點在項目上打了一個可見字元,由宿主找下一個名字以它開頭的項目 */
103
+ | { type: 'typeahead'; char: string }
95
104
 
96
105
  const REORDER_KEYS: readonly string[] = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
97
106
  const isReorderKey = (key: string): key is RovingReorderKey => REORDER_KEYS.includes(key)
@@ -107,6 +116,7 @@ const isReorderKey = (key: string): key is RovingReorderKey => REORDER_KEYS.incl
107
116
  * | ← | 樹:展開的先收合、否則回上一層;平面清單:不動 | 上一個;第一個 → 回項目 |
108
117
  * | Tab / Shift+Tab | 一下離開這一串 | 同左(X7:先回項目、再由瀏覽器往外走) |
109
118
  * | Enter / 空白鍵 | 這一項自己的預設動作 | 那個東西自己的動作 |
119
+ * | 可見字元(只有 `typeahead` 宿主 = 樹)| 打字跳位:下一個名字以它開頭的項目(W3C APG Tree View) | 不處理(按鈕上打字沒有意義) |
110
120
  */
111
121
  export function resolveRovingKey(input: RovingKeyInput): RovingKeyAction {
112
122
  const { key } = input
@@ -191,6 +201,8 @@ export function resolveRovingKey(input: RovingKeyInput): RovingKeyAction {
191
201
  case ' ':
192
202
  return { type: 'activate-item' }
193
203
  default:
204
+ // 打字跳位(樹):單一可見字元(空白鍵已在上面 = 選取;Shift+字母 也是一個可見字元,大小寫由宿主比對時折疊)
205
+ if (input.typeahead && key.length === 1 && key !== ' ') return { type: 'typeahead', char: key }
194
206
  return { type: 'none' }
195
207
  }
196
208
  }
@@ -322,6 +334,8 @@ export interface RovingDomHost {
322
334
  onParent?: () => void
323
335
  /** 項目本身的預設動作要由宿主執行時才傳(樹 = 選取);不傳 = 項目是原生按鈕,交給瀏覽器 */
324
336
  onActivateItem?: () => void
337
+ /** 打字跳位(只有 `typeahead: true` 的宿主):宿主自己累積字串、找項目、搬焦點 */
338
+ onTypeahead?: (char: string) => void
325
339
  }
326
340
 
327
341
  /**
@@ -348,6 +362,12 @@ export function applyRovingAction(action: RovingKeyAction, host: RovingDomHost):
348
362
  event.preventDefault()
349
363
  host.onActivateItem()
350
364
  return
365
+ case 'typeahead':
366
+ // 判定只在 typeahead 宿主才會回這格;擋預設是為了不讓 Firefox「輸入時搜尋」之類的瀏覽器功能吃掉字元
367
+ if (!host.onTypeahead) return
368
+ event.preventDefault()
369
+ host.onTypeahead(action.char)
370
+ return
351
371
  default:
352
372
  break
353
373
  }
package/src/lib/utils.ts CHANGED
@@ -80,7 +80,7 @@ const twMerge = extendTailwindMerge({
80
80
  // color.spec.md「Hover 換色配對總則」,待辦總帳 B8)。它們寫的是 background-image,不是底色 ——
81
81
  // 不註冊的話 tailwind-merge 把 `bg-*` 一律猜成 bg-color,`cn('bg-surface', 'bg-interaction-hover')`
82
82
  // 會把 bg-surface 刪掉,底色整個消失(2026-09-25 以 tailwind-merge 3.5 實測)。登記在 bg-image 才對得上它真正寫的屬性。
83
- 'bg-image': ['bg-interaction-hover', 'bg-interaction-active'],
83
+ 'bg-image': ['bg-interaction-hover', 'bg-interaction-active', 'bg-interaction-selected', 'bg-interaction-selected-hover', 'bg-interaction-selected-active'],
84
84
  },
85
85
  },
86
86
  })