@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.
Files changed (119) hide show
  1. package/package.json +93 -0
  2. package/src/README.md +32 -0
  3. package/src/components/Accordion/accordion.tsx +104 -0
  4. package/src/components/Alert/alert.tsx +188 -0
  5. package/src/components/AppShell/_demo-helpers.tsx +198 -0
  6. package/src/components/AppShell/app-shell.tsx +364 -0
  7. package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
  8. package/src/components/Avatar/avatar.tsx +368 -0
  9. package/src/components/Badge/badge.tsx +104 -0
  10. package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
  11. package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
  12. package/src/components/Button/button-group.tsx +96 -0
  13. package/src/components/Button/button.tsx +539 -0
  14. package/src/components/Calendar/calendar.tsx +411 -0
  15. package/src/components/Carousel/carousel.tsx +371 -0
  16. package/src/components/Chart/chart.tsx +376 -0
  17. package/src/components/Checkbox/checkbox-group.tsx +94 -0
  18. package/src/components/Checkbox/checkbox.tsx +237 -0
  19. package/src/components/Chip/chip.tsx +359 -0
  20. package/src/components/CircularProgress/circular-progress.tsx +204 -0
  21. package/src/components/Coachmark/coachmark.tsx +255 -0
  22. package/src/components/Combobox/combobox.tsx +826 -0
  23. package/src/components/Command/command.tsx +187 -0
  24. package/src/components/DataTable/active-editor-controller.ts +72 -0
  25. package/src/components/DataTable/cell-registry.tsx +520 -0
  26. package/src/components/DataTable/column-types.ts +180 -0
  27. package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
  28. package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
  29. package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
  30. package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
  31. package/src/components/DataTable/data-table.css +165 -0
  32. package/src/components/DataTable/data-table.tsx +2924 -0
  33. package/src/components/DataTable/filter-operators.ts +225 -0
  34. package/src/components/DataTable/filter-tree.ts +313 -0
  35. package/src/components/DataTable/lib/column-meta.ts +79 -0
  36. package/src/components/DateGrid/date-grid.tsx +209 -0
  37. package/src/components/DatePicker/date-picker.tsx +1114 -0
  38. package/src/components/DescriptionList/description-list.tsx +141 -0
  39. package/src/components/Dialog/dialog.tsx +267 -0
  40. package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
  41. package/src/components/Empty/empty.tsx +108 -0
  42. package/src/components/Field/field-context.ts +136 -0
  43. package/src/components/Field/field-types.ts +52 -0
  44. package/src/components/Field/field-wrapper.tsx +348 -0
  45. package/src/components/Field/field.tsx +535 -0
  46. package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
  47. package/src/components/FileItem/file-item.tsx +322 -0
  48. package/src/components/FileUpload/file-upload.tsx +326 -0
  49. package/src/components/FileViewer/file-viewer-types.ts +76 -0
  50. package/src/components/FileViewer/file-viewer.tsx +1065 -0
  51. package/src/components/FileViewer/image-renderer.tsx +256 -0
  52. package/src/components/HoverCard/hover-card.tsx +79 -0
  53. package/src/components/Input/input.tsx +233 -0
  54. package/src/components/LinkInput/link-input.tsx +304 -0
  55. package/src/components/Menu/menu-item.tsx +334 -0
  56. package/src/components/NameCard/name-card.tsx +319 -0
  57. package/src/components/Notice/notice.tsx +196 -0
  58. package/src/components/NumberInput/number-input.tsx +203 -0
  59. package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
  60. package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
  61. package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
  62. package/src/components/PeoplePicker/people-picker.tsx +455 -0
  63. package/src/components/PeoplePicker/person-display.tsx +358 -0
  64. package/src/components/Popover/popover.tsx +183 -0
  65. package/src/components/ProgressBar/progress-bar.tsx +157 -0
  66. package/src/components/README.md +58 -0
  67. package/src/components/RadioGroup/radio-group.tsx +261 -0
  68. package/src/components/Rating/rating.tsx +295 -0
  69. package/src/components/ScrollArea/scroll-area.tsx +110 -0
  70. package/src/components/SegmentedControl/segmented-control.tsx +304 -0
  71. package/src/components/Select/select.tsx +658 -0
  72. package/src/components/SelectMenu/select-menu.tsx +430 -0
  73. package/src/components/SelectionControl/selection-item.tsx +261 -0
  74. package/src/components/Separator/separator.tsx +48 -0
  75. package/src/components/Sheet/sheet.tsx +240 -0
  76. package/src/components/Sidebar/sidebar.tsx +1280 -0
  77. package/src/components/Skeleton/skeleton.tsx +35 -0
  78. package/src/components/Slider/slider.tsx +158 -0
  79. package/src/components/Steps/steps.tsx +850 -0
  80. package/src/components/Switch/switch.tsx +285 -0
  81. package/src/components/Tabs/tabs.tsx +515 -0
  82. package/src/components/Tag/tag.tsx +246 -0
  83. package/src/components/Textarea/textarea.tsx +280 -0
  84. package/src/components/TimePicker/time-columns.tsx +260 -0
  85. package/src/components/TimePicker/time-picker.tsx +419 -0
  86. package/src/components/Toast/toast.tsx +129 -0
  87. package/src/components/Tooltip/tooltip.tsx +68 -0
  88. package/src/components/TreeView/tree-view.tsx +1031 -0
  89. package/src/hooks/use-controllable.ts +40 -0
  90. package/src/hooks/use-is-narrow-viewport.ts +19 -0
  91. package/src/hooks/use-is-touch-device.ts +21 -0
  92. package/src/hooks/use-overflow-items.ts +256 -0
  93. package/src/index.ts +85 -0
  94. package/src/lib/README.md +82 -0
  95. package/src/lib/drag-visual.ts +272 -0
  96. package/src/lib/i18n/README.md +60 -0
  97. package/src/lib/i18n/i18n-context.tsx +129 -0
  98. package/src/lib/multi-select-ordering.ts +61 -0
  99. package/src/lib/utils.ts +93 -0
  100. package/src/patterns/README.md +67 -0
  101. package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
  102. package/src/patterns/header-canonical/chrome-header.tsx +175 -0
  103. package/src/patterns/header-canonical/header-canonical.css +27 -0
  104. package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
  105. package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
  106. package/src/patterns/resize-handle/resize-handle.tsx +188 -0
  107. package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
  108. package/src/tokens/README.md +53 -0
  109. package/src/tokens/color/primitives.css +429 -0
  110. package/src/tokens/color/semantic.css +539 -0
  111. package/src/tokens/elevation/overlay-geometry.ts +13 -0
  112. package/src/tokens/layoutSpace/layoutSpace.css +36 -0
  113. package/src/tokens/motion/motion.css +30 -0
  114. package/src/tokens/motion/motion.ts +17 -0
  115. package/src/tokens/opacity/opacity.css +23 -0
  116. package/src/tokens/radius/radius.css +19 -0
  117. package/src/tokens/typography/typography.css +118 -0
  118. package/src/tokens/uiSize/icon-size.ts +52 -0
  119. package/src/tokens/uiSize/uiSize.css +125 -0
@@ -0,0 +1,319 @@
1
+ // @benchmark-unverified-blanket: file-level retraction per M22 (d) — claims herein not individually URL-cited; treat as unverified visual/usage rumor unless retrofit per-claim. Hook escape preserved.
2
+ import * as React from 'react'
3
+ import { MessageCircle, Phone, ChevronDown } from 'lucide-react'
4
+ import { cn } from '@/lib/utils'
5
+ import { Avatar, type AvatarData } from '@/design-system/components/Avatar/avatar'
6
+ import { Button } from '@/design-system/components/Button/button'
7
+ import { DescriptionList, DescriptionItem } from '@/design-system/components/DescriptionList/description-list'
8
+ import { ScrollArea } from '@/design-system/components/ScrollArea/scroll-area'
9
+ import { ItemContent } from '@/design-system/patterns/element-anatomy/item-anatomy'
10
+
11
+ /**
12
+ * NameCardDefaultActions — canonical 預設 action 組合
13
+ *
14
+ * ── 為什麼需要 export ──
15
+ * NameCard 的 action 列在世界級 chat/contact app 是「關係型快速動作」的 canonical
16
+ * (Slack / iMessage / LinkedIn / Figma 皆如此):**Chat / Message + Audio call**。
17
+ * 跨 consumer(avatar.principles / name-card.stories / future product code)應該用
18
+ * 同一組預設,避免每個範例各自發明 action 組合,讓 reader 誤以為 action 會隨情境自動變。
19
+ *
20
+ * ── 使用方式(hover context 必含 onViewMore,見 name-card.spec.md 「View more」節)──
21
+ * <NameCard name="..." actions={<NameCardDefaultActions />} onViewMore={...} />
22
+ *
23
+ * ── 何時要換成自訂 action ──
24
+ * - Single-action 情境(只要「傳訊息」)→ consumer 傳 `<Button>傳訊息</Button>`
25
+ * - 特定情境的 action(管理員「撤銷邀請」/ HR「離職管理」)→ consumer 自訂
26
+ * - 非人員關係動作(「訂購此商品」)→ 根本不應該用 NameCard
27
+ *
28
+ * Chat + Audio call 是 **default**,不是 **only**——consumer 可覆寫,但需有明確理由。
29
+ */
30
+ export const NameCardDefaultActions = () => (
31
+ <>
32
+ <Button variant="tertiary" size="sm" startIcon={MessageCircle}>Chat</Button>
33
+ <Button variant="tertiary" size="sm" startIcon={Phone} endIcon={ChevronDown}>Audio call</Button>
34
+ </>
35
+ )
36
+
37
+ /**
38
+ * NameCard — 人員 HoverCard 的內容元件
39
+ *
40
+ * ── Status 對齊 Avatar presence canonical(2026-04-20) ──
41
+ * status type = `online | away | busy | offline`,跟 Avatar 的 presence prop 對齊
42
+ * (世界級 Slack / Teams / Discord term);dot 色走 `--status-*` semantic token
43
+ * (獨立於 success/error/warning,避免語義衝突)。
44
+ *
45
+ * ── Avatar 對齊 ──
46
+ * 跟 FileItem rich 統一:右側 text column 用 justify-center + minHeight=avatar。
47
+ * 短文字置中於 avatar,長文字(多行名字)自然撐高。
48
+ *
49
+ * ── Info fields ──
50
+ * Status message / ID / Employee number 等唯讀屬性全部用 DescriptionList 家族
51
+ * 承載(不手刻 dt/dd),canonical 由 DS primitive own。
52
+ */
53
+
54
+ const AVATAR_SIZE = 64
55
+
56
+ type StatusType = 'online' | 'away' | 'busy' | 'offline'
57
+
58
+ // Presence semantic tokens(見 color/semantic.css)——跟 Avatar status dot 共用
59
+ const STATUS_DOT_COLOR: Record<StatusType, string> = {
60
+ online: 'var(--status-online)',
61
+ away: 'var(--status-away)',
62
+ busy: 'var(--status-busy)',
63
+ offline: 'var(--status-offline)',
64
+ }
65
+
66
+ const STATUS_LABEL: Record<StatusType, string> = {
67
+ online: 'Online',
68
+ away: 'Away',
69
+ busy: 'Busy',
70
+ offline: 'Offline',
71
+ }
72
+
73
+ /**
74
+ * NameCard SSOT — 預設 field keys(v11 always-render canonical,2026-05-06)
75
+ *
76
+ * ── 為什麼 SSOT ──
77
+ * User explicit rule:「所有 namecard 預設顯示的資訊都是要一樣完整的」。前 v10 props
78
+ * 全 optional + body conditional render → consumer 漏傳 fields 視覺缺 section,每個範例
79
+ * 各自不一致(同 person 在 DataTable / PeoplePicker / avatar.principles 看起來不同)。
80
+ *
81
+ * v11 canonical:NameCard **always** renders 4 default sections regardless of consumer 是否
82
+ * 傳資料 — 缺 data → 對應 DescriptionItem 顯 `EMPTY_PLACEHOLDER`("—"),section 結構不收合。
83
+ * 視覺結構 SSOT 在此元件,不依賴 consumer。
84
+ *
85
+ * ── 對齊 world-class ──
86
+ * Slack profile card / Linear member card / Notion person card / GitHub user card / Figma user card
87
+ * 都是 fixed schema(role / email / location / department / department / pronouns 等),
88
+ * 不會因為某 user 沒填 phone 整個 phone field 不見 — 缺 = 顯 `Not set` 或留白。
89
+ *
90
+ * ── 為什麼 placeholder 不 hide ──
91
+ * Hide → consumer 不知道少傳 → 視覺漂移;Placeholder → 永遠看到「該欄該有」+ dev-warn 提示
92
+ * consumer 補資料,自動防漂移(M19 ensure-canonical 對齊)。
93
+ */
94
+ // **2026-05-07 v15.7 user directive**:default render 只 `id` + `employeeNumber` 兩個。
95
+ // Email / Phone / Department / Location 等其他 description 一律 opt-in by consumer 透過
96
+ // `fields` array prop。對齊 user 明確「應該確保所有都只有這兩個,因為我並沒有要求你要選其他的」。
97
+ export const NAMECARD_DEFAULT_FIELD_KEYS = ['id', 'employeeNumber'] as const
98
+ type NameCardDefaultFieldKey = typeof NAMECARD_DEFAULT_FIELD_KEYS[number]
99
+
100
+ const DEFAULT_FIELD_LABEL: Record<NameCardDefaultFieldKey, string> = {
101
+ id: 'ID',
102
+ employeeNumber: 'Employee number',
103
+ }
104
+
105
+ const FIELD_PLACEHOLDER = '—'
106
+
107
+ export interface NameCardProps extends React.HTMLAttributes<HTMLDivElement> {
108
+ name: string
109
+ avatar?: AvatarData
110
+ subtitle?: string
111
+ status?: StatusType
112
+ statusMessage?: React.ReactNode
113
+ actions?: React.ReactNode
114
+ /**
115
+ * Consumer 傳的 field 資料(partial)。預設 keys 走 `NAMECARD_DEFAULT_FIELD_KEYS` —
116
+ * email / phone / department / location 永遠 render(缺資料顯 `—`)。Consumer 想新增
117
+ * 自訂 field 直接傳入(在 default 之後 append),想 override default key value 也直接傳。
118
+ */
119
+ fields?: { label: string; value: React.ReactNode }[]
120
+ /**
121
+ * Default field 的真實值。Object key = NAMECARD_DEFAULT_FIELD_KEYS 之一。
122
+ * 缺 key → render placeholder。Dev mode 會 console.warn 提醒消費者補資料。
123
+ */
124
+ defaultFieldValues?: Partial<Record<NameCardDefaultFieldKey, React.ReactNode>>
125
+ onViewMore?: () => void
126
+ viewMoreLabel?: string
127
+ }
128
+
129
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
130
+ const NameCard = React.forwardRef<HTMLDivElement, NameCardProps>(
131
+ (
132
+ {
133
+ name,
134
+ avatar,
135
+ subtitle,
136
+ status,
137
+ statusMessage,
138
+ actions,
139
+ fields,
140
+ defaultFieldValues,
141
+ onViewMore,
142
+ viewMoreLabel = 'View more',
143
+ className,
144
+ ...props
145
+ },
146
+ ref,
147
+ ) => {
148
+ // v11 always-render canonical:default fields 永遠 render(缺資料顯 placeholder),
149
+ // consumer 自訂 fields 在 default 之後 append。Status section 也永遠 render(無 status
150
+ // 顯「Status not set」placeholder),統一視覺結構。
151
+ //
152
+ // **Dedup canonical(2026-05-07 v15.8 fix Bug E)**:consumer 的 `fields` array 若含
153
+ // label 撞 default(eg. 「ID」「Employee number」),consumer 值 win — defaults 那一行
154
+ // 跳過(否則 same label 連 render 兩次,如 default placeholder `—` + consumer 真值)。
155
+ // 這是遷移期 forgiving 行為:DEV warn 提示應改用 `defaultFieldValues`,但 production
156
+ // 不破壞既有 consumer。對齊 React `key` 唯一性 + Linear / Slack profile card 一 label
157
+ // 一 row idiom。
158
+ const allFields = React.useMemo(() => {
159
+ const consumerLabels = new Set((fields ?? []).map((f) => f.label))
160
+ const defaults = NAMECARD_DEFAULT_FIELD_KEYS
161
+ .map((key) => ({
162
+ label: DEFAULT_FIELD_LABEL[key],
163
+ value: defaultFieldValues?.[key] ?? FIELD_PLACEHOLDER,
164
+ }))
165
+ .filter((d) => !consumerLabels.has(d.label))
166
+ return fields && fields.length > 0 ? [...defaults, ...fields] : defaults
167
+ }, [defaultFieldValues, fields])
168
+
169
+ // Dev warn:consumer 透過 `fields` 傳 default key label(legacy pattern)→ 應改 `defaultFieldValues`
170
+ if (process.env.NODE_ENV !== 'production' && fields) {
171
+ const legacyEntry = fields.find((f) =>
172
+ Object.values(DEFAULT_FIELD_LABEL).includes(f.label as string),
173
+ )
174
+ if (legacyEntry) {
175
+ // eslint-disable-next-line no-console
176
+ console.warn(
177
+ `[NameCard] "${name}":legacy pattern — fields[].label="${legacyEntry.label}" ` +
178
+ `is a default field. Migrate to defaultFieldValues={{ id, employeeNumber }} prop ` +
179
+ `to align with NAMECARD_DEFAULT_FIELD_KEYS canonical.`,
180
+ )
181
+ }
182
+ }
183
+
184
+ // Dev mode warn:consumer 沒傳 default field 任何 key → 提示補完(避免漂移成 placeholder-only)
185
+ if (process.env.NODE_ENV !== 'production' && !defaultFieldValues) {
186
+ // eslint-disable-next-line no-console
187
+ console.warn(
188
+ `[NameCard] "${name}":no defaultFieldValues passed — sections will render placeholders. ` +
189
+ `Pass at least { id, employeeNumber } via defaultFieldValues prop. ` +
190
+ `For other description items (email/phone/department/location etc),use \`fields\` prop array.`,
191
+ )
192
+ }
193
+
194
+ // Layout canonical(2026-04-23):Header + Actions 固定上,Body(status + fields)可捲動,
195
+ // View more 固定下。**NameCard 自己約束高度**,不依賴 consumer HoverCardContent 設 flex:
196
+ // - `max-h-[var(--radix-hover-card-content-available-height,...)]`:HoverCard / Popover
197
+ // context 自動繼承 Radix viewport-aware 變數;standalone 落到 100vh fallback
198
+ // - 內部 `flex flex-col + overflow-hidden`:Header(shrink-0)+ Body(flex-1 min-h-0 ScrollArea)
199
+ // + Footer(shrink-0)三層 chrome
200
+ // 世界級對照:Slack / Linear / GitHub / Notion hover-profile popover 皆此 chrome pattern。
201
+ return (
202
+ <div
203
+ ref={ref}
204
+ className={cn(
205
+ 'w-[320px] flex flex-col overflow-hidden',
206
+ 'max-h-[var(--radix-hover-card-content-available-height,var(--radix-popover-content-available-height,100vh))]',
207
+ className,
208
+ )}
209
+ {...props}
210
+ >
211
+ {/* ── HEADER(固定): profile + actions ── */}
212
+ <div className="shrink-0 flex flex-col">
213
+ <div className="flex items-start gap-3 px-4 py-3">
214
+ <Avatar
215
+ src={avatar?.src}
216
+ alt={avatar?.alt ?? name}
217
+ color={avatar?.color}
218
+ size={AVATAR_SIZE}
219
+ status={status}
220
+ className="shrink-0"
221
+ />
222
+ {/* NameCard typography:label body-lg(16/1.5) + desc body(14/1.5) = reading mode + size="lg"。
223
+ labelClassName escape hatch 加 font-medium(card context 語意)+ labelTruncate=false 允許 wrap。 */}
224
+ <ItemContent
225
+ label={name}
226
+ description={subtitle}
227
+ mode="reading"
228
+ size="lg"
229
+ labelTruncate={false}
230
+ labelClassName="text-body-lg font-medium text-foreground"
231
+ className="justify-center"
232
+ style={{ minHeight: AVATAR_SIZE }}
233
+ />
234
+ </div>
235
+
236
+ {/* Action buttons — 均分空間 + 填滿格子(canonical):多個 action 等寬瓜分容器,
237
+ 單一 action 也撐滿容器。`grid grid-flow-col auto-cols-fr` + `[&>*]:w-full`。
238
+ 世界級對照:iOS contact card / macOS contact / LinkedIn profile card 的 action row。 */}
239
+ {actions && (
240
+ <div className="grid grid-flow-col auto-cols-fr gap-2 px-4 pb-3 [&>*]:w-full">
241
+ {actions}
242
+ </div>
243
+ )}
244
+ </div>
245
+
246
+ {/* ── BODY(可捲動,v12 status-conditional 2026-05-14):status + fields ──
247
+ **v12 rule**(per user 拍板「不應該顯示『狀態沒有被設定』,production 每 user 一定有
248
+ presence state,undefined 頂多是 loading transient 還沒讀到」):status undefined →
249
+ 隱藏整 status badge + status message block(loading 期間 skip),禁 render「Status not
250
+ set」這種 placeholder(語義錯,user presence 不會「沒設定」)。**NameCard-specific 不外推
251
+ 至 DS 其他元件**(FileItem / DescriptionList / DataTable cell 各自 placeholder 邏輯
252
+ unrelated)。Fields section 仍 always-render(info schema 性質)。 */}
253
+ <ScrollArea className="flex-1 min-h-0 border-t border-divider">
254
+ {/* Status section:`status` defined 才 render(v12 conditional canonical) */}
255
+ {status && (
256
+ <div className="px-4 py-3 flex flex-col gap-3">
257
+ <div className="flex items-center gap-2 px-3 py-2 bg-muted rounded-md">
258
+ <span
259
+ className="w-2.5 h-2.5 rounded-full shrink-0"
260
+ style={{ backgroundColor: STATUS_DOT_COLOR[status] }}
261
+ />
262
+ <span className="text-body">{STATUS_LABEL[status]}</span>
263
+ </div>
264
+ {/* Status message — 只在 status defined 才 render(語意配對:status badge + status
265
+ message 是一組;沒 status 就沒 status message)。缺 statusMessage 顯 placeholder。 */}
266
+ <DescriptionList>
267
+ <DescriptionItem label="Status message">
268
+ {statusMessage ?? <span className="text-fg-muted">{FIELD_PLACEHOLDER}</span>}
269
+ </DescriptionItem>
270
+ </DescriptionList>
271
+ </div>
272
+ )}
273
+
274
+ {/* Fields section:status defined 才有 border-t separator;無 status section 時去除 border-t(因 ScrollArea 起點已有上方 border-t,不重疊) */}
275
+ <div className={cn('px-4 py-3', status && 'border-t border-divider')}>
276
+ <DescriptionList cols={2}>
277
+ {allFields.map((f) => (
278
+ <DescriptionItem key={f.label} label={f.label}>
279
+ {f.value === FIELD_PLACEHOLDER
280
+ ? <span className="text-fg-muted">{FIELD_PLACEHOLDER}</span>
281
+ : f.value}
282
+ </DescriptionItem>
283
+ ))}
284
+ </DescriptionList>
285
+ </div>
286
+ </ScrollArea>
287
+
288
+ {/* ── FOOTER(固定): View more,py-3 canonical(12px,比一般 link 按鈕多呼吸) ── */}
289
+ {onViewMore && (
290
+ <div className="shrink-0 border-t border-divider px-4 py-3">
291
+ <Button variant="link" size="sm" onClick={onViewMore} className="w-full">{viewMoreLabel}</Button>
292
+ </div>
293
+ )}
294
+ </div>
295
+ )
296
+ },
297
+ )
298
+ NameCard.displayName = 'NameCard'
299
+
300
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
301
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
302
+ export const nameCardMeta = {
303
+ component: 'NameCard',
304
+ family: null, // non-family composite / overlay / layout
305
+ variants: {
306
+
307
+ },
308
+ sizes: {
309
+
310
+ },
311
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
312
+ tokens: {
313
+ bg: ['bg-muted'],
314
+ fg: ['text-foreground'],
315
+ ring: [],
316
+ },
317
+ } as const
318
+
319
+ export { NameCard }
@@ -0,0 +1,196 @@
1
+ import * as React from 'react'
2
+ import { X as XIcon, Info, CircleCheck, TriangleAlert, XCircle, type LucideIcon } from 'lucide-react'
3
+ import { cn } from '@/lib/utils'
4
+ import { Button } from '@/design-system/components/Button/button'
5
+ import { ItemContent, ItemPrefix } from '@/design-system/patterns/element-anatomy/item-anatomy'
6
+
7
+ /**
8
+ * Notice — Toast / Alert 共用的視覺佈局層
9
+ *
10
+ * ── Typography: md tier ──
11
+ * title: text-body (14px) leading-compact — 有 description 時加 font-medium
12
+ * description: text-body (14px) leading-compact + text-fg-secondary (neutral-8)
13
+ * 14px 配 14px — 視覺層級靠 font-weight + color 區分,不靠 font-size。
14
+ *
15
+ * ── Padding(固定,不隨 density 變) ──
16
+ * px = px-4(16px)
17
+ * py = py-3(12px)
18
+ * gap = gap-2(8px)
19
+ * Toast/Alert 是浮動通知,不是工作區域元件——density 控制表單/選單的緊湊度,
20
+ * 通知的尺寸應該固定,不隨 density 縮放。
21
+ *
22
+ * ── Icon: md tier ──
23
+ * icon size: 16px(ICON_SIZE.md)
24
+ *
25
+ * ── Dismiss X(chrome corner close,Cat 3 Action group region)──
26
+ * 用 Button iconOnly dismiss **size="xs"** — 非 Inline Action、非自刻 button。
27
+ * Rationale(Notification banner family canonical):
28
+ * - Notice / Alert / Toast 屬 **notification banner family**(ephemeral、px-4 py-3 固定不隨 density),
29
+ * dismiss 是邊角小 affordance,xs 視覺不搶眼不跟 content 競爭。見 `overlay-surface.spec.md`
30
+ * 「Chrome dismiss size canonical」三家族分類(Modal sm / Non-modal xs / Notification xs)
31
+ * - Close 左側可加 refresh / share(action group region),皆統一 xs
32
+ * - `dismiss` prop 自動套 variant="text" + fg-muted override
33
+ * SSOT:patterns/element-anatomy/inline-action.spec.md「Dismiss canonical — X close only」
34
+ * + components/Alert/alert.spec.md「Chrome corner close X canonical」。
35
+ */
36
+
37
+ export type NoticeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'error'
38
+
39
+ const VARIANT_ICON: Record<NoticeVariant, LucideIcon | null> = {
40
+ neutral: null,
41
+ info: Info,
42
+ success: CircleCheck,
43
+ warning: TriangleAlert,
44
+ error: XCircle,
45
+ }
46
+
47
+ export const SUBTLE_ICON_COLOR: Record<NoticeVariant, string> = {
48
+ neutral: 'text-fg-muted',
49
+ info: 'text-info-text',
50
+ success: 'text-success-text',
51
+ warning: 'text-warning-text',
52
+ error: 'text-error-text',
53
+ }
54
+
55
+ const NOTICE_LAYOUT = [
56
+ 'flex items-start gap-2 w-full',
57
+ 'text-body leading-compact',
58
+ 'px-4 py-3',
59
+ ].join(' ')
60
+
61
+ export interface NoticeProps
62
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
63
+ variant?: NoticeVariant
64
+ title: React.ReactNode
65
+ description?: React.ReactNode
66
+ endContent?: React.ReactNode
67
+ dismissible?: boolean
68
+ onDismiss?: () => void
69
+ /** ARIA label for the dismiss button. Override for i18n. Default: "關閉通知" */
70
+ dismissAriaLabel?: string
71
+ iconClassName?: string
72
+ /**
73
+ * ARIA role 由 wrapping consumer 決定(Alert / Toast / 自管 host),Notice 預設不帶 role。
74
+ * Notice 是 layout primitive,Alert / Toast 是 live region 擁有者——避免 nested live region
75
+ * 造成 screen reader 重複朗讀。明文傳遞才覆寫。
76
+ */
77
+ role?: 'status' | 'alert'
78
+ /**
79
+ * 對應 role 的 aria-live 策略,wrapping consumer 決定;Notice 預設 undefined 不帶 live region。
80
+ */
81
+ 'aria-live'?: 'polite' | 'assertive' | 'off'
82
+ }
83
+
84
+ const Notice = React.forwardRef<HTMLDivElement, NoticeProps>(
85
+ (
86
+ {
87
+ variant = 'neutral',
88
+ title,
89
+ description,
90
+ endContent,
91
+ dismissible = true,
92
+ onDismiss,
93
+ dismissAriaLabel = '關閉通知', // i18n-allow: DS default; consumer override via dismissAriaLabel prop
94
+ iconClassName,
95
+ className,
96
+ ...props
97
+ },
98
+ ref,
99
+ ) => {
100
+ const StatusIcon = VARIANT_ICON[variant]
101
+
102
+ return (
103
+ <div
104
+ ref={ref}
105
+ className={cn(NOTICE_LAYOUT, className)}
106
+ {...props}
107
+ >
108
+ {StatusIcon && (
109
+ <ItemPrefix>
110
+ <StatusIcon size={16} className={cn('shrink-0', iconClassName)} aria-hidden />
111
+ </ItemPrefix>
112
+ )}
113
+
114
+ {/* Title + description 消費 ItemContent primitive(SSOT)。
115
+ Label 有 desc 時 font-medium(Notice idiom:title 跟 desc 對照時 title 要更重)。 */}
116
+ <ItemContent
117
+ label={title}
118
+ description={description}
119
+ labelClassName={description ? 'font-medium' : undefined}
120
+ />
121
+
122
+ {(endContent || dismissible) && (
123
+ <div className="flex items-center gap-2 shrink-0 h-[1lh]">
124
+ {endContent}
125
+ {dismissible && (
126
+ <Button
127
+ data-dismiss
128
+ iconOnly
129
+ dismiss
130
+ size="xs"
131
+ startIcon={XIcon}
132
+ aria-label={dismissAriaLabel}
133
+ onClick={onDismiss}
134
+ />
135
+ )}
136
+ </div>
137
+ )}
138
+ </div>
139
+ )
140
+ },
141
+ )
142
+ Notice.displayName = 'Notice'
143
+
144
+ // Singleton MutationObserver + subscription fan-out(2026-04-22 D3 perf audit):
145
+ // 先前每個 useInverseTheme consumer(Alert / Toast / Notice instance 等)各建一個 MO,
146
+ // N 個 Notice = N 個 observers。singleton 共用一個 MO + pub/sub 讓 theme swap 只做一次 DOM read。
147
+ let themeObserverStarted = false
148
+ const themeSubscribers = new Set<() => void>()
149
+
150
+ function getInverseTheme(): 'dark' | 'light' {
151
+ if (typeof document === 'undefined') return 'dark'
152
+ const current = document.documentElement.getAttribute('data-theme') ?? 'light'
153
+ return current === 'dark' ? 'light' : 'dark'
154
+ }
155
+
156
+ function startThemeObserver() {
157
+ if (themeObserverStarted || typeof document === 'undefined') return
158
+ themeObserverStarted = true
159
+ const root = document.documentElement
160
+ const observer = new MutationObserver(() => {
161
+ themeSubscribers.forEach((cb) => cb())
162
+ })
163
+ observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
164
+ }
165
+
166
+ function subscribe(cb: () => void): () => void {
167
+ startThemeObserver()
168
+ themeSubscribers.add(cb)
169
+ return () => themeSubscribers.delete(cb)
170
+ }
171
+
172
+ export function useInverseTheme(): 'dark' | 'light' {
173
+ // useSyncExternalStore canonical (React 18+):單一 external source 被 N consumers 訂閱
174
+ return React.useSyncExternalStore(subscribe, getInverseTheme, getInverseTheme)
175
+ }
176
+
177
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
178
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
179
+ export const noticeMeta = {
180
+ component: 'Notice',
181
+ family: null, // non-family composite / overlay / layout
182
+ variants: {
183
+
184
+ },
185
+ sizes: {
186
+
187
+ },
188
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
189
+ tokens: {
190
+ bg: [],
191
+ fg: ['text-error-text', 'text-fg-muted', 'text-fg-secondary', 'text-info-text', 'text-success-text', 'text-warning-text'],
192
+ ring: [],
193
+ },
194
+ } as const
195
+
196
+ export { Notice }