@qijenchen/design-system 0.1.0-beta.89 → 0.1.0-beta.91

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.
@@ -1 +1 @@
1
- {"version":3,"file":"use-truncated.js","sources":["../../src/hooks/use-truncated.ts"],"sourcesContent":["import * as React from 'react'\n\n// ── useTruncated — 單行文字截斷偵測引擎(SSOT)─────────────────────────────────\n// 收斂 Breadcrumb TruncatedLabel / DataTable TruncateCell / Tag 三處**逐字重複**的\n// module-level shared ResizeObserver 引擎 + isTruncated 狀態機為唯一 owner(M17 SSOT)。\n//\n// 世界級對照(M8):MUI/MUI-X 官方無第一方 primitive,社群共識 = 「hook 量測 scrollWidth>clientWidth\n// + ref + 以 Tooltip `open` prop 條件控制」(issue #37211);此 hook 即該低階量測層,\n// 上層 `<TruncatedText>`(patterns/element-anatomy)對應 Ant `Typography.Text ellipsis` 高階元件。\n//\n// 三處變異點皆以 options 注入,**行為零漂移**:\n// - Breadcrumb / DataTable:預設 scrollWidth>clientWidth。\n// - Tag:trigger≠量測元素(observe root、量測內層 span)+ Canvas measureText(flex 內 scrollWidth\n// 不可靠)+ useLayoutEffect(避免 paint flash)+ deps=[children] → 全走 options。\n\ntype RoCallback = (entry: ResizeObserverEntry) => void\n\nlet sharedRO: ResizeObserver | null = null\nconst sharedROCallbacks = new WeakMap<Element, RoCallback>()\n\n// 全 DS 共用單一 ResizeObserver,dispatch 到 per-element callback(2026-04-22 DataTable D3 perf audit\n// 成果:10 col × 100 row = 1 RO 而非 1000)。element 卸載時 cleanup,singleton RO 本身不 disconnect。\nfunction getSharedRO(): ResizeObserver {\n if (sharedRO) return sharedRO\n sharedRO = new ResizeObserver((entries) => {\n entries.forEach((e) => {\n const cb = sharedROCallbacks.get(e.target)\n if (cb) cb(e)\n })\n })\n return sharedRO\n}\n\nfunction observeShared(el: Element, cb: RoCallback): () => void {\n const obs = getSharedRO()\n sharedROCallbacks.set(el, cb)\n obs.observe(el)\n return () => {\n sharedROCallbacks.delete(el)\n obs.unobserve(el)\n }\n}\n\nexport interface UseTruncatedOptions {\n /**\n * 偵測策略:回傳 `true` = 截斷、`false` = 未截斷、`undefined` = 本次無法量測(保留前一狀態)。\n * 預設 = `el.scrollWidth > el.clientWidth`(觀察元素自身)。\n * Tag 傳自訂 fn:observe root、內部 `querySelector('[data-tag-text]')` + Canvas measureText。\n */\n measure?: (observedEl: HTMLElement) => boolean | undefined\n /** deps 變更時 cleanup + 重新量測 / 重新訂閱(Tag 傳 `[children]`)。預設 `[]`(mount-once)。 */\n deps?: React.DependencyList\n /**\n * 是否在首次 paint 後以 `requestAnimationFrame` + `setTimeout(100)` 再量一次,捕獲首幀 layout\n * 未完成 / 字型 async load 的假陰性(Breadcrumb 已採此 robust 版)。預設 `true`。\n */\n recheckAfterPaint?: boolean\n /** `'layoutEffect'` = `useLayoutEffect`(量測在 paint 前,避免 flash;Tag 用)/ `'effect'` = `useEffect`。預設 `'effect'`。 */\n timing?: 'effect' | 'layoutEffect'\n}\n\nconst defaultMeasure = (el: HTMLElement): boolean => el.scrollWidth > el.clientWidth\n\n/**\n * 偵測 ref 所指元素的文字是否被截斷,回傳 `{ ref, isTruncated }`。\n * `isTruncated` 用來驅動 Tooltip「僅截斷時顯示」(對齊 `tooltip.principles.stories.tsx`「沒被截斷就不該顯示 tooltip」)。\n */\nexport function useTruncated<E extends HTMLElement = HTMLElement>(\n options: UseTruncatedOptions = {},\n): { ref: React.MutableRefObject<E | null>; isTruncated: boolean } {\n const { measure = defaultMeasure, deps = [], recheckAfterPaint = true, timing = 'effect' } = options\n // MutableRefObject(consumer 可經 callback ref 寫入 .current 以與 forwardedRef 合併,如 Tag)。\n const ref = React.useRef<E | null>(null)\n const [isTruncated, setIsTruncated] = React.useState(false)\n\n // measure 每 render 可能是新 closure(consumer inline);收進 ref 讓 effect 不因它重訂閱,\n // 但 RO 回呼仍讀到最新版。\n const measureRef = React.useRef(measure)\n measureRef.current = measure\n\n // timing 為 call-site 固定字面值(某 consumer 永遠同一值)→ 每個 component instance 每 render\n // 呼叫同一個 effect hook,符合 rules-of-hooks。\n const useIsoEffect = timing === 'layoutEffect' ? React.useLayoutEffect : React.useEffect\n\n useIsoEffect(() => {\n const el = ref.current\n if (!el) return\n const check = () => {\n const r = measureRef.current(el)\n if (r !== undefined) setIsTruncated(r)\n }\n check()\n let raf = 0\n let t: ReturnType<typeof setTimeout> | undefined\n if (recheckAfterPaint) {\n raf = requestAnimationFrame(check)\n t = setTimeout(check, 100)\n }\n const cleanup = observeShared(el, check)\n return () => {\n if (raf) cancelAnimationFrame(raf)\n if (t) clearTimeout(t)\n cleanup()\n }\n // deps 由 consumer 決定(Tag: [children];其餘: []);measure 走 ref 不需進 deps。\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { ref, isTruncated }\n}\n"],"names":[],"mappings":";AAiBA,IAAI,WAAkC;AACtC,MAAM,wCAAwB,QAAA;AAI9B,SAAS,cAA8B;AACrC,MAAI,SAAU,QAAO;AACrB,aAAW,IAAI,eAAe,CAAC,YAAY;AACzC,YAAQ,QAAQ,CAAC,MAAM;AACrB,YAAM,KAAK,kBAAkB,IAAI,EAAE,MAAM;AACzC,UAAI,OAAO,CAAC;AAAA,IACd,CAAC;AAAA,EACH,CAAC;AACD,SAAO;AACT;AAEA,SAAS,cAAc,IAAa,IAA4B;AAC9D,QAAM,MAAM,YAAA;AACZ,oBAAkB,IAAI,IAAI,EAAE;AAC5B,MAAI,QAAQ,EAAE;AACd,SAAO,MAAM;AACX,sBAAkB,OAAO,EAAE;AAC3B,QAAI,UAAU,EAAE;AAAA,EAClB;AACF;AAoBA,MAAM,iBAAiB,CAAC,OAA6B,GAAG,cAAc,GAAG;AAMlE,SAAS,aACd,UAA+B,IACkC;AACjE,QAAM,EAAE,UAAU,gBAAgB,OAAO,CAAA,GAAI,oBAAoB,MAAM,SAAS,SAAA,IAAa;AAE7F,QAAM,MAAM,MAAM,OAAiB,IAAI;AACvC,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAS,KAAK;AAI1D,QAAM,aAAa,MAAM,OAAO,OAAO;AACvC,aAAW,UAAU;AAIrB,QAAM,eAAe,WAAW,iBAAiB,MAAM,kBAAkB,MAAM;AAE/E,eAAa,MAAM;AACjB,UAAM,KAAK,IAAI;AACf,QAAI,CAAC,GAAI;AACT,UAAM,QAAQ,MAAM;AAClB,YAAM,IAAI,WAAW,QAAQ,EAAE;AAC/B,UAAI,MAAM,OAAW,gBAAe,CAAC;AAAA,IACvC;AACA,UAAA;AACA,QAAI,MAAM;AACV,QAAI;AACJ,QAAI,mBAAmB;AACrB,YAAM,sBAAsB,KAAK;AACjC,UAAI,WAAW,OAAO,GAAG;AAAA,IAC3B;AACA,UAAM,UAAU,cAAc,IAAI,KAAK;AACvC,WAAO,MAAM;AACX,UAAI,0BAA0B,GAAG;AACjC,UAAI,gBAAgB,CAAC;AACrB,cAAA;AAAA,IACF;AAAA,EAGF,GAAG,IAAI;AAEP,SAAO,EAAE,KAAK,YAAA;AAChB;"}
1
+ {"version":3,"file":"use-truncated.js","sources":["../../src/hooks/use-truncated.ts"],"sourcesContent":["import * as React from 'react'\n\n// ── useTruncated — 單行文字截斷偵測引擎(SSOT)─────────────────────────────────\n// 收斂 Breadcrumb TruncatedLabel / DataTable TruncateCell(兩處 module-level shared ResizeObserver 引擎\n// **逐字重複**,僅變數名不同)+ Tag(原 per-instance `new ResizeObserver`)三處的截斷偵測 + isTruncated\n// 狀態機為唯一 owner(M17 SSOT);Tag 亦收斂進共用 shared RO,行為等價。\n//\n// 世界級對照(M8):MUI/MUI-X 官方無第一方 primitive,社群共識 = 「hook 量測 scrollWidth>clientWidth\n// + ref + 以 Tooltip `open` prop 條件控制」(issue #37211);此 hook 即該低階量測層,\n// 上層 `<TruncatedText>`(patterns/element-anatomy)對應 Ant `Typography.Text ellipsis` 高階元件。\n//\n// 三處變異點皆以 options 注入,**行為零漂移**:\n// - Breadcrumb / DataTable:預設 scrollWidth>clientWidth。\n// - Tag:trigger≠量測元素(observe root、量測內層 span)+ Canvas measureText(flex 內 scrollWidth\n// 不可靠)+ useLayoutEffect(避免 paint flash)+ deps=[children] → 全走 options。\n\ntype RoCallback = (entry: ResizeObserverEntry) => void\n\nlet sharedRO: ResizeObserver | null = null\nconst sharedROCallbacks = new WeakMap<Element, RoCallback>()\n\n// 全 DS 共用單一 ResizeObserver,dispatch 到 per-element callback(2026-04-22 DataTable D3 perf audit\n// 成果:10 col × 100 row = 1 RO 而非 1000)。element 卸載時 cleanup,singleton RO 本身不 disconnect。\nfunction getSharedRO(): ResizeObserver {\n if (sharedRO) return sharedRO\n sharedRO = new ResizeObserver((entries) => {\n entries.forEach((e) => {\n const cb = sharedROCallbacks.get(e.target)\n if (cb) cb(e)\n })\n })\n return sharedRO\n}\n\nfunction observeShared(el: Element, cb: RoCallback): () => void {\n const obs = getSharedRO()\n sharedROCallbacks.set(el, cb)\n obs.observe(el)\n return () => {\n sharedROCallbacks.delete(el)\n obs.unobserve(el)\n }\n}\n\nexport interface UseTruncatedOptions {\n /**\n * 偵測策略:回傳 `true` = 截斷、`false` = 未截斷、`undefined` = 本次無法量測(保留前一狀態)。\n * 預設 = `el.scrollWidth > el.clientWidth`(觀察元素自身)。\n * Tag 傳自訂 fn:observe root、內部 `querySelector('[data-tag-text]')` + Canvas measureText。\n */\n measure?: (observedEl: HTMLElement) => boolean | undefined\n /** deps 變更時 cleanup + 重新量測 / 重新訂閱(Tag 傳 `[children]`)。預設 `[]`(mount-once)。 */\n deps?: React.DependencyList\n /**\n * 是否在首次 paint 後以 `requestAnimationFrame` + `setTimeout(100)` 再量一次,捕獲首幀 layout\n * 未完成 / 字型 async load 的假陰性(Breadcrumb 已採此 robust 版)。預設 `true`。\n */\n recheckAfterPaint?: boolean\n /** `'layoutEffect'` = `useLayoutEffect`(量測在 paint 前,避免 flash;Tag 用)/ `'effect'` = `useEffect`。預設 `'effect'`。 */\n timing?: 'effect' | 'layoutEffect'\n}\n\nconst defaultMeasure = (el: HTMLElement): boolean => el.scrollWidth > el.clientWidth\n\n/**\n * 偵測 ref 所指元素的文字是否被截斷,回傳 `{ ref, isTruncated }`。\n * `isTruncated` 用來驅動 Tooltip「僅截斷時顯示」(對齊 `tooltip.principles.stories.tsx`「沒被截斷就不該顯示 tooltip」)。\n */\nexport function useTruncated<E extends HTMLElement = HTMLElement>(\n options: UseTruncatedOptions = {},\n): { ref: React.MutableRefObject<E | null>; isTruncated: boolean } {\n const { measure = defaultMeasure, deps = [], recheckAfterPaint = true, timing = 'effect' } = options\n // MutableRefObject(consumer 可經 callback ref 寫入 .current 以與 forwardedRef 合併,如 Tag)。\n const ref = React.useRef<E | null>(null)\n const [isTruncated, setIsTruncated] = React.useState(false)\n\n // measure 每 render 可能是新 closure(consumer inline);收進 ref 讓 effect 不因它重訂閱,\n // 但 RO 回呼仍讀到最新版。\n const measureRef = React.useRef(measure)\n measureRef.current = measure\n\n // timing 為 call-site 固定字面值(某 consumer 永遠同一值)→ 每個 component instance 每 render\n // 呼叫同一個 effect hook,符合 rules-of-hooks。\n const useIsoEffect = timing === 'layoutEffect' ? React.useLayoutEffect : React.useEffect\n\n useIsoEffect(() => {\n const el = ref.current\n if (!el) return\n const check = () => {\n const r = measureRef.current(el)\n if (r !== undefined) setIsTruncated(r)\n }\n check()\n let raf = 0\n let t: ReturnType<typeof setTimeout> | undefined\n if (recheckAfterPaint) {\n raf = requestAnimationFrame(check)\n t = setTimeout(check, 100)\n }\n const cleanup = observeShared(el, check)\n return () => {\n if (raf) cancelAnimationFrame(raf)\n if (t) clearTimeout(t)\n cleanup()\n }\n // deps 由 consumer 決定(Tag: [children];其餘: []);measure 走 ref 不需進 deps。\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { ref, isTruncated }\n}\n"],"names":[],"mappings":";AAkBA,IAAI,WAAkC;AACtC,MAAM,wCAAwB,QAAA;AAI9B,SAAS,cAA8B;AACrC,MAAI,SAAU,QAAO;AACrB,aAAW,IAAI,eAAe,CAAC,YAAY;AACzC,YAAQ,QAAQ,CAAC,MAAM;AACrB,YAAM,KAAK,kBAAkB,IAAI,EAAE,MAAM;AACzC,UAAI,OAAO,CAAC;AAAA,IACd,CAAC;AAAA,EACH,CAAC;AACD,SAAO;AACT;AAEA,SAAS,cAAc,IAAa,IAA4B;AAC9D,QAAM,MAAM,YAAA;AACZ,oBAAkB,IAAI,IAAI,EAAE;AAC5B,MAAI,QAAQ,EAAE;AACd,SAAO,MAAM;AACX,sBAAkB,OAAO,EAAE;AAC3B,QAAI,UAAU,EAAE;AAAA,EAClB;AACF;AAoBA,MAAM,iBAAiB,CAAC,OAA6B,GAAG,cAAc,GAAG;AAMlE,SAAS,aACd,UAA+B,IACkC;AACjE,QAAM,EAAE,UAAU,gBAAgB,OAAO,CAAA,GAAI,oBAAoB,MAAM,SAAS,SAAA,IAAa;AAE7F,QAAM,MAAM,MAAM,OAAiB,IAAI;AACvC,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAS,KAAK;AAI1D,QAAM,aAAa,MAAM,OAAO,OAAO;AACvC,aAAW,UAAU;AAIrB,QAAM,eAAe,WAAW,iBAAiB,MAAM,kBAAkB,MAAM;AAE/E,eAAa,MAAM;AACjB,UAAM,KAAK,IAAI;AACf,QAAI,CAAC,GAAI;AACT,UAAM,QAAQ,MAAM;AAClB,YAAM,IAAI,WAAW,QAAQ,EAAE;AAC/B,UAAI,MAAM,OAAW,gBAAe,CAAC;AAAA,IACvC;AACA,UAAA;AACA,QAAI,MAAM;AACV,QAAI;AACJ,QAAI,mBAAmB;AACrB,YAAM,sBAAsB,KAAK;AACjC,UAAI,WAAW,OAAO,GAAG;AAAA,IAC3B;AACA,UAAM,UAAU,cAAc,IAAI,KAAK;AACvC,WAAO,MAAM;AACX,UAAI,0BAA0B,GAAG;AACjC,UAAI,gBAAgB,CAAC;AACrB,cAAA;AAAA,IACF;AAAA,EAGF,GAAG,IAAI;AAEP,SAAO,EAAE,KAAK,YAAA;AAChB;"}
package/llms-full.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @qijenchen/design-system — 完整設計參考(llms-full)
2
2
 
3
- > 全 component / pattern 的 variants / sizes / 禁止事項。build-time 從 spec.md frontmatter 生成,禁手改。v0.1.0-beta.89
3
+ > 全 component / pattern 的 variants / sizes / 禁止事項。build-time 從 spec.md frontmatter 生成,禁手改。v0.1.0-beta.91
4
4
 
5
5
  # Components
6
6
 
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @qijenchen/design-system
2
2
 
3
3
  > World-class React design system(Radix/shadcn + Tailwind v4 + 自訂 design token)。
4
- > 57 components + 4 public patterns + design tokens。v0.1.0-beta.89
4
+ > 57 components + 4 public patterns + design tokens。v0.1.0-beta.91
5
5
 
6
6
  本檔由 source(spec.md frontmatter + Storybook index)build-time 自動生成,**禁手改**(CI --check drift gate 守)。
7
7
  每元件 / pattern 的完整 variants / sizes / 禁止事項 全文見 [llms-full.txt](./llms-full.txt)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qijenchen/design-system",
3
- "version": "0.1.0-beta.89",
3
+ "version": "0.1.0-beta.91",
4
4
  "private": false,
5
5
  "description": "World-class design system — components, patterns, tokens, hooks (single source of truth for team distribution).",
6
6
  "type": "module",
@@ -370,5 +370,8 @@ export const IntegrateRouterLinkAutoCollapse: Story = {
370
370
  await expect(link, '摺疊的 router-link「產品團隊」應保留為可點 <a href="/org/team">(修前退化成純文字)').not.toBeNull()
371
371
  await expect(link?.textContent).toContain('產品團隊')
372
372
  })
373
+ // 收尾關摺疊選單:Radix DropdownMenu(modal)開著時對背景 nav 上 aria-hidden,背景麵包屑連結
374
+ // 仍 focusable → axe aria-hidden-focus。斷言已驗完,按 Escape 還原(選單開只為跑斷言,關掉無損展示)。
375
+ await userEvent.keyboard('{Escape}')
373
376
  },
374
377
  }
@@ -13,7 +13,7 @@ import {
13
13
  DropdownMenuItem,
14
14
  } from '@/design-system/components/DropdownMenu/dropdown-menu'
15
15
 
16
- // ── TruncatedLabel ── 2026-07-19:truncate+tooltip 引擎 + presentation 已抽成 SSOT primitive
16
+ // ── TruncatedText ── 2026-07-19:truncate+tooltip 引擎 + presentation 已抽成 SSOT primitive
17
17
  // `patterns/element-anatomy/truncated-text`(`<TruncatedText>` 消費 `useTruncated` hook)。本檔改為
18
18
  // 消費 `<TruncatedText display="block" tooltip={fullText}>`,原 file-local shared RO + TruncatedLabel 已移除。
19
19
 
@@ -146,7 +146,7 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, BreadcrumbListProps>(
146
146
  const declarativeContent = React.useMemo(() => {
147
147
  if (!items) return null
148
148
  // 2026-07-04 修:非字串 label 的 key 原用 Math.random() → 每次 render 新 key 造成
149
- // BreadcrumbItem unmount/remount(重置 TruncatedLabel ResizeObserver 與 state)→ 改 stable idx key
149
+ // BreadcrumbItem unmount/remount(重置 TruncatedText ResizeObserver 與 state)→ 改 stable idx key
150
150
  const renderItem = (spec: BreadcrumbItemSpec, role: 'root' | 'middle' | 'current', idx: number) => (
151
151
  <BreadcrumbItem key={`${role}-${typeof spec.label === 'string' ? spec.label : idx}`} role={role}>
152
152
  {role === 'current'
@@ -342,7 +342,7 @@ BreadcrumbList.displayName = 'BreadcrumbList'
342
342
  * 設計回應 user 兩 challenges:
343
343
  * (a) Root 也 truncate(shrink:3,不是 shrink-0)
344
344
  * (b) 不用 fixed max-width — flex-shrink hierarchy 容器寬時自然展開不浪費空間,
345
- * 窄時按優先級縮 + TruncatedLabel 內部 CSS truncate + tooltip。
345
+ * 窄時按優先級縮 + TruncatedText 內部 CSS truncate + tooltip。
346
346
  */
347
347
  interface BreadcrumbItemProps extends React.ComponentPropsWithoutRef<'li'> {
348
348
  role?: 'root' | 'middle' | 'current' | 'ellipsis'
@@ -396,7 +396,7 @@ const BreadcrumbLink = React.forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(
396
396
  ({ asChild, className, children, startIcon: StartIcon, ...props }, ref) => {
397
397
  const { size } = React.useContext(BreadcrumbContext)
398
398
  // 2026-05-12 fix(user 抓 image 2 Deep story 麵包屑沒符合 single-line + truncate canonical):
399
- // 純文字 children → auto-wrap TruncatedLabel(canonical「single-line + ellipsis + tooltip
399
+ // 純文字 children → auto-wrap TruncatedText(canonical「single-line + ellipsis + tooltip
400
400
  // on truncate」per spec.md / Polaris breadcrumb)。Non-string children(consumer 自訂 icon+text
401
401
  // 結構)→ pass-through 不 force-wrap(consumer own truncate)。
402
402
  const wrappedChildren = typeof children === 'string'
@@ -446,7 +446,7 @@ interface BreadcrumbPageProps extends React.ComponentPropsWithoutRef<'span'> {
446
446
  const BreadcrumbPage = React.forwardRef<HTMLSpanElement, BreadcrumbPageProps>(
447
447
  ({ className, children, startIcon: StartIcon, ...props }, ref) => {
448
448
  const { size } = React.useContext(BreadcrumbContext)
449
- // 2026-05-12 fix(同 BreadcrumbLink):純文字 children → auto-wrap TruncatedLabel
449
+ // 2026-05-12 fix(同 BreadcrumbLink):純文字 children → auto-wrap TruncatedText
450
450
  const wrappedChildren = typeof children === 'string'
451
451
  ? <TruncatedText display="block" tooltip={children}>{children}</TruncatedText>
452
452
  : children
@@ -404,7 +404,7 @@ function columnSizeStyle(
404
404
  const SYSTEM_COL_IDS = new Set([SELECT_COL_ID, '__drag__', '__actions__'])
405
405
  const isSystemColumn = (colId: string) => SYSTEM_COL_IDS.has(colId)
406
406
 
407
- // ── TruncateCell ── 2026-07-19:truncate+tooltip 引擎 + presentation 已抽成 SSOT primitive
407
+ // ── TruncatedText ── 2026-07-19:truncate+tooltip 引擎 + presentation 已抽成 SSOT primitive
408
408
  // `patterns/element-anatomy/truncated-text`(`<TruncatedText>` 消費 `useTruncated` hook)。原 file-local
409
409
  // shared RO(2026-04-22 D3 perf audit 的「全 DS 共用單一 RO」特性由 hook 承接)+ TruncateCell 已移除,
410
410
  // 改用 `<TruncatedText tooltip={children} className={...}>`(display 預設 inline,保 cell baseline)。
@@ -1473,10 +1473,10 @@ function DataTableInner<TData>(
1473
1473
  const meta = cell.column.columnDef.meta
1474
1474
  const colType = meta?.type as ColumnType | undefined
1475
1475
  const wrap = autoRowHeight && meta?.wrap === true
1476
- // 已知 compound 欄位(Tag / PersonDisplay / LinkInput 等自帶 layout)直接 bypass TruncateCell,
1476
+ // 已知 compound 欄位(Tag / PersonDisplay / LinkInput 等自帶 layout)直接 bypass TruncatedText,
1477
1477
  // 因為 `truncate` 的 inline baseline context 會破壞自訂 layout 的垂直對齊。
1478
1478
  // 2026-05-09 D-path:date / time 加入(showDisplayEndIcon → Field naked-view 需 full width 才能
1479
- // 右對齊 ItemSuffix。TruncateCell 的 `<span truncate min-w-0>` block-display 會 collapse Field
1479
+ // 右對齊 ItemSuffix。TruncatedText 的 `<span truncate min-w-0>` block-display 會 collapse Field
1480
1480
  // to content size,讓 Calendar / Clock icon 緊貼 value text 而非右邊緣)。
1481
1481
  const isKnownCompound = colType === 'select' || colType === 'multiSelect' || colType === 'person' || colType === 'multiPerson' || colType === 'url' || colType === 'date' || colType === 'time'
1482
1482
  const rowId = cell.row.id
@@ -1517,8 +1517,8 @@ function DataTableInner<TData>(
1517
1517
  content = flexRender(cell.column.columnDef.cell, cell.getContext())
1518
1518
  }
1519
1519
  // Consumer 自訂 cell(無 colType)若回傳 React element,視為 compound — consumer 自己處理
1520
- // 對齊與截斷。回傳 primitive(string / number)才走 TruncateCell
1521
- // 理由:TruncateCell 的 `span.truncate` 強制 white-space:nowrap + inline baseline,
1520
+ // 對齊與截斷。回傳 primitive(string / number)才走 TruncatedText
1521
+ // 理由:TruncatedText 的 `span.truncate` 強制 white-space:nowrap + inline baseline,
1522
1522
  // 對 inline-flex / icon+text 自訂結構會拉歪(見 circular-progress sync table 案例)。
1523
1523
  // **edit mode bypass**(2026-05-05 v9 Bug 2 修):editing cell 內部是 Field 控件
1524
1524
  // (Input/Textarea/Select etc.)自管 layout + 替代元素(textarea)不該被包進 inline span
@@ -1752,7 +1752,7 @@ function DataTableInner<TData>(
1752
1752
  // - **沒有** cell 自己 box-shadow ring — focus / hover / open ring 由 Field naked 自帶
1753
1753
  // state machine 提供(對齊 user「狀態樣式取決於原輸入框」reminder)
1754
1754
  // 字級隨 size:sm/md text-body / lg text-body-lg(fieldDisplayTextClass),對齊 Field family
1755
- // size→font SSOT。此為非-Field content(consumer 自訂 cell / TruncateCell 純文字)的 fallback 字級;
1755
+ // size→font SSOT。此為非-Field content(consumer 自訂 cell / TruncatedText 純文字)的 fallback 字級;
1756
1756
  // typed cell 各自的 Field 控件已自帶 size→font(cell-registry 傳 size)。
1757
1757
  'group/cell flex text-foreground font-normal shrink-0 relative self-stretch',
1758
1758
  fieldDisplayTextClass(size),
@@ -541,9 +541,16 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
541
541
  commitDraft(now)
542
542
  }
543
543
 
544
+ // typeable 模式外層 div 用 PopoverAnchor(僅定位,不注入 trigger a11y);非 typeable 用 PopoverTrigger
545
+ // (div 即 combobox 觸發)。修 dim-10 遺留的 axe aria-valid-attr-value(critical):PopoverTrigger 對
546
+ // typeable div 注入 aria-controls 指向「closed 時 unmount 的 popover content id」,而 typeable 已剝
547
+ // aria-expanded → axe「collapsed control 可缺 controlled 元素」豁免失效 → 判 aria-controls 值不合法。
548
+ // typeable 開啟走 controlled setOpen(Calendar icon click / input ArrowDown),不需 div 的 trigger onClick,
549
+ // 故改 anchor 零行為損失;combobox 語意全在內層 <input>(APG editable-combobox,見下方 :588)。
550
+ const TriggerOrAnchor = typeable ? PopoverAnchor : PopoverTrigger
544
551
  return (
545
552
  <Popover open={open} onOpenChange={setOpen}>
546
- <PopoverTrigger asChild>
553
+ <TriggerOrAnchor asChild>
547
554
  <div
548
555
  ref={ref}
549
556
  // a11y(2026-07-14 dim-10 修):typeable 模式 combobox 語意(name / state / 鍵盤)
@@ -643,7 +650,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
643
650
  <CalendarIcon size={iconSize} className="text-fg-muted" aria-hidden />
644
651
  </ItemSuffix>
645
652
  </div>
646
- </PopoverTrigger>
653
+ </TriggerOrAnchor>
647
654
  <PopoverContent
648
655
  className="w-auto p-0"
649
656
  align="start"
@@ -34,7 +34,7 @@ export const SizeAlignment: Story = {
34
34
  <div className="flex items-center gap-3">
35
35
  <span className="text-body w-10 shrink-0">音量</span>
36
36
  <Slider size={size} defaultValue={[40]} aria-label="音量" className="flex-1" />
37
- <NumberInput size={size} value={40} onChange={() => {}} className="w-20 shrink-0" />
37
+ <NumberInput size={size} value={40} onChange={() => {}} aria-label="音量數值" className="w-20 shrink-0" />
38
38
  </div>
39
39
  </div>
40
40
  ))}
@@ -208,10 +208,14 @@ function TagInner(
208
208
  </div>
209
209
  )
210
210
 
211
- if (!isTruncated) return tag
212
-
211
+ // 永遠 wrap Tooltip、以 open 控制可見(對齊 truncated-text.spec.md:49「永遠 wrap」+ :55「❌ 條件 wrap」canonical
212
+ // + useTruncated 家族;tooltip.principles.stories.tsx:190「沒被截斷就不該顯示 tooltip」由 open={false} 機械保證)。
213
+ // 未截斷 open={false} → tooltip 靜默(TooltipTrigger asChild 只把 handler/data-state 合併到 tag div,零多餘 DOM、
214
+ // 視覺與裸 tag 等價,pixel-identical)。always-wrap 保 tag div 生命週期穩定 → isTruncated false→true 不 remount:
215
+ // 先前條件-wrap(未截斷回裸 tag / 截斷才 wrap)切換 JSX 樹會 remount tag div,但 useTruncated deps=[children] 未變
216
+ // → effect 不重跑重新 observe 新 div → shared RO 卡在舊 detached div → isTruncated 卡 true(未截斷仍顯示 tooltip)。
213
217
  return (
214
- <Tooltip>
218
+ <Tooltip open={isTruncated ? undefined : false}>
215
219
  <TooltipTrigger asChild>{tag}</TooltipTrigger>
216
220
  <TooltipContent>{children}</TooltipContent>
217
221
  </Tooltip>
@@ -1,8 +1,9 @@
1
1
  import * as React from 'react'
2
2
 
3
3
  // ── useTruncated — 單行文字截斷偵測引擎(SSOT)─────────────────────────────────
4
- // 收斂 Breadcrumb TruncatedLabel / DataTable TruncateCell / Tag 三處**逐字重複**的
5
- // module-level shared ResizeObserver 引擎 + isTruncated 狀態機為唯一 owner(M17 SSOT)。
4
+ // 收斂 Breadcrumb TruncatedLabel / DataTable TruncateCell(兩處 module-level shared ResizeObserver 引擎
5
+ // **逐字重複**,僅變數名不同)+ Tag(原 per-instance `new ResizeObserver`)三處的截斷偵測 + isTruncated
6
+ // 狀態機為唯一 owner(M17 SSOT);Tag 亦收斂進共用 shared RO,行為等價。
6
7
  //
7
8
  // 世界級對照(M8):MUI/MUI-X 官方無第一方 primitive,社群共識 = 「hook 量測 scrollWidth>clientWidth
8
9
  // + ref + 以 Tooltip `open` prop 條件控制」(issue #37211);此 hook 即該低階量測層,