@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.
- package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -1
- package/dist/components/Breadcrumb/breadcrumb.js +13 -51
- package/dist/components/Breadcrumb/breadcrumb.js.map +1 -1
- package/dist/components/DataTable/data-table.d.ts.map +1 -1
- package/dist/components/DataTable/data-table.js +3 -40
- package/dist/components/DataTable/data-table.js.map +1 -1
- package/dist/components/Tag/tag.d.ts.map +1 -1
- package/dist/components/Tag/tag.js +11 -15
- package/dist/components/Tag/tag.js.map +1 -1
- package/dist/hooks/use-truncated.d.ts +27 -0
- package/dist/hooks/use-truncated.d.ts.map +1 -0
- package/dist/hooks/use-truncated.js +57 -0
- package/dist/hooks/use-truncated.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/patterns/element-anatomy/index.d.ts +1 -0
- package/dist/patterns/element-anatomy/index.d.ts.map +1 -1
- package/dist/patterns/element-anatomy/index.js +2 -0
- package/dist/patterns/element-anatomy/index.js.map +1 -1
- package/dist/patterns/element-anatomy/item-anatomy.js +1 -1
- package/dist/patterns/element-anatomy/truncated-text.d.ts +27 -0
- package/dist/patterns/element-anatomy/truncated-text.d.ts.map +1 -0
- package/dist/patterns/element-anatomy/truncated-text.js +16 -0
- package/dist/patterns/element-anatomy/truncated-text.js.map +1 -0
- package/ds-story-manifest.json +4 -3
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/components/Breadcrumb/breadcrumb.stories.tsx +66 -0
- package/src/components/Breadcrumb/breadcrumb.tsx +27 -77
- package/src/components/Calendar/calendar.stories.tsx +1 -1
- package/src/components/DataTable/data-table.tsx +9 -47
- package/src/components/Tag/tag.tsx +16 -16
- package/src/hooks/use-truncated.ts +110 -0
- package/src/index.ts +3 -0
- package/src/patterns/element-anatomy/index.ts +1 -0
- package/src/patterns/element-anatomy/truncated-text.spec.md +57 -0
- 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 {
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
330
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
341
|
-
? <DropdownMenuItem key={`collapsed-${j}`} asChild
|
|
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
|
-
? <
|
|
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
|
-
? <
|
|
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={(
|
|
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
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
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
|
-
: <
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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'
|
|
@@ -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'
|