@qijenchen/design-system 0.1.0-beta.3
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/package.json +93 -0
- package/src/README.md +32 -0
- package/src/components/Accordion/accordion.tsx +104 -0
- package/src/components/Alert/alert.tsx +188 -0
- package/src/components/AppShell/_demo-helpers.tsx +198 -0
- package/src/components/AppShell/app-shell.tsx +364 -0
- package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
- package/src/components/Avatar/avatar.tsx +368 -0
- package/src/components/Badge/badge.tsx +104 -0
- package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
- package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
- package/src/components/Button/button-group.tsx +96 -0
- package/src/components/Button/button.tsx +539 -0
- package/src/components/Calendar/calendar.tsx +411 -0
- package/src/components/Carousel/carousel.tsx +371 -0
- package/src/components/Chart/chart.tsx +376 -0
- package/src/components/Checkbox/checkbox-group.tsx +94 -0
- package/src/components/Checkbox/checkbox.tsx +237 -0
- package/src/components/Chip/chip.tsx +359 -0
- package/src/components/CircularProgress/circular-progress.tsx +204 -0
- package/src/components/Coachmark/coachmark.tsx +255 -0
- package/src/components/Combobox/combobox.tsx +826 -0
- package/src/components/Command/command.tsx +187 -0
- package/src/components/DataTable/active-editor-controller.ts +72 -0
- package/src/components/DataTable/cell-registry.tsx +520 -0
- package/src/components/DataTable/column-types.ts +180 -0
- package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
- package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
- package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
- package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
- package/src/components/DataTable/data-table.css +165 -0
- package/src/components/DataTable/data-table.tsx +2924 -0
- package/src/components/DataTable/filter-operators.ts +225 -0
- package/src/components/DataTable/filter-tree.ts +313 -0
- package/src/components/DataTable/lib/column-meta.ts +79 -0
- package/src/components/DateGrid/date-grid.tsx +209 -0
- package/src/components/DatePicker/date-picker.tsx +1114 -0
- package/src/components/DescriptionList/description-list.tsx +141 -0
- package/src/components/Dialog/dialog.tsx +267 -0
- package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
- package/src/components/Empty/empty.tsx +108 -0
- package/src/components/Field/field-context.ts +136 -0
- package/src/components/Field/field-types.ts +52 -0
- package/src/components/Field/field-wrapper.tsx +348 -0
- package/src/components/Field/field.tsx +535 -0
- package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
- package/src/components/FileItem/file-item.tsx +322 -0
- package/src/components/FileUpload/file-upload.tsx +326 -0
- package/src/components/FileViewer/file-viewer-types.ts +76 -0
- package/src/components/FileViewer/file-viewer.tsx +1065 -0
- package/src/components/FileViewer/image-renderer.tsx +256 -0
- package/src/components/HoverCard/hover-card.tsx +79 -0
- package/src/components/Input/input.tsx +233 -0
- package/src/components/LinkInput/link-input.tsx +304 -0
- package/src/components/Menu/menu-item.tsx +334 -0
- package/src/components/NameCard/name-card.tsx +319 -0
- package/src/components/Notice/notice.tsx +196 -0
- package/src/components/NumberInput/number-input.tsx +203 -0
- package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
- package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
- package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
- package/src/components/PeoplePicker/people-picker.tsx +455 -0
- package/src/components/PeoplePicker/person-display.tsx +358 -0
- package/src/components/Popover/popover.tsx +183 -0
- package/src/components/ProgressBar/progress-bar.tsx +157 -0
- package/src/components/README.md +58 -0
- package/src/components/RadioGroup/radio-group.tsx +261 -0
- package/src/components/Rating/rating.tsx +295 -0
- package/src/components/ScrollArea/scroll-area.tsx +110 -0
- package/src/components/SegmentedControl/segmented-control.tsx +304 -0
- package/src/components/Select/select.tsx +658 -0
- package/src/components/SelectMenu/select-menu.tsx +430 -0
- package/src/components/SelectionControl/selection-item.tsx +261 -0
- package/src/components/Separator/separator.tsx +48 -0
- package/src/components/Sheet/sheet.tsx +240 -0
- package/src/components/Sidebar/sidebar.tsx +1280 -0
- package/src/components/Skeleton/skeleton.tsx +35 -0
- package/src/components/Slider/slider.tsx +158 -0
- package/src/components/Steps/steps.tsx +850 -0
- package/src/components/Switch/switch.tsx +285 -0
- package/src/components/Tabs/tabs.tsx +515 -0
- package/src/components/Tag/tag.tsx +246 -0
- package/src/components/Textarea/textarea.tsx +280 -0
- package/src/components/TimePicker/time-columns.tsx +260 -0
- package/src/components/TimePicker/time-picker.tsx +419 -0
- package/src/components/Toast/toast.tsx +129 -0
- package/src/components/Tooltip/tooltip.tsx +68 -0
- package/src/components/TreeView/tree-view.tsx +1031 -0
- package/src/hooks/use-controllable.ts +40 -0
- package/src/hooks/use-is-narrow-viewport.ts +19 -0
- package/src/hooks/use-is-touch-device.ts +21 -0
- package/src/hooks/use-overflow-items.ts +256 -0
- package/src/index.ts +85 -0
- package/src/lib/README.md +82 -0
- package/src/lib/drag-visual.ts +272 -0
- package/src/lib/i18n/README.md +60 -0
- package/src/lib/i18n/i18n-context.tsx +129 -0
- package/src/lib/multi-select-ordering.ts +61 -0
- package/src/lib/utils.ts +93 -0
- package/src/patterns/README.md +67 -0
- package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
- package/src/patterns/header-canonical/chrome-header.tsx +175 -0
- package/src/patterns/header-canonical/header-canonical.css +27 -0
- package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
- package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
- package/src/patterns/resize-handle/resize-handle.tsx +188 -0
- package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
- package/src/tokens/README.md +53 -0
- package/src/tokens/color/primitives.css +429 -0
- package/src/tokens/color/semantic.css +539 -0
- package/src/tokens/elevation/overlay-geometry.ts +13 -0
- package/src/tokens/layoutSpace/layoutSpace.css +36 -0
- package/src/tokens/motion/motion.css +30 -0
- package/src/tokens/motion/motion.ts +17 -0
- package/src/tokens/opacity/opacity.css +23 -0
- package/src/tokens/radius/radius.css +19 -0
- package/src/tokens/typography/typography.css +118 -0
- package/src/tokens/uiSize/icon-size.ts +52 -0
- package/src/tokens/uiSize/uiSize.css +125 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag visual SSOT(2026-05-06 v14.5)
|
|
3
|
+
*
|
|
4
|
+
* DS canonical drag visual,3 處 consumer(TreeView / DataTable row drag / DataTable column reorder)
|
|
5
|
+
* 共用同一組 styling constant — 對齊 TreeView 最早 codified 的 pattern。
|
|
6
|
+
*
|
|
7
|
+
* ## Pattern
|
|
8
|
+
*
|
|
9
|
+
* - **Source element**(被拖中的 row/column):`opacity-30`(半透,user 仍看得到原位)
|
|
10
|
+
* — 不是 `opacity:0` 全隱形,因 user 拖太遠看不到「源頭」會迷失方向。對齊 TreeView 最早 v1 + Notion / Atlassian / Linear。
|
|
11
|
+
* - **Ghost preview**(浮動 follow cursor):透過 dnd-kit `<DragOverlay>` portal,clone source 的 outerHTML
|
|
12
|
+
* strip transform/transition/opacity → render 在 portal 層,visually 跟 cursor 走。
|
|
13
|
+
* - **Drop indicator**(目標位置藍細線):2px(`h-0.5` / `w-0.5`)`bg-primary` absolute line:
|
|
14
|
+
* - **Row context**(TreeView / table row drag):水平線 在 target row 的 top(before)/ bottom(after)
|
|
15
|
+
* - **Column context**(table column reorder):垂直線 在 target column 的 left(before)/ right(after)
|
|
16
|
+
* - **Inside-drop highlight**(nested target,如拖到 TreeView 子層 / Table nested row 內):整 row `bg-primary-subtle`
|
|
17
|
+
*
|
|
18
|
+
* ## Why centralize
|
|
19
|
+
*
|
|
20
|
+
* 三處 drag 之前各自實作不一致(TreeView opacity:30 + indicator vs DataTable row opacity:0 no indicator
|
|
21
|
+
* vs Column reorder opacity:0 + indicator)— M23 / M27 violation(DS 內 canonical 沒對齊)。
|
|
22
|
+
* 抽到此 module 後 3 處 import 同 constants,未來改 1 處全 sync。
|
|
23
|
+
*
|
|
24
|
+
* ## Token usage
|
|
25
|
+
*
|
|
26
|
+
* - `bg-primary`:semantic state token(`--primary`)— 跟 focus state ring color 同 source(M23 一致)
|
|
27
|
+
* - `bg-primary-subtle`:semantic primary subtle(`--primary-subtle`)— inside-drop dim background
|
|
28
|
+
* - `h-0.5` / `w-0.5`:Tailwind size token = 2px(對齊 hairline divider thickness 概念)
|
|
29
|
+
* - `opacity-30`:Tailwind 30% opacity(半透但仍清楚看到 outline)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type * as React from 'react'
|
|
33
|
+
import type { Modifier } from '@dnd-kit/core'
|
|
34
|
+
|
|
35
|
+
// ── Source element styling(被拖中的 source row/column)─────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Source element 拖中的 opacity className。
|
|
39
|
+
*
|
|
40
|
+
* **Reuse `opacity-disabled` token**(2026-05-06 v14.5.2 對齊 Atlassian Pragmatic):
|
|
41
|
+
* Atlassian dnd guidelines 也用 `opacity.disabled` token 給 drag source dim(他們值 0.40,
|
|
42
|
+
* 我們值 0.45,role 一致)。不另開 drag-source 專屬 token — single-role-per-value 哲學
|
|
43
|
+
* + DS internal SSOT。
|
|
44
|
+
*/
|
|
45
|
+
export const dragSourceClass = 'opacity-disabled' as const
|
|
46
|
+
|
|
47
|
+
/** Source element 拖中的 inline style(consume DS token `--opacity-disabled`)*/
|
|
48
|
+
export function dragSourceStyle(isDragging: boolean): React.CSSProperties {
|
|
49
|
+
return isDragging ? { opacity: 'var(--opacity-disabled)' } : {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* DragOverlay ghost 視覺 canonical(2026-05-06 v14.5.2 對齊 dnd-kit / Atlassian / Material):
|
|
54
|
+
* **不 dim**(opacity:1)+ elevation shadow `shadow-[var(--elevation-200)]` + 顯式 bg
|
|
55
|
+
* (`bg-surface` 或 `bg-surface-raised`)+ border。Lifted preview pattern,跟 surface 拉開
|
|
56
|
+
* 視覺距離靠 shadow 不靠 opacity。
|
|
57
|
+
*
|
|
58
|
+
* Consumer 自己組 className(因 ghost 結構各自不同 — TreeView 是 icon+label compact pill,
|
|
59
|
+
* DataTable 是 cloned row HTML wrapper),這裡只 codify 規則 不導出 class string。
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
// ── Drop indicator className(target row/column edge 顯藍細線)──────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Row drop indicator(水平線,跨 row 全寬)
|
|
66
|
+
*
|
|
67
|
+
* - `before`:在 target row top edge,提示「放開後會插入這 row 之前」
|
|
68
|
+
* - `after`:在 target row bottom edge,提示「放開後會插入這 row 之後」
|
|
69
|
+
*
|
|
70
|
+
* **Indent option**:nested context(TreeView / table nested rows)可加 `style={{ left: indentPx }}`
|
|
71
|
+
* 讓 indicator 隨 depth 縮排,視覺對齊 row content 起始位置。
|
|
72
|
+
*/
|
|
73
|
+
export const dropIndicatorRow = {
|
|
74
|
+
before: 'absolute top-0 left-0 right-0 h-0.5 bg-primary z-10 pointer-events-none',
|
|
75
|
+
after: 'absolute bottom-0 left-0 right-0 h-0.5 bg-primary z-10 pointer-events-none',
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Column drop indicator(垂直線,跨 column header height)
|
|
80
|
+
*
|
|
81
|
+
* - `before`:在 target column left edge(-1px outset 對齊 grid line)
|
|
82
|
+
* - `after`:在 target column right edge(-1px outset 對齊 grid line)
|
|
83
|
+
*
|
|
84
|
+
* 兩種變體,視覺一致(2px primary line),只有 DOM mechanism 不同 — consumer 視 use case 選:
|
|
85
|
+
*
|
|
86
|
+
* - **`absoluteDiv`**(TreeView pattern):render absolute `<div>` child;
|
|
87
|
+
* consumer 必須有 `position: relative` parent。
|
|
88
|
+
* - **`pseudoBefore` / `pseudoAfter`**:用 Tailwind `before:` / `after:` pseudo-element;
|
|
89
|
+
* consumer 不需 child element(適合 cloneElement 等不能加 child 的場景)。
|
|
90
|
+
*
|
|
91
|
+
* 兩變體 thickness / color / z-index 完全一致,差別純 implementation 機制。
|
|
92
|
+
*/
|
|
93
|
+
export const dropIndicatorColumn = {
|
|
94
|
+
// Absolute div 變體(consumer render <div>)
|
|
95
|
+
before: 'absolute top-0 bottom-0 left-[-1px] w-0.5 bg-primary z-10 pointer-events-none',
|
|
96
|
+
after: 'absolute top-0 bottom-0 right-[-1px] w-0.5 bg-primary z-10 pointer-events-none',
|
|
97
|
+
// Pseudo-element 變體(用 Tailwind before:/after:)
|
|
98
|
+
pseudoBefore: 'before:absolute before:top-0 before:bottom-0 before:left-[-1px] before:w-0.5 before:bg-primary before:z-10 before:pointer-events-none before:content-[""]',
|
|
99
|
+
pseudoAfter: 'after:absolute after:top-0 after:bottom-0 after:right-[-1px] after:w-0.5 after:bg-primary after:z-10 after:pointer-events-none after:content-[""]',
|
|
100
|
+
} as const
|
|
101
|
+
|
|
102
|
+
// ── Inside-drop highlight(nested context,target row 整列亮藍 subtle)─────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Nested 拖入 highlight(TreeView / nested rows 拖到子層)。整 row 加 background。
|
|
106
|
+
*/
|
|
107
|
+
export const dropIndicatorInside = 'bg-primary-subtle' as const
|
|
108
|
+
|
|
109
|
+
// ── Cursor classes ────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/** Draggable element 拖中時的 cursor(grabbing)*/
|
|
112
|
+
export const dragActiveCursor = 'cursor-grabbing' as const
|
|
113
|
+
/**
|
|
114
|
+
* Combined drag handle cursor canonical(2026-05-07 v15.7 user directive):
|
|
115
|
+
* **只 cursor-grab,不變 grabbing**。對齊 Material / Carbon / Polaris canonical —
|
|
116
|
+
* drag affordance 由 visible button 提供,cursor 變化反而干擾 indicator+ghost 視覺焦點。
|
|
117
|
+
* Consumer 直接 cn(...)。
|
|
118
|
+
*/
|
|
119
|
+
export const dragHandleCursorClass = 'cursor-grab' as const
|
|
120
|
+
|
|
121
|
+
// ── Modifier:Ghost 跟 cursor 維持初始相對位置 SSOT(v15.7 user directive) ─
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Ghost 跟 cursor 維持「cursor 在 ghost 點下時相對位置」SSOT(對齊 user directive
|
|
125
|
+
* 「ghost 和 cursor 要確保相對位置是合理的而且有 ssot」)。
|
|
126
|
+
*
|
|
127
|
+
* **Why need this**:dnd-kit DragOverlay 預設 ghost.top-left = source rect (`setNodeRef`)
|
|
128
|
+
* 的 top-left。當 source ≠ activator 位置不同(e.g. activator = portal'd Button 在
|
|
129
|
+
* table outer 邊緣;source = primary row 在 table 內 column region),cursor 點下
|
|
130
|
+
* activator → cursor 落在 source rect 的 negative offset → ghost 永遠相對 source rect
|
|
131
|
+
* 起算 → cursor 跟 ghost 起始就有大 offset(e.g. 100+ px),drag 期間維持錯位。
|
|
132
|
+
*
|
|
133
|
+
* **本 modifier**:把 (cursor.initial - source.left, cursor.initial - source.top)
|
|
134
|
+
* 加進 transform → ghost.top-left 永遠對齊 cursor 位置(cursor 在 ghost 左前角)。
|
|
135
|
+
* Consumer 套進 DndContext.modifiers array,所有 drag scenario 套用 = SSOT。
|
|
136
|
+
*
|
|
137
|
+
* **對齊世界級**:Linear / Notion / Jira / AG Grid 的 row drag ghost 都跟 cursor
|
|
138
|
+
* 維持初始相對位置(整列拖時 cursor 在 click 點;button 拖時 cursor 在 button 位置)—
|
|
139
|
+
* 這個 modifier 把「整列拖 + 整列 ghost」UX 在 button-only / 多 region 場景下也達到。
|
|
140
|
+
*/
|
|
141
|
+
export const snapToCursorModifier: Modifier = ({ transform, activatorEvent, draggingNodeRect }) => {
|
|
142
|
+
if (!activatorEvent || !draggingNodeRect) return transform
|
|
143
|
+
// **codex P1 fix(2026-05-07 v15.12)**:KeyboardSensor 走 KeyboardEvent,沒
|
|
144
|
+
// `clientX/clientY` → `offsetX/offsetY = NaN` → ghost transform 變 NaN/NaN →
|
|
145
|
+
// overlay 定位+collision 全 corrupt → keyboard-initiated drag 直接壞。Guard 條件:
|
|
146
|
+
// 只 pointer/mouse event 才平移,keyboard 走 dnd-kit 預設(無 modifier 偏移)。
|
|
147
|
+
if (!('clientX' in activatorEvent) || !('clientY' in activatorEvent)) return transform
|
|
148
|
+
const ev = activatorEvent as PointerEvent | MouseEvent
|
|
149
|
+
if (typeof ev.clientX !== 'number' || typeof ev.clientY !== 'number') return transform
|
|
150
|
+
const offsetX = ev.clientX - draggingNodeRect.left
|
|
151
|
+
const offsetY = ev.clientY - draggingNodeRect.top
|
|
152
|
+
return {
|
|
153
|
+
...transform,
|
|
154
|
+
x: transform.x + offsetX,
|
|
155
|
+
y: transform.y + offsetY,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Reorder noop helper(SSOT 對齊 row + column reorder canonical) ────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Drop position 等同 source 原位 → noop。對齊 Linear / Notion / Jira / TreeView SSOT:
|
|
163
|
+
* user 拉到 source 鄰近(視覺等同原位)應 cancel,不 commit reorder 也不顯 indicator。
|
|
164
|
+
*
|
|
165
|
+
* Consumer 在 onDragOver 用此判斷是否畫 indicator;onDragEnd 用同一函數判斷是否
|
|
166
|
+
* 觸發 reorder callback —— 兩處共享一個 invariant,避免「indicator 顯示但 commit 不 fire」
|
|
167
|
+
* 或反向 drift。
|
|
168
|
+
*
|
|
169
|
+
* 規則:
|
|
170
|
+
* - source idx N,drop on idx N (self) → noop
|
|
171
|
+
* - source idx N,drop on idx N-1 with side='after' = N(原位)→ noop
|
|
172
|
+
* - source idx N,drop on idx N+1 with side='before' = N(原位)→ noop
|
|
173
|
+
*/
|
|
174
|
+
export function isReorderNoop(activeIdx: number, overIdx: number, side: 'before' | 'after'): boolean {
|
|
175
|
+
if (activeIdx === overIdx) return true
|
|
176
|
+
if (side === 'after' && overIdx + 1 === activeIdx) return true
|
|
177
|
+
if (side === 'before' && overIdx - 1 === activeIdx) return true
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Ghost reconstruction(跨 region table row → 完整橫跨 ghost) ───────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 為 dnd-kit DragOverlay 重建跨 region 的完整 row ghost(2026-05-07 v15.9 Bug A+B 修)。
|
|
185
|
+
*
|
|
186
|
+
* **Why cross-region**:Pinned column DataTable 結構 = 三 region(left / center / right)
|
|
187
|
+
* 各 mount 一個 row div(同 `row.id`),listeners 只在 primary(center)。之前 ghost 只
|
|
188
|
+
* 抓 primary region row → cells 只有 center 欄(missing pinned SKU / Updated)→ user 看
|
|
189
|
+
* 到 ghost「跟 source 不一樣」(Bug B)。virtualized + 無 pinning 下亦可能因 region 多
|
|
190
|
+
* 個 mount 衍生不可預期(Bug A)。
|
|
191
|
+
*
|
|
192
|
+
* **Algorithm**:
|
|
193
|
+
* 1. 收集 `[role="row"][data-sortable-row-id="${id}"]` 所有 match(可能 1-3 個 region)
|
|
194
|
+
* 2. DOM 文檔順序剛好是 left → center → right(因 body region 排列順序)
|
|
195
|
+
* 3. 從每個 region row 的 children(role=cell)抽出 outerHTML,串成單一 flex row
|
|
196
|
+
* 4. 總寬 = sum(region offsetWidth)
|
|
197
|
+
* 5. Wrap 用 primary region 的 className(視覺一致)+ inline `display:flex; width:total`
|
|
198
|
+
*
|
|
199
|
+
* **Single-region case**(無 pinning,常見):退化成 single row clone + sanitize,行為跟舊版同。
|
|
200
|
+
*
|
|
201
|
+
* **Returns** `{ html, width }` 或 `null`(找不到 row)。
|
|
202
|
+
*/
|
|
203
|
+
export function reconstructFullRowGhost(
|
|
204
|
+
rowId: string,
|
|
205
|
+
tableRoot: HTMLElement | null,
|
|
206
|
+
): { html: string; width: number } | null {
|
|
207
|
+
// **codex P1 fix(2026-05-07 v15.13)**:tableRoot 強制 required(non-optional)。
|
|
208
|
+
// 之前 fallback document 仍允許多 DataTable 同頁 row.id reuse(default index-based)→
|
|
209
|
+
// cross-instance ghost 污染。strict-scope canonical:caller 必傳 tableRef.current
|
|
210
|
+
// (`[data-data-table-outer]`),null 直接 return null(caller 該守 ref 已 mount)。
|
|
211
|
+
if (!tableRoot) return null
|
|
212
|
+
const escaped = rowId.replace(/(["\\])/g, '\\$1')
|
|
213
|
+
const allRows = Array.from(
|
|
214
|
+
tableRoot.querySelectorAll<HTMLElement>(
|
|
215
|
+
`[role="row"][data-sortable-row-id="${escaped}"]`,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
if (allRows.length === 0) return null
|
|
219
|
+
|
|
220
|
+
// Single-region:直接 clone 整 row + sanitize(無 pinning 場景,跟舊版一致)
|
|
221
|
+
if (allRows.length === 1) {
|
|
222
|
+
const sourceEl = allRows[0]
|
|
223
|
+
const clone = sourceEl.cloneNode(true) as HTMLElement
|
|
224
|
+
sanitizeGhostClone(clone, sourceEl.offsetWidth)
|
|
225
|
+
return { html: clone.outerHTML, width: sourceEl.offsetWidth }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Multi-region:抽 cells 串接。Primary region(`data-row-drag-source="true"`)的
|
|
229
|
+
// className + role 給 outer wrapper,確保 hover bg / border / row-height token 一致。
|
|
230
|
+
const primaryRow =
|
|
231
|
+
allRows.find((r) => r.dataset.rowDragSource === 'true') ?? allRows[0]
|
|
232
|
+
const cellsHtml: string[] = []
|
|
233
|
+
let totalWidth = 0
|
|
234
|
+
for (const regionRow of allRows) {
|
|
235
|
+
totalWidth += regionRow.offsetWidth
|
|
236
|
+
for (const cell of Array.from(
|
|
237
|
+
regionRow.querySelectorAll<HTMLElement>('[role="cell"],[role="gridcell"]'),
|
|
238
|
+
)) {
|
|
239
|
+
const cellClone = cell.cloneNode(true) as HTMLElement
|
|
240
|
+
// Strip drag handle portal(只在 primary region)+ inline transform 殘餘
|
|
241
|
+
cellClone.querySelectorAll('[data-drag-handle-portal]').forEach((n) => n.remove())
|
|
242
|
+
cellClone.style.transform = 'none'
|
|
243
|
+
cellsHtml.push(cellClone.outerHTML)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// 套 primary row 的 class(cn 結果含 hover/border/sizing token)+ display:flex 顯式
|
|
247
|
+
// 容納 cells,inline style override transform/position/opacity 避免 stale 殘餘
|
|
248
|
+
const inlineStyle = `display:flex; width:${totalWidth}px; transform:none; position:static; opacity:1; transition:none;`
|
|
249
|
+
const html = `<div role="row" class="${primaryRow.className}" style="${inlineStyle}">${cellsHtml.join('')}</div>`
|
|
250
|
+
return { html, width: totalWidth }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Reset ghost clone inline styles + strip 干擾 attributes(transform/opacity/data-row-index 等)。
|
|
255
|
+
* Internal helper, not exported.
|
|
256
|
+
*/
|
|
257
|
+
function sanitizeGhostClone(el: HTMLElement, width: number): void {
|
|
258
|
+
el.style.position = 'static'
|
|
259
|
+
el.style.transform = 'none'
|
|
260
|
+
el.style.transition = 'none'
|
|
261
|
+
el.style.opacity = '1'
|
|
262
|
+
el.style.zIndex = ''
|
|
263
|
+
el.style.width = `${width}px`
|
|
264
|
+
el.removeAttribute('data-row-index')
|
|
265
|
+
el.removeAttribute('aria-rowindex')
|
|
266
|
+
el.removeAttribute('data-hovered')
|
|
267
|
+
el.querySelectorAll('[data-drag-handle-portal]').forEach((n) => n.remove())
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Type exports for consumer ─────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
export type DropPosition = 'before' | 'after' | 'inside'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# DS i18n(Route B infrastructure)
|
|
2
|
+
|
|
3
|
+
**Status**: shipped 2026-04-24(additive,backward compatible)
|
|
4
|
+
|
|
5
|
+
## 什麼
|
|
6
|
+
|
|
7
|
+
可選的 context-based i18n layer — consumer 在 app root 包 `<I18nProvider labels={...}>` 一次,DS 元件內部 opt-in 用 `useI18n()` hook 讀 labels。沒包 provider 完全不影響既有 prop-based 行為。
|
|
8
|
+
|
|
9
|
+
## 為什麼(vs Route A prop-only)
|
|
10
|
+
|
|
11
|
+
Route A(2026-04-24 原 ship)每元件暴露 `dismissAriaLabel` / `closeAriaLabel` 等個別 prop。Consumer 要全 app 多語 → 每 call site 傳 prop = 重複 noise。
|
|
12
|
+
|
|
13
|
+
Route B provider 一次注入 label catalog,per-call-site override 仍可(prop > context > default fallback chain)。
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
// Consumer app root
|
|
19
|
+
import { I18nProvider } from '@/design-system/lib/i18n/i18n-context'
|
|
20
|
+
|
|
21
|
+
const enLabels = {
|
|
22
|
+
notice: { dismiss: 'Dismiss' },
|
|
23
|
+
'file-viewer': { close: 'Close viewer', download: 'Download' },
|
|
24
|
+
carousel: { previous: 'Previous slide', next: 'Next slide' },
|
|
25
|
+
// ... consumer 覆寫需要的項
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
<I18nProvider labels={enLabels}>
|
|
29
|
+
<App />
|
|
30
|
+
</I18nProvider>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
DS 元件內部(逐步 migration):
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { useI18n } from '@/design-system/lib/i18n/i18n-context'
|
|
37
|
+
|
|
38
|
+
const { t } = useI18n()
|
|
39
|
+
<Button aria-label={propLabel ?? t('notice', 'dismiss', '關閉通知')} />
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Fallback chain
|
|
43
|
+
|
|
44
|
+
1. **Consumer prop**(call-site,最優先)
|
|
45
|
+
2. **Context labels**(`labels[componentKey][labelKey]`)
|
|
46
|
+
3. **DS default**(fallback 參數,i18n-allow 的 CJK)
|
|
47
|
+
|
|
48
|
+
## Migration status
|
|
49
|
+
|
|
50
|
+
- **infrastructure**:✅ shipped(`i18n-context.tsx`)
|
|
51
|
+
- **元件逐步 adopt**:Notice / FileViewer labels object / etc 已 Route A prop-based;Route B 可並存,未來有需要再逐元件 opt-in 換成 context reader
|
|
52
|
+
|
|
53
|
+
## 世界級對照
|
|
54
|
+
|
|
55
|
+
- **Material MUI**:`createTheme({ locale })` — monolithic locale
|
|
56
|
+
- **Ant Design**:`<ConfigProvider locale={zhCN}>` — context inject locale
|
|
57
|
+
- **Carbon**:`<PrefixContext>` + locale 分離 — modular
|
|
58
|
+
- **Radix Primitives**:無 i18n layer,留給 consumer
|
|
59
|
+
|
|
60
|
+
本 DS 選「additive optional context」對齊 Ant / Carbon 彈性路徑:consumer 想要時包一次,不想要保持 prop API。
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DS i18n Route B — additive I18nProvider + useI18n hook
|
|
5
|
+
*
|
|
6
|
+
* ── 背景 ──
|
|
7
|
+
* Route A(2026-04-24 shipped)= individual prop override(Notice dismissAriaLabel /
|
|
8
|
+
* FileViewer labels / etc)+ `// i18n-allow` marker。
|
|
9
|
+
*
|
|
10
|
+
* Route B(本 module)= opt-in context provider,consumer 傳 labels catalog 一次性
|
|
11
|
+
* 注入全 DS,個別 prop 不需每個 call site 傳。**完全 backward compatible** — 沒
|
|
12
|
+
* 包 provider 不影響既有 prop-based 行為。
|
|
13
|
+
*
|
|
14
|
+
* ── Fallback chain(每個 label resolution)──
|
|
15
|
+
* 1. Consumer 傳的 prop(e.g. `<Notice dismissAriaLabel="X" />`)
|
|
16
|
+
* 2. Context labels(useI18n().t('notice', 'dismiss', default))
|
|
17
|
+
* 3. DS default(CJK `i18n-allow` rationale)
|
|
18
|
+
*
|
|
19
|
+
* ── Usage ──
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* // Consumer app root
|
|
23
|
+
* import { I18nProvider } from '@/design-system/lib/i18n/i18n-context'
|
|
24
|
+
*
|
|
25
|
+
* const labels = {
|
|
26
|
+
* notice: { dismiss: 'Dismiss' },
|
|
27
|
+
* fileViewer: { close: 'Close viewer', download: 'Download' },
|
|
28
|
+
* // ... 覆寫需要的項
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* <I18nProvider labels={labels}>
|
|
32
|
+
* <App />
|
|
33
|
+
* </I18nProvider>
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* ```tsx
|
|
37
|
+
* // DS component internal(opt-in migration)
|
|
38
|
+
* import { useI18n } from '@/design-system/lib/i18n/i18n-context'
|
|
39
|
+
*
|
|
40
|
+
* const { t } = useI18n()
|
|
41
|
+
* <button aria-label={props.dismissAriaLabel ?? t('notice', 'dismiss', '關閉通知')}>
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* ── 為什麼 opt-in(不強制)──
|
|
45
|
+
* DS 目前大半 component 都走 prop override,強制 all-or-nothing migration 需要
|
|
46
|
+
* 17 files × multi-location refactor 的協調成本。Route B 讓既有 prop API 繼續
|
|
47
|
+
* 工作,同時開放 context-based i18n 給想統一管理的 consumer。
|
|
48
|
+
*
|
|
49
|
+
* ── 為什麼 hierarchical key(component, label)而非 flat key ──
|
|
50
|
+
* Flat key(e.g. 'notice.dismiss')需要命名 convention + grep-based validation;
|
|
51
|
+
* hierarchical object 天然分 namespace + TypeScript 可做 discriminated union。
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Labels catalog 結構:`{ [componentKey]: { [labelKey]: translatedString } }`
|
|
56
|
+
*
|
|
57
|
+
* Consumer 可透過 module augmentation 擴充 type safety:
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* declare module '@/design-system/lib/i18n/i18n-context' {
|
|
61
|
+
* interface I18nLabels {
|
|
62
|
+
* notice?: { dismiss?: string }
|
|
63
|
+
* fileViewer?: { close?: string; download?: string }
|
|
64
|
+
* }
|
|
65
|
+
* }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
// code-quality-allow: dead-export — public API surface — consumer-exposed for future use
|
|
69
|
+
export interface I18nLabels {
|
|
70
|
+
[componentKey: string]: Record<string, string | undefined> | undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const I18nContext = React.createContext<I18nLabels | null>(null)
|
|
74
|
+
|
|
75
|
+
export interface I18nProviderProps {
|
|
76
|
+
/** Labels catalog。空 object OK(代表用 all defaults)。Partial 覆寫 OK(只填需要翻的)。 */
|
|
77
|
+
labels: I18nLabels
|
|
78
|
+
children: React.ReactNode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* I18nProvider — consumer-optional wrapper 注入 labels catalog。
|
|
83
|
+
*
|
|
84
|
+
* 沒包 → `useI18n().t()` 每次返 fallback(= DS CJK defaults)。
|
|
85
|
+
* 包 → 該 labels 取代對應 DS defaults;未填的 key 仍 fallback。
|
|
86
|
+
*/
|
|
87
|
+
// code-quality-allow: dead-export — public API surface — consumer-exposed for future use
|
|
88
|
+
export function I18nProvider({ labels, children }: I18nProviderProps) {
|
|
89
|
+
// Memoize context value to avoid triggering consumer re-renders when parent re-renders
|
|
90
|
+
const value = React.useMemo(() => labels, [labels])
|
|
91
|
+
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// code-quality-allow: dead-export — public API surface — consumer-exposed for future use
|
|
95
|
+
export interface I18nHookResult {
|
|
96
|
+
/**
|
|
97
|
+
* Translate:return labels[componentKey]?.[labelKey] ?? fallback。
|
|
98
|
+
*
|
|
99
|
+
* @param componentKey - 元件 namespace(kebab-case,e.g. 'notice' / 'file-viewer')
|
|
100
|
+
* @param labelKey - 該元件內的 label 名(camelCase,e.g. 'dismiss' / 'close')
|
|
101
|
+
* @param fallback - DS default(無 context / no key 時用)
|
|
102
|
+
*/
|
|
103
|
+
t: (componentKey: string, labelKey: string, fallback: string) => string
|
|
104
|
+
/** 當前 labels catalog(null = 無 provider)。Debug / advanced use */
|
|
105
|
+
labels: I18nLabels | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* useI18n — DS 元件內消費 labels catalog。
|
|
110
|
+
*
|
|
111
|
+
* **Fallback chain**(每 label resolution):
|
|
112
|
+
* 1. Consumer 傳的 prop(call site 最優先)
|
|
113
|
+
* 2. Context `labels[componentKey][labelKey]`
|
|
114
|
+
* 3. DS default(fallback 參數,i18n-allow 的 CJK)
|
|
115
|
+
*
|
|
116
|
+
* 無 I18nProvider 環境:`labels` = null,`t()` 每次直接返 fallback。**不拋 error**
|
|
117
|
+
*(跟 Field context 等 DS 其他 optional context 同 pattern)。
|
|
118
|
+
*/
|
|
119
|
+
// code-quality-allow: dead-export — public API surface — consumer-exposed for future use
|
|
120
|
+
export function useI18n(): I18nHookResult {
|
|
121
|
+
const labels = React.useContext(I18nContext)
|
|
122
|
+
const t = React.useCallback<I18nHookResult['t']>(
|
|
123
|
+
(componentKey, labelKey, fallback) => {
|
|
124
|
+
return labels?.[componentKey]?.[labelKey] ?? fallback
|
|
125
|
+
},
|
|
126
|
+
[labels],
|
|
127
|
+
)
|
|
128
|
+
return { t, labels }
|
|
129
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* multi-select-ordering — SSOT primitive for "Select All" / bulk-select value ordering
|
|
3
|
+
*
|
|
4
|
+
* **Canonical**:Path A(preserve existing user click order + append unselected options
|
|
5
|
+
* in source/canonical order,with dedup)。
|
|
6
|
+
*
|
|
7
|
+
* **Source basis(Layer A + Codex M31 Round 4 共識,2026-05-16 user verbatim approve「就照
|
|
8
|
+
* 你們的共識做到完美確保有 SSOT」)**:
|
|
9
|
+
*
|
|
10
|
+
* Ant Design 跨元件「Select All」 bulk-select canonical 證據:
|
|
11
|
+
* - **Transfer**(`components/transfer/index.tsx`): `Array.from(new Set([...prevKeys, ...keys]))`
|
|
12
|
+
* — preserve previous selection order + append new bulk keys with dedup
|
|
13
|
+
* - **Table rowSelection selectAll**(`components/table/hooks/useSelection.tsx`): existing keySet
|
|
14
|
+
* + flattened source-order append via `Array.from(keySet)`
|
|
15
|
+
* - **Checkbox.Group**(`components/checkbox/Group.tsx`): 強制 sort source order(更嚴格 B 變體,
|
|
16
|
+
* 但只用於 onChange callback,非 bulk-select 動作)
|
|
17
|
+
*
|
|
18
|
+
* **PeoplePicker / SelectMenu「全部」 footer 場景對應**:
|
|
19
|
+
* - 動作語意 = Transfer / Table rowSelection 的「Select All」(bulk 選 remaining items)
|
|
20
|
+
* - 跟 Checkbox.Group 不同(後者沒 Select All button,只是 onChange 回 sort)
|
|
21
|
+
* - **採 path A**(preserve+append)為 ordering canonical
|
|
22
|
+
*
|
|
23
|
+
* **為何 SSOT(per user「以後遇到此類設計都有相同邏輯不會偏移」)**:
|
|
24
|
+
* 1. 集中 ordering logic 一處,future 改 ordering policy 只改本檔
|
|
25
|
+
* 2. Future consumers(e.g. DataTable filter checkAll / 任何新 multi-select 元件 with Select All
|
|
26
|
+
* footer)必 import 本 primitive,hook `check_select_all_canonical.sh` 機械強制
|
|
27
|
+
* 3. 對齊 mindset #2「優先消費既有」+ M17「SSOT 必可傳播」+ M23「DS internal canonical 優先」
|
|
28
|
+
*
|
|
29
|
+
* **世界級對照**:Ant Transfer + Table rowSelection canonical(2026-05-16 Round 4 grep verify)。
|
|
30
|
+
* 非「跟世界級對齊」一句空話 — 直接 codify Ant 兩個 bulk-select 元件的 implementation 行為。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply "Select All" canonical:preserve existing selection order + append unselected options
|
|
35
|
+
* in source order(with dedup)。
|
|
36
|
+
*
|
|
37
|
+
* @param existing — 既有 selection array(user click 累積順序)
|
|
38
|
+
* @param all — 所有可選 options 值 array(source / canonical order — 通常 = `options.filter(!disabled).map(v)`)
|
|
39
|
+
* @returns 新 selection array,既有部分維持原順序在前,新加入部分按 source order append 在後
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* applySelectAll(['c', 'a'], ['a', 'b', 'c', 'd']) // => ['c', 'a', 'b', 'd']
|
|
44
|
+
* applySelectAll([], ['a', 'b', 'c']) // => ['a', 'b', 'c']
|
|
45
|
+
* applySelectAll(['a', 'b', 'c'], ['a', 'b', 'c']) // => ['a', 'b', 'c']
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function applySelectAll<T>(existing: readonly T[], all: readonly T[]): T[] {
|
|
49
|
+
const existingSet = new Set(existing)
|
|
50
|
+
const unselected = all.filter((v) => !existingSet.has(v))
|
|
51
|
+
return [...existing, ...unselected]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Companion:clear all selection。
|
|
56
|
+
* Trivial wrapper for symmetry(consumer 用 `applySelectAll` ↔ `clearSelection` pair 達成
|
|
57
|
+
* 「全選 → 取消全選」 toggle canonical)。
|
|
58
|
+
*/
|
|
59
|
+
export function clearSelection<T>(): T[] {
|
|
60
|
+
return []
|
|
61
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx'
|
|
2
|
+
import { extendTailwindMerge } from 'tailwind-merge'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 擴充 tailwind-merge,讓它認識設計系統的自訂 typography 與 text-color utilities。
|
|
6
|
+
*
|
|
7
|
+
* 預設 tailwind-merge 看到 `text-{xxx}` 自訂 class 會用 heuristic 猜它是 font-size
|
|
8
|
+
* 還是 color——猜錯就會把不該放在同一組的 class 誤判為衝突,然後 strip 掉其中一個。
|
|
9
|
+
*
|
|
10
|
+
* 實際發生過的 bug:`text-body`(font-size 14px)和 `text-fg-secondary`(color)
|
|
11
|
+
* 被同時放進 font-size group,tailwind-merge 把 `text-body` 吃掉,導致元件
|
|
12
|
+
* inherit 父層 16px,description 永遠跟 label 同字級。
|
|
13
|
+
*
|
|
14
|
+
* 修法:**font-size group 和 text-color group 都明確列舉**,不留猜測空間。
|
|
15
|
+
*/
|
|
16
|
+
const twMerge = extendTailwindMerge({
|
|
17
|
+
extend: {
|
|
18
|
+
classGroups: {
|
|
19
|
+
'font-size': [
|
|
20
|
+
'text-h1', 'text-h2', 'text-h3', 'text-h4', 'text-h5', 'text-h6',
|
|
21
|
+
'text-body-lg', 'text-body', 'text-caption', 'text-footnote',
|
|
22
|
+
],
|
|
23
|
+
// 自訂 text-color utilities(對應 semantic.css 的 `@theme inline --color-*` bridge)。
|
|
24
|
+
//
|
|
25
|
+
// 任何新增的 text-{semantic-name} color utility 都必須在這裡登記,否則
|
|
26
|
+
// tailwind-merge 會誤判成 font-size 與 typography 衝突(歷史 bug:
|
|
27
|
+
// text-body + text-fg-secondary 被 strip)。
|
|
28
|
+
//
|
|
29
|
+
// 完整家族對齊 semantic.css: 5 狀態色(primary/error/success/warning/info)
|
|
30
|
+
// × 3 互動階(base/hover/active)+ text(on-subtle-bg)+ subtle(含 primary)
|
|
31
|
+
// + notification(紅 badge)+ neutral foreground 四階。
|
|
32
|
+
'text-color': [
|
|
33
|
+
// Neutral foreground 家族
|
|
34
|
+
'text-foreground',
|
|
35
|
+
'text-fg-secondary',
|
|
36
|
+
'text-fg-muted',
|
|
37
|
+
'text-fg-disabled',
|
|
38
|
+
'text-inverse-fg',
|
|
39
|
+
'text-on-emphasis', // 白字於飽和色底(Avatar color variants / Steps filled indicator)
|
|
40
|
+
|
|
41
|
+
// Status 基色(base)
|
|
42
|
+
'text-primary',
|
|
43
|
+
'text-error',
|
|
44
|
+
'text-success',
|
|
45
|
+
'text-warning',
|
|
46
|
+
'text-info',
|
|
47
|
+
'text-notification',
|
|
48
|
+
|
|
49
|
+
// Status 互動階(hover / active)
|
|
50
|
+
'text-primary-hover',
|
|
51
|
+
'text-primary-active',
|
|
52
|
+
'text-error-hover',
|
|
53
|
+
'text-error-active',
|
|
54
|
+
'text-success-hover',
|
|
55
|
+
'text-success-active',
|
|
56
|
+
'text-warning-hover',
|
|
57
|
+
'text-warning-active',
|
|
58
|
+
'text-info-hover',
|
|
59
|
+
'text-info-active',
|
|
60
|
+
|
|
61
|
+
// on-subtle-bg 版本(深色文字配淺色底)
|
|
62
|
+
'text-primary-text',
|
|
63
|
+
'text-error-text',
|
|
64
|
+
'text-success-text',
|
|
65
|
+
'text-warning-text',
|
|
66
|
+
'text-info-text',
|
|
67
|
+
|
|
68
|
+
// Subtle 文字(少用,保持完整家族一致)
|
|
69
|
+
'text-primary-subtle',
|
|
70
|
+
'text-error-subtle',
|
|
71
|
+
'text-success-subtle',
|
|
72
|
+
'text-warning-subtle',
|
|
73
|
+
'text-info-subtle',
|
|
74
|
+
],
|
|
75
|
+
// Custom opacity utility(對應 tokens/opacity/opacity.css `@utility opacity-disabled`)。
|
|
76
|
+
// 不註冊 group → tailwind-merge 用 default opacity-N heuristic,可能誤判 class 衝突。
|
|
77
|
+
'opacity': ['opacity-disabled'],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* cn() — Tailwind class 合併工具
|
|
84
|
+
*
|
|
85
|
+
* 用法:
|
|
86
|
+
* cn("px-4 py-2", isActive && "bg-primary", className)
|
|
87
|
+
*
|
|
88
|
+
* 原理:clsx 處理條件式 class,twMerge 解決 Tailwind class 衝突
|
|
89
|
+
* 例如 cn("px-4", "px-2") → "px-2"(後者優先)
|
|
90
|
+
*/
|
|
91
|
+
export function cn(...inputs: ClassValue[]) {
|
|
92
|
+
return twMerge(clsx(inputs))
|
|
93
|
+
}
|