@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,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 }
|