@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,430 @@
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 { Plus, Search } from 'lucide-react'
4
+ import type { LucideIcon } from 'lucide-react'
5
+ import { cn } from '@/lib/utils'
6
+ import type { AvatarData } from '@/design-system/components/Avatar/avatar'
7
+ import { Popover, PopoverContent, PopoverTrigger } from '@/design-system/components/Popover/popover'
8
+ import { Command, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from '@/design-system/components/Command/command'
9
+ import { Command as CommandPrimitive } from 'cmdk'
10
+ import { MenuItem, MenuFooter } from '@/design-system/components/Menu/menu-item'
11
+ import { Empty } from '@/design-system/components/Empty/empty'
12
+ import { CircularProgress } from '@/design-system/components/CircularProgress/circular-progress'
13
+ import { OVERLAY_SIDE_OFFSET } from '@/design-system/tokens/elevation/overlay-geometry'
14
+ import { getMenuListMinHeight } from '@/design-system/components/Field/field-types'
15
+ import { RowSizeProvider } from '@/design-system/patterns/element-anatomy/item-anatomy'
16
+ import { applySelectAll, clearSelection } from '@/design-system/lib/multi-select-ordering'
17
+ import { ICON_SIZE } from '@/design-system/tokens/uiSize/icon-size'
18
+
19
+ /**
20
+ * SelectMenu — Popover + Command 組成的完整下拉選單
21
+ *
22
+ * ── 功能 ──
23
+ * 單選 / 多選、搜尋過濾、分組、可建立新選項(creatable)
24
+ * 多選有 footer「全部」checkbox
25
+ *
26
+ * ── 架構 ──
27
+ * Popover(浮動容器)
28
+ * └── Command(cmdk,搜尋 + 鍵盤導覽)
29
+ * ├── CommandInput(搜尋框)
30
+ * ├── CommandList(選項列表)
31
+ * │ └── CommandGroup → MenuItem
32
+ * └── Footer(多選全選)
33
+ */
34
+
35
+ // ── Types ──
36
+
37
+ export interface SelectMenuOption {
38
+ value: string
39
+ label: string
40
+ description?: string
41
+ icon?: LucideIcon
42
+ avatar?: AvatarData
43
+ disabled?: boolean
44
+ group?: string
45
+ }
46
+
47
+ export interface SelectMenuGroupConfig {
48
+ key: string
49
+ label: string
50
+ }
51
+
52
+ type SizeKey = 'sm' | 'md' | 'lg'
53
+
54
+ // ── Component ──
55
+
56
+ export interface SelectMenuProps {
57
+ /** 選項列表 */
58
+ options: SelectMenuOption[]
59
+ /** 群組定義(key 對應 option.group) */
60
+ groups?: SelectMenuGroupConfig[]
61
+
62
+ /** 當前值(單選 string,多選 string[]) */
63
+ value?: string | string[] | null
64
+ /** 值變更 callback */
65
+ onValueChange?: (value: string | string[]) => void
66
+
67
+ /** 多選模式 */
68
+ multiple?: boolean
69
+ /** 顯示搜尋框 */
70
+ searchable?: boolean
71
+ /** 可建立新選項 */
72
+ creatable?: boolean
73
+ /** 建立新選項 callback */
74
+ onCreate?: (value: string) => void
75
+ /** creatable 的 label 格式,預設 '直接使用「{query}」' */
76
+ createLabel?: (query: string) => string
77
+
78
+ /** 觸發元件(asChild) */
79
+ children: React.ReactNode
80
+ /** 搜尋框 placeholder */
81
+ searchPlaceholder?: string
82
+ /** 空選項提示 */
83
+ emptyText?: string
84
+ /** Loading 狀態(2026-05-15 audit B fix per user verbatim「dropdown 隨時可開,讀取在 panel 中間 CircularProgress」)
85
+ * true → render `<Empty icon={<CircularProgress size={48}/>} description={loadingText} />` 取代 options;
86
+ * trigger 不變,user 隨時可開 dropdown。對齊 MUI Autocomplete `loadingText` dropdown-body + Ant Select
87
+ * loading idiom + DS 既有 `empty.spec.md:182` 「全頁 loading = Empty + CircularProgress compose」SSOT。
88
+ */
89
+ loading?: boolean
90
+
91
+ /** 尺寸 */
92
+ size?: SizeKey
93
+ /** 對齊方式 */
94
+ align?: 'start' | 'end'
95
+ /** 列表最少顯示幾行選項高度(預設 3),影響空狀態最小高度 */
96
+ minRows?: number
97
+ /** 最小寬度(px),預設跟隨觸發元件 */
98
+ minWidth?: number
99
+
100
+ /** 受控 open 狀態 */
101
+ open?: boolean
102
+ /** 預設打開(uncontrolled initial state)— 2026-05-15 audit Dim 26 V1 fix per user verbatim「A:1」approval */
103
+ defaultOpen?: boolean
104
+ /** open 狀態變更 callback */
105
+ onOpenChange?: (open: boolean) => void
106
+
107
+ /** 自訂選項 label 渲染(預設渲染 option.label 純文字) */
108
+ renderLabel?: (option: SelectMenuOption) => React.ReactNode
109
+ /** 攔截 PopoverContent 的 onOpenAutoFocus(如 Select searchable 需阻止 focus 搶走) */
110
+ onOpenAutoFocus?: (e: Event) => void
111
+
112
+ /**
113
+ * Popover 內容容器的 DOM id。Combobox / 自定 trigger 用 `aria-controls` 指向此 id 時,
114
+ * 需傳入相同 id 讓 AT 能找到對應的 listbox。
115
+ */
116
+ contentId?: string
117
+
118
+ className?: string
119
+ }
120
+
121
+ // shadcn canonical:forwardRef + displayName 統一。SelectMenu 是 Popover + Command
122
+ // composite,自身無 DOM host(trigger 由 consumer 以 asChild children 提供),ref 簽名
123
+ // 保留但不附著(consumer 想取 trigger DOM 直接在 children 上自己 ref)。className 合併到
124
+ // PopoverContent(contextually 最接近 user-facing surface)。
125
+ const SelectMenu = React.forwardRef<HTMLElement, SelectMenuProps>(function SelectMenu({
126
+ options,
127
+ groups,
128
+ value,
129
+ onValueChange,
130
+ multiple = false,
131
+ searchable = false,
132
+ creatable = false,
133
+ onCreate,
134
+ createLabel = (q) => `直接使用「${q}」`,
135
+ children,
136
+ searchPlaceholder = '搜尋…', // i18n-allow: DS default; consumer override via searchPlaceholder prop
137
+ emptyText = '沒有符合的選項', // i18n-allow: DS default; consumer override via emptyText prop
138
+ loading = false,
139
+ size = 'md',
140
+ align = 'start',
141
+ minRows = 3,
142
+ minWidth,
143
+ open: controlledOpen,
144
+ defaultOpen,
145
+ onOpenChange: controlledOnOpenChange,
146
+ renderLabel,
147
+ onOpenAutoFocus,
148
+ contentId,
149
+ className,
150
+ }, _ref) {
151
+ // ── State ──
152
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false)
153
+ const open = controlledOpen ?? internalOpen
154
+ const setOpen = controlledOnOpenChange ?? setInternalOpen
155
+ const [search, setSearch] = React.useState('')
156
+
157
+ // ── Value helpers ──
158
+ const selectedValues = React.useMemo<string[]>(() => {
159
+ if (value == null) return []
160
+ return Array.isArray(value) ? value : [value]
161
+ }, [value])
162
+
163
+ const isSelected = React.useCallback(
164
+ (v: string) => selectedValues.includes(v),
165
+ [selectedValues]
166
+ )
167
+
168
+ const handleSelect = React.useCallback(
169
+ (optionValue: string) => {
170
+ if (multiple) {
171
+ const next = isSelected(optionValue)
172
+ ? selectedValues.filter((v) => v !== optionValue)
173
+ : [...selectedValues, optionValue]
174
+ onValueChange?.(next)
175
+ } else {
176
+ onValueChange?.(optionValue)
177
+ setOpen(false)
178
+ }
179
+ },
180
+ [multiple, selectedValues, isSelected, onValueChange, setOpen]
181
+ )
182
+
183
+ // ── Multi-select: select all ──
184
+ const selectableOptions = React.useMemo(
185
+ () => options.filter((o) => !o.disabled),
186
+ [options]
187
+ )
188
+
189
+ const allState: boolean | 'indeterminate' = React.useMemo(() => {
190
+ if (!multiple) return false
191
+ const count = selectableOptions.filter((o) => isSelected(o.value)).length
192
+ if (count === 0) return false
193
+ if (count === selectableOptions.length) return true
194
+ return 'indeterminate'
195
+ }, [multiple, selectableOptions, isSelected])
196
+
197
+ // 2026-05-16 SSOT canonical fix(Claude+Codex M31 Round 4 共識 + user verbatim「就照你們
198
+ // 的共識做到完美確保有 SSOT」):
199
+ //
200
+ // 原 fully-replace `selectableOptions.map(v)` = source order reset,但**Ant Design 跨元件 grep
201
+ // 證據顯示 source-reset 沒 Ant precedent**(Transfer + Table rowSelection 都是 preserve+append)。
202
+ // 改 `applySelectAll(selectedValues, all)` SSOT primitive 對齊 Ant Transfer canonical:
203
+ // `Array.from(new Set([...prevKeys, ...keys]))` — preserve existing + append unselected。
204
+ //
205
+ // SSOT in `@/design-system/lib/multi-select-ordering` — 未來新 multi-select with Select All
206
+ // footer 必 consume 此 primitive(hook `check_select_all_canonical.sh` 機械強制),
207
+ // 不再各自 reimplement → 防 ordering policy drift。
208
+ const handleSelectAll = React.useCallback(() => {
209
+ if (!multiple) return
210
+ if (allState === true) {
211
+ onValueChange?.(clearSelection())
212
+ } else {
213
+ onValueChange?.(applySelectAll(selectedValues, selectableOptions.map((o) => o.value)))
214
+ }
215
+ }, [multiple, allState, selectableOptions, selectedValues, onValueChange])
216
+
217
+ // ── Creatable ──
218
+ const showCreate = React.useMemo(() => {
219
+ if (!creatable || !search.trim()) return false
220
+ return !options.some(
221
+ (o) => o.label.toLowerCase() === search.trim().toLowerCase()
222
+ )
223
+ }, [creatable, search, options])
224
+
225
+ // ── Grouping ──
226
+ const groupedOptions = React.useMemo(() => {
227
+ if (!groups?.length) return [{ key: '__default', label: '', options }]
228
+ const grouped = groups.map((g) => ({
229
+ ...g,
230
+ options: options.filter((o) => o.group === g.key),
231
+ }))
232
+ const ungrouped = options.filter((o) => !o.group)
233
+ if (ungrouped.length) {
234
+ grouped.unshift({ key: '__default', label: '', options: ungrouped })
235
+ }
236
+ return grouped
237
+ }, [groups, options])
238
+
239
+ // ── Reset search on close ──
240
+ React.useEffect(() => {
241
+ if (!open) setSearch('')
242
+ }, [open])
243
+
244
+ // RowSizeProvider 讓 PopoverContent 子樹內任何 <ItemIcon> / <ItemAvatar> /
245
+ // <ItemInlineAction> 都自動讀到對的 size,跟 SidebarProvider / TreeView 同一條規則。
246
+ // (注:Popover 透過 Portal 渲染,context 仍然會跨 portal 傳遞——React context 是 tree-based
247
+ // 不是 DOM-based,Portal 不影響 context propagation)
248
+ return (
249
+ <Popover open={open} onOpenChange={setOpen}>
250
+ <PopoverTrigger asChild>{children}</PopoverTrigger>
251
+ <RowSizeProvider value={size}>
252
+ <PopoverContent
253
+ id={contentId}
254
+ // w-auto override PopoverContent default w-72(rich-popover canonical)— SelectMenu 走「跟 trigger 同寬」
255
+ // canonical(spec L72)。minWidth = max(trigger-width, 240px sensible-min)— 對齊 shadcn / Material / Ant
256
+ // select dropdown 共識(2026-05-04 D1 verify SelectMenu spec implementation)。
257
+ className={cn(
258
+ 'p-0 w-auto rounded-lg border border-border bg-surface-raised overflow-hidden',
259
+ className
260
+ )}
261
+ style={{
262
+ boxShadow: 'var(--elevation-200)',
263
+ minWidth: minWidth ?? 'max(var(--radix-popover-trigger-width), 15rem)',
264
+ }}
265
+ align={align}
266
+ sideOffset={OVERLAY_SIDE_OFFSET}
267
+ onOpenAutoFocus={onOpenAutoFocus}
268
+ // **2026-05-07 v15.16 nested portal fix**:Tag dismiss inside trigger
269
+ // 區的 OverflowIndicator HoverCard popup(獨立 Radix portal,DOM 不在
270
+ // PopoverContent 內)— Radix DismissableLayer document-level outside
271
+ // detection 跨 portal 視為「outside」→ SelectMenu 被誤關閉。
272
+ // 攔 `onPointerDownOutside`,檢查 click target 是否在另一個 Radix portal
273
+ // 內,是 → preventDefault 取消 close。對齊 Ant Design Select multiSelect
274
+ // tagRender 行為(連續移除不關 dropdown)。
275
+ // SSOT propagation:fix 在 SelectMenu level → Combobox / 其他 SelectMenu
276
+ // consumer 自動受益。
277
+ // **2026-05-07 v15.16 nested portal fix**:Tag dismiss inside trigger 區的
278
+ // OverflowIndicator HoverCard popup(獨立 Radix portal,DOM 不在 SelectMenu
279
+ // PopoverContent 內)— Radix DismissableLayer document-level pointerdown +
280
+ // focusin 偵測「outside」→ SelectMenu 被誤關閉。
281
+ // 攔 `onInteractOutside`(統一 pointerdown + focusin),檢查 click target 是否
282
+ // 在另一個 Radix portal wrapper(`[data-radix-popper-content-wrapper]`),
283
+ // 是 → preventDefault 取消 close。對齊 Ant Design Select multiSelect tagRender
284
+ // 行為(連續移除不關 dropdown)。
285
+ // SSOT propagation:fix 在 SelectMenu level → Combobox / 所有 SelectMenu
286
+ // consumer 自動受益。
287
+ onInteractOutside={(e) => {
288
+ const target = e.detail.originalEvent.target as HTMLElement | null
289
+ if (target?.closest('[data-radix-popper-content-wrapper]')) {
290
+ e.preventDefault()
291
+ }
292
+ }}
293
+ >
294
+ <Command shouldFilter={searchable} className="bg-transparent">
295
+ {searchable && (
296
+ <div className={cn(
297
+ 'flex items-center gap-2 px-3 py-1 border-b border-divider',
298
+ size === 'lg' ? 'min-h-[calc(var(--field-height-lg)+8px)]'
299
+ : size === 'sm' ? 'min-h-[calc(var(--field-height-sm)+8px)]'
300
+ : 'min-h-[calc(var(--field-height-md)+8px)]',
301
+ )}>
302
+ <Search size={ICON_SIZE[size as 'sm' | 'md' | 'lg']} className="shrink-0 text-fg-muted" aria-hidden />
303
+ <CommandPrimitive.Input
304
+ placeholder={searchPlaceholder}
305
+ value={search}
306
+ onValueChange={setSearch}
307
+ className={cn(
308
+ 'flex w-full bg-transparent outline-none placeholder:text-fg-muted',
309
+ // M24 disabled state precedence:disabled 時 placeholder 切 fg-disabled(audit dim 34)
310
+ 'disabled:placeholder:text-fg-disabled disabled:text-fg-disabled disabled:cursor-not-allowed',
311
+ size === 'lg' ? 'text-body-lg leading-compact' : 'text-body leading-compact',
312
+ )}
313
+ />
314
+ </div>
315
+ )}
316
+ {/* **2026-05-07 v15.13 R2 fix**:minHeight 從 CommandList 搬到 CommandEmpty。
317
+ 原本 CommandList 永遠套 `minHeight = field-height × minRows + 16px`,結果
318
+ user 過濾出 < minRows 個 match 時 list 底下空一片(eg. 打 'c' 出 2 個 match
319
+ 卻撐高到 3 row 容量,1 row 留白)。 Fix:只有 empty state 才需要 minHeight 撐
320
+ 起 placeholder 視覺;有 results 時 CommandList 自然 fit content。 */}
321
+ <CommandList className="relative">
322
+ <CommandEmpty
323
+ className="flex items-center justify-center"
324
+ style={{ minHeight: getMenuListMinHeight(size, minRows) }}
325
+ >
326
+ {loading
327
+ ? <Empty icon={<CircularProgress size={48}/>} className="py-6" />
328
+ : <Empty description={emptyText} className="py-6" />}
329
+ </CommandEmpty>
330
+
331
+ {groupedOptions.map((group, gi) => (
332
+ <React.Fragment key={group.key}>
333
+ {gi > 0 && <CommandSeparator />}
334
+ <CommandGroup className="p-0 py-2">
335
+ {group.label && (
336
+ <MenuItem size={size} header>{group.label}</MenuItem>
337
+ )}
338
+ {group.options.map((opt) => (
339
+ <CommandItem
340
+ key={opt.value}
341
+ value={opt.label}
342
+ keywords={opt.description ? [opt.description] : undefined}
343
+ disabled={opt.disabled}
344
+ onSelect={() => handleSelect(opt.value)}
345
+ className="p-0 rounded-none data-[selected=true]:bg-transparent"
346
+ >
347
+ <MenuItem
348
+ size={size}
349
+ startIcon={opt.icon}
350
+ avatar={opt.avatar}
351
+ description={opt.description}
352
+ checkbox={multiple}
353
+ checked={isSelected(opt.value)}
354
+ selected={!multiple && isSelected(opt.value)}
355
+ disabled={opt.disabled}
356
+ >
357
+ {renderLabel ? renderLabel(opt) : opt.label}
358
+ </MenuItem>
359
+ </CommandItem>
360
+ ))}
361
+ </CommandGroup>
362
+ </React.Fragment>
363
+ ))}
364
+
365
+ {/* Creatable item */}
366
+ {showCreate && (
367
+ <>
368
+ <CommandSeparator />
369
+ <CommandGroup className="p-0 py-2">
370
+ <CommandItem
371
+ value={search}
372
+ onSelect={() => {
373
+ onCreate?.(search.trim())
374
+ setSearch('')
375
+ }}
376
+ className="p-0 rounded-none data-[selected=true]:bg-transparent"
377
+ >
378
+ <MenuItem size={size} startIcon={Plus}>
379
+ {createLabel(search.trim())}
380
+ </MenuItem>
381
+ </CommandItem>
382
+ </CommandGroup>
383
+ </>
384
+ )}
385
+ </CommandList>
386
+
387
+ {/* Multi-select footer: Select All
388
+ - 沒有選項時不顯示(selectableOptions.length === 0)
389
+ - 搜尋有文字時不顯示(search 非空 = 使用者在找特定項目,「全選」沒意義) */}
390
+ {multiple && selectableOptions.length > 0 && !search && (
391
+ <MenuFooter>
392
+ <MenuItem
393
+ size={size}
394
+ checkbox
395
+ checked={allState}
396
+ onClick={handleSelectAll}
397
+ >
398
+ 全部
399
+ </MenuItem>
400
+ </MenuFooter>
401
+ )}
402
+ </Command>
403
+ </PopoverContent>
404
+ </RowSizeProvider>
405
+ </Popover>
406
+ )
407
+ })
408
+
409
+ SelectMenu.displayName = 'SelectMenu'
410
+
411
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
412
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
413
+ export const selectMenuMeta = {
414
+ component: 'SelectMenu',
415
+ family: 4,
416
+ variants: {
417
+
418
+ },
419
+ sizes: {
420
+
421
+ },
422
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
423
+ tokens: {
424
+ bg: ['bg-surface-raised', 'bg-transparent'],
425
+ fg: ['text-fg-muted'],
426
+ ring: [],
427
+ },
428
+ } as const
429
+
430
+ export { SelectMenu }
@@ -0,0 +1,261 @@
1
+ import * as React from 'react'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import type { LucideIcon } from 'lucide-react'
4
+ import { cn } from '@/lib/utils'
5
+ import { Avatar, type AvatarData } from '@/design-system/components/Avatar/avatar'
6
+ import { ICON_SIZE, AVATAR_SIZE } from '@/design-system/patterns/element-anatomy/item-anatomy'
7
+
8
+ // ── Selection Item Styles ───────────────────────────────────────────────────
9
+ // Checkbox 和 RadioGroup 共用的 item 佈局。
10
+ //
11
+ // 結構(item-anatomy.spec.md 4-slot 模型):
12
+ // [control] [optional prefix(icon|avatar)] [content(label/desc)] [optional suffix]
13
+ //
14
+ // padding 公式:py = (field-height - 1lh) / 2
15
+ // - 單行時 item 高度 = field-height(對齊同 size 的 Input)
16
+ // - 多行時 padding 不變(文字間距一致)
17
+ // - density 切換時 field-height 自動調整,padding 跟著算
18
+ //
19
+ // 容器設 text-body / text-body-lg 建立 1lh context(div 上正常繼承)。
20
+ //
21
+ // ── 為什麼 NOT 消費 ROW_PADDING_BY_SIZE(item-anatomy.tsx SSOT,2026-04-24 consolidation)──
22
+ // menu / sidebar / tree 3 cva 統一消費 ROW_PADDING_BY_SIZE;SelectionItem 刻意不消費,
23
+ // 因 typography 不同(mode 差異,非 drift):
24
+ // - ROW_PADDING_BY_SIZE:`text-body leading-compact`(scanning mode,緊湊)
25
+ // - SelectionItem:`text-body`(**無 leading-compact** — reading mode,Checkbox/Radio 搭配
26
+ // 較長 label + description,需預設 1.5 leading 而非 1.3 compact)
27
+ // py 公式本身相同 — 若 field-height token 變動,本檔需手動同步(contained,由本註解 anchor 追)。
28
+
29
+ // code-quality-allow: dead-export — public API surface — consumer-exposed for future use
30
+ export const selectionItemStyles = cva(
31
+ 'flex items-start gap-2',
32
+ {
33
+ variants: {
34
+ size: {
35
+ sm: 'text-body py-[calc((var(--field-height-sm)_-_1lh)_/_2)]',
36
+ md: 'text-body py-[calc((var(--field-height-md)_-_1lh)_/_2)]',
37
+ lg: 'text-body-lg py-[calc((var(--field-height-lg)_-_1lh)_/_2)]',
38
+ },
39
+ },
40
+ defaultVariants: {
41
+ size: 'md',
42
+ },
43
+ }
44
+ )
45
+
46
+ type SizeKey = 'sm' | 'md' | 'lg'
47
+
48
+ // Avatar 尺寸 + Icon 尺寸從 item-layout module 共用,不在此 re-declare(避免漂移)
49
+ // AVATAR_SIZE / ICON_SIZE 都是 item-layout 的 canonical 常數。
50
+ //
51
+ // SelectionItem 跟 MenuItem 的差異:SelectionItem 有 control(checkbox/radio)。
52
+ // block 模式時 **control 跟 prefix 一起走 block 高度**——兩者都在 text block center,
53
+ // 維持「selection + identity」是一組的視覺語意,不會歪斜。
54
+ const AVATAR_PX = AVATAR_SIZE
55
+
56
+ // ── Block 對齊容器 ──
57
+ // sm/md: reading mode (body 14/1.5 + body 14/1.5) — gap token `reading`
58
+ // lg: reading-lg mode (body-lg 16/1.5 + body 14/1.5) — gap token `reading-lg`
59
+ // desc 永遠 body(14) line-height;`1lh` 會 resolve 到 label 的 line-height(sm/md=21, lg=24)
60
+ const blockAlignClass: Record<SizeKey, string> = {
61
+ sm: 'h-[calc(1lh+var(--item-gap-label-desc-reading)+var(--font-body-size)*1.5)]',
62
+ md: 'h-[calc(1lh+var(--item-gap-label-desc-reading)+var(--font-body-size)*1.5)]',
63
+ lg: 'h-[calc(1lh+var(--item-gap-label-desc-reading-lg)+var(--font-body-size)*1.5)]',
64
+ }
65
+
66
+ // ── Selection Item ──────────────────────────────────────────────────────────
67
+ // 通用 item 行:control + 可選 prefix(icon/avatar) + label + description。
68
+ // control 永遠包在 h-[1lh] 容器內,對齊第一行 label。
69
+ // prefix 走 24px 閾值規則,各自獨立對齊。
70
+
71
+ export interface SelectionItemProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof selectionItemStyles> {
72
+ /** Checkbox 或 RadioGroupItem 元素(永遠存在,永遠 inline 對齊) */
73
+ control: React.ReactNode
74
+ /** Label 文字 */
75
+ label: React.ReactNode
76
+ /** 描述文字(fg-secondary;reading mode 永遠 14px) */
77
+ description?: React.ReactNode
78
+ /**
79
+ * 可選的左側 icon(在 control 之後、label 之前)。LucideIcon 型別,元件內部控制尺寸
80
+ * (16/16/20px @ sm/md/lg)。**永遠 inline 對齊第一行 label**(icon ≤24px)。
81
+ * 與 `avatar` 互斥。
82
+ */
83
+ icon?: LucideIcon
84
+ /**
85
+ * 可選的左側 avatar(在 control 之後、label 之前)。`AvatarData` 資料型別,元件內部渲染 Avatar。
86
+ * 尺寸由 `description` 自動決定(跟 MenuItem 同 convention):
87
+ * - 無 desc → inline(20/24/24px),跟 control 同步在 label 第一行
88
+ * - 有 desc → block(32/32/40px),跟 control 同步在 text block center
89
+ *
90
+ * Block 模式時 **control(checkbox/radio)也一起走 block 高度**——兩者都在
91
+ * text block center,不會歪斜。與 `icon` 互斥。
92
+ */
93
+ avatar?: AvatarData
94
+ /** htmlFor(label 指向 control 的 id) */
95
+ htmlFor?: string
96
+ /** disabled 狀態影響 label 顏色 */
97
+ disabled?: boolean
98
+ /**
99
+ * Label 最大行數(line-clamp 截斷)。
100
+ *
101
+ * - `undefined`(預設 prop 值未傳)→ 套用元件預設 `'none'`(form 欄位允許任意長度)
102
+ * - 數字 → 截斷到該行數
103
+ * - `'none'` → 明確不截斷(語意等同預設)
104
+ *
105
+ * 為什麼用 `'none'` 而不是 `undefined`?React props 的 destructure default 在
106
+ * `undefined` 時會接管,要明確覆寫必須用非 undefined 的 sentinel。
107
+ */
108
+ labelMaxLines?: number | 'none'
109
+ /**
110
+ * Description 最大行數。預設 `'none'`(不截)。
111
+ */
112
+ descMaxLines?: number | 'none'
113
+ className?: string
114
+ }
115
+
116
+ /** 把 maxLines 轉成 line-clamp class;'none' / 0 → 空字串 */
117
+ function lineClampClass(maxLines: number | 'none'): string {
118
+ if (maxLines === 'none' || !maxLines) return ''
119
+ if (maxLines === 1) return 'line-clamp-1'
120
+ if (maxLines === 2) return 'line-clamp-2'
121
+ if (maxLines === 3) return 'line-clamp-3'
122
+ if (maxLines === 4) return 'line-clamp-4'
123
+ if (maxLines === 5) return 'line-clamp-5'
124
+ if (maxLines === 6) return 'line-clamp-6'
125
+ return ''
126
+ }
127
+
128
+ // ── PrefixSlot — 24px 閾值規則 ──
129
+ // icon(永遠 ≤24px)→ inline;avatar + 無 desc → inline;avatar + 有 desc → block(centered on text block)
130
+ type PrefixSlotProps = {
131
+ icon: LucideIcon | undefined
132
+ avatar: AvatarData | undefined
133
+ sizeKey: SizeKey
134
+ alignClass: string
135
+ avatarPx: number
136
+ disabled: boolean | undefined
137
+ }
138
+ function PrefixSlot({ icon: Icon, avatar, sizeKey, alignClass, avatarPx, disabled }: PrefixSlotProps) {
139
+ if (!Icon && !avatar) return null
140
+ return (
141
+ <div className={cn(alignClass, 'flex items-center shrink-0')}>
142
+ {Icon && (
143
+ <Icon
144
+ size={ICON_SIZE[sizeKey]}
145
+ className={cn('shrink-0', disabled && 'text-fg-disabled')}
146
+ aria-hidden
147
+ />
148
+ )}
149
+ {!Icon && avatar && (
150
+ <Avatar src={avatar.src} alt={avatar.alt} color={avatar.color} hoverCard={avatar.hoverCard} size={avatarPx} />
151
+ )}
152
+ </div>
153
+ )
154
+ }
155
+
156
+ // ── ContentSlot — label + optional description ──
157
+ // inline-style fontSize 繞 tailwind-merge 把 text-body / text-fg-secondary 誤判同組衝突的 bug
158
+ type ContentSlotProps = {
159
+ htmlFor: string | undefined
160
+ disabled: boolean | undefined
161
+ label: React.ReactNode
162
+ description: React.ReactNode | undefined
163
+ sizeKey: SizeKey
164
+ labelClampClass: string
165
+ descClampClass: string
166
+ }
167
+ function ContentSlot({ htmlFor, disabled, label, description, sizeKey, labelClampClass, descClampClass }: ContentSlotProps) {
168
+ return (
169
+ <div className="min-w-0 flex-1">
170
+ <label
171
+ htmlFor={htmlFor}
172
+ className={cn(
173
+ 'cursor-pointer block break-words',
174
+ labelClampClass,
175
+ disabled ? 'text-fg-disabled cursor-not-allowed' : 'text-foreground',
176
+ )}
177
+ >
178
+ {label}
179
+ </label>
180
+ {description && (
181
+ <p
182
+ className={cn(
183
+ sizeKey === 'lg' ? 'mt-[var(--item-gap-label-desc-reading-lg)]' : 'mt-[var(--item-gap-label-desc-reading)]',
184
+ 'break-words',
185
+ descClampClass,
186
+ disabled ? 'text-fg-disabled' : 'text-fg-secondary',
187
+ )}
188
+ style={{ fontSize: 'var(--font-body-size)' }}
189
+ >
190
+ {description}
191
+ </p>
192
+ )}
193
+ </div>
194
+ )
195
+ }
196
+
197
+ const SelectionItem = React.forwardRef<HTMLDivElement, SelectionItemProps>(
198
+ (
199
+ {
200
+ control,
201
+ label,
202
+ description,
203
+ icon: Icon,
204
+ avatar,
205
+ htmlFor,
206
+ disabled,
207
+ size,
208
+ labelMaxLines = 'none',
209
+ descMaxLines = 'none',
210
+ className,
211
+ ...props
212
+ },
213
+ ref
214
+ ) => {
215
+ const sizeKey: SizeKey = size ?? 'md'
216
+ if (process.env.NODE_ENV !== 'production' && Icon && avatar) {
217
+ // eslint-disable-next-line no-console
218
+ console.warn('[SelectionItem] `icon` 和 `avatar` 互斥,只會渲染 icon。')
219
+ }
220
+ // Block 對齊:control 跟 prefix(avatar)一起走 block 高度,「selection + identity」視覺單元不歪斜
221
+ const useBlock = !!avatar && !Icon && !!description && AVATAR_PX.block[sizeKey] > 24
222
+ const avatarPx = useBlock ? AVATAR_PX.block[sizeKey] : AVATAR_PX.inline[sizeKey]
223
+ const alignClass = useBlock ? blockAlignClass[sizeKey] : 'h-[1lh]'
224
+
225
+ return (
226
+ <div ref={ref} className={cn(selectionItemStyles({ size }), className)} {...props}>
227
+ <div className={cn(alignClass, 'flex items-center shrink-0')}>{control}</div>
228
+ <PrefixSlot icon={Icon} avatar={avatar} sizeKey={sizeKey} alignClass={alignClass} avatarPx={avatarPx} disabled={disabled} />
229
+ <ContentSlot
230
+ htmlFor={htmlFor}
231
+ disabled={disabled}
232
+ label={label}
233
+ description={description}
234
+ sizeKey={sizeKey}
235
+ labelClampClass={lineClampClass(labelMaxLines)}
236
+ descClampClass={lineClampClass(descMaxLines)}
237
+ />
238
+ </div>
239
+ )
240
+ }
241
+ )
242
+ SelectionItem.displayName = 'SelectionItem'
243
+
244
+ // Story auto-compile metadata — Phase 1+2 migration
245
+ export const selectionItemMeta = {
246
+ component: 'SelectionItem',
247
+ family: 2,
248
+ variants: {},
249
+ sizes: {
250
+ sm: {},
251
+ md: {},
252
+ lg: {},
253
+ },
254
+ defaultSize: 'md',
255
+ states: ['default', 'hover', 'selected', 'focus-visible', 'disabled'],
256
+ tokens: {
257
+ fg: ['text-foreground', 'text-fg-secondary', 'text-fg-disabled'],
258
+ },
259
+ } as const
260
+
261
+ export { SelectionItem }