@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,295 @@
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 { Star, type LucideIcon } from 'lucide-react'
4
+ import { cn } from '@/lib/utils'
5
+ import { useFieldContext } from '@/design-system/components/Field/field-context'
6
+
7
+ /**
8
+ * Rating — 星星評分元件
9
+ *
10
+ * 世界級對照:Ant Design `<Rate>`、Material MUI `<Rating>`。
11
+ * shadcn 核心沒有 Rating,本元件自建。
12
+ *
13
+ * ── 使用情境 ──
14
+ * - review / feedback:商品評分 / 服務評分(可編輯 + 唯讀兩種)
15
+ * - display:已提交評分的唯讀呈現(商品清單星等)
16
+ *
17
+ * ── 視覺 ──
18
+ * 填色用 `var(--warning)`(yellow-6,世界級黃星 convention;與 warning 語意共用色相
19
+ * 但語境不同,評分 = UX convention color 非 status)。
20
+ * 空色用 `var(--color-neutral-4)`(灰色;與 disabled/empty 同級)。
21
+ *
22
+ * ── 互動 ──
23
+ * interactive(預設):hover 預覽、click 設值、keyboard Left/Right 改值
24
+ * readOnly:純顯示,不響應 hover / click
25
+ *
26
+ * ── 精度 ──
27
+ * precision="full"(預設) — 整星(1, 2, 3, 4, 5)
28
+ * precision="half" — 半星(0.5, 1, 1.5, 2, 2.5, ..., 5)
29
+ */
30
+
31
+ // ── Icon size canonical(2026-04-21 AR48 修正)──
32
+ //
33
+ // Rating 的「一顆星」視覺重量接近 **Avatar / identity icon**,不是純 inline icon。
34
+ // 理由:
35
+ // - 星星是 filled shape(解析整個 icon 是重量感的一部分),不像純 outline icon 靠 stroke
36
+ // - Field 內 Rating 跟 Avatar / Tag 並排時視覺份量要對齊,否則 row height 一致但 icon 看起來比重量不對
37
+ // - 世界級對照:Ant Rate in Form = 20px、Material MUI Rating fontSize=inherit 預設約 24、Airbnb 評分星 24px
38
+ //
39
+ // 因此 Field 內 Rating icon size 對齊 **item-anatomy inline Avatar sizes**:sm=20 / md=24 / lg=24。
40
+ // 非 icon tier(16/16/20)——star 不是次要 affordance icon,它是主要資料視覺。
41
+ //
42
+ // Container 高度仍對齊 `--field-height-*`(sm=28 / md=32 / lg=36),讓 Rating 可與其他
43
+ // field-height family 元件(Input / Select)並排時 row height 對齊。
44
+ //
45
+ // ── 使用情境 ──
46
+ // - **Standalone**(獨立展示評分,如商品卡 / 評論)→ 預設 `xs`(container 24,icon 20,
47
+ // 對齊 Avatar sm 20px;iOS HIG / Airbnb 商品卡星星 20-24px)
48
+ // - **Field 內**(表單評分欄位)→ 跟 Field 尺寸對齊(sm=20 / md=24 / lg=24,default md)
49
+ const SIZE_PX = { xs: 20, sm: 20, md: 24, lg: 24 } as const
50
+ const CONTAINER_HEIGHT: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
51
+ xs: 'h-field-xs',
52
+ sm: 'h-field-sm',
53
+ md: 'h-field-md',
54
+ lg: 'h-field-lg',
55
+ }
56
+
57
+ export interface RatingProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
58
+ /** 當前評分(0 ~ max) */
59
+ value?: number
60
+ /** 預設值(uncontrolled) */
61
+ defaultValue?: number
62
+ /** 評分改變 callback */
63
+ onChange?: (value: number) => void
64
+ /** 滿分(預設 5) */
65
+ max?: number
66
+ /** 尺寸。standalone 建議 xs(24px);Field 內跟隨 Field size 傳 sm/md/lg */
67
+ size?: 'xs' | 'sm' | 'md' | 'lg'
68
+ /** 精度:full = 整星,half = 半星 */
69
+ precision?: 'full' | 'half'
70
+ /** 唯讀(無 hover / click 響應) */
71
+ readOnly?: boolean
72
+ /** 完全停用 */
73
+ disabled?: boolean
74
+ /**
75
+ * Loading 狀態 — 正在取得既有評分 / 正在儲存。
76
+ * 視覺同 disabled(composite 整塊 opacity-disabled)但 semantic 不同:
77
+ * loading = 暫時性等待(aria-busy),disabled = 永久業務規則(aria-disabled)。
78
+ * 詳 rating.spec.md「Interactive vs ReadOnly」+「Loading canonical」
79
+ */
80
+ loading?: boolean
81
+ /** 自訂 icon(預設 Star);傳 LucideIcon */
82
+ icon?: LucideIcon
83
+ /** a11y label(readOnly 時必填,interactive 時建議填) */
84
+ 'aria-label'?: string
85
+ }
86
+
87
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
88
+ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
89
+ (
90
+ {
91
+ value,
92
+ defaultValue = 0,
93
+ onChange,
94
+ max = 5,
95
+ size: sizeProp,
96
+ precision = 'full',
97
+ readOnly = false,
98
+ disabled = false,
99
+ loading = false,
100
+ icon: Icon = Star,
101
+ className,
102
+ ...props
103
+ },
104
+ ref,
105
+ ) => {
106
+ // Context-aware default size(AR31 canonical):
107
+ // - Field 內(有 FieldContext.size) → 跟 Field size 對齊(sm / md / lg)
108
+ // - Standalone(無 Field context) → default `xs`(24px,對齊 Avatar / Tag sm / iOS HIG standalone)
109
+ // consumer 可傳 size 顯式 override。世界級對照:Material Rating standalone 24dp、
110
+ // Ant Rate in Form 跟 Form.itemSize,standalone 24px。
111
+ const fieldCtx = useFieldContext()
112
+ const fieldSize = fieldCtx?.size as ('sm' | 'md' | 'lg' | undefined)
113
+ const size: 'xs' | 'sm' | 'md' | 'lg' =
114
+ sizeProp ?? fieldSize ?? 'xs'
115
+ const [internalValue, setInternalValue] = React.useState(defaultValue)
116
+ const [hoverValue, setHoverValue] = React.useState<number | null>(null)
117
+ const isControlled = value !== undefined
118
+ const currentValue = isControlled ? value : internalValue
119
+ const displayValue = hoverValue ?? currentValue
120
+ const iconPx = SIZE_PX[size]
121
+ const isInteractive = !readOnly && !disabled && !loading
122
+
123
+ const setValue = (v: number) => {
124
+ if (!isControlled) setInternalValue(v)
125
+ onChange?.(v)
126
+ }
127
+
128
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
129
+ if (!isInteractive) return
130
+ const step = precision === 'half' ? 0.5 : 1
131
+ // Full ARIA slider pattern(WAI-ARIA):Arrow / Home / End 支援 — D4 UX audit 2026-04-22
132
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
133
+ e.preventDefault()
134
+ setValue(Math.min(max, currentValue + step))
135
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
136
+ e.preventDefault()
137
+ setValue(Math.max(0, currentValue - step))
138
+ } else if (e.key === 'Home') {
139
+ e.preventDefault()
140
+ setValue(0)
141
+ } else if (e.key === 'End') {
142
+ e.preventDefault()
143
+ setValue(max)
144
+ }
145
+ }
146
+
147
+ return (
148
+ <div
149
+ ref={ref}
150
+ role={isInteractive ? 'slider' : 'img'}
151
+ aria-valuenow={isInteractive ? currentValue : undefined}
152
+ aria-valuemin={isInteractive ? 0 : undefined}
153
+ aria-valuemax={isInteractive ? max : undefined}
154
+ aria-valuetext={isInteractive ? `${currentValue} of ${max} stars` : undefined}
155
+ aria-disabled={disabled || undefined}
156
+ // a11y: aria-readonly 只允許於 slider role(非 img)— axe aria-allowed-attr 2026-04-25
157
+ aria-readonly={isInteractive && readOnly ? true : undefined}
158
+ aria-busy={loading || undefined}
159
+ tabIndex={isInteractive ? 0 : undefined}
160
+ onKeyDown={handleKeyDown}
161
+ onMouseLeave={() => setHoverValue(null)}
162
+ className={cn(
163
+ 'inline-flex items-center gap-1',
164
+ // Container 對齊 field-height family,讓 Rating 可與 Input/Select/Button 並排 row-align
165
+ CONTAINER_HEIGHT[size],
166
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md',
167
+ // disabled 跟 loading 視覺相同(composite uniform dim),semantic 由 aria-disabled / aria-busy 區分
168
+ (disabled || loading) && 'opacity-disabled pointer-events-none',
169
+ className,
170
+ )}
171
+ {...props}
172
+ >
173
+ {Array.from({ length: max }, (_, i) => {
174
+ const starValue = i + 1
175
+ const fillRatio = Math.max(0, Math.min(1, displayValue - i)) // 0..1
176
+ const isHalf = precision === 'half' && fillRatio > 0 && fillRatio < 1
177
+
178
+ return (
179
+ <StarIcon
180
+ key={i}
181
+ Icon={Icon}
182
+ sizePx={iconPx}
183
+ fillRatio={fillRatio}
184
+ isHalf={isHalf}
185
+ interactive={isInteractive}
186
+ onHover={(halfFirst) => {
187
+ if (!isInteractive) return
188
+ const v = precision === 'half' && halfFirst ? starValue - 0.5 : starValue
189
+ setHoverValue(v)
190
+ }}
191
+ onClick={(halfFirst) => {
192
+ if (!isInteractive) return
193
+ const v = precision === 'half' && halfFirst ? starValue - 0.5 : starValue
194
+ setValue(v)
195
+ }}
196
+ />
197
+ )
198
+ })}
199
+ </div>
200
+ )
201
+ },
202
+ )
203
+ Rating.displayName = 'Rating'
204
+
205
+ // ── StarIcon: 單顆星 + half-precision overlay ─────────────────────────────
206
+
207
+ interface StarIconProps {
208
+ Icon: LucideIcon
209
+ sizePx: number
210
+ fillRatio: number // 0..1
211
+ isHalf: boolean
212
+ interactive: boolean
213
+ onHover: (halfFirst: boolean) => void
214
+ onClick: (halfFirst: boolean) => void
215
+ }
216
+
217
+ const FILL_FILLED = 'var(--warning)' // yellow-6 — 黃星 convention
218
+ const FILL_EMPTY = 'var(--divider)' // 灰色空星(neutral-4 借 divider semantic alias,user 2026-05-09 拍板;對齊 Material rgba(0,0,0,0.26) muted-fill canonical)
219
+
220
+ function StarIcon({ Icon, sizePx, fillRatio, isHalf, interactive, onHover, onClick }: StarIconProps) {
221
+ // a11y(2026-04-25 axe nested-interactive fix):inner 點擊目標改 <span>(非 interactive
222
+ // element),不會跟外層 role='slider' 形成 nested-interactive 違規。鍵盤控制統一在外層
223
+ // slider 的 arrow keys,inner 只處理 mouse click 定位。Ant Rate / Material MUI 同模式。
224
+ if (!isHalf) {
225
+ // Full: 一整顆 fill(filled 或 empty)
226
+ const fill = fillRatio >= 1 ? FILL_FILLED : FILL_EMPTY
227
+ return (
228
+ <span
229
+ role="presentation"
230
+ onMouseEnter={interactive ? () => onHover(false) : undefined}
231
+ onClick={interactive ? () => onClick(false) : undefined}
232
+ className={cn(
233
+ 'inline-flex',
234
+ interactive ? 'cursor-pointer' : 'cursor-default',
235
+ )}
236
+ style={{ color: fill }}
237
+ aria-hidden
238
+ >
239
+ {/* stroke="none" 移除 Lucide Star 預設的 outline stroke(1.5px 黑線),
240
+ 讓星星是純 fill-only 的 shape——fill 與 outline 同色視覺上仍有亮度差。
241
+ 世界級對照:Ant Rate / Material MUI Rating 皆純 fill,無 outline stroke。*/}
242
+ <Icon size={sizePx} fill={fill} stroke="none" className="shrink-0" />
243
+ </span>
244
+ )
245
+ }
246
+
247
+ // Half: 兩個重疊 icon,左半 filled / 右半 empty + 兩個 hover zone 切半星
248
+ return (
249
+ <span className="relative inline-flex" style={{ width: sizePx, height: sizePx }}>
250
+ <Icon size={sizePx} fill={FILL_EMPTY} stroke="none" className="absolute inset-0" style={{ color: FILL_EMPTY }} />
251
+ <span className="absolute inset-0 overflow-hidden" style={{ width: sizePx * fillRatio }}>
252
+ <Icon size={sizePx} fill={FILL_FILLED} stroke="none" style={{ color: FILL_FILLED }} />
253
+ </span>
254
+ {interactive && (
255
+ <>
256
+ <span
257
+ role="presentation"
258
+ onMouseEnter={() => onHover(true)}
259
+ onClick={() => onClick(true)}
260
+ className="absolute inset-y-0 left-0 w-1/2 cursor-pointer"
261
+ aria-hidden
262
+ />
263
+ <span
264
+ role="presentation"
265
+ onMouseEnter={() => onHover(false)}
266
+ onClick={() => onClick(false)}
267
+ className="absolute inset-y-0 right-0 w-1/2 cursor-pointer"
268
+ aria-hidden
269
+ />
270
+ </>
271
+ )}
272
+ </span>
273
+ )
274
+ }
275
+
276
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
277
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
278
+ export const ratingMeta = {
279
+ component: 'Rating',
280
+ family: 4,
281
+ variants: {
282
+
283
+ },
284
+ sizes: {
285
+
286
+ },
287
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
288
+ tokens: {
289
+ bg: ['bg-transparent'],
290
+ fg: [],
291
+ ring: ['ring-ring'],
292
+ },
293
+ } as const
294
+
295
+ export { Rating }
@@ -0,0 +1,110 @@
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 ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ /**
7
+ * ScrollArea — 自訂樣式的捲動區(Radix ScrollArea primitive 包裝)
8
+ *
9
+ * 世界級對照:shadcn `ScrollArea` / Ant Design scrollable 容器的 pattern。
10
+ *
11
+ * ── 為什麼需要 ScrollArea ──
12
+ * Native scrollbar 跨 OS 不一致:
13
+ * macOS: overlay(不吃寬度,預設隱藏,滾動時浮出)
14
+ * Windows / Linux: always-visible(永遠吃 ~15-17px 寬度)
15
+ *
16
+ * 結果:同一個 DataTable / Sheet / Dialog 內容在 macOS 看起來對齊,在 Windows
17
+ * 右側被吃 17px 跑版(「Left pinned + Row Actions」那張圖的問題)。
18
+ *
19
+ * ScrollArea 用 Radix 包裝自建 overlay 捲軸 → **跨 OS 一致不吃寬度**,
20
+ * 捲動時浮現(hover / scroll 自動顯示)。
21
+ *
22
+ * ── 何時用 ──
23
+ * - DataTable 橫向捲動(水平跑版最明顯場景)
24
+ * - Sheet / Dialog 垂直內容捲動(body 太長)
25
+ * - Sidebar nav 長列表
26
+ * - 任何「內容可能溢出容器」且「跨 OS 視覺必須一致」的場景
27
+ *
28
+ * ── 何時不用 ──
29
+ * - 全頁捲動(瀏覽器 document scroll,保持 native 即可;ScrollArea 是 sub-region)
30
+ * - 單行 truncate(用 text-overflow:ellipsis 就夠)
31
+ * - 極短內容(不會捲動 → 不需 wrapper)
32
+ */
33
+
34
+ const ScrollArea = React.forwardRef<
35
+ React.ElementRef<typeof ScrollAreaPrimitive.Root>,
36
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
37
+ >(({ className, children, ...props }, ref) => (
38
+ <ScrollAreaPrimitive.Root
39
+ ref={ref}
40
+ className={cn('relative flex flex-col overflow-hidden', className)}
41
+ {...props}
42
+ >
43
+ {/* Viewport canonical(2026-04-23):Root 用 `flex flex-col`,Viewport 用 `flex-1 min-h-0`。
44
+ 原 `h-full` 在 Root 為 flex item(外層 flex-1 min-h-0)時失效 — Chrome 不把
45
+ flex-computed 高度視為 `height: 100%` 的 definite anchor,導致 Viewport 撐成
46
+ content height 而非 parent height → 失去 scroll 能力。
47
+ 改 Root 為 flex container,Viewport 成 flex item,flex algorithm 給 definite height。
48
+ `w-full` 保留(水平維度不受影響)。 */}
49
+ {/* a11y(2026-04-25 axe scrollable-region-focusable fix):Viewport 需 tabIndex=0
50
+ 才能 keyboard focus(Safari 不自動把 scroll container 標 focusable)。focus-
51
+ visible:outline 用 DS focus ring,不破壞視覺。 */}
52
+ <ScrollAreaPrimitive.Viewport
53
+ tabIndex={0}
54
+ className="flex-1 min-h-0 w-full rounded-[inherit] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
55
+ >
56
+ {children}
57
+ </ScrollAreaPrimitive.Viewport>
58
+ <ScrollBar />
59
+ <ScrollAreaPrimitive.Corner />
60
+ </ScrollAreaPrimitive.Root>
61
+ ))
62
+ ScrollArea.displayName = 'ScrollArea'
63
+
64
+ const ScrollBar = React.forwardRef<
65
+ React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
66
+ React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
67
+ >(({ className, orientation = 'vertical', ...props }, ref) => (
68
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
69
+ ref={ref}
70
+ orientation={orientation}
71
+ className={cn(
72
+ 'flex touch-none select-none transition-colors',
73
+ orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
74
+ orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
75
+ className,
76
+ )}
77
+ {...props}
78
+ >
79
+ {/* Thumb 用 scrollbar-thumb / -hover semantic alias(2026-05-09 抽 SSOT,跟 DataTable fake scrollbar
80
+ 共享 token)。Token 值仍 = `--border` / `--border-hover`(neutral-5/-6)— 世界級 SaaS
81
+ (Linear / Notion / Figma / macOS)scrollbar thumb 慣例「很淡、幾乎看不見,hover 略深」。
82
+ 前身直接 `bg-border` borrowing(2026-04 ship)→ semantic alias 收斂 SSOT(避免 thumb 視覺
83
+ 未來演化時誤動 Field/Input/Checkbox border 視覺)。對齊 shadcn ScrollArea source `bg-border`。 */}
84
+ <ScrollAreaPrimitive.ScrollAreaThumb
85
+ className={cn('relative flex-1 rounded-full bg-[var(--scrollbar-thumb)] hover:bg-[var(--scrollbar-thumb-hover)] transition-colors')}
86
+ />
87
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
88
+ ))
89
+ ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
90
+
91
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
92
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
93
+ export const scrollAreaMeta = {
94
+ component: 'ScrollArea',
95
+ family: null, // non-family composite / overlay / layout
96
+ variants: {
97
+
98
+ },
99
+ sizes: {
100
+
101
+ },
102
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
103
+ tokens: {
104
+ bg: [],
105
+ fg: [],
106
+ ring: [],
107
+ },
108
+ } as const
109
+
110
+ export { ScrollArea, ScrollBar }