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

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 (40) hide show
  1. package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -1
  2. package/dist/components/Breadcrumb/breadcrumb.js +13 -51
  3. package/dist/components/Breadcrumb/breadcrumb.js.map +1 -1
  4. package/dist/components/DataTable/data-table.d.ts.map +1 -1
  5. package/dist/components/DataTable/data-table.js +3 -40
  6. package/dist/components/DataTable/data-table.js.map +1 -1
  7. package/dist/components/Tag/tag.d.ts.map +1 -1
  8. package/dist/components/Tag/tag.js +11 -15
  9. package/dist/components/Tag/tag.js.map +1 -1
  10. package/dist/hooks/use-truncated.d.ts +27 -0
  11. package/dist/hooks/use-truncated.d.ts.map +1 -0
  12. package/dist/hooks/use-truncated.js +57 -0
  13. package/dist/hooks/use-truncated.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/patterns/element-anatomy/index.d.ts +1 -0
  19. package/dist/patterns/element-anatomy/index.d.ts.map +1 -1
  20. package/dist/patterns/element-anatomy/index.js +2 -0
  21. package/dist/patterns/element-anatomy/index.js.map +1 -1
  22. package/dist/patterns/element-anatomy/item-anatomy.js +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/ds-story-manifest.json +4 -3
  28. package/llms-full.txt +1 -1
  29. package/llms.txt +1 -1
  30. package/package.json +1 -1
  31. package/src/components/Breadcrumb/breadcrumb.stories.tsx +66 -0
  32. package/src/components/Breadcrumb/breadcrumb.tsx +27 -77
  33. package/src/components/Calendar/calendar.stories.tsx +1 -1
  34. package/src/components/DataTable/data-table.tsx +9 -47
  35. package/src/components/Tag/tag.tsx +16 -16
  36. package/src/hooks/use-truncated.ts +110 -0
  37. package/src/index.ts +3 -0
  38. package/src/patterns/element-anatomy/index.ts +1 -0
  39. package/src/patterns/element-anatomy/truncated-text.spec.md +57 -0
  40. package/src/patterns/element-anatomy/truncated-text.tsx +61 -0
@@ -5,7 +5,7 @@ import { Slot } from '@radix-ui/react-slot'
5
5
  import { ChevronRight, MoreHorizontal, type LucideIcon } from 'lucide-react'
6
6
  import { cn } from '@/lib/utils'
7
7
  import { ItemInlineActionButton } from '@/design-system/patterns/element-anatomy/item-anatomy'
8
- import { Tooltip, TooltipTrigger, TooltipContent } from '@/design-system/components/Tooltip/tooltip'
8
+ import { TruncatedText } from '@/design-system/patterns/element-anatomy/truncated-text'
9
9
  import {
10
10
  DropdownMenu,
11
11
  DropdownMenuTrigger,
@@ -13,70 +13,9 @@ import {
13
13
  DropdownMenuItem,
14
14
  } from '@/design-system/components/DropdownMenu/dropdown-menu'
15
15
 
16
- // ── TruncatedLabel ────────────────────────────────────────────────────────────
17
- // `data-table.tsx TruncateCell` + `tag.tsx isTruncated` SSOT pattern
18
- // (shared ResizeObserver + scrollWidth > clientWidth wrap Tooltip)。
19
- // **TODO** future:Rule-of-3 達 → 抽 `patterns/element-anatomy/truncated-text.tsx` 共用
20
- // (本 component / DataTable TruncateCell / Tag inner 三處同 idiom,符合 M30 SSOT 抽取門檻)。
21
-
22
- type RoCallback = (entry: ResizeObserverEntry) => void
23
- let sharedRO: ResizeObserver | null = null
24
- const sharedROCallbacks = new WeakMap<Element, RoCallback>()
25
- function getSharedRO(): ResizeObserver {
26
- if (sharedRO) return sharedRO
27
- sharedRO = new ResizeObserver((entries) => {
28
- entries.forEach((e) => {
29
- const cb = sharedROCallbacks.get(e.target)
30
- if (cb) cb(e)
31
- })
32
- })
33
- return sharedRO
34
- }
35
- function observeShared(el: Element, cb: RoCallback): () => void {
36
- const obs = getSharedRO()
37
- sharedROCallbacks.set(el, cb)
38
- obs.observe(el)
39
- return () => { sharedROCallbacks.delete(el); obs.unobserve(el) }
40
- }
41
-
42
- function TruncatedLabel({ children, fullText }: { children: React.ReactNode; fullText?: string }) {
43
- const ref = React.useRef<HTMLSpanElement>(null)
44
- const [isTruncated, setIsTruncated] = React.useState(false)
45
- React.useEffect(() => {
46
- const el = ref.current
47
- if (!el) return
48
- const check = () => setIsTruncated(el.scrollWidth > el.clientWidth)
49
- check()
50
- // 2026-05-11 fix:首幀 layout 未完成 / 字型 async load → scrollWidth=clientWidth 假陰性。
51
- // RAF + 短延遲再驗一次,捕獲字型 / 容器尺寸後變(對齊 TruncateCell / Tag SSOT pattern)。
52
- const raf = requestAnimationFrame(check)
53
- const t = setTimeout(check, 100)
54
- const cleanup = observeShared(el, check)
55
- return () => {
56
- cancelAnimationFrame(raf)
57
- clearTimeout(t)
58
- cleanup()
59
- }
60
- }, [])
61
- // Tooltip canonical:per `tooltip.principles.stories.tsx:190`「Tooltip 是資訊補救 — 文字被
62
- // truncate 時才顯示完整內容。沒被截斷就不該顯示 tooltip」
63
- //
64
- // 2026-05-11 fix(playwright tooltip-on-truncate 卡 hover 沒 tooltip):
65
- // 原本 isTruncated=false 直接 return 裸 span / true 才 wrap Tooltip → JSX 樹結構改變
66
- // → React 把 span unmount + remount(因為 wrapper component 變),ref 換到新 span,
67
- // useEffect [] 不重跑(同 component instance)→ 觀察的 DOM 跟實際 DOM 對不上。
68
- // Fix:**永遠 wrap Tooltip**(同 DOM 節點生命週期);`open` 由 isTruncated 控制 —
69
- // 沒被 truncate 就 force `open={false}`,有 truncate 就 `undefined`(uncontrolled,
70
- // hover 走 Radix default behavior)。對齊 canonical「沒被截斷就不該 tooltip」。
71
- return (
72
- <Tooltip open={isTruncated ? undefined : false}>
73
- <TooltipTrigger asChild>
74
- <span ref={ref} className="truncate min-w-0 block">{children}</span>
75
- </TooltipTrigger>
76
- <TooltipContent>{fullText ?? children}</TooltipContent>
77
- </Tooltip>
78
- )
79
- }
16
+ // ── TruncatedLabel ── 2026-07-19:truncate+tooltip 引擎 + presentation 已抽成 SSOT primitive
17
+ // `patterns/element-anatomy/truncated-text`(`<TruncatedText>` 消費 `useTruncated` hook)。本檔改為
18
+ // 消費 `<TruncatedText display="block" tooltip={fullText}>`,原 file-local shared RO + TruncatedLabel 已移除。
80
19
 
81
20
  /**
82
21
  * Breadcrumb — 顯示當前頁面在階層中的位置
@@ -212,10 +151,10 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, BreadcrumbListProps>(
212
151
  <BreadcrumbItem key={`${role}-${typeof spec.label === 'string' ? spec.label : idx}`} role={role}>
213
152
  {role === 'current'
214
153
  ? <BreadcrumbPage startIcon={spec.startIcon}>
215
- <TruncatedLabel fullText={typeof spec.label === 'string' ? spec.label : undefined}>{spec.label}</TruncatedLabel>
154
+ <TruncatedText display="block" tooltip={typeof spec.label === 'string' ? spec.label : undefined}>{spec.label}</TruncatedText>
216
155
  </BreadcrumbPage>
217
156
  : <BreadcrumbLink href={spec.href} asChild={spec.asChild} startIcon={spec.startIcon}>
218
- <TruncatedLabel fullText={typeof spec.label === 'string' ? spec.label : undefined}>{spec.label}</TruncatedLabel>
157
+ <TruncatedText display="block" tooltip={typeof spec.label === 'string' ? spec.label : undefined}>{spec.label}</TruncatedText>
219
158
  </BreadcrumbLink>
220
159
  }
221
160
  </BreadcrumbItem>
@@ -326,19 +265,30 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, BreadcrumbListProps>(
326
265
  {collapsedItems.map((item, j) => {
327
266
  // 2026-05-12 Round 4.5 fix(codex M31 Layer C 抓):consumer BreadcrumbItem children 常包
328
267
  // `<BreadcrumbLink href>` = anchor button-like。直接放進 `<DropdownMenuItem>` 會 nested
329
- // interactive(menuitem within button violates HTML / a11y)。Fix:extract href + label,
330
- // `asChild` 把 anchor 接到 menuitem 對齊 declarative path line 245 canonical pattern。
268
+ // interactive(menuitem within button violates HTML / a11y)。Fix:把實際 link 元素接到
269
+ // menuitem `asChild`,對齊 declarative path canonical pattern。
270
+ // 2026-07-19 fix(DA3 對抗稽核抓):原只讀 `c.props.href` → `<BreadcrumbLink asChild>
271
+ // <RouterLink to=…>` 這種(BreadcrumbLink 無 href、RouterLink child 帶 `to`)摺疊後
272
+ // dropdown 失去導航目標。修:asChild 時**保留整個 link 子元素**(RouterLink/<Link to>/<a>,
273
+ // 含 to/href/onClick/state 全 forward,前端路由不退化成整頁重載);純 href 才重建 <a>。
331
274
  const innerChildren = (item.props as { children?: React.ReactNode }).children
332
- let href: string | undefined
333
- let label: React.ReactNode = innerChildren
275
+ let menuChild: React.ReactNode | undefined // 接進 menuitem(asChild)的實際 link 元素
276
+ let label: React.ReactNode = innerChildren // fallback:無 link 時的純內容
334
277
  React.Children.forEach(innerChildren, (c) => {
335
- if (React.isValidElement<{ href?: string; children?: React.ReactNode }>(c)) {
336
- if (c.props.href) href = c.props.href
278
+ if (!React.isValidElement<{ href?: string; asChild?: boolean; children?: React.ReactNode }>(c)) return
279
+ if (c.props.asChild && React.isValidElement(c.props.children)) {
280
+ // BreadcrumbLink asChild → 實際 link = 其 child(RouterLink / <Link to> / <a>),整個保留
281
+ menuChild = c.props.children
282
+ const grand = (c.props.children.props as { children?: React.ReactNode }).children
283
+ if (grand != null) label = grand
284
+ } else if (c.props.href != null) {
285
+ // BreadcrumbLink href → 重建 <a href>(對齊原 canonical,避免 nested interactive)
286
+ menuChild = <a href={c.props.href}>{c.props.children}</a>
337
287
  if (c.props.children != null) label = c.props.children
338
288
  }
339
289
  })
340
- return href
341
- ? <DropdownMenuItem key={`collapsed-${j}`} asChild><a href={href}>{label}</a></DropdownMenuItem>
290
+ return menuChild
291
+ ? <DropdownMenuItem key={`collapsed-${j}`} asChild>{menuChild}</DropdownMenuItem>
342
292
  : <DropdownMenuItem key={`collapsed-${j}`}>{label}</DropdownMenuItem>
343
293
  })}
344
294
  </DropdownMenuContent>
@@ -450,7 +400,7 @@ const BreadcrumbLink = React.forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(
450
400
  // on truncate」per spec.md / Polaris breadcrumb)。Non-string children(consumer 自訂 icon+text
451
401
  // 結構)→ pass-through 不 force-wrap(consumer own truncate)。
452
402
  const wrappedChildren = typeof children === 'string'
453
- ? <TruncatedLabel fullText={children}>{children}</TruncatedLabel>
403
+ ? <TruncatedText display="block" tooltip={children}>{children}</TruncatedText>
454
404
  : children
455
405
  const sharedClassName = cn(
456
406
  'inline-flex items-center gap-2',
@@ -498,7 +448,7 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, BreadcrumbPageProps>(
498
448
  const { size } = React.useContext(BreadcrumbContext)
499
449
  // 2026-05-12 fix(同 BreadcrumbLink):純文字 children → auto-wrap TruncatedLabel。
500
450
  const wrappedChildren = typeof children === 'string'
501
- ? <TruncatedLabel fullText={children}>{children}</TruncatedLabel>
451
+ ? <TruncatedText display="block" tooltip={children}>{children}</TruncatedText>
502
452
  : children
503
453
  return (
504
454
  <span
@@ -51,7 +51,7 @@ export const TeamCalendar: Story = {
51
51
  defaultReferenceDate={now}
52
52
  events={events}
53
53
  onEventClick={(e) => alert(`點了事件:${e.title}`)}
54
- onDateClick={(d) => console.log('點 date cell:', d)}
54
+ onDateClick={() => { /* demo:實際 app 在此開「當日新增事件」面板 */ }}
55
55
  onCreateEvent={() => alert('開啟新事件對話框')}
56
56
  />
57
57
  </div>
@@ -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
+ // ── TruncateCell ── 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):
@@ -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)
@@ -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
 
@@ -0,0 +1,110 @@
1
+ import * as React from 'react'
2
+
3
+ // ── useTruncated — 單行文字截斷偵測引擎(SSOT)─────────────────────────────────
4
+ // 收斂 Breadcrumb TruncatedLabel / DataTable TruncateCell / Tag 三處**逐字重複**的
5
+ // module-level shared ResizeObserver 引擎 + isTruncated 狀態機為唯一 owner(M17 SSOT)。
6
+ //
7
+ // 世界級對照(M8):MUI/MUI-X 官方無第一方 primitive,社群共識 = 「hook 量測 scrollWidth>clientWidth
8
+ // + ref + 以 Tooltip `open` prop 條件控制」(issue #37211);此 hook 即該低階量測層,
9
+ // 上層 `<TruncatedText>`(patterns/element-anatomy)對應 Ant `Typography.Text ellipsis` 高階元件。
10
+ //
11
+ // 三處變異點皆以 options 注入,**行為零漂移**:
12
+ // - Breadcrumb / DataTable:預設 scrollWidth>clientWidth。
13
+ // - Tag:trigger≠量測元素(observe root、量測內層 span)+ Canvas measureText(flex 內 scrollWidth
14
+ // 不可靠)+ useLayoutEffect(避免 paint flash)+ deps=[children] → 全走 options。
15
+
16
+ type RoCallback = (entry: ResizeObserverEntry) => void
17
+
18
+ let sharedRO: ResizeObserver | null = null
19
+ const sharedROCallbacks = new WeakMap<Element, RoCallback>()
20
+
21
+ // 全 DS 共用單一 ResizeObserver,dispatch 到 per-element callback(2026-04-22 DataTable D3 perf audit
22
+ // 成果:10 col × 100 row = 1 RO 而非 1000)。element 卸載時 cleanup,singleton RO 本身不 disconnect。
23
+ function getSharedRO(): ResizeObserver {
24
+ if (sharedRO) return sharedRO
25
+ sharedRO = new ResizeObserver((entries) => {
26
+ entries.forEach((e) => {
27
+ const cb = sharedROCallbacks.get(e.target)
28
+ if (cb) cb(e)
29
+ })
30
+ })
31
+ return sharedRO
32
+ }
33
+
34
+ function observeShared(el: Element, cb: RoCallback): () => void {
35
+ const obs = getSharedRO()
36
+ sharedROCallbacks.set(el, cb)
37
+ obs.observe(el)
38
+ return () => {
39
+ sharedROCallbacks.delete(el)
40
+ obs.unobserve(el)
41
+ }
42
+ }
43
+
44
+ export interface UseTruncatedOptions {
45
+ /**
46
+ * 偵測策略:回傳 `true` = 截斷、`false` = 未截斷、`undefined` = 本次無法量測(保留前一狀態)。
47
+ * 預設 = `el.scrollWidth > el.clientWidth`(觀察元素自身)。
48
+ * Tag 傳自訂 fn:observe root、內部 `querySelector('[data-tag-text]')` + Canvas measureText。
49
+ */
50
+ measure?: (observedEl: HTMLElement) => boolean | undefined
51
+ /** deps 變更時 cleanup + 重新量測 / 重新訂閱(Tag 傳 `[children]`)。預設 `[]`(mount-once)。 */
52
+ deps?: React.DependencyList
53
+ /**
54
+ * 是否在首次 paint 後以 `requestAnimationFrame` + `setTimeout(100)` 再量一次,捕獲首幀 layout
55
+ * 未完成 / 字型 async load 的假陰性(Breadcrumb 已採此 robust 版)。預設 `true`。
56
+ */
57
+ recheckAfterPaint?: boolean
58
+ /** `'layoutEffect'` = `useLayoutEffect`(量測在 paint 前,避免 flash;Tag 用)/ `'effect'` = `useEffect`。預設 `'effect'`。 */
59
+ timing?: 'effect' | 'layoutEffect'
60
+ }
61
+
62
+ const defaultMeasure = (el: HTMLElement): boolean => el.scrollWidth > el.clientWidth
63
+
64
+ /**
65
+ * 偵測 ref 所指元素的文字是否被截斷,回傳 `{ ref, isTruncated }`。
66
+ * `isTruncated` 用來驅動 Tooltip「僅截斷時顯示」(對齊 `tooltip.principles.stories.tsx`「沒被截斷就不該顯示 tooltip」)。
67
+ */
68
+ export function useTruncated<E extends HTMLElement = HTMLElement>(
69
+ options: UseTruncatedOptions = {},
70
+ ): { ref: React.MutableRefObject<E | null>; isTruncated: boolean } {
71
+ const { measure = defaultMeasure, deps = [], recheckAfterPaint = true, timing = 'effect' } = options
72
+ // MutableRefObject(consumer 可經 callback ref 寫入 .current 以與 forwardedRef 合併,如 Tag)。
73
+ const ref = React.useRef<E | null>(null)
74
+ const [isTruncated, setIsTruncated] = React.useState(false)
75
+
76
+ // measure 每 render 可能是新 closure(consumer inline);收進 ref 讓 effect 不因它重訂閱,
77
+ // 但 RO 回呼仍讀到最新版。
78
+ const measureRef = React.useRef(measure)
79
+ measureRef.current = measure
80
+
81
+ // timing 為 call-site 固定字面值(某 consumer 永遠同一值)→ 每個 component instance 每 render
82
+ // 呼叫同一個 effect hook,符合 rules-of-hooks。
83
+ const useIsoEffect = timing === 'layoutEffect' ? React.useLayoutEffect : React.useEffect
84
+
85
+ useIsoEffect(() => {
86
+ const el = ref.current
87
+ if (!el) return
88
+ const check = () => {
89
+ const r = measureRef.current(el)
90
+ if (r !== undefined) setIsTruncated(r)
91
+ }
92
+ check()
93
+ let raf = 0
94
+ let t: ReturnType<typeof setTimeout> | undefined
95
+ if (recheckAfterPaint) {
96
+ raf = requestAnimationFrame(check)
97
+ t = setTimeout(check, 100)
98
+ }
99
+ const cleanup = observeShared(el, check)
100
+ return () => {
101
+ if (raf) cancelAnimationFrame(raf)
102
+ if (t) clearTimeout(t)
103
+ cleanup()
104
+ }
105
+ // deps 由 consumer 決定(Tag: [children];其餘: []);measure 走 ref 不需進 deps。
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, deps)
108
+
109
+ return { ref, isTruncated }
110
+ }
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'