@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,204 @@
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 { cn } from '@/lib/utils'
4
+
5
+ /**
6
+ * CircularProgress — 圓形進度指示(determinate + indeterminate 雙模式)
7
+ *
8
+ * 世界級對照:Material `CircularProgress` / Chakra `CircularProgress` — 同元件管
9
+ * determinate(有 `value`)+ indeterminate(無 `value`)兩態。本 DS 採此流派作為
10
+ * circular progress 的 SSOT,`Spinner` 名稱廢除(遷至本元件)。
11
+ *
12
+ * ── 姊妹元件 ──
13
+ * `ProgressBar` = linear determinate(大區塊、頁面級、表單步驟、上傳 bar)
14
+ * `CircularProgress` = circular 兩態(本元件,inline 小空間 / Button loading / Field loading)
15
+ *
16
+ * ── API ──
17
+ * value: undefined → indeterminate(旋轉 partial arc,Spinner 樣式)
18
+ * number 0-100 → determinate(固定 arc + track)
19
+ * size: 自由 px(預設 24,≤ 64 建議)— 跟 Avatar / Lucide icon 同策略
20
+ * label: 可選 inline label,font-size inherit parent,color text-fg-muted
21
+ * affix: (determinate only)'value' | ReactNode
22
+ *
23
+ * ── 視覺 ──
24
+ * SVG 雙圓:底 track(`var(--secondary)`)+ 進度 arc(`text-primary`,不隨狀態變色)。
25
+ * stroke-linecap round,rotate -90deg。
26
+ * Indeterminate mode:arc 固定 25%,外層 `animate-spin` 旋轉,視覺同 Spinner。
27
+ *
28
+ * ── 不設 status prop(決策 2026-04-20)──
29
+ * 世界級沒有「success / error CircularProgress」的穩態呈現——完成 / 失敗的語義應由
30
+ * consumer 在業務邏輯上**替換 CircularProgress 為實際內容**(status icon + label、
31
+ * 成功的結果、錯誤訊息等),而非讓 CircularProgress 變色 + 加 check icon。這種「綠底
32
+ * 空心 circle + check icon 並排」是 DS 典型 over-designing 的 anti-pattern(參考:
33
+ * Material / Chakra / Ant / Polaris 全部沒有此 variant)。
34
+ *
35
+ * Consumer 端範例:
36
+ * {uploading ? <CircularProgress /> : done ? <Check /> : error ? <AlertCircle /> : null}
37
+ *
38
+ * ── A11y ──
39
+ * 有 value → role="progressbar" + aria-valuenow/min/max
40
+ * 無 value → role="status"(loading 語義) + aria-label(如傳)或 aria-hidden(否則)
41
+ */
42
+
43
+ // Indeterminate mode 的 arc 比例(Material 流派:25%-30% 視覺最平衡)
44
+ const INDETERMINATE_ARC_RATIO = 0.25
45
+
46
+ export interface CircularProgressProps extends React.HTMLAttributes<HTMLSpanElement> {
47
+ /**
48
+ * 進度 0-100。
49
+ * - undefined → indeterminate(旋轉 partial arc,Spinner 樣式)
50
+ * - number → determinate(固定 arc + track)
51
+ */
52
+ value?: number
53
+ /** 直徑(px)。預設 24,建議 ≤ 64。 */
54
+ size?: number
55
+ /** 狀態色(lifecycle,與 ProgressBar 同契約)。 */
56
+ /**
57
+ * 視覺 label(inline 顯示於右側)。
58
+ * - font-size 繼承 parent(不設 text-size class,CSS inherit)
59
+ * - color 鎖 `text-fg-muted`(neutral-7)
60
+ * - 塞在元件內時預設不用(e.g. Button loading);全頁 / Empty overlay 可用
61
+ */
62
+ label?: string
63
+ /**
64
+ * 右側 affix(determinate only;indeterminate 忽略)。
65
+ * - `'value'` → `{value}%` 文字
66
+ * - ReactNode → 客製(若需顯示「已完成」,consumer 端整個 swap 為 Check icon,不走此 prop)
67
+ */
68
+ affix?: 'value' | React.ReactNode
69
+ }
70
+
71
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
72
+ const CircularProgress = React.forwardRef<HTMLSpanElement, CircularProgressProps>(
73
+ (
74
+ {
75
+ value,
76
+ size = 24,
77
+ label,
78
+ affix,
79
+ className,
80
+ 'aria-label': ariaLabel,
81
+ ...props
82
+ },
83
+ ref,
84
+ ) => {
85
+ const isDeterminate = typeof value === 'number'
86
+ const clampedValue = isDeterminate ? Math.max(0, Math.min(100, value)) : 0
87
+ const strokeWidth = Math.max(2, Math.round(size / 10))
88
+ const radius = (size - strokeWidth) / 2
89
+ const circumference = 2 * Math.PI * radius
90
+ const dashOffset = isDeterminate
91
+ ? circumference * (1 - clampedValue / 100)
92
+ : circumference * (1 - INDETERMINATE_ARC_RATIO)
93
+
94
+ const hasLabel = typeof label === 'string' && label.length > 0
95
+ const hasAriaLabel = typeof ariaLabel === 'string' && ariaLabel.length > 0
96
+ const shouldAnnounce = hasAriaLabel || hasLabel
97
+
98
+ // Affix(determinate only)
99
+ let affixNode: React.ReactNode = null
100
+ if (isDeterminate) {
101
+ if (affix === 'value') {
102
+ affixNode = (
103
+ <span className="text-caption text-foreground tabular-nums shrink-0">
104
+ {Math.round(clampedValue)}%
105
+ </span>
106
+ )
107
+ } else if (
108
+ React.isValidElement(affix) ||
109
+ typeof affix === 'string' ||
110
+ typeof affix === 'number'
111
+ ) {
112
+ affixNode = affix
113
+ }
114
+ }
115
+
116
+ const a11yRole = isDeterminate ? 'progressbar' : shouldAnnounce ? 'status' : undefined
117
+ const a11yLabel = hasAriaLabel ? ariaLabel : hasLabel ? label : undefined
118
+ const a11yValueAttrs = isDeterminate
119
+ ? {
120
+ 'aria-valuenow': Math.round(clampedValue),
121
+ 'aria-valuemin': 0,
122
+ 'aria-valuemax': 100,
123
+ }
124
+ : {}
125
+
126
+ const graphic = (
127
+ <span
128
+ ref={ref}
129
+ role={a11yRole}
130
+ aria-label={a11yLabel}
131
+ aria-hidden={!a11yRole ? true : undefined}
132
+ {...a11yValueAttrs}
133
+ className={cn(
134
+ // align-middle:inline context 內讓 SVG 對齊 adjacent text 的 x-height 中線
135
+ //(不加會按 baseline 對齊,在 inline-flex cell 裡視覺下沉 1-2px 看起來歪)
136
+ 'inline-flex shrink-0 align-middle text-primary',
137
+ // motion-reduce:Material 流派 — prefers-reduced-motion 時不停止旋轉(loading 仍需可見回饋),
138
+ // 而是放慢到 3s/cycle(預設 1s),保留資訊不刺激前庭。
139
+ !isDeterminate && 'animate-spin motion-reduce:[animation-duration:3s]',
140
+ className,
141
+ )}
142
+ style={{ width: size, height: size }}
143
+ {...props}
144
+ >
145
+ <svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} aria-hidden>
146
+ <circle
147
+ cx={size / 2}
148
+ cy={size / 2}
149
+ r={radius}
150
+ fill="none"
151
+ stroke="var(--secondary)"
152
+ strokeWidth={strokeWidth}
153
+ />
154
+ <circle
155
+ cx={size / 2}
156
+ cy={size / 2}
157
+ r={radius}
158
+ fill="none"
159
+ stroke="currentColor"
160
+ strokeWidth={strokeWidth}
161
+ strokeDasharray={circumference}
162
+ strokeDashoffset={dashOffset}
163
+ strokeLinecap="round"
164
+ transform={`rotate(-90 ${size / 2} ${size / 2})`}
165
+ className={isDeterminate ? 'transition-[stroke-dashoffset] duration-300' : undefined}
166
+ />
167
+ </svg>
168
+ </span>
169
+ )
170
+
171
+ // 單純 graphic(無 label / affix)
172
+ if (!hasLabel && !affixNode) return graphic
173
+
174
+ return (
175
+ <span className={cn('inline-flex items-center', (hasLabel || affixNode) && 'gap-2')}>
176
+ {graphic}
177
+ {hasLabel && <span className="text-fg-muted">{label}</span>}
178
+ {affixNode}
179
+ </span>
180
+ )
181
+ },
182
+ )
183
+ CircularProgress.displayName = 'CircularProgress'
184
+
185
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
186
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
187
+ export const circularProgressMeta = {
188
+ component: 'CircularProgress',
189
+ family: null, // non-family composite / overlay / layout
190
+ variants: {
191
+
192
+ },
193
+ sizes: {
194
+
195
+ },
196
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
197
+ tokens: {
198
+ bg: [],
199
+ fg: ['text-fg-muted', 'text-foreground', 'text-primary'],
200
+ ring: [],
201
+ },
202
+ } as const
203
+
204
+ export { CircularProgress }
@@ -0,0 +1,255 @@
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 { cn } from '@/lib/utils'
4
+ import {
5
+ Popover,
6
+ PopoverTrigger,
7
+ PopoverContent,
8
+ PopoverBody,
9
+ PopoverFooter,
10
+ PopoverHeader,
11
+ PopoverTitle,
12
+ } from '@/design-system/components/Popover/popover'
13
+ import { Button } from '@/design-system/components/Button/button'
14
+ import { AspectRatio } from '@/design-system/components/AspectRatio/aspect-ratio'
15
+ import { OVERLAY_SIDE_OFFSET } from '@/design-system/tokens/elevation/overlay-geometry'
16
+
17
+ /**
18
+ * Coachmark — 功能介紹 / onboarding tour 的浮層卡片
19
+ *
20
+ * 世界級對照:Apple HIG「Coachmark」(Apple 命名原處)/ Material「Feature Discovery」/
21
+ * Ant Design `<Tour>` / Shepherd.js / react-joyride / Intercom Product Tours。
22
+ *
23
+ * 本元件 = **Popover 的 composition pattern**,consume 相同 overlay-surface SSOT:
24
+ * - Header(可選,多步驟建議傳 `kind="tips" | "new-features" | 自訂 title`,
25
+ * single-step 預設無 header 避免視覺過重)
26
+ * - Media 區(圖 / 截圖 / illustration,full-width 邊緣對齊,由 AspectRatio 管比例)
27
+ * - Body(SurfaceBody padding):title + description 左對齊
28
+ * - Footer(SurfaceFooter padding,但 justify-between):step 計數左 / actions 右
29
+ *
30
+ * ── 單 vs 多步驟 canonical(世界級行為規則) ──
31
+ * 1. **Single step**(無 `onPrev` 且 `isLastStep`):CTA 文字 = `doneLabel ?? '知道了'`
32
+ * (Apple HIG / Intercom 慣例;不用 "Next" 因為沒有下一步)
33
+ * 2. **Multi step 第一步**(無 `onPrev`,有 `onNext`):CTA = "Next",Skip 顯示
34
+ * 3. **Multi step 中間 / 最後步**(有 `onPrev`):**Skip 不顯示**(使用者已投入進度,
35
+ * 再給 Skip 會讓「放棄」入口與「回上一步」衝突 — Linear / Pendo / Shepherd.js
36
+ * 同樣規則)。CTA = `isLastStep ? 'Done' : 'Next'`
37
+ * 4. **不強制 autoFocus 任何按鈕** — Radix Popover 預設 focus 第一個 focusable
38
+ * (通常是 Prev 或 Skip),本元件不額外拉焦點到 Next,避免使用者以為一按 Enter
39
+ * 就會推進(實際上可能還在讀 body)。想推進者 tab 到 Next 再 Enter。
40
+ *
41
+ * ── 為什麼 Body+Footer 消費 overlay-surface ──
42
+ * 避免 padding token 漂移:Dialog / Popover / Coachmark 三者共用同一套 Header/Body/Footer
43
+ * padding(px-loose / py-tight),改 Dialog 就三邊自動跟進。
44
+ */
45
+
46
+ interface CoachmarkStep {
47
+ current: number
48
+ total: number
49
+ }
50
+
51
+ export interface CoachmarkProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children' | 'title'> {
52
+ /** 控制顯示(controlled mode)*/
53
+ open?: boolean
54
+ /** 預設打開(uncontrolled initial state)— 2026-05-15 audit Dim 26 V1 fix per user verbatim「A:1」approval */
55
+ defaultOpen?: boolean
56
+ onOpenChange?: (open: boolean) => void
57
+ /** 觸發 anchor 元素。通常傳 trigger element;Coachmark 浮層會定位於此 */
58
+ children: React.ReactNode
59
+ /** 頂部 media 區(圖片 / illustration / video 等);不傳則無 media */
60
+ image?: React.ReactNode
61
+ /**
62
+ * Media 區域的長寬比(ratio = 寬 / 高)。預設 `16/9`(onboarding feature tour
63
+ * 世界級 convention — Intercom / Pendo / Shepherd.js)。其他常用:`4/3` 產品截圖 /
64
+ * `1/1` 方圖 / `3/4` 直式 portrait。消費獨立的 `AspectRatio` primitive 元件。
65
+ */
66
+ mediaRatio?: number
67
+ /**
68
+ * 頂部 header 類型(多步驟 tour 建議傳)。
69
+ * - `'tips'` → header title = "使用技巧"
70
+ * - `'new-features'` → header title = "新功能介紹"
71
+ * - `ReactNode` → 自訂 title(string / JSX)
72
+ * - undefined → 無 header(單步驟常用)
73
+ */
74
+ kind?: 'tips' | 'new-features' | React.ReactNode
75
+ /** 標題(bold) */
76
+ title?: React.ReactNode
77
+ /** 說明文字(normal weight,多行 OK) */
78
+ description?: React.ReactNode
79
+ /** 步驟計數(2 of 3);若需多步導覽 consumer 自行管理 current */
80
+ step?: CoachmarkStep
81
+ /** Skip 按鈕 callback;不傳則不顯示 Skip。多步驟中間 / 最後步自動隱藏(有 onPrev 時) */
82
+ onSkip?: () => void
83
+ /** Next 按鈕 callback;不傳則不顯示 Next */
84
+ onNext?: () => void
85
+ /** Previous 按鈕 callback(多步 tour 第 2+ 步顯示);不傳則不顯示 */
86
+ onPrev?: () => void
87
+ /**
88
+ * 最後一步 flag。影響 primary CTA 文字:
89
+ * - single step(無 onPrev 且 isLastStep):CTA = `doneLabel ?? '知道了'`
90
+ * - multi-step 最後步(有 onPrev 且 isLastStep):CTA = 'Done'
91
+ * - 其他:CTA = 'Next'
92
+ */
93
+ isLastStep?: boolean
94
+ /** 自訂單步驟完成 CTA 文字(預設 `'知道了'`)。僅 single-step 使用 */
95
+ doneLabel?: string
96
+ /** 浮層定位(對齊 Popover props) */
97
+ side?: 'top' | 'right' | 'bottom' | 'left'
98
+ align?: 'start' | 'center' | 'end'
99
+ sideOffset?: number
100
+ /** 外殼寬度(預設 w-80 = 320px,比一般 Popover 寬,因要放 media + 文字) */
101
+ className?: string
102
+ }
103
+
104
+ // i18n-allow: DS-internal kind → title 預設對照表;consumer 透過 `title` prop 直接覆寫
105
+ const KIND_TITLE: Record<'tips' | 'new-features', string> = {
106
+ tips: '使用技巧',
107
+ 'new-features': '新功能介紹',
108
+ }
109
+
110
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
111
+ const Coachmark = React.forwardRef<HTMLDivElement, CoachmarkProps>(
112
+ (
113
+ {
114
+ open,
115
+ defaultOpen,
116
+ onOpenChange,
117
+ children,
118
+ image,
119
+ mediaRatio = 16 / 9,
120
+ kind,
121
+ title,
122
+ description,
123
+ step,
124
+ onSkip,
125
+ onNext,
126
+ onPrev,
127
+ isLastStep = false,
128
+ doneLabel = '知道了', // i18n-allow: DS default; consumer override via doneLabel prop
129
+ side = 'bottom',
130
+ align = 'center',
131
+ sideOffset = OVERLAY_SIDE_OFFSET,
132
+ className,
133
+ ...props
134
+ },
135
+ ref,
136
+ ) => {
137
+ // 單/多步驟行為推導
138
+ const isSingleStep = isLastStep && !onPrev
139
+ const showSkip = Boolean(onSkip) && !onPrev // canonical:有 onPrev → 不顯示 Skip
140
+ const nextLabel = isSingleStep ? doneLabel : isLastStep ? 'Done' : 'Next'
141
+
142
+ const hasFooterContent = Boolean(step || showSkip || onNext || onPrev)
143
+ const stepText = step ? `${step.current} of ${step.total}` : null
144
+
145
+ // Header title 解析
146
+ const headerTitle =
147
+ kind === 'tips' || kind === 'new-features'
148
+ ? KIND_TITLE[kind as 'tips' | 'new-features']
149
+ : kind
150
+
151
+ return (
152
+ <Popover open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
153
+ <PopoverTrigger asChild>{children}</PopoverTrigger>
154
+ <PopoverContent
155
+ ref={ref}
156
+ side={side}
157
+ align={align}
158
+ sideOffset={sideOffset}
159
+ className={cn('w-80 p-0 overflow-hidden', className)}
160
+ // 禁止 Radix 開啟時自動 focus 第一個 focusable(預設會 focus Prev / Skip / Next),
161
+ // Coachmark 的 CTA 不該被 auto-focus 偷觸發(user 可能還在讀 body,按 Enter 就推進)。
162
+ // 想推進的 user 自己 tab 到 CTA 即可。
163
+ onOpenAutoFocus={(e) => e.preventDefault()}
164
+ {...props}
165
+ >
166
+ {headerTitle && (
167
+ // Header title 走 `<PopoverTitle>` 共用 Popover typography canonical(text-body font-medium)。
168
+ // **不 hideClose** — 對齊 Popover / Dialog / 所有 overlay 家族 canonical:header 必有 dismiss X
169
+ // (user 可隨時關閉,跟 Skip / Done 是不同入口,canonical 重複不冗)
170
+ <PopoverHeader>
171
+ <PopoverTitle>{headerTitle}</PopoverTitle>
172
+ </PopoverHeader>
173
+ )}
174
+
175
+ {image && (
176
+ <AspectRatio ratio={mediaRatio} className="w-full overflow-hidden bg-muted">
177
+ {image}
178
+ </AspectRatio>
179
+ )}
180
+
181
+ {(title || description) && (
182
+ // 對齊 Dialog / Popover canonical:body 左對齊(不中置)
183
+ // Why: Coachmark 雖是 onboarding / feature discovery,但文字可讀性 > 視覺焦點;
184
+ // 中文 / 長句中置會「每行起點不同」造成閱讀鋸齒,左對齊最穩。
185
+ // 世界級參考:Notion / Linear / Figma onboarding tour 皆左對齊;Intercom Messenger 亦如是。
186
+ <PopoverBody className="flex flex-col">
187
+ {title && (
188
+ <h3 className="text-body-lg font-medium text-foreground">{title}</h3>
189
+ )}
190
+ {description && (
191
+ // title(body-lg 16)+ desc(body 14)→ reading-lg token(label tier 決定)
192
+ // 對齊 Dialog / Sheet canonical;移除原 gap-1(4px)drift
193
+ <p className="mt-[var(--item-gap-label-desc-reading-lg)] text-body text-fg-secondary">{description}</p>
194
+ )}
195
+ </PopoverBody>
196
+ )}
197
+
198
+ {hasFooterContent && (
199
+ // 專屬 Coachmark canonical:footer 無上方分隔線(media + body + footer 視覺一氣呵成,
200
+ // 不像 Dialog 需要 header/footer 分隔強化結構)。override SurfaceFooter default border-t
201
+ <PopoverFooter className="justify-between !border-t-0">
202
+ {stepText ? (
203
+ // step 文字走 text-body 跟 body content 字體一致
204
+ <span className="text-body text-fg-secondary tabular-nums">
205
+ {stepText}
206
+ </span>
207
+ ) : (
208
+ <span aria-hidden /> /* 保持 justify-between space */
209
+ )}
210
+ <div className="flex items-center gap-2">
211
+ {onPrev && (
212
+ <Button variant="tertiary" size="sm" onClick={onPrev}>
213
+ Previous
214
+ </Button>
215
+ )}
216
+ {showSkip && (
217
+ <Button variant="tertiary" size="sm" onClick={onSkip}>
218
+ Skip
219
+ </Button>
220
+ )}
221
+ {onNext && (
222
+ <Button variant="primary" size="sm" onClick={onNext}>
223
+ {nextLabel}
224
+ </Button>
225
+ )}
226
+ </div>
227
+ </PopoverFooter>
228
+ )}
229
+ </PopoverContent>
230
+ </Popover>
231
+ )
232
+ },
233
+ )
234
+ Coachmark.displayName = 'Coachmark'
235
+
236
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
237
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
238
+ export const coachmarkMeta = {
239
+ component: 'Coachmark',
240
+ family: null, // non-family composite / overlay / layout
241
+ variants: {
242
+
243
+ },
244
+ sizes: {
245
+
246
+ },
247
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
248
+ tokens: {
249
+ bg: ['bg-muted'],
250
+ fg: ['text-fg-secondary', 'text-foreground'],
251
+ ring: [],
252
+ },
253
+ } as const
254
+
255
+ export { Coachmark }