@qijenchen/design-system 0.1.0-beta.88 → 0.1.0-beta.90

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 (37) hide show
  1. package/dist/components/Breadcrumb/breadcrumb.d.ts +1 -1
  2. package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -1
  3. package/dist/components/Breadcrumb/breadcrumb.js +4 -47
  4. package/dist/components/Breadcrumb/breadcrumb.js.map +1 -1
  5. package/dist/components/DataTable/data-table.d.ts.map +1 -1
  6. package/dist/components/DataTable/data-table.js +4 -41
  7. package/dist/components/DataTable/data-table.js.map +1 -1
  8. package/dist/components/Tag/tag.d.ts.map +1 -1
  9. package/dist/components/Tag/tag.js +12 -17
  10. package/dist/components/Tag/tag.js.map +1 -1
  11. package/dist/hooks/use-truncated.d.ts +27 -0
  12. package/dist/hooks/use-truncated.d.ts.map +1 -0
  13. package/dist/hooks/use-truncated.js +57 -0
  14. package/dist/hooks/use-truncated.js.map +1 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/patterns/element-anatomy/index.d.ts +1 -0
  20. package/dist/patterns/element-anatomy/index.d.ts.map +1 -1
  21. package/dist/patterns/element-anatomy/index.js +2 -0
  22. package/dist/patterns/element-anatomy/index.js.map +1 -1
  23. package/dist/patterns/element-anatomy/truncated-text.d.ts +27 -0
  24. package/dist/patterns/element-anatomy/truncated-text.d.ts.map +1 -0
  25. package/dist/patterns/element-anatomy/truncated-text.js +16 -0
  26. package/dist/patterns/element-anatomy/truncated-text.js.map +1 -0
  27. package/llms-full.txt +1 -1
  28. package/llms.txt +1 -1
  29. package/package.json +1 -1
  30. package/src/components/Breadcrumb/breadcrumb.tsx +12 -74
  31. package/src/components/DataTable/data-table.tsx +14 -52
  32. package/src/components/Tag/tag.tsx +23 -19
  33. package/src/hooks/use-truncated.ts +111 -0
  34. package/src/index.ts +3 -0
  35. package/src/patterns/element-anatomy/index.ts +1 -0
  36. package/src/patterns/element-anatomy/truncated-text.spec.md +57 -0
  37. package/src/patterns/element-anatomy/truncated-text.tsx +61 -0
@@ -45,6 +45,7 @@ import { ICON_SIZE } from '@/design-system/tokens/uiSize/icon-size'
45
45
  import { dragSourceStyle, dropIndicatorRow, dropIndicatorColumn, dragActiveCursor, isReorderNoop, reconstructFullRowGhost, snapToCursorModifier } from '@/design-system/lib/drag-visual'
46
46
  import { nakedCellEditableDisplayHover, fieldDisplayTextClass } from '@/design-system/components/Field/field-wrapper'
47
47
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/design-system/components/Tooltip/tooltip'
48
+ import { TruncatedText } from '@/design-system/patterns/element-anatomy/truncated-text'
48
49
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/design-system/components/DropdownMenu/dropdown-menu'
49
50
  import { ItemInlineActionButton } from '@/design-system/patterns/element-anatomy/item-anatomy'
50
51
  import { columnTypeDefaults, type ColumnType } from './column-types'
@@ -403,50 +404,11 @@ function columnSizeStyle(
403
404
  const SYSTEM_COL_IDS = new Set([SELECT_COL_ID, '__drag__', '__actions__'])
404
405
  const isSystemColumn = (colId: string) => SYSTEM_COL_IDS.has(colId)
405
406
 
406
- // ── TruncateCell ─────────────────────────────────────────────────────────────
407
- // Shared ResizeObserver(2026-04-22 D3 perf audit):從 per-cell RO 改為全 DS 共用一個 RO
408
- // dispatch 到 per-element callback。10 col × 100 row = 1 RO(before:1000 RO)
409
- // OS 一致的 RO 行為;element 卸載時 cleanup
410
-
411
- type RoCallback = (entry: ResizeObserverEntry) => void
412
- let sharedResizeObserver: ResizeObserver | null = null
413
- const roCallbacks = new WeakMap<Element, RoCallback>()
414
-
415
- function getSharedRO(): ResizeObserver {
416
- if (sharedResizeObserver) return sharedResizeObserver
417
- sharedResizeObserver = new ResizeObserver((entries) => {
418
- entries.forEach((entry) => {
419
- const cb = roCallbacks.get(entry.target)
420
- if (cb) cb(entry)
421
- })
422
- })
423
- return sharedResizeObserver
424
- }
425
-
426
- function observeShared(el: Element, cb: RoCallback): () => void {
427
- const obs = getSharedRO()
428
- roCallbacks.set(el, cb)
429
- obs.observe(el)
430
- return () => {
431
- roCallbacks.delete(el)
432
- obs.unobserve(el)
433
- }
434
- }
435
-
436
- function TruncateCell({ children, className }: { children: React.ReactNode; className?: string }) {
437
- const ref = React.useRef<HTMLSpanElement>(null)
438
- const [isTruncated, setIsTruncated] = React.useState(false)
439
- React.useEffect(() => {
440
- const el = ref.current
441
- if (!el) return
442
- const check = () => setIsTruncated(el.scrollWidth > el.clientWidth)
443
- check()
444
- return observeShared(el, check)
445
- }, [])
446
- const span = <span ref={ref} className={cn('truncate min-w-0', className)}>{children}</span>
447
- if (!isTruncated) return span
448
- return <Tooltip><TooltipTrigger asChild>{span}</TooltipTrigger><TooltipContent>{children}</TooltipContent></Tooltip>
449
- }
407
+ // ── TruncatedText ── 2026-07-19:truncate+tooltip 引擎 + presentation 已抽成 SSOT primitive
408
+ // `patterns/element-anatomy/truncated-text`(`<TruncatedText>` 消費 `useTruncated` hook)。原 file-local
409
+ // shared RO(2026-04-22 D3 perf audit 的「全 DS 共用單一 RO」特性由 hook 承接)+ TruncateCell 已移除,
410
+ // 改用 `<TruncatedText tooltip={children} className={...}>`(display 預設 inline,保 cell baseline)
411
+ // compound 欄位仍在 renderCellContent bypass(見下)—— 只 primitive string/number 走 TruncatedText。
450
412
 
451
413
  // ── L4 Row Drag: SortableRowContext(v15 Path B;2026-07-14 JSDoc 對齊實作)──────
452
414
  // 每 region(left / center / right)各 mount 一次 SortableRowProvider,依 role 分流(v15.4 split):
@@ -1511,10 +1473,10 @@ function DataTableInner<TData>(
1511
1473
  const meta = cell.column.columnDef.meta
1512
1474
  const colType = meta?.type as ColumnType | undefined
1513
1475
  const wrap = autoRowHeight && meta?.wrap === true
1514
- // 已知 compound 欄位(Tag / PersonDisplay / LinkInput 等自帶 layout)直接 bypass TruncateCell,
1476
+ // 已知 compound 欄位(Tag / PersonDisplay / LinkInput 等自帶 layout)直接 bypass TruncatedText,
1515
1477
  // 因為 `truncate` 的 inline baseline context 會破壞自訂 layout 的垂直對齊。
1516
1478
  // 2026-05-09 D-path:date / time 加入(showDisplayEndIcon → Field naked-view 需 full width 才能
1517
- // 右對齊 ItemSuffix。TruncateCell 的 `<span truncate min-w-0>` block-display 會 collapse Field
1479
+ // 右對齊 ItemSuffix。TruncatedText 的 `<span truncate min-w-0>` block-display 會 collapse Field
1518
1480
  // to content size,讓 Calendar / Clock icon 緊貼 value text 而非右邊緣)。
1519
1481
  const isKnownCompound = colType === 'select' || colType === 'multiSelect' || colType === 'person' || colType === 'multiPerson' || colType === 'url' || colType === 'date' || colType === 'time'
1520
1482
  const rowId = cell.row.id
@@ -1555,8 +1517,8 @@ function DataTableInner<TData>(
1555
1517
  content = flexRender(cell.column.columnDef.cell, cell.getContext())
1556
1518
  }
1557
1519
  // Consumer 自訂 cell(無 colType)若回傳 React element,視為 compound — consumer 自己處理
1558
- // 對齊與截斷。回傳 primitive(string / number)才走 TruncateCell
1559
- // 理由:TruncateCell 的 `span.truncate` 強制 white-space:nowrap + inline baseline,
1520
+ // 對齊與截斷。回傳 primitive(string / number)才走 TruncatedText
1521
+ // 理由:TruncatedText 的 `span.truncate` 強制 white-space:nowrap + inline baseline,
1560
1522
  // 對 inline-flex / icon+text 自訂結構會拉歪(見 circular-progress sync table 案例)。
1561
1523
  // **edit mode bypass**(2026-05-05 v9 Bug 2 修):editing cell 內部是 Field 控件
1562
1524
  // (Input/Textarea/Select etc.)自管 layout + 替代元素(textarea)不該被包進 inline span
@@ -1565,7 +1527,7 @@ function DataTableInner<TData>(
1565
1527
  return isEditingThisCell ? content
1566
1528
  : wrap ? <span className="break-words min-w-0">{content}</span>
1567
1529
  : (isKnownCompound || isConsumerCompound) ? content
1568
- : <TruncateCell>{content}</TruncateCell>
1530
+ : <TruncatedText>{content}</TruncatedText>
1569
1531
  }
1570
1532
 
1571
1533
  // 2026-05-18 改 import ICON_SIZE SSOT(per user『做完』approval,消除 M17 違反 7+ 重複 ternary)
@@ -1790,7 +1752,7 @@ function DataTableInner<TData>(
1790
1752
  // - **沒有** cell 自己 box-shadow ring — focus / hover / open ring 由 Field naked 自帶
1791
1753
  // state machine 提供(對齊 user「狀態樣式取決於原輸入框」reminder)
1792
1754
  // 字級隨 size:sm/md text-body / lg text-body-lg(fieldDisplayTextClass),對齊 Field family
1793
- // size→font SSOT。此為非-Field content(consumer 自訂 cell / TruncateCell 純文字)的 fallback 字級;
1755
+ // size→font SSOT。此為非-Field content(consumer 自訂 cell / TruncatedText 純文字)的 fallback 字級;
1794
1756
  // typed cell 各自的 Field 控件已自帶 size→font(cell-registry 傳 size)。
1795
1757
  'group/cell flex text-foreground font-normal shrink-0 relative self-stretch',
1796
1758
  fieldDisplayTextClass(size),
@@ -2216,9 +2178,9 @@ function DataTableInner<TData>(
2216
2178
  canSort && 'focus-visible:ring-2 focus-visible:ring-ring rounded-md',
2217
2179
  )}
2218
2180
  >
2219
- <TruncateCell className={cn('min-w-0', align === 'right' && 'text-right', align === 'center' && 'text-center')}>
2181
+ <TruncatedText className={cn('min-w-0', align === 'right' && 'text-right', align === 'center' && 'text-center')}>
2220
2182
  {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
2221
- </TruncateCell>
2183
+ </TruncatedText>
2222
2184
  {canSort && sortDir && !isMultiSort && (
2223
2185
  // 2026-05-18 改 per user 拍板「DataTable sort 跟 row size 變」+「做完」approval:
2224
2186
  // 原固定 14 違反 uiSize.spec.md Icon Tier(sm/md→16, lg→20)。改 ICON_SIZE[size]
@@ -5,6 +5,7 @@ import type { LucideIcon } from "lucide-react"
5
5
 
6
6
  import { cn } from "@/lib/utils"
7
7
  import { Tooltip, TooltipTrigger, TooltipContent } from "@/design-system/components/Tooltip/tooltip"
8
+ import { useTruncated } from "@/design-system/hooks/use-truncated"
8
9
  import { ItemInlineActionButton } from "@/design-system/patterns/element-anatomy/item-anatomy"
9
10
  import { CAT_SUBTLE, CAT_SOLID, CAT_INTERACT } from "@/design-system/tokens/categorical-color"
10
11
 
@@ -129,16 +130,16 @@ function TagInner(
129
130
  forwardedRef: React.ForwardedRef<HTMLDivElement>,
130
131
  ) {
131
132
  const solidClass = solid ? SOLID_CLASSES[color ?? 'neutral'] : undefined
132
- const ownRef = React.useRef<HTMLDivElement | null>(null)
133
- const [isTruncated, setIsTruncated] = React.useState(false)
134
-
135
- React.useLayoutEffect(() => {
136
- const el = ownRef.current
137
- if (!el) return
138
- const ctx = getMeasureCtx()
139
- const check = () => {
133
+ // 截斷偵測用 SSOT 引擎 `useTruncated`(patterns/element-anatomy 的 hook)。Tag 走**變異型**:
134
+ // - measure = Canvas measureText(flex 內 scrollWidth 不可靠,line 20):observe root、量測內層
135
+ // `[data-tag-text]` span(trigger≠量測元素);回 undefined = 無法量測時保留前一 state。
136
+ // - timing='layoutEffect'(避免 paint flash)/ deps=[children](children 變重量)/ recheckAfterPaint=false
137
+ // (Tag 原本即無 rAF/timeout 二次量)—— 三處變異全走 options,行為零漂移。
138
+ const { ref: ownRef, isTruncated } = useTruncated<HTMLDivElement>({
139
+ measure: (el) => {
140
140
  const textSpan = el.querySelector('[data-tag-text]')
141
- if (!textSpan || !ctx) return
141
+ const ctx = getMeasureCtx()
142
+ if (!textSpan || !ctx) return undefined
142
143
  const text = textSpan.textContent || ''
143
144
  const cs = getComputedStyle(textSpan)
144
145
  ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
@@ -146,13 +147,12 @@ function TagInner(
146
147
  const padL = parseFloat(cs.paddingLeft) || 0
147
148
  const padR = parseFloat(cs.paddingRight) || 0
148
149
  const needed = textWidth + padL + padR
149
- setIsTruncated(needed > (textSpan as HTMLElement).clientWidth + 1)
150
- }
151
- check()
152
- const obs = new ResizeObserver(check)
153
- obs.observe(el)
154
- return () => obs.disconnect()
155
- }, [children])
150
+ return needed > (textSpan as HTMLElement).clientWidth + 1
151
+ },
152
+ deps: [children],
153
+ timing: 'layoutEffect',
154
+ recheckAfterPaint: false,
155
+ })
156
156
 
157
157
  const label = typeof children === 'string' ? children : ''
158
158
 
@@ -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>
@@ -0,0 +1,111 @@
1
+ import * as React from 'react'
2
+
3
+ // ── useTruncated — 單行文字截斷偵測引擎(SSOT)─────────────────────────────────
4
+ // 收斂 Breadcrumb TruncatedLabel / DataTable TruncateCell(兩處 module-level shared ResizeObserver 引擎
5
+ // **逐字重複**,僅變數名不同)+ Tag(原 per-instance `new ResizeObserver`)三處的截斷偵測 + isTruncated
6
+ // 狀態機為唯一 owner(M17 SSOT);Tag 亦收斂進共用 shared RO,行為等價。
7
+ //
8
+ // 世界級對照(M8):MUI/MUI-X 官方無第一方 primitive,社群共識 = 「hook 量測 scrollWidth>clientWidth
9
+ // + ref + 以 Tooltip `open` prop 條件控制」(issue #37211);此 hook 即該低階量測層,
10
+ // 上層 `<TruncatedText>`(patterns/element-anatomy)對應 Ant `Typography.Text ellipsis` 高階元件。
11
+ //
12
+ // 三處變異點皆以 options 注入,**行為零漂移**:
13
+ // - Breadcrumb / DataTable:預設 scrollWidth>clientWidth。
14
+ // - Tag:trigger≠量測元素(observe root、量測內層 span)+ Canvas measureText(flex 內 scrollWidth
15
+ // 不可靠)+ useLayoutEffect(避免 paint flash)+ deps=[children] → 全走 options。
16
+
17
+ type RoCallback = (entry: ResizeObserverEntry) => void
18
+
19
+ let sharedRO: ResizeObserver | null = null
20
+ const sharedROCallbacks = new WeakMap<Element, RoCallback>()
21
+
22
+ // 全 DS 共用單一 ResizeObserver,dispatch 到 per-element callback(2026-04-22 DataTable D3 perf audit
23
+ // 成果:10 col × 100 row = 1 RO 而非 1000)。element 卸載時 cleanup,singleton RO 本身不 disconnect。
24
+ function getSharedRO(): ResizeObserver {
25
+ if (sharedRO) return sharedRO
26
+ sharedRO = new ResizeObserver((entries) => {
27
+ entries.forEach((e) => {
28
+ const cb = sharedROCallbacks.get(e.target)
29
+ if (cb) cb(e)
30
+ })
31
+ })
32
+ return sharedRO
33
+ }
34
+
35
+ function observeShared(el: Element, cb: RoCallback): () => void {
36
+ const obs = getSharedRO()
37
+ sharedROCallbacks.set(el, cb)
38
+ obs.observe(el)
39
+ return () => {
40
+ sharedROCallbacks.delete(el)
41
+ obs.unobserve(el)
42
+ }
43
+ }
44
+
45
+ export interface UseTruncatedOptions {
46
+ /**
47
+ * 偵測策略:回傳 `true` = 截斷、`false` = 未截斷、`undefined` = 本次無法量測(保留前一狀態)。
48
+ * 預設 = `el.scrollWidth > el.clientWidth`(觀察元素自身)。
49
+ * Tag 傳自訂 fn:observe root、內部 `querySelector('[data-tag-text]')` + Canvas measureText。
50
+ */
51
+ measure?: (observedEl: HTMLElement) => boolean | undefined
52
+ /** deps 變更時 cleanup + 重新量測 / 重新訂閱(Tag 傳 `[children]`)。預設 `[]`(mount-once)。 */
53
+ deps?: React.DependencyList
54
+ /**
55
+ * 是否在首次 paint 後以 `requestAnimationFrame` + `setTimeout(100)` 再量一次,捕獲首幀 layout
56
+ * 未完成 / 字型 async load 的假陰性(Breadcrumb 已採此 robust 版)。預設 `true`。
57
+ */
58
+ recheckAfterPaint?: boolean
59
+ /** `'layoutEffect'` = `useLayoutEffect`(量測在 paint 前,避免 flash;Tag 用)/ `'effect'` = `useEffect`。預設 `'effect'`。 */
60
+ timing?: 'effect' | 'layoutEffect'
61
+ }
62
+
63
+ const defaultMeasure = (el: HTMLElement): boolean => el.scrollWidth > el.clientWidth
64
+
65
+ /**
66
+ * 偵測 ref 所指元素的文字是否被截斷,回傳 `{ ref, isTruncated }`。
67
+ * `isTruncated` 用來驅動 Tooltip「僅截斷時顯示」(對齊 `tooltip.principles.stories.tsx`「沒被截斷就不該顯示 tooltip」)。
68
+ */
69
+ export function useTruncated<E extends HTMLElement = HTMLElement>(
70
+ options: UseTruncatedOptions = {},
71
+ ): { ref: React.MutableRefObject<E | null>; isTruncated: boolean } {
72
+ const { measure = defaultMeasure, deps = [], recheckAfterPaint = true, timing = 'effect' } = options
73
+ // MutableRefObject(consumer 可經 callback ref 寫入 .current 以與 forwardedRef 合併,如 Tag)。
74
+ const ref = React.useRef<E | null>(null)
75
+ const [isTruncated, setIsTruncated] = React.useState(false)
76
+
77
+ // measure 每 render 可能是新 closure(consumer inline);收進 ref 讓 effect 不因它重訂閱,
78
+ // 但 RO 回呼仍讀到最新版。
79
+ const measureRef = React.useRef(measure)
80
+ measureRef.current = measure
81
+
82
+ // timing 為 call-site 固定字面值(某 consumer 永遠同一值)→ 每個 component instance 每 render
83
+ // 呼叫同一個 effect hook,符合 rules-of-hooks。
84
+ const useIsoEffect = timing === 'layoutEffect' ? React.useLayoutEffect : React.useEffect
85
+
86
+ useIsoEffect(() => {
87
+ const el = ref.current
88
+ if (!el) return
89
+ const check = () => {
90
+ const r = measureRef.current(el)
91
+ if (r !== undefined) setIsTruncated(r)
92
+ }
93
+ check()
94
+ let raf = 0
95
+ let t: ReturnType<typeof setTimeout> | undefined
96
+ if (recheckAfterPaint) {
97
+ raf = requestAnimationFrame(check)
98
+ t = setTimeout(check, 100)
99
+ }
100
+ const cleanup = observeShared(el, check)
101
+ return () => {
102
+ if (raf) cancelAnimationFrame(raf)
103
+ if (t) clearTimeout(t)
104
+ cleanup()
105
+ }
106
+ // deps 由 consumer 決定(Tag: [children];其餘: []);measure 走 ref 不需進 deps。
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, deps)
109
+
110
+ return { ref, isTruncated }
111
+ }
package/src/index.ts CHANGED
@@ -601,6 +601,8 @@ export type {
601
601
  // - patterns/element-anatomy:ItemInlineActionButtonProps
602
602
  // - patterns/element-anatomy:ItemInlineActionProps
603
603
  // - patterns/element-anatomy:RowSizeProvider
604
+ // - patterns/element-anatomy:TruncatedText
605
+ // - patterns/element-anatomy:TruncatedTextProps
604
606
 
605
607
  // ─── Tokens(JS mirrors — token SSOT 程式面)──────────────────────────────
606
608
  export * from './tokens/categorical-color'
@@ -614,6 +616,7 @@ export * from './hooks/use-controllable'
614
616
  export * from './hooks/use-is-narrow-viewport'
615
617
  export * from './hooks/use-is-touch-device'
616
618
  export * from './hooks/use-overflow-items'
619
+ export * from './hooks/use-truncated'
617
620
 
618
621
  // ─── Lib utilities ────────────────────────────────────────────────────────
619
622
  export * from './lib/drag-visual'
@@ -3,3 +3,4 @@
3
3
  // import { ... } from '@qijenchen/design-system/{components,patterns}/<Dir>'
4
4
  // Regenerate: node scripts/gen-component-indexes.mjs
5
5
  export * from './item-anatomy'
6
+ export * from './truncated-text'
@@ -0,0 +1,57 @@
1
+ ---
2
+ component: TruncatedText
3
+ family: non-family
4
+ isInternal: true
5
+ benchmark:
6
+ - Ant Design Typography.Text ellipsis={{ tooltip }}: https://ant.design/components/typography
7
+ - MUI 社群共識 useIsOverflow hook + 條件 Tooltip: https://github.com/mui/material-ui/issues/37211
8
+ - Carbon Overflow content(truncate 樣式 + hover tooltip 同 pattern): https://carbondesignsystem.com/patterns/overflow-content/
9
+ ---
10
+
11
+ # TruncatedText / useTruncated 設計原則(SSOT)
12
+
13
+ **Layout Family**:non-family(hosted inline element,無 own layout —— 嵌在宿主 row/cell/pill 內)。
14
+
15
+ **定位**:「單行文字**可能**溢出容器 → 溢出時 `text-ellipsis` 截斷 + **僅截斷時**顯示 tooltip 補全」的共用引擎與呈現層。收斂 Breadcrumb label / DataTable 純文字 cell / Tag 三處**先前逐字重複**的截斷偵測 + tooltip 邏輯為單一 SSOT(2026-07-19,user 拍板)。
16
+
17
+ ## 兩層架構(對齊世界級聯集)
18
+
19
+ | 層 | 物件 | 職責 | 對標 |
20
+ |----|------|------|------|
21
+ | 底層量測引擎 | **`useTruncated(options?)` hook**(public,`hooks/use-truncated`)| 全 DS 共用單一 `ResizeObserver`(dispatch 到 per-element callback,`WeakMap`)+ `isTruncated` 狀態機 + 二次量測時序。回 `{ ref, isTruncated }` | MUI/MUI-X 社群「hook 量測 + 條件 Tooltip」 |
22
+ | 上層組合 | **`<TruncatedText>` component**(@internal,本檔)| 消費 hook + 封裝「永遠 wrap Tooltip、`open` 控制可見」的截斷 span 呈現 | Ant `Typography.Text ellipsis` 高階元件 |
23
+
24
+ **為何兩層**:Breadcrumb / DataTable 結構同構(截斷 span 即 tooltip trigger)→ 用 component;Tag 因 **trigger(整個 tag root)≠ 量測元素(內層 `[data-tag-text]` span)** + Canvas `measureText`(flex 內 `scrollWidth` 不可靠)+ 截斷才 `tabIndex=0`/focus-ring 的自訂 a11y → **只用 hook 自組**。缺任一層 = 假 SSOT(只抽 component 則 Tag 被迫留自己的 RO copy;只抽 hook 則兩個 component consumer 各自重寫 wrap JSX)。
25
+
26
+ ## `open` 控制而非條件 wrap(canonical)
27
+
28
+ 「**永遠** wrap Tooltip、`open={isTruncated ? undefined : false}`」而非「未截斷回裸 span、截斷才 wrap」:後者切換 JSX 樹 → span unmount/remount → ref 換到新 DOM、`useEffect([])` 不重跑 → 觀察的 DOM 與實際 DOM 對不上(Breadcrumb 2026-05-11 抓的 bug)。`open={false}` 時 `TooltipTrigger asChild` 只把 handler/`data-state` 合併到同一 span(無多餘 DOM、tooltip 靜默),與裸 span 視覺行為等價。對齊 `components/Tooltip/tooltip.principles.stories.tsx`「沒被截斷就不該顯示 tooltip」。
29
+
30
+ ## 何時用 / 何時不用
31
+
32
+ - **用**:宿主 row/cell/pill 內「寬度受限的單行文字」,溢出時要保留可讀性(hover/focus 見全文)。
33
+ - **不用**:(a) 多行文字(本 primitive 是 `truncate` 單行 `white-space:nowrap`,不處理多行);(b) compound content(icon+field/select/person 等自帶 layout)—— `truncate` 的 inline baseline 會 collapse Field/inline-flex 對齊(DataTable 於 `renderCellContent` 對 compound 欄位**bypass** `<TruncatedText>`,只 primitive string/number 才走);(c) 內容永遠放得下(不需 tooltip 補救)。
34
+
35
+ ## 消費者變異點(hook options)
36
+
37
+ | option | 預設 | 用途 |
38
+ |--------|------|------|
39
+ | `measure` | `scrollWidth > clientWidth`(觀察元素自身)| Tag 傳 Canvas `measureText`(observe root、量測內層 span);回 `undefined` = 保留前狀態 |
40
+ | `deps` | `[]`(mount-once)| Tag 傳 `[children]`(children 變重量)|
41
+ | `recheckAfterPaint` | `true`(rAF + `setTimeout(100)` 二次量,抓字型 async 假陰性)| Tag 傳 `false`(原無二次量)|
42
+ | `timing` | `'effect'` | Tag 傳 `'layoutEffect'`(避免 paint flash)|
43
+
44
+ `<TruncatedText>` props:`children` / `className`(align passthrough)/ `tooltip`(預設 = children;Breadcrumb 傳 `fullText`)/ `display`(`'inline'` 預設保 baseline;`'block'` Breadcrumb 用)。
45
+
46
+ ## A11y 預設
47
+
48
+ - 截斷才顯示 tooltip(補全被裁文字);未截斷不顯示(避免噪音)。
49
+ - Tooltip 經 Radix `TooltipTrigger`/`TooltipContent`:hover + keyboard focus 皆可觸發(對齊 W3C APG Tooltip pattern)。
50
+ - Tag 變異:截斷時 root `tabIndex=0` + focus-visible ring(讓 Tab 到即見全文,APG 前提 trigger 可 focus);未截斷不入 tab 序。此 a11y 由 Tag 讀 `isTruncated` 自渲染,不進 hook(consumer 自控)。
51
+
52
+ ## 禁止事項
53
+
54
+ - ❌ 把 Tag 的偵測改回 `scrollWidth`(flex 內不可靠 → 行為漂移)。
55
+ - ❌ 用條件 wrap(未截斷裸 span / 截斷才 wrap)—— 見上「`open` 控制」canonical。
56
+ - ❌ 對 compound content 套 `<TruncatedText>`(baseline collapse);走宿主 bypass。
57
+ - ❌ 多 consumer 各自 `new ResizeObserver` / 複製 shared RO 引擎 —— 一律消費 `useTruncated`(SSOT)。
@@ -0,0 +1,61 @@
1
+ import * as React from 'react'
2
+
3
+ import { cn } from '@/lib/utils'
4
+ import { Tooltip, TooltipTrigger, TooltipContent } from '@/design-system/components/Tooltip/tooltip'
5
+ import { useTruncated } from '@/design-system/hooks/use-truncated'
6
+
7
+ // ── 消費的 SSOT ──
8
+ // - hooks/use-truncated(useTruncated):截斷偵測引擎 + isTruncated 狀態(本 primitive 的量測核心)
9
+ // - components/Tooltip(Tooltip/TooltipTrigger/TooltipContent):截斷才顯示完整文字的補救 overlay
10
+ // - lib/utils(cn):className 合併
11
+ // - spec: patterns/element-anatomy/truncated-text.spec.md(本 primitive「何時用/為什麼」)
12
+ // - governing: components/Tooltip/tooltip.principles.stories.tsx「沒被截斷就不該顯示 tooltip」→ 由 open 控制機械保證
13
+
14
+ // ── TruncatedText — 單行截斷 + 截斷才顯示 tooltip 的組合式 primitive ──────────────
15
+ // 消費 `useTruncated`(量測引擎 SSOT)+ 封裝「永遠 wrap Tooltip、以 `open` 控制可見」的正解結構。
16
+ // 收斂 Breadcrumb `TruncatedLabel` 與 DataTable `TruncateCell` 兩處同構的 truncate-span+tooltip presentation。
17
+ // Tag 因 trigger≠量測元素(整個 tag root vs 內層 text span)+ Canvas measure + 自訂 a11y,正當地
18
+ // 只消費 `useTruncated` hook、不走本 component(見 tag.tsx)。
19
+ //
20
+ // 「永遠 wrap + `open={isTruncated?undefined:false}`」而非「未截斷回裸 span、截斷才 wrap」:
21
+ // 後者切換 JSX 樹會讓 span unmount/remount → ref 換到新 DOM、useEffect[] 不重跑 → 觀察的 DOM 與
22
+ // 實際 DOM 對不上(Breadcrumb 2026-05-11 抓的 bug)。always-wrap 保 DOM 節點生命週期穩定;
23
+ // `open={false}` 時 TooltipTrigger asChild 只把 handler/data-state 合併到同一 span(無多餘 DOM、
24
+ // tooltip 靜默),與裸 span 視覺行為等價。
25
+
26
+ /** @internal 隨 `TruncatedText`(DS-internal 組合 primitive)一併不進 root barrel front-door;subpath 仍可取。 */
27
+ export interface TruncatedTextProps {
28
+ children: React.ReactNode
29
+ /** 附加到截斷 span 的 className(如 DataTable 的 text-align)。 */
30
+ className?: string
31
+ /**
32
+ * Tooltip 顯示內容,預設 = `children`。當 `children` 為非字串 ReactNode(icon+text 等)、
33
+ * 但已知完整文字時傳入(Breadcrumb 的 `fullText`)。
34
+ */
35
+ tooltip?: React.ReactNode
36
+ /**
37
+ * span 的 display:`'inline'`(預設,保 baseline/垂直對齊,DataTable cell 用)/ `'block'`(Breadcrumb 用)。
38
+ * ⚠️ DataTable 刻意用 inline —— `block` 會改 baseline 使 Field/inline-flex cell 垂直對齊 collapse。
39
+ */
40
+ display?: 'block' | 'inline'
41
+ }
42
+
43
+ /**
44
+ * @internal DS-internal 組合 primitive:由 Breadcrumb / DataTable 內部消費以統一 truncate-tooltip 呈現。
45
+ * end-user app 若需「單行截斷 + tooltip」直接消費 public `useTruncated` hook 自組(trigger/a11y 自控),
46
+ * 或經 per-component subpath 取用本 component。不進 root barrel front-door。
47
+ */
48
+ export function TruncatedText({ children, className, tooltip, display = 'inline' }: TruncatedTextProps) {
49
+ const { ref, isTruncated } = useTruncated<HTMLSpanElement>()
50
+ return (
51
+ <Tooltip open={isTruncated ? undefined : false}>
52
+ <TooltipTrigger asChild>
53
+ <span ref={ref} className={cn('truncate min-w-0', display === 'block' && 'block', className)}>
54
+ {children}
55
+ </span>
56
+ </TooltipTrigger>
57
+ <TooltipContent>{tooltip ?? children}</TooltipContent>
58
+ </Tooltip>
59
+ )
60
+ }
61
+ TruncatedText.displayName = 'TruncatedText'