@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,237 @@
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 CheckboxPrimitive from "@radix-ui/react-checkbox"
4
+ import { Check, Minus } from "lucide-react"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+
7
+ import { cn } from "@/lib/utils"
8
+ import type { FieldMode, FieldVariant } from "@/design-system/components/Field/field-types"
9
+ import { useFieldContext } from "@/design-system/components/Field/field-context"
10
+ import { SelectionItem } from "@/design-system/components/SelectionControl/selection-item"
11
+ import { CheckboxGroupContext } from "./checkbox-group"
12
+
13
+ // ── Variants ────────────────────────────────────────────────────────────────
14
+ // 三種尺寸(sm/md=16px, lg=20px),對齊 icon 系統與 SelectionItem。
15
+
16
+ const checkboxVariants = cva(
17
+ [
18
+ 'grid place-content-center shrink-0 rounded-md',
19
+ 'border border-border bg-surface',
20
+ 'transition-colors duration-150',
21
+ 'hover:border-border-hover',
22
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
23
+ 'data-[state=checked]:bg-primary data-[state=checked]:text-on-emphasis data-[state=checked]:border-primary',
24
+ 'data-[state=checked]:hover:bg-primary-hover data-[state=checked]:hover:border-primary-hover',
25
+ 'data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-on-emphasis data-[state=indeterminate]:border-primary',
26
+ 'data-[state=indeterminate]:hover:bg-primary-hover data-[state=indeterminate]:hover:border-primary-hover',
27
+ 'disabled:cursor-not-allowed disabled:bg-disabled disabled:border-transparent disabled:hover:border-transparent',
28
+ 'disabled:data-[state=checked]:bg-disabled disabled:data-[state=checked]:text-fg-disabled disabled:data-[state=checked]:border-transparent',
29
+ 'disabled:data-[state=indeterminate]:bg-disabled disabled:data-[state=indeterminate]:text-fg-disabled disabled:data-[state=indeterminate]:border-transparent',
30
+ // readOnly:鎖定互動但維持 checked/unchecked 視覺
31
+ 'data-[readonly=true]:pointer-events-none data-[readonly=true]:cursor-default',
32
+ 'data-[readonly=true]:hover:border-border',
33
+ ],
34
+ {
35
+ variants: {
36
+ size: {
37
+ sm: 'h-4 w-4',
38
+ md: 'h-4 w-4',
39
+ lg: 'h-5 w-5',
40
+ },
41
+ },
42
+ defaultVariants: {
43
+ size: 'md',
44
+ },
45
+ }
46
+ )
47
+
48
+ // ── Check Icon Size ─────────────────────────────────────────────────────────
49
+ const checkIconSize: Record<string, number> = { sm: 12, md: 12, lg: 16 }
50
+
51
+ // ── Check Icon Stroke Weight ────────────────────────────────────────────────
52
+ // 16px 以下 icon 視覺不夠顯眼 → 用較粗 stroke 補償。Lucide 預設 strokeWidth=2 在
53
+ // 12px 下 render 約 1px 線寬,視覺偏細;加粗到 3.5(render ≈ 1.75px)才有足夠視覺權重。
54
+ // 16px 用 2.5(render ≈ 1.67px)讓 checked 態的 check icon 夠顯眼。
55
+ //
56
+ // 為什麼不是 3 / 2:本 session 實測 3 / 2 在 storybook 上兩個 size 的 render 線寬差僅
57
+ // 0.17px(1.5 vs 1.33),使用者肉眼看不出差異(image #64 回報)。改為 3.5 / 2.5:
58
+ // - md 12px × 3.5 → 1.75px 線寬
59
+ // - lg 16px × 2.5 → 1.67px 線寬
60
+ // 兩者仍接近但 md 的線寬 **絕對值** 跟 16px 預設(1.33)有更明顯差異,視覺上「小 check 更粗」。
61
+ //
62
+ // 世界級對照:iOS HIG / Material 3 / Polaris 的 checkmark 在 <16px 下皆加粗 compensate。
63
+ // 為什麼不用 Lucide absoluteStrokeWidth:那保持「絕對 px 粗細」,我們反而要「小尺寸比例更粗」。
64
+ //
65
+ // Check 與 Minus(indeterminate)共用此規則;Switch 的 SPECS.checkStroke 採同樣值。
66
+ // 2026-05-18 簡化 per user 視覺證「sm/md 3.5 vs lg 2.5 看不出差別」(image #64 + 2nd round
67
+ // 圖一 video proof)+「做完」approval:
68
+ // - 原 {3.5, 3.5, 2.5} → effective render thickness 1.75 / 1.75 / 1.67 = 跨 size 差 0.08px(視覺看不出)
69
+ // - 改 {3, 3, 2.5} 保留 sm/md 小尺寸 legibility insurance(per iOS HIG / Material 3 cite)
70
+ // + lg 仍稍粗於 Lucide default 2(保留 compensation 主旨,但不過度差異化)
71
+ const checkStrokeWidth: Record<string, number> = { sm: 3, md: 3, lg: 2.5 }
72
+
73
+ // ── Types ───────────────────────────────────────────────────────────────────
74
+
75
+ export interface CheckboxProps
76
+ extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
77
+ VariantProps<typeof checkboxVariants> {
78
+ /**
79
+ * Inline label。提供時 Checkbox 自動透過 SelectionItem 包裝,
80
+ * 套用 text-body / text-foreground / disabled 色 的 codified 樣式。
81
+ * 在 <Field> context 內時此 prop 會被忽略(由 FieldLabel 接管)。
82
+ */
83
+ label?: React.ReactNode
84
+ /**
85
+ * Inline description(secondary 文字)。須與 label 搭配使用。
86
+ * 套用 text-body / text-fg-secondary 樣式。
87
+ * 在 <Field> context 內時此 prop 會被忽略(由 FieldDescription 接管)。
88
+ */
89
+ description?: React.ReactNode
90
+ /**
91
+ * readonly 模式:鎖定互動但維持 checked/unchecked 視覺正確。
92
+ * 與 disabled 的差異:readonly 不降色(可讀),disabled 降色(弱化)。
93
+ * 用於表單 readonly 呈現、DataTable cell 非編輯態。
94
+ */
95
+ readOnly?: boolean
96
+ /**
97
+ * Field mode(2026-05-05 Phase B3 align):
98
+ * edit — 一般可互動 checkbox(預設)
99
+ * display — **純展示**:渲染 ✓ / —(無互動 primitive、無 input chrome);
100
+ * 對齊 Carbon read-only / DataTable boolean cell 場景。取代既有 BooleanDisplay。
101
+ * readonly — 同 readOnly prop:checkbox 視覺保留 + 鎖互動 + a11y readonly signal
102
+ * disabled — 同 disabled prop:降色 + 鎖互動
103
+ */
104
+ mode?: FieldMode
105
+ /**
106
+ * Visual chrome — checkbox 本體無 input wrapper variant,本 prop 對 checkbox 主體無視覺影響;
107
+ * 為對齊 Field 4-mode + chrome 透傳契約而保留(M19 一致性)。
108
+ */
109
+ variant?: FieldVariant
110
+ }
111
+
112
+ // ── Component ───────────────────────────────────────────────────────────────
113
+
114
+ const Checkbox = React.forwardRef<
115
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
116
+ CheckboxProps
117
+ >(
118
+ (
119
+ {
120
+ className,
121
+ size,
122
+ label,
123
+ description,
124
+ readOnly = false,
125
+ disabled,
126
+ mode,
127
+ // chrome 對 Checkbox 主體無視覺影響(無 input wrapper)— 接收純為 prop 一致性;destructure 防 leak 到 DOM。
128
+ variant: _chrome,
129
+ id: idProp,
130
+ ...props
131
+ },
132
+ ref
133
+ ) => {
134
+ const sizeKey = size ?? 'md'
135
+ const iconPx = checkIconSize[sizeKey]
136
+ const iconStrokeWidth = checkStrokeWidth[sizeKey]
137
+
138
+ // ── mode='display' ─────────────────────────────────────────────────────
139
+ // 純展示模式:無互動 primitive、無 input variant,渲染 ✓ / —。
140
+ // 取代既有 BooleanDisplay(2026-05-05 Phase B3 retire)— 該 helper 已併入 Checkbox。
141
+ // 顯示規則:checked=true → ✓(foreground);其他(false / null / undefined / indeterminate)→ —(fg-muted)
142
+ if (mode === 'display') {
143
+ const isChecked = props.checked === true
144
+ return isChecked
145
+ ? <span className="text-foreground">✓</span>
146
+ : <span className="text-fg-muted">—</span>
147
+ }
148
+
149
+ // Field context:Checkbox 單獨塞進 Field(binary toggle)時,忽略自己的 label 讓 FieldLabel 接管
150
+ //
151
+ // **例外**:Checkbox 是 CheckboxGroup 的 child 時(multi-select 情境),**每個 checkbox
152
+ // 的 label 是它自己的選項名**,FieldLabel 只是群組名稱 — 此時 label **必須保留**,
153
+ // 不能被 Field context 吞掉。AR50 的根因就是這個 branch 之前誤把 group 內的 checkbox
154
+ // label 全清空,導致 sheet 內 3 個 checkbox 沒 label。
155
+ const fieldCtx = useFieldContext()
156
+ const checkboxGroupCtx = React.useContext(CheckboxGroupContext)
157
+ const insideField = fieldCtx?.hasFieldWrapper === true
158
+ const insideGroup = checkboxGroupCtx?.inGroup === true
159
+ const shouldSuppressLabel = insideField && !insideGroup
160
+ const effectiveLabel = shouldSuppressLabel ? undefined : label
161
+ const effectiveDescription = shouldSuppressLabel ? undefined : description
162
+
163
+ // Id 連結
164
+ //
165
+ // ── 2026-04-21 bug fix ──
166
+ // 原本:`idProp ?? fieldCtx?.id ?? generatedId`。
167
+ // 在 Field 內 fieldCtx.id 存在,CheckboxGroup 所有 children 共用同一個 id →
168
+ // 每個 checkbox 的 `<label htmlFor={sameId}>` 全指向第一個 checkbox →
169
+ // **點任何 label 都只開關第一個 checkbox(real bug)**。
170
+ //
171
+ // 修法:group 內的 checkbox 強制用 generatedId(唯一),不沿用 Field id;
172
+ // solo in Field(binary toggle)才沿用 fieldCtx.id 讓 FieldLabel htmlFor 生效。
173
+ const generatedId = React.useId()
174
+ const inputId = idProp ?? (insideGroup ? generatedId : (fieldCtx?.id ?? generatedId))
175
+
176
+ const rootEl = (
177
+ <CheckboxPrimitive.Root
178
+ id={inputId}
179
+ ref={ref}
180
+ disabled={disabled}
181
+ aria-readonly={readOnly || undefined}
182
+ data-readonly={readOnly || undefined}
183
+ tabIndex={readOnly ? -1 : undefined}
184
+ aria-describedby={fieldCtx?.descriptionId}
185
+ className={cn(checkboxVariants({ size }), className)}
186
+ {...props}
187
+ >
188
+ <CheckboxPrimitive.Indicator className="grid place-content-center text-current">
189
+ {props.checked === 'indeterminate'
190
+ ? <Minus style={{ width: iconPx, height: iconPx }} strokeWidth={iconStrokeWidth} />
191
+ : <Check style={{ width: iconPx, height: iconPx }} strokeWidth={iconStrokeWidth} />
192
+ }
193
+ </CheckboxPrimitive.Indicator>
194
+ </CheckboxPrimitive.Root>
195
+ )
196
+
197
+ // 無 label → 只渲染 checkbox 本體
198
+ if (effectiveLabel == null) return rootEl
199
+
200
+ // 有 label → 透過 SelectionItem 包裝(control 在左、label+description 在右)
201
+ return (
202
+ <SelectionItem
203
+ control={rootEl}
204
+ label={effectiveLabel}
205
+ description={effectiveDescription}
206
+ htmlFor={inputId}
207
+ disabled={disabled}
208
+ size={sizeKey}
209
+ />
210
+ )
211
+ }
212
+ )
213
+ Checkbox.displayName = CheckboxPrimitive.Root.displayName
214
+
215
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
216
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
217
+ export const checkboxMeta = {
218
+ component: 'Checkbox',
219
+ family: 4,
220
+ variants: {
221
+
222
+ },
223
+ sizes: {
224
+ sm: { fieldHeight: 28, iconSize: 16, typography: 'body' },
225
+ md: { fieldHeight: 32, iconSize: 16, typography: 'body' },
226
+ lg: { fieldHeight: 40, iconSize: 20, typography: 'body' },
227
+ },
228
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
229
+ tokens: {
230
+ bg: ['bg-disabled', 'bg-primary', 'bg-primary-hover', 'bg-surface'],
231
+ fg: ['text-fg-disabled', 'text-fg-secondary', 'text-foreground'],
232
+ ring: ['ring-ring'],
233
+ },
234
+ defaultSize: 'md',
235
+ } as const
236
+
237
+ export { Checkbox, checkboxVariants }
@@ -0,0 +1,359 @@
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 ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
4
+ import { cva } from 'class-variance-authority'
5
+ import type { LucideIcon } from 'lucide-react'
6
+ import { cn } from '@/lib/utils'
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuTrigger,
10
+ DropdownMenuContent,
11
+ DropdownMenuCheckboxItem,
12
+ } from '@/design-system/components/DropdownMenu/dropdown-menu'
13
+ import {
14
+ useScrollEdges,
15
+ useScrollByPage,
16
+ buildFadeMask,
17
+ ARROW_BUTTON_WIDTH,
18
+ OverflowScrollArrow,
19
+ OverflowMenuTriggerButton,
20
+ } from '@/design-system/patterns/horizontal-overflow/horizontal-overflow'
21
+
22
+ /**
23
+ * Chip — Material Design Filter Chip
24
+ *
25
+ * 基於 Radix ToggleGroup,橋接設計系統 token。
26
+ * 必須在 <ChipGroup> 內使用。
27
+ *
28
+ * ── 內部結構(鏡射 Button)──
29
+ * [startIcon?] [<span px-1>label</span>] [<span gap-1>badge? + endIcon?</span>]
30
+ *
31
+ * ── Size ──
32
+ * 單一 size = h-field-sm(28/32 density-aware)
33
+ * 對齊 Material 3 / Atlassian / Polaris 世界級共識
34
+ *
35
+ * ── State ──
36
+ * default bg-surface border-border text-foreground
37
+ * hover border-border-hover(對齊 Input / SegmentedControl)
38
+ * selected bg-primary-subtle border-primary-hover text-primary-hover
39
+ * disabled cursor-not-allowed text-fg-disabled
40
+ *
41
+ * ── 詳見 chip.spec.md ──
42
+ */
43
+
44
+ // ── Chip item ────────────────────────────────────────────────────────────────
45
+
46
+ const chipVariants = cva(
47
+ [
48
+ 'inline-flex items-center justify-center',
49
+ 'h-field-sm px-3 gap-1',
50
+ 'rounded-full border border-border',
51
+ // 預設文字: text-fg-secondary (neutral-8) — 對齊 SegmentedControl / Tabs 未選狀態
52
+ 'bg-surface text-fg-secondary',
53
+ 'text-body leading-compact font-medium whitespace-nowrap',
54
+ 'transition-colors duration-150',
55
+ 'cursor-pointer select-none',
56
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
57
+ // hover(未選):border 加深一階 + 文字轉深,對齊 Input / SegmentedControl hover
58
+ 'hover:border-border-hover hover:text-foreground',
59
+ // selected: 文字 + 邊框都用 primary-hover,底色維持 bg-surface 不變
60
+ // ── pill 風格 canonical 選中規則,跟 SegmentedControl 完全一致:
61
+ // primary-hover 同時染文字和線條;底色不改 (不用 primary-subtle)。
62
+ 'data-[state=on]:border-primary-hover data-[state=on]:text-primary-hover',
63
+ // disabled:cursor-not-allowed + 鎖 hover 不變色
64
+ // 不用 pointer-events-none(否則 cursor-not-allowed 不會顯示)
65
+ 'disabled:cursor-not-allowed disabled:text-fg-disabled',
66
+ 'disabled:hover:border-border disabled:hover:text-fg-disabled',
67
+ ]
68
+ )
69
+
70
+ export interface ChipProps
71
+ extends React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> {
72
+ /** 左側 icon(LucideIcon),最多一個 */
73
+ startIcon?: LucideIcon
74
+ /** 右側 badge(通常是計數指示器)*/
75
+ badge?: React.ReactNode
76
+ /** 右側 icon(少見,通常是 ChevronDown 指示可展開)*/
77
+ endIcon?: LucideIcon
78
+ }
79
+
80
+ const Chip = React.forwardRef<
81
+ React.ElementRef<typeof ToggleGroupPrimitive.Item>,
82
+ ChipProps
83
+ >(({ className, startIcon: StartIcon, badge, endIcon: EndIcon, children, ...props }, ref) => {
84
+ const hasSuffix = badge != null || EndIcon !== undefined
85
+
86
+ return (
87
+ <ToggleGroupPrimitive.Item
88
+ ref={ref}
89
+ className={cn(chipVariants(), className)}
90
+ {...props}
91
+ >
92
+ {StartIcon && <StartIcon size={16} aria-hidden />}
93
+ {children != null && <span className="px-1">{children}</span>}
94
+ {hasSuffix && (
95
+ <span className="inline-flex items-center gap-1">
96
+ {badge}
97
+ {EndIcon && <EndIcon size={16} aria-hidden />}
98
+ </span>
99
+ )}
100
+ </ToggleGroupPrimitive.Item>
101
+ )
102
+ })
103
+ Chip.displayName = 'Chip'
104
+
105
+ // ── ChipGroup ────────────────────────────────────────────────────────────────
106
+
107
+ export type ChipGroupLayout = 'wrap' | 'scroll' | 'menu'
108
+
109
+ export type ChipGroupProps = React.ComponentPropsWithoutRef<
110
+ typeof ToggleGroupPrimitive.Root
111
+ > & {
112
+ /** Overflow 處理模式。預設 `wrap`(塞不下換行)。詳見 chip.spec.md */
113
+ layout?: ChipGroupLayout
114
+ }
115
+
116
+ const ChipGroup = React.forwardRef<HTMLDivElement, ChipGroupProps>(
117
+ ({ layout = 'wrap', className, children, ...props }, ref) => {
118
+ if (layout === 'scroll') {
119
+ return (
120
+ <ScrollChipGroup ref={ref} className={className} {...props}>
121
+ {children}
122
+ </ScrollChipGroup>
123
+ )
124
+ }
125
+ if (layout === 'menu') {
126
+ return (
127
+ <MenuChipGroup ref={ref} className={className} {...props}>
128
+ {children}
129
+ </MenuChipGroup>
130
+ )
131
+ }
132
+ // wrap(預設)
133
+ return (
134
+ <ToggleGroupPrimitive.Root
135
+ ref={ref}
136
+ className={cn('flex flex-wrap gap-2', className)}
137
+ {...props}
138
+ >
139
+ {children}
140
+ </ToggleGroupPrimitive.Root>
141
+ )
142
+ }
143
+ )
144
+ ChipGroup.displayName = 'ChipGroup'
145
+
146
+ // ── Scroll / Menu modes ──
147
+ // Fade mask / arrows / menu trigger 全部從 horizontal-overflow pattern module 取用。
148
+ // 詳見 `patterns/horizontal-overflow/horizontal-overflow.spec.md`。
149
+ //
150
+ // Canonical 規則:所有 overflow affordance 一律是 Button text sm iconOnly,
151
+ // 不論在 Chip / Tab / Step / SegmentedControl。Chip menu trigger 曾經用
152
+ // chip-shape 圓形 button,已改回 text button 對齊 mental model。
153
+
154
+ // ── ScrollChipGroup ──────────────────────────────────────────────────────────
155
+
156
+ const ScrollChipGroup = React.forwardRef<
157
+ HTMLDivElement,
158
+ React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>
159
+ >(({ className, children, ...props }, ref) => {
160
+ const { scrollRef, atStart, atEnd, canScroll } = useScrollEdges<HTMLDivElement>()
161
+ const scrollByPage = useScrollByPage(scrollRef)
162
+ const maskImage = buildFadeMask({
163
+ canScroll,
164
+ atStart,
165
+ atEnd,
166
+ reserveArrowWidth: ARROW_BUTTON_WIDTH,
167
+ })
168
+
169
+ return (
170
+ <div className="relative">
171
+ <div
172
+ ref={scrollRef}
173
+ className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
174
+ style={{ maskImage, WebkitMaskImage: maskImage }}
175
+ >
176
+ <ToggleGroupPrimitive.Root
177
+ ref={ref}
178
+ className={cn('flex flex-nowrap gap-2 w-fit', className)}
179
+ {...props}
180
+ >
181
+ {children}
182
+ </ToggleGroupPrimitive.Root>
183
+ </div>
184
+ {!atStart && canScroll && (
185
+ <OverflowScrollArrow direction="left" onClick={() => scrollByPage('left')} />
186
+ )}
187
+ {!atEnd && canScroll && (
188
+ <OverflowScrollArrow direction="right" onClick={() => scrollByPage('right')} />
189
+ )}
190
+ </div>
191
+ )
192
+ })
193
+ ScrollChipGroup.displayName = 'ScrollChipGroup'
194
+
195
+ // ── MenuChipGroup ────────────────────────────────────────────────────────────
196
+ // Show-all navigator pattern (Chrome tab dropdown / VS Code editor tabs):
197
+ // - Menu 永遠顯示全部 chips,每個都用 DropdownMenuCheckboxItem + checked 反映 selection
198
+ // - 點 menu item = toggle selection + scrollIntoView,把該 chip 捲到中央
199
+ // - 不用動態 overflow 計算,menu 內容穩定
200
+ //
201
+ // Menu trigger 使用 canonical `<OverflowMenuTriggerButton>`(= Button text sm iconOnly
202
+ // + ChevronDown),跟 Tabs menu trigger 完全同一套——overflow affordance 屬於工具層,
203
+ // 不該用 chip 自己的視覺語言(圓形 outlined)去渲染,否則 mental model 錯誤(使用者
204
+ // 會誤以為是第 N+1 個可選 chip)。
205
+ //
206
+ // 過去 Chip menu trigger 曾用 `chipVariants() + aspect-square + p-0` 做成圓形 pill,
207
+ // 已按 `horizontal-overflow.spec.md` 的 canonical 規則改回 text button。
208
+ //
209
+ // Fade mask 仍保留(reserveArrowWidth: 0),軟化內容硬邊。
210
+
211
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
212
+ const MenuChipGroup = React.forwardRef<
213
+ HTMLDivElement,
214
+ React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>
215
+ >(({ className, children, ...props }, ref) => {
216
+ const { scrollRef, atStart, atEnd, canScroll } = useScrollEdges<HTMLDivElement>()
217
+
218
+ // Local ref map — 追蹤每個 chip 的 DOM 元素供 scrollIntoView 使用。
219
+ // 不用 useOverflowIndices 因為 menu 永遠顯示全部, 不需要動態 overflow 計算。
220
+ // code-quality-allow: long-function — helper fn 結構緊密,拆 sub-fn 會跨 fn 傳 state 反而複雜
221
+ const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map())
222
+ // 2026-05-16 audit codex Round 6:capture rAF + cancel on unmount(defensive hygiene)
223
+ const scrollRafIdRef = React.useRef<number>(0)
224
+ React.useEffect(() => () => { if (scrollRafIdRef.current) cancelAnimationFrame(scrollRafIdRef.current) }, [])
225
+ const registerItem = React.useCallback(
226
+ (index: number) => (el: HTMLElement | null) => {
227
+ if (el) itemRefs.current.set(index, el)
228
+ else itemRefs.current.delete(index)
229
+ },
230
+ []
231
+ )
232
+
233
+ const menuMaskImage = buildFadeMask({ canScroll, atStart, atEnd, reserveArrowWidth: 0 })
234
+
235
+ const items = React.useMemo(
236
+ () => React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement[],
237
+ [children]
238
+ )
239
+
240
+ const groupType = (props as { type?: 'single' | 'multiple' }).type ?? 'single'
241
+ const groupValue = (props as { value?: string | string[] }).value
242
+ const groupOnValueChange = (
243
+ props as { onValueChange?: (value: string | string[]) => void }
244
+ ).onValueChange
245
+
246
+ // code-quality-allow: long-function — helper fn 結構緊密,拆 sub-fn 會跨 fn 傳 state 反而複雜
247
+ const isSelected = React.useCallback(
248
+ (chipValue: string): boolean => {
249
+ if (groupType === 'multiple') {
250
+ return Array.isArray(groupValue) && groupValue.includes(chipValue)
251
+ }
252
+ return groupValue === chipValue
253
+ },
254
+ [groupType, groupValue]
255
+ )
256
+
257
+ // code-quality-allow: long-function — multi / single / none selection mode each has 事務性 branch,拆 fn 會跨 fn 傳 chipValue/index/groupOnValueChange state 反而難讀
258
+ const toggleFromMenu = React.useCallback(
259
+ (chipValue: string, index: number) => {
260
+ if (!groupOnValueChange) {
261
+ if (import.meta.env?.DEV) {
262
+ console.warn(
263
+ '[ChipGroup] layout="menu" 需要 controlled 使用(請傳 value + onValueChange),否則 menu items 無法同步主 chip 選擇狀態。'
264
+ )
265
+ }
266
+ return
267
+ }
268
+ if (groupType === 'multiple') {
269
+ const current = (Array.isArray(groupValue) ? groupValue : []) as string[]
270
+ const next = current.includes(chipValue)
271
+ ? current.filter((v) => v !== chipValue)
272
+ : [...current, chipValue]
273
+ ;(groupOnValueChange as (v: string[]) => void)(next)
274
+ } else {
275
+ ;(groupOnValueChange as (v: string) => void)(chipValue)
276
+ }
277
+ // scrollIntoView: 讓剛選中的 chip 出現在視圖中央
278
+ if (scrollRafIdRef.current) cancelAnimationFrame(scrollRafIdRef.current)
279
+ scrollRafIdRef.current = requestAnimationFrame(() => {
280
+ scrollRafIdRef.current = 0
281
+ itemRefs.current.get(index)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
282
+ })
283
+ },
284
+ [groupType, groupValue, groupOnValueChange]
285
+ )
286
+
287
+ const enhancedItems = items.map((child, i) =>
288
+ React.cloneElement(
289
+ child as React.ReactElement<{ ref?: React.Ref<HTMLElement> }>,
290
+ { ref: registerItem(i) }
291
+ )
292
+ )
293
+
294
+ return (
295
+ <div className="flex items-center gap-2">
296
+ <div
297
+ ref={scrollRef}
298
+ className="flex-1 min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
299
+ style={{ maskImage: menuMaskImage, WebkitMaskImage: menuMaskImage }}
300
+ >
301
+ <ToggleGroupPrimitive.Root
302
+ ref={ref}
303
+ className={cn('flex flex-nowrap gap-2 w-fit', className)}
304
+ {...props}
305
+ >
306
+ {enhancedItems}
307
+ </ToggleGroupPrimitive.Root>
308
+ </div>
309
+ {canScroll && (
310
+ <DropdownMenu>
311
+ <DropdownMenuTrigger asChild>
312
+ <OverflowMenuTriggerButton
313
+ aria-label={`選項選單(共 ${items.length} 個)`}
314
+ />
315
+ </DropdownMenuTrigger>
316
+ <DropdownMenuContent align="end">
317
+ {items.map((chip, index) => {
318
+ const chipProps = chip.props as { value?: string; children?: React.ReactNode; disabled?: boolean }
319
+ const chipValue = chipProps.value
320
+ if (typeof chipValue !== 'string') return null
321
+ return (
322
+ <DropdownMenuCheckboxItem
323
+ key={chipValue}
324
+ checked={isSelected(chipValue)}
325
+ disabled={chipProps.disabled}
326
+ onCheckedChange={() => toggleFromMenu(chipValue, index)}
327
+ >
328
+ {chipProps.children}
329
+ </DropdownMenuCheckboxItem>
330
+ )
331
+ })}
332
+ </DropdownMenuContent>
333
+ </DropdownMenu>
334
+ )}
335
+ </div>
336
+ )
337
+ })
338
+ MenuChipGroup.displayName = 'MenuChipGroup'
339
+
340
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
341
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
342
+ export const chipMeta = {
343
+ component: 'Chip',
344
+ family: 3,
345
+ variants: {
346
+
347
+ },
348
+ sizes: {
349
+
350
+ },
351
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
352
+ tokens: {
353
+ bg: ['bg-primary-subtle', 'bg-surface'],
354
+ fg: ['text-fg-disabled', 'text-fg-secondary', 'text-foreground'],
355
+ ring: ['ring-ring'],
356
+ },
357
+ } as const
358
+
359
+ export { Chip, ChipGroup, chipVariants }