@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,358 @@
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 { X } from 'lucide-react'
4
+ import { EMPTY_DISPLAY } from '@/design-system/components/Field/field-wrapper'
5
+ import { Tag } from '@/design-system/components/Tag/tag'
6
+ import { OverflowIndicator } from '@/design-system/components/OverflowIndicator/overflow-indicator'
7
+ import { Avatar } from '@/design-system/components/Avatar/avatar'
8
+ import { NameCard, NameCardDefaultActions } from '@/design-system/components/NameCard/name-card'
9
+ import { useTableIsScrolling } from '@/design-system/components/Field/field-context'
10
+ import { ItemPrefix } from '@/design-system/patterns/element-anatomy/item-anatomy'
11
+ import {
12
+ getAvatarStackVisibleCount,
13
+ AVATAR_STACK_AVATAR_PX,
14
+ AVATAR_STACK_OVERFLOW_CHIP_PX,
15
+ } from './avatar-stack-overflow'
16
+
17
+ // ── Types ───────────────────────────────────────────────────────────────────
18
+
19
+ // PersonData 承載 NameCard 所需的完整資訊。DS 全域 person avatar 的 hoverCard NameCard 永遠
20
+ // 顯示同一組 sections(name + subtitle + status + 4 default fields + 自訂 fields + actions + View more)
21
+ // — 缺資料顯 placeholder,不會 collapse。對齊 avatar.spec.md「person avatar hover → NameCard」
22
+ // DS-wide canonical(2026-05-06 v11 always-render schema 升級)。
23
+ export interface PersonData {
24
+ name: string
25
+ avatarUrl?: string
26
+ /** 角色 / 部門 / ID 等 meta 單行(NameCard subtitle) */
27
+ description?: string
28
+ /** Presence 狀態(對齊 Avatar presence canonical)。**2026-05-14 v12 update**(per user 拍板):
29
+ * production 每 user 一定有 presence state,**undefined = loading transient(資料還沒讀到)**,
30
+ * 不是「user 沒設定」。NameCard 在 undefined 期間隱藏整 status block,**禁** render「Status not set」
31
+ * placeholder 文字。 */
32
+ status?: 'online' | 'away' | 'busy' | 'offline'
33
+ /** Status 訊息(NameCard status section)。只在 status defined 時 render,缺則顯 `—` placeholder。
34
+ * Status undefined 整 block skip(無 statusMessage 也跟著 skip)。 */
35
+ statusMessage?: React.ReactNode
36
+ /** **2026-05-07 v15.7 user directive**:NameCard default 只 render `id` + `employeeNumber`,
37
+ * 其他 description 一律 opt-in by consumer 透過 `fields` array prop。對齊
38
+ * `NAMECARD_DEFAULT_FIELD_KEYS = ['id', 'employeeNumber']`。 */
39
+ id?: string
40
+ employeeNumber?: string
41
+ /** 自訂額外 fields(在 default fields 之後 append)。Email / Phone / Department / Location
42
+ * / 任何其他 description 一律走這個 prop(opt-in,consumer 自選)。 */
43
+ fields?: { label: string; value: string }[]
44
+ /** 跳至完整 profile 頁的 handler(hover NameCard 必含,不傳時 fallback noop placeholder) */
45
+ onViewProfile?: () => void
46
+ }
47
+
48
+ export type PersonValue = string | PersonData
49
+
50
+ function resolvePerson(value: PersonValue): PersonData {
51
+ return typeof value === 'string' ? { name: value } : value
52
+ }
53
+
54
+ // buildPersonNameCard — DS 全域 person avatar hoverCard 的 canonical NameCard JSX 建構器。
55
+ // SSOT for「avatar hover NameCard 一致視覺」— 任何 person avatar consumer 都走這個 helper,
56
+ // 不可繞道直接 build NameCard。
57
+ //
58
+ // **2026-05-07 v15.7 user directive**:default field values 只 `id` + `employeeNumber`,
59
+ // 對齊 NAMECARD_DEFAULT_FIELD_KEYS。其他 description(email/phone/department/location/etc)
60
+ // consumer 想顯式透過 `person.fields` opt-in 傳入。
61
+ function buildPersonNameCard(person: PersonData): React.ReactNode {
62
+ return (
63
+ <NameCard
64
+ name={person.name}
65
+ subtitle={person.description}
66
+ avatar={{ src: person.avatarUrl, alt: person.name }}
67
+ status={person.status}
68
+ statusMessage={person.statusMessage}
69
+ defaultFieldValues={{
70
+ id: person.id,
71
+ employeeNumber: person.employeeNumber,
72
+ }}
73
+ fields={person.fields}
74
+ actions={<NameCardDefaultActions />}
75
+ // onViewMore hover context 必含(avatar.spec.md canonical)。consumer 傳
76
+ // `onViewProfile` 則用真 handler,否則 noop placeholder(UI 仍渲染 View more
77
+ // footer,避免 preview 變死路)。
78
+ onViewMore={person.onViewProfile ?? (() => {})}
79
+ />
80
+ )
81
+ }
82
+
83
+ // ── Avatar Size ─────────────────────────────────────────────────────────────
84
+ // 與 Tag 高度對齊:sm=20px, md/lg=24px(對齊 item-anatomy AVATAR_SIZE.inline)
85
+
86
+ const AVATAR_PX: Record<'sm' | 'md' | 'lg', number> = { sm: 20, md: 24, lg: 24 }
87
+
88
+ // ── PersonAvatar ────────────────────────────────────────────────────────────
89
+ // Consume DS `Avatar` primitive(2026-04-22 refactor,M1 SSOT consumption)+ 預設 NameCard
90
+ // hoverCard(avatar.spec.md DS-wide「person avatar hover → NameCard」canonical)。
91
+ //
92
+ // 之前用 local `<img>` / `<User icon />` hand-craft 繞過 DS Avatar,違反 M1。本次 refactor:
93
+ // - 所有 person avatar 經過 DS Avatar primitive(size 對應 uiSize family,fallback / icon / badge 集中管理)
94
+ // - 人員資訊 → NameCard(subtitle = description,actions = NameCardDefaultActions)
95
+
96
+ // 2026-05-13 (a) perf fix(per codex Layer C HoverCard subtree dominant):
97
+ // useMemo `buildPersonNameCard` per-person stable ref。原 every render call → new JSX ref →
98
+ // Avatar.memo bails → HoverCard subtree 重建。Stable ref → memo skip → big win on scroll。
99
+ //
100
+ // (c) push-up scroll-defer:當 DataTable virtualizer.isScrolling=true,**完全不 build NameCard**
101
+ // (Avatar 收 undefined → 跳 HoverCard wrapper)。原 (c) v1 在 Avatar 層 skip wrapper 但 NameCard
102
+ // JSX subtree 仍在此處 build → 浪費 React reconciliation work。push 到此處才真省。
103
+ function PersonAvatar({
104
+ person,
105
+ size = 'md',
106
+ className = '',
107
+ style,
108
+ }: {
109
+ person: PersonData
110
+ size?: 'sm' | 'md' | 'lg'
111
+ className?: string
112
+ style?: React.CSSProperties
113
+ }) {
114
+ const isTableScrolling = useTableIsScrolling()
115
+ const nameCard = React.useMemo(
116
+ () => (isTableScrolling ? undefined : buildPersonNameCard(person)),
117
+ [person, isTableScrolling]
118
+ )
119
+ return (
120
+ <Avatar
121
+ src={person.avatarUrl}
122
+ alt={person.name}
123
+ size={AVATAR_PX[size]}
124
+ className={className}
125
+ style={style}
126
+ hoverCard={nameCard}
127
+ />
128
+ )
129
+ }
130
+
131
+ // ── Single Person Display ───────────────────────────────────────────────────
132
+
133
+ // 2026-05-14 item-anatomy SSOT fix(per codex+Layer A 共識 path (a) + user 拍板「全部做完」):
134
+ // outer 改 items-start + Avatar 外包 ItemPrefix primitive consumption。單行視覺 = items-center 等效;
135
+ // 多行(autoRowHeight cell)避免 avatar+name center 整 row 不對齊 first-line text top。M1 消費既有
136
+ // 對齊 TreeView / MenuItem / SelectionItem 共用 ItemPrefix wrap chevron/icon/avatar canonical。
137
+ function PersonDisplay({ value, size = 'md' }: { value?: PersonValue | null; size?: 'sm' | 'md' | 'lg' }) {
138
+ if (!value) return <span className="text-fg-muted">{EMPTY_DISPLAY}</span>
139
+
140
+ const person = resolvePerson(value)
141
+
142
+ // 2026-05-14 I1 fix(per codex addendum verdict):outer `inline-flex` → `flex w-full`
143
+ // 完成 truncate 寬度約束鏈。原 inline-flex content-width parent constrain 不到 name span
144
+ // → cell overflow-hidden 硬裁 → ellipsis dots 不可見。改 flex(block-level full width)
145
+ // + inner name span `flex-1 min-w-0 truncate` 真實 truncate-with-ellipsis 顯示。
146
+ // 對齊 GitHub Primer ActionList / Slack users_select / Atlassian UserPicker truncation canonical。
147
+ return (
148
+ <span className="flex items-start gap-2 min-w-0 w-full">
149
+ <ItemPrefix><PersonAvatar person={person} size={size} /></ItemPrefix>
150
+ <span className="truncate flex-1 min-w-0">{person.name}</span>
151
+ </span>
152
+ )
153
+ }
154
+ PersonDisplay.displayName = 'PersonDisplay'
155
+
156
+ // ── Multi Person Display ────────────────────────────────────────────────────
157
+ // 多人堆疊:avatar 重疊(-2px),不顯示人名。
158
+ // 第一個 avatar z-index 最高(在最上面),依此類推。
159
+ // 溢出時顯示 +N 指示器,hover 出 tooltip 列出溢出的人(avatar + 人名)。
160
+
161
+ function MultiPersonDisplay({
162
+ value,
163
+ size = 'md',
164
+ max,
165
+ measured = false,
166
+ onRemove,
167
+ }: {
168
+ value?: PersonValue[] | null
169
+ size?: 'sm' | 'md' | 'lg'
170
+ /** 最多顯示幾個 avatar(不含 +N),預設 3。`measured=true` 時忽略此 prop(改 container width 算)*/
171
+ max?: number
172
+ /**
173
+ * 2026-05-15 codex Round 5 C+ SSOT fix:`measured=true` 啟動 container-width 量測(取代 hardcode `max ?? 3`)
174
+ * → display + edit stack 同 algorithm(同 cell width → 同 overflow 判斷),不再 display 用固定 3 vs edit
175
+ * 用 useOverflowCount。對齊 field-controls.spec.md:286 「4-mode 共享 renderer」contract + user round 3
176
+ * verbatim「同空間兩判斷點」SSOT directive。Default false 保 backward compat(non-cell context 仍 max ?? 3)。
177
+ */
178
+ measured?: boolean
179
+ /** 傳入時啟用 dismiss(edit mode),callback 接收被移除的 person */
180
+ onRemove?: (person: PersonValue) => void
181
+ }) {
182
+ if (!value || value.length === 0) return <span className="text-fg-muted">{EMPTY_DISPLAY}</span>
183
+
184
+ // 2026-05-15 Bug 3 fix(Claude+Codex Step 5 比稿 consensus):消費 shared `avatar-stack-overflow`
185
+ // primitive。原 inline canvas-based formula 是 dual-implementation 違反 user SSOT「同 cell width 同
186
+ // overflow 判斷」(edit path 用 Combobox useOverflowCount DOM offsetWidth / display path 用 inline
187
+ // canvas)。**抽 primitive 統一**:display + edit 共用 `getAvatarStackVisibleCount` formula。
188
+ // SSOT in `./avatar-stack-overflow.ts`,M14 mechanical guard 防 future drift。
189
+ const containerRef = React.useRef<HTMLSpanElement>(null)
190
+ const [measuredCount, setMeasuredCount] = React.useState<number | null>(null)
191
+ React.useLayoutEffect(() => {
192
+ if (!measured) return
193
+ const el = containerRef.current
194
+ if (!el) return
195
+ const calc = () => {
196
+ const visible = getAvatarStackVisibleCount({
197
+ availablePx: el.clientWidth,
198
+ total: value.length,
199
+ avatarPx: AVATAR_STACK_AVATAR_PX[size],
200
+ overflowChipPx: AVATAR_STACK_OVERFLOW_CHIP_PX[size],
201
+ })
202
+ setMeasuredCount(visible)
203
+ }
204
+ calc()
205
+ const ro = new ResizeObserver(calc)
206
+ ro.observe(el)
207
+ return () => ro.disconnect()
208
+ }, [measured, size, value])
209
+
210
+ const resolvedMax = measured && measuredCount !== null ? measuredCount : (max ?? 3)
211
+ const people = value.map(resolvePerson)
212
+ const visible = people.slice(0, resolvedMax)
213
+ const hidden = people.slice(resolvedMax)
214
+ const overflow = hidden.length
215
+
216
+ // 單人回退到 PersonDisplay(顯示名字)
217
+ if (people.length === 1) {
218
+ return <PersonDisplay value={value[0]} size={size} />
219
+ }
220
+
221
+ // 2026-05-14 item-anatomy SSOT fix(per codex+Layer A 共識):outer items-start + avatar stack
222
+ // 鎖 first-line baseline(整 stack 是 prefix slot,h-[1lh] 對齊 first line)。
223
+ return (
224
+ <span ref={containerRef} className="inline-flex items-start min-w-0">
225
+ <ItemPrefix className="!justify-start"><span className="inline-flex items-center min-w-0">
226
+ {visible.map((person, i) => {
227
+ // **2026-05-07 v15.11 Bug D 升級 SSOT**:visible avatar 也支援 inline dismiss
228
+ // (對齊 user directive「avatar = tag」)。Dismiss overlay 走 `AvatarDismissOverlay`
229
+ // 共用 SSOT(下方 export),Combobox tagRenderer / 此處 / 任何 future avatar consumer
230
+ // 都用同一視覺 — 紅圈 X 對齊 avatar 右上,hover/focus-visible 才顯。
231
+ const handleDismiss = onRemove ? () => onRemove(value![i]) : undefined
232
+ return (
233
+ <span key={person.name + i} className={`relative inline-flex group/avatar ${i > 0 ? '-ml-0.5' : ''}`} style={{ zIndex: visible.length - i }}>
234
+ <PersonAvatar
235
+ person={person}
236
+ size={size}
237
+ className="ring-2 ring-[var(--surface)]"
238
+ />
239
+ {handleDismiss && <AvatarDismissOverlay onRemove={handleDismiss} label={person.name} />}
240
+ </span>
241
+ )
242
+ })}
243
+ {overflow > 0 && (
244
+ <OverflowIndicator
245
+ count={overflow}
246
+ size={size}
247
+ className="ring-2 ring-[var(--surface)] -ml-0.5"
248
+ >
249
+ {hidden.map((person, i) => (
250
+ <Tag
251
+ key={person.name + i}
252
+ color="neutral"
253
+ size="sm"
254
+ // Tag.avatar 是 ReactNode(非 AvatarData object)——傳 <Avatar> 元素。
255
+ // Tag 內部用 `w-4 h-4 rounded-full` 容器 slot,Avatar 填滿 object-cover。
256
+ // **hoverCard 必帶**(avatar.spec.md DS-wide canonical:所有 person avatar 必 hover → NameCard)。
257
+ // 跟 PersonAvatar 共用 `buildPersonNameCard` helper 確保顯示資訊一致。
258
+ avatar={
259
+ <Avatar
260
+ src={person.avatarUrl}
261
+ alt={person.name}
262
+ size={16}
263
+ hoverCard={buildPersonNameCard(person)}
264
+ />
265
+ }
266
+ onDismiss={onRemove ? () => onRemove(value![resolvedMax + i]) : undefined}
267
+ >
268
+ {person.name}
269
+ </Tag>
270
+ ))}
271
+ </OverflowIndicator>
272
+ )}
273
+ </span></ItemPrefix>
274
+ </span>
275
+ )
276
+ }
277
+ MultiPersonDisplay.displayName = 'MultiPersonDisplay'
278
+
279
+ // ── AvatarDismissOverlay ────────────────────────────────────────────────────
280
+ // SSOT for「person avatar overlay dismiss」(2026-05-07 v15.12,user spec confirmed)。
281
+ //
282
+ // **Visual canonical**(對齊 DS new token `--surface-strong`):
283
+ // - **12×12 圓**(固定,不隨 field size 變)
284
+ // - **bg `--surface-strong`**(neutral-6),hover → `--surface-strong-hover`
285
+ // (light=neutral-5 / dark=neutral-7,跨 mode 對稱)
286
+ // - **X icon size=12 strokeWidth=3.5**(icon 跟底色一樣大,對齊 checkbox checkmark
287
+ // sm/md stroke 規格)
288
+ // - **text-on-emphasis**(白 X,確保飽和色底對比)
289
+ // - **位置 `absolute top-0 right-0`**(button 右上角貼齊 avatar 右上角,完全在 avatar
290
+ // 內 — user-confirmed canonical)
291
+ //
292
+ // **a11y**(codex P1 fix):`opacity-0` 而非 `display:none` — element 在 DOM/tab-order,
293
+ // keyboard tab 可達,觸控 focus-within 也顯。Hover / focus-within / focus-visible
294
+ // 三條件之一觸發 `opacity-100`。
295
+ //
296
+ // **Why centralize**:Combobox tagRenderer (PeoplePicker stack mode) + MultiPersonDisplay
297
+ // dismiss 共用 SSOT,改 1 處全 sync(M17 propagation)。
298
+ function AvatarDismissOverlay({ onRemove, label }: { onRemove: () => void; label: string }) {
299
+ return (
300
+ <button
301
+ type="button"
302
+ onClick={(e) => { e.stopPropagation(); onRemove() }}
303
+ aria-label={`移除 ${label}`}
304
+ className={[
305
+ // **Position(2026-05-07 v15.15 user-confirmed)**:asymmetric `-top-px -right-1`
306
+ // (top -1px / right -4px)— field padding-y(4px sm/md)緊 → top 只 -1px 安全;
307
+ // padding-x 12px 寬鬆 → right 凸 4px 達 badge canonical visual。對齊 ClickUp
308
+ // 世界級 idiom(asymmetric offset by avatar/field size constraint)。
309
+ 'absolute -top-px -right-1 z-10',
310
+ 'inline-flex items-center justify-center',
311
+ // **12×12 + 2px white ring**(SSOT match stacked avatar,Slack/Material/iOS
312
+ // notification badge 2px ring canonical)。改用 `[box-shadow:...]` 而非 `ring-2`
313
+ // 避免跟下方 `focus-visible:ring-2` 在 tailwind-merge 衝突(同 ring family
314
+ // override 互殺)。Box-shadow inset 0 不影響 layout,也不被 focus-visible ring
315
+ // 蓋掉(focus 那邊另一條 outline ring 不同 layer)。
316
+ 'w-3 h-3 rounded-full [box-shadow:0_0_0_2px_var(--surface)]',
317
+ // bg-surface-strong = neutral-6-opaque / hover = neutral-7-opaque(both modes,
318
+ // step-7 dark 公式自動 lighter → engaged 跨 mode 對稱)
319
+ 'bg-surface-strong text-on-emphasis hover:bg-surface-strong-hover',
320
+ // a11y(codex P1 fix):opacity 而非 display:none — element 在 DOM/tab-order,
321
+ // keyboard 可達。Hover / focus-within / focus-visible 三條件之一觸發。
322
+ 'opacity-0 group-hover/avatar:opacity-100 group-focus-within/avatar:opacity-100 focus-visible:opacity-100',
323
+ 'transition-opacity duration-150',
324
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
325
+ ].join(' ')}
326
+ >
327
+ <X size={12} strokeWidth={3.5} aria-hidden />
328
+ </button>
329
+ )
330
+ }
331
+
332
+ // ── PersonAvatarTag(Combobox tagRenderer SSOT for stack mode)─────────────
333
+ // PeoplePicker `multiDisplay='stack'` 模式 wraps Combobox,tagRenderer 不能用 Tag pill
334
+ // (那是 pill mode),改 render 此元件 — Avatar overlap 視覺 + AvatarDismissOverlay。
335
+ // 對齊 user directive「avatar = tag 概念,差別只在視覺,SSOT 一致」(2026-05-07 v15.13)。
336
+ //
337
+ // **架構**(v15.13 重構):本元件**不自包** `group/avatar` / `-ml-0.5` overlap wrapper,
338
+ // 因 Combobox `tagRenderer` 結果會被內部 `<div shrink-0>` 包成 measurement wrapper
339
+ // (useOverflowCount 必要)。把 overlap + group 拉到 Combobox 的 `tagWrapperClassName`
340
+ // 上,sibling-level overlap + group selector 才能正確 chain → AvatarDismissOverlay 的
341
+ // `group-hover/avatar:opacity-100` 才會通。
342
+ function PersonAvatarTag({
343
+ person, size = 'md', onRemove,
344
+ }: {
345
+ person: PersonData
346
+ size?: 'sm' | 'md' | 'lg'
347
+ onRemove?: () => void
348
+ }) {
349
+ return (
350
+ <>
351
+ <PersonAvatar person={person} size={size} className="ring-2 ring-[var(--surface)]" />
352
+ {onRemove && <AvatarDismissOverlay onRemove={onRemove} label={person.name} />}
353
+ </>
354
+ )
355
+ }
356
+ PersonAvatarTag.displayName = 'PersonAvatarTag'
357
+
358
+ export { PersonDisplay, MultiPersonDisplay, PersonAvatarTag, buildPersonNameCard, resolvePerson }
@@ -0,0 +1,183 @@
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 * as PopoverPrimitive from "@radix-ui/react-popover"
4
+ import { X as XIcon } from "lucide-react"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import { SurfaceHeader, SurfaceBody, SurfaceFooter } from "@/design-system/patterns/overlay-surface/overlay-surface"
8
+ import { Button } from "@/design-system/components/Button/button"
9
+ import { OVERLAY_SIDE_OFFSET, OVERLAY_COLLISION_PADDING } from "@/design-system/tokens/elevation/overlay-geometry"
10
+
11
+ /**
12
+ * Popover — Radix Popover + 設計系統 token
13
+ *
14
+ * ── 視覺 ──
15
+ * 與 Dialog 對齊:bg-surface-raised / rounded-lg / border-border / elevation-200。
16
+ * density 永遠鎖 md(non-modal 輕量浮層不隨頁面 density 放大)。
17
+ *
18
+ * ── 結構 ──
19
+ * PopoverContent:外殼(bg / border / radius / shadow / density),無內距。
20
+ * PopoverHeader / PopoverBody / PopoverFooter:消費 overlay-surface pattern
21
+ * 共用的 SurfaceHeader / SurfaceBody / SurfaceFooter primitives(padding SSOT)。
22
+ *
23
+ * ── Header dismiss X(2026-04-20 決策) ──
24
+ * 所有 PopoverHeader 一律附右上 X 按鈕(對齊 Dialog 的 canonical)。Popover 雖是
25
+ * non-modal + click-outside-to-close,但有 header 的 Popover 通常結構化程度高
26
+ * (title / 多區塊),明確的「關閉」入口讓使用者更易退出。無 header 的簡單 Popover
27
+ * 不加 X(click-outside / Esc 即可)。
28
+ */
29
+
30
+ const Popover = PopoverPrimitive.Root
31
+ const PopoverTrigger = PopoverPrimitive.Trigger
32
+ const PopoverAnchor = PopoverPrimitive.Anchor
33
+ const PopoverClose = PopoverPrimitive.Close
34
+
35
+ // AutoFocus canonical(對齊 Dialog / Sheet / Material / Polaris)—
36
+ // 開啟時 focus 落在 body 第一個有意義互動元素,避免 focus 到 close X 觸發 tooltip leak
37
+ const handlePopoverOpenAutoFocus = (e: Event) => {
38
+ e.preventDefault()
39
+ const content = e.currentTarget as HTMLElement
40
+ const firstBodyTarget = content.querySelector<HTMLElement>(
41
+ '[data-popover-body] input:not([disabled]),[data-popover-body] textarea:not([disabled]),[data-popover-body] select:not([disabled]),[data-popover-body] button:not([disabled]):not([data-dismiss]),input:not([disabled]),textarea:not([disabled]),button:not([disabled]):not([data-dismiss])'
42
+ )
43
+ const firstFooterButton = content.querySelector<HTMLElement>(
44
+ '[data-popover-footer] button:not([disabled]):not([data-dismiss])'
45
+ )
46
+ ;(firstBodyTarget ?? firstFooterButton ?? content).focus({ preventScroll: true })
47
+ }
48
+
49
+ const PopoverContent = React.forwardRef<
50
+ React.ElementRef<typeof PopoverPrimitive.Content>,
51
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
52
+ >(({ className, align = "center", sideOffset = OVERLAY_SIDE_OFFSET, collisionPadding = OVERLAY_COLLISION_PADDING, onOpenAutoFocus, ...props }, ref) => (
53
+ <PopoverPrimitive.Portal>
54
+ <PopoverPrimitive.Content
55
+ ref={ref}
56
+ align={align}
57
+ sideOffset={sideOffset}
58
+ collisionPadding={collisionPadding}
59
+ data-density="md"
60
+ onOpenAutoFocus={onOpenAutoFocus ?? handlePopoverOpenAutoFocus}
61
+ className={cn(
62
+ "z-50 w-72 rounded-lg border border-border bg-surface-raised text-foreground shadow-[var(--elevation-200)] outline-none",
63
+ // 2026-05-04 viewport-aware max-h SSOT(從 NameCard 升 DS-wide):header/footer 永遠 in-viewport,body 壓縮 scroll
64
+ // 2026-05-05 audit dim 35 補:加 `min-h-0` 完成 M25 chain invariant(flex item default min-h: auto 阻 shrink)
65
+ "max-h-[var(--radix-popover-content-available-height,100vh)] flex flex-col overflow-hidden min-h-0",
66
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
67
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
68
+ "origin-[var(--radix-popover-content-transform-origin)]",
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ </PopoverPrimitive.Portal>
74
+ ))
75
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName
76
+
77
+ // PopoverHeader: SurfaceHeader + Close X(對齊 Dialog 的 canonical,見 docblock)
78
+ // justify-between 讓 children 與 Close 分左右。
79
+ interface PopoverHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
80
+ /**
81
+ * 隱藏右上 close X(預設 false,顯示)。
82
+ * Coachmark / Tour 類 composition 用 Skip / Done 自管 close,不需 X。
83
+ */
84
+ hideClose?: boolean
85
+ }
86
+
87
+ const PopoverHeader = React.forwardRef<HTMLDivElement, PopoverHeaderProps>(
88
+ ({ className, children, hideClose = false, ...props }, ref) => (
89
+ // Popover lightweight chrome canonical(2026-05-04 重思 v2):
90
+ // 覆寫 `--chrome-slot-h: 1.25rem` (20px) → unbounded button 佔位縮成 20,**匹配 PopoverTitle
91
+ // text-body line-height (14×1.5≈21,floor 20)**。Header 維持 padding-based 自然撐開:
92
+ // max(21 title, 20 slot) + py-tight(12*2) = 45 → 自然比 Dialog/Sheet 48 輕一級。
93
+ // Q10 穩定:title-only / title+close 都 = title + py 主導,slot 不 dominate。
94
+ // 無 min-h / 無 py override — 修正前一版過度設計。
95
+ <SurfaceHeader
96
+ ref={ref}
97
+ className={cn("justify-between [--chrome-slot-h:1.25rem]", className)}
98
+ {...props}
99
+ >
100
+ <div className="flex-1 min-w-0">{children}</div>
101
+ {!hideClose && (
102
+ <PopoverPrimitive.Close asChild>
103
+ {/* Dismiss X = native sm,SurfaceHeader 負 my trick 讓 layout 佔位 24 → 匹配 inner 24 */}
104
+ <Button data-dismiss iconOnly dismiss size="sm" startIcon={XIcon} aria-label="關閉" />
105
+ </PopoverPrimitive.Close>
106
+ )}
107
+ </SurfaceHeader>
108
+ ),
109
+ )
110
+ PopoverHeader.displayName = "PopoverHeader"
111
+
112
+ // PopoverBody / PopoverFooter: wrap SurfaceBody / SurfaceFooter with data-popover-*
113
+ // attributes so handlePopoverOpenAutoFocus 可正確定位 body 內第一個 interactive 元素
114
+ //
115
+ // ── List-as-region 場景(menu / Cmd+K / nav)──
116
+ // 不再提供 `flush` variant(2026-05-01 移除,對齊 DialogBody / SheetBody canonical)。
117
+ // consumer 用 SurfaceBody className override 撤掉 chrome padding + 自管 list outer wrapper:
118
+ // `<PopoverBody className="!px-0 !py-0"><div className="py-2">{items}</div></PopoverBody>`
119
+ // 或乾脆不用 PopoverBody,直接 PopoverContent > 自管 list 結構(naked popover)。
120
+ // 詳 DialogBody comment + `tokens/layoutSpace/layoutSpace.spec.md`「List-as-region in overlay body」
121
+ const PopoverBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
122
+ ({ ...props }, ref) => <SurfaceBody ref={ref} data-popover-body {...props} />,
123
+ )
124
+ PopoverBody.displayName = "PopoverBody"
125
+
126
+ const PopoverFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
127
+ ({ ...props }, ref) => <SurfaceFooter ref={ref} data-popover-footer {...props} />
128
+ )
129
+ PopoverFooter.displayName = "PopoverFooter"
130
+
131
+ // PopoverTitle(2026-04-22 v4 non-modal canonical):`text-body font-medium`(14px)
132
+ // Rationale:Popover / Coachmark 屬 **non-modal 輕量浮層**,跟 density 鎖 md 同源的視覺語言 —
133
+ // chrome 全體輕量:
134
+ // - density 鎖 md(不隨 page 放大)
135
+ // - dismiss X 透過 v5 unbounded trick layout 佔位 24
136
+ // - **title `text-body`(14px)跟 Dialog / Sheet modal 的 body-lg(16px)形成重量級 vs 輕量級視覺區分**
137
+ // 世界級對照:Figma / Notion / Linear / Material 的 popover / filter panel / inline settings
138
+ // 的 header title 多半比 modal dialog title 小一級(16→14 或同比),視覺宣告「此浮層可忽略」
139
+ //
140
+ // Coachmark 同樣消費 PopoverTitle 作 header 小標籤(如 "新功能介紹" / "Tip 1 of 3"),
141
+ // CoachmarkBody 的主 title 另走 text-body-lg(見 coachmark.tsx L178)。
142
+ const PopoverTitle = React.forwardRef<
143
+ HTMLHeadingElement,
144
+ React.HTMLAttributes<HTMLHeadingElement>
145
+ >(({ className, ...props }, ref) => (
146
+ <h2
147
+ ref={ref}
148
+ className={cn("text-body font-medium truncate", className)}
149
+ {...props}
150
+ />
151
+ ))
152
+ PopoverTitle.displayName = "PopoverTitle"
153
+
154
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
155
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
156
+ export const popoverMeta = {
157
+ component: 'Popover',
158
+ family: null, // non-family composite / overlay / layout
159
+ variants: {
160
+
161
+ },
162
+ sizes: {
163
+
164
+ },
165
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
166
+ tokens: {
167
+ bg: ['bg-surface-raised'],
168
+ fg: ['text-foreground'],
169
+ ring: [],
170
+ },
171
+ } as const
172
+
173
+ export {
174
+ Popover,
175
+ PopoverTrigger,
176
+ PopoverAnchor,
177
+ PopoverContent,
178
+ PopoverClose,
179
+ PopoverHeader,
180
+ PopoverBody,
181
+ PopoverFooter,
182
+ PopoverTitle,
183
+ }