@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,376 @@
1
+ import * as React from 'react'
2
+ import * as RechartsPrimitive from 'recharts'
3
+ import { cn } from '@/lib/utils'
4
+
5
+ /**
6
+ * Chart — shadcn-style wrapper over Recharts + 本 DS token
7
+ *
8
+ * 結構對齊 shadcn chart(ChartContainer / ChartTooltipContent / ChartLegendContent),
9
+ * 但所有視覺(tooltip / legend / grid / axis)改用本 DS token。
10
+ *
11
+ * ── Color mapping ──
12
+ * ChartConfig.{key}.color 接受 2 種形式:
13
+ * 1. CSS var 字串('var(--chart-1)' 等)
14
+ * 2. 任何合法 CSS color
15
+ * 預設建議使用 --chart-1..5(本 DS 提供的 5 色類別 token)
16
+ *
17
+ * ── 視覺 token ──
18
+ * Tooltip: bg-surface-raised / border-border / shadow-[elevation-200] / rounded-md
19
+ * Legend text: text-fg-secondary / text-caption
20
+ * Grid: stroke-divider
21
+ * Axis tick: text-fg-muted / text-caption
22
+ */
23
+
24
+ export type ChartConfig = {
25
+ [k in string]: {
26
+ label?: React.ReactNode
27
+ icon?: React.ComponentType
28
+ } & (
29
+ | { color?: string; theme?: never }
30
+ | { color?: never; theme: Record<'light' | 'dark', string> }
31
+ )
32
+ }
33
+
34
+ type ChartContextProps = { config: ChartConfig }
35
+
36
+ const ChartContext = React.createContext<ChartContextProps | null>(null)
37
+
38
+ function useChart() {
39
+ const ctx = React.useContext(ChartContext)
40
+ if (!ctx) throw new Error('useChart 必須在 <ChartContainer> 內使用')
41
+ return ctx
42
+ }
43
+
44
+ // ── ChartContainer ─────────────────────────────────────────────────────────
45
+
46
+ interface ChartContainerProps extends React.ComponentProps<'div'> {
47
+ config: ChartConfig
48
+ children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children']
49
+ }
50
+
51
+ const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
52
+ ({ id, className, children, config, ...props }, ref) => {
53
+ const uniqueId = React.useId()
54
+ const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
55
+ // Memoize provider value(2026-04-22 D3 perf audit):避免 render 重建 wrapper object
56
+ const ctxValue = React.useMemo(() => ({ config }), [config])
57
+
58
+ return (
59
+ <ChartContext.Provider value={ctxValue}>
60
+ <div
61
+ ref={ref}
62
+ data-chart={chartId}
63
+ className={cn(
64
+ // 整體視覺套用本 DS typography + token
65
+ // 預設 aspect-video(16:9)— Recharts ResponsiveContainer 需 parent 有高度。
66
+ // Consumer 若需其他比例,包 <AspectRatio ratio={n}> 覆寫(AspectRatio 的 padding-bottom 高度會蓋過此 class)。
67
+ 'flex aspect-video justify-center text-caption',
68
+ // recharts 內部預設樣式覆寫:grid / axis / tooltip shadow 等
69
+ "[&_.recharts-cartesian-grid_line]:stroke-divider",
70
+ "[&_.recharts-cartesian-axis-tick_text]:fill-fg-muted",
71
+ "[&_.recharts-polar-grid_[stroke='#ccc']]:stroke-divider",
72
+ "[&_.recharts-reference-line_[stroke='#ccc']]:stroke-divider",
73
+ "[&_.recharts-dot[stroke='#fff']]:stroke-transparent",
74
+ "[&_.recharts-layer]:outline-none",
75
+ "[&_.recharts-sector[stroke='#fff']]:stroke-transparent",
76
+ "[&_.recharts-sector]:outline-none",
77
+ "[&_.recharts-surface]:outline-none",
78
+ className,
79
+ )}
80
+ {...props}
81
+ >
82
+ <ChartStyle id={chartId} config={config} />
83
+ <RechartsPrimitive.ResponsiveContainer>
84
+ {children}
85
+ </RechartsPrimitive.ResponsiveContainer>
86
+ </div>
87
+ </ChartContext.Provider>
88
+ )
89
+ },
90
+ )
91
+ ChartContainer.displayName = 'ChartContainer'
92
+
93
+ // ── ChartStyle ──────────────────────────────────────────────────────────────
94
+ // 將 config 內每個 key 的 color 注入 scoped CSS variable(`--color-{key}`),
95
+ // 供 Recharts `fill={`var(--color-${key})`}` 直接消費。
96
+
97
+ const THEMES = { light: '', dark: '[data-theme=dark] ' } as const
98
+
99
+ function ChartStyle({ id, config }: { id: string; config: ChartConfig }) {
100
+ const entries = Object.entries(config).filter(([, v]) => 'color' in v || 'theme' in v)
101
+ if (entries.length === 0) return null
102
+
103
+ return (
104
+ <style
105
+ dangerouslySetInnerHTML={{
106
+ __html: Object.entries(THEMES)
107
+ .map(([theme, prefix]) => {
108
+ const vars = entries
109
+ .map(([key, item]) => {
110
+ const color =
111
+ 'theme' in item ? item.theme?.[theme as keyof typeof item.theme] : item.color
112
+ return color ? ` --color-${key}: ${color};` : null
113
+ })
114
+ .filter(Boolean)
115
+ .join('\n')
116
+ return `${prefix}[data-chart=${id}] {\n${vars}\n}`
117
+ })
118
+ .join('\n'),
119
+ }}
120
+ />
121
+ )
122
+ }
123
+
124
+ // ── ChartTooltip / ChartTooltipContent ─────────────────────────────────────
125
+
126
+ const ChartTooltip = RechartsPrimitive.Tooltip
127
+
128
+ type RechartsTooltipPayloadItem = {
129
+ value?: string | number
130
+ name?: string | number
131
+ dataKey?: string | number
132
+ color?: string
133
+ payload?: unknown
134
+ [key: string]: unknown
135
+ }
136
+
137
+ interface ChartTooltipContentProps extends Omit<React.ComponentProps<'div'>, 'color'> {
138
+ active?: boolean
139
+ payload?: RechartsTooltipPayloadItem[]
140
+ label?: unknown
141
+ labelFormatter?: (value: unknown, payload: RechartsTooltipPayloadItem[]) => React.ReactNode
142
+ labelClassName?: string
143
+ formatter?: (
144
+ value: unknown,
145
+ name: unknown,
146
+ item: RechartsTooltipPayloadItem,
147
+ index: number,
148
+ payload: unknown,
149
+ ) => React.ReactNode
150
+ color?: string
151
+ hideLabel?: boolean
152
+ hideIndicator?: boolean
153
+ indicator?: 'line' | 'dot' | 'dashed'
154
+ nameKey?: string
155
+ labelKey?: string
156
+ }
157
+
158
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
159
+ const ChartTooltipContent = React.forwardRef<HTMLDivElement, ChartTooltipContentProps>(
160
+ (
161
+ {
162
+ active,
163
+ payload,
164
+ className,
165
+ indicator = 'dot',
166
+ hideLabel = false,
167
+ hideIndicator = false,
168
+ label,
169
+ labelFormatter,
170
+ labelClassName,
171
+ formatter,
172
+ color,
173
+ nameKey,
174
+ labelKey,
175
+ },
176
+ ref,
177
+ ) => {
178
+ const { config } = useChart()
179
+
180
+ const tooltipLabel = React.useMemo(() => {
181
+ if (hideLabel || !payload?.length) return null
182
+ const [item] = payload
183
+ const key = `${labelKey || item.dataKey || item.name || 'value'}`
184
+ const itemConfig = getPayloadConfig(config, item, key)
185
+ const value =
186
+ !labelKey && typeof label === 'string'
187
+ ? config[label as keyof typeof config]?.label || label
188
+ : itemConfig?.label
189
+ if (labelFormatter) return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
190
+ if (!value) return null
191
+ return <div className={cn('font-medium', labelClassName)}>{value}</div>
192
+ }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
193
+
194
+ if (!active || !payload?.length) return null
195
+
196
+ const nestLabel = payload.length === 1 && indicator !== 'dot'
197
+
198
+ return (
199
+ <div
200
+ ref={ref}
201
+ className={cn(
202
+ 'grid min-w-32 items-start gap-1.5 rounded-md border border-border bg-surface-raised px-2.5 py-1.5 text-caption shadow-[var(--elevation-200)]',
203
+ className,
204
+ )}
205
+ >
206
+ {!nestLabel ? tooltipLabel : null}
207
+ <div className="grid gap-1.5">
208
+ {payload.map((item, index) => {
209
+ const key = `${nameKey || item.name || item.dataKey || 'value'}`
210
+ const itemConfig = getPayloadConfig(config, item, key)
211
+ const indicatorColor = color || (item.payload as { fill?: string })?.fill || item.color
212
+
213
+ return (
214
+ <div
215
+ key={item.dataKey || index}
216
+ className={cn(
217
+ 'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-fg-muted',
218
+ indicator === 'dot' && 'items-center',
219
+ )}
220
+ >
221
+ {formatter && item?.value !== undefined && item.name ? (
222
+ formatter(item.value, item.name, item, index, item.payload)
223
+ ) : (
224
+ <>
225
+ {itemConfig?.icon ? (
226
+ <itemConfig.icon />
227
+ ) : (
228
+ !hideIndicator && (
229
+ <div
230
+ className={cn('shrink-0 rounded-xs border-(--color-border) bg-(--color-bg)', {
231
+ 'h-2.5 w-2.5': indicator === 'dot',
232
+ 'w-1': indicator === 'line',
233
+ 'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
234
+ 'my-0.5': nestLabel && indicator === 'dashed',
235
+ })}
236
+ style={
237
+ {
238
+ '--color-bg': indicatorColor,
239
+ '--color-border': indicatorColor,
240
+ } as React.CSSProperties
241
+ }
242
+ />
243
+ )
244
+ )}
245
+ <div
246
+ className={cn(
247
+ 'flex flex-1 justify-between leading-none',
248
+ nestLabel ? 'items-end' : 'items-center',
249
+ )}
250
+ >
251
+ <div className="grid gap-1.5">
252
+ {nestLabel ? tooltipLabel : null}
253
+ <span className="text-fg-secondary">{itemConfig?.label || item.name}</span>
254
+ </div>
255
+ {item.value && (
256
+ <span className="text-foreground font-mono font-medium tabular-nums">
257
+ {item.value.toLocaleString()}
258
+ </span>
259
+ )}
260
+ </div>
261
+ </>
262
+ )}
263
+ </div>
264
+ )
265
+ })}
266
+ </div>
267
+ </div>
268
+ )
269
+ },
270
+ )
271
+ ChartTooltipContent.displayName = 'ChartTooltipContent'
272
+
273
+ // ── ChartLegend / ChartLegendContent ───────────────────────────────────────
274
+
275
+ const ChartLegend = RechartsPrimitive.Legend
276
+
277
+ type RechartsLegendPayloadItem = {
278
+ value?: unknown
279
+ dataKey?: string | number
280
+ color?: string
281
+ payload?: unknown
282
+ [key: string]: unknown
283
+ }
284
+
285
+ interface ChartLegendContentProps extends React.ComponentProps<'div'> {
286
+ payload?: RechartsLegendPayloadItem[]
287
+ verticalAlign?: 'top' | 'middle' | 'bottom'
288
+ hideIcon?: boolean
289
+ nameKey?: string
290
+ }
291
+
292
+ const ChartLegendContent = React.forwardRef<HTMLDivElement, ChartLegendContentProps>(
293
+ ({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey }, ref) => {
294
+ const { config } = useChart()
295
+ if (!payload?.length) return null
296
+
297
+ return (
298
+ <div
299
+ ref={ref}
300
+ className={cn(
301
+ 'flex items-center justify-center gap-4',
302
+ verticalAlign === 'top' ? 'pb-3' : 'pt-3',
303
+ className,
304
+ )}
305
+ >
306
+ {payload.map((item) => {
307
+ const key = `${nameKey || item.dataKey || 'value'}`
308
+ const itemConfig = getPayloadConfig(config, item, key)
309
+ return (
310
+ <div
311
+ key={String(item.value)}
312
+ className="flex items-center gap-1.5 text-fg-secondary [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-fg-muted"
313
+ >
314
+ {itemConfig?.icon && !hideIcon ? (
315
+ <itemConfig.icon />
316
+ ) : (
317
+ <div className="h-2 w-2 shrink-0 rounded-xs" style={{ backgroundColor: item.color }} />
318
+ )}
319
+ {itemConfig?.label}
320
+ </div>
321
+ )
322
+ })}
323
+ </div>
324
+ )
325
+ },
326
+ )
327
+ ChartLegendContent.displayName = 'ChartLegendContent'
328
+
329
+ // ── helpers ────────────────────────────────────────────────────────────────
330
+
331
+ function getPayloadConfig(
332
+ config: ChartConfig,
333
+ payload: unknown,
334
+ key: string,
335
+ ) {
336
+ if (typeof payload !== 'object' || payload === null) return undefined
337
+ const payloadPayload =
338
+ 'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
339
+ ? payload.payload
340
+ : undefined
341
+ let configLabelKey: string = key
342
+ if (key in (payload as Record<string, unknown>) && typeof (payload as Record<string, unknown>)[key] === 'string') {
343
+ configLabelKey = (payload as Record<string, string>)[key]
344
+ } else if (payloadPayload && key in payloadPayload && typeof (payloadPayload as Record<string, unknown>)[key] === 'string') {
345
+ configLabelKey = (payloadPayload as Record<string, string>)[key]
346
+ }
347
+ return configLabelKey in config ? config[configLabelKey] : config[key]
348
+ }
349
+
350
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
351
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
352
+ export const chartMeta = {
353
+ component: 'Chart',
354
+ family: null, // non-family composite / overlay / layout
355
+ variants: {
356
+
357
+ },
358
+ sizes: {
359
+
360
+ },
361
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
362
+ tokens: {
363
+ bg: ['bg-surface-raised', 'bg-transparent'],
364
+ fg: ['text-fg-muted', 'text-fg-secondary', 'text-foreground'],
365
+ ring: [],
366
+ },
367
+ } as const
368
+
369
+ export {
370
+ ChartContainer,
371
+ ChartTooltip,
372
+ ChartTooltipContent,
373
+ ChartLegend,
374
+ ChartLegendContent,
375
+ ChartStyle,
376
+ }
@@ -0,0 +1,94 @@
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
+ // ── CheckboxGroupContext ────────────────────────────────────────────────────
6
+ // 讓內部 `<Checkbox>` 知道「我在 CheckboxGroup 裡」→ 即使 Field context 也存在,
7
+ // checkbox 仍然保留自己的 label(每個 checkbox 是 group 的一個選項,FieldLabel 只是群組名稱)。
8
+ //
9
+ // 反之:Checkbox **單獨**塞進 Field(無 CheckboxGroup 包)時,Checkbox 才忽略自己的 label
10
+ // 讓 FieldLabel 接管 —— 那是「binary toggle in Field」的場景。
11
+ //
12
+ // 這是 AR50 的根因:沒這個 context 前,CheckboxGroup 內部的 Checkbox 會誤以為「被 Field 包了」
13
+ // 就把 label 全丟掉,導致 Sheet 範例的 Checkboxes 全部沒 label。
14
+ export const CheckboxGroupContext = React.createContext<{ inGroup: true } | null>(null)
15
+
16
+ /**
17
+ * CheckboxGroup — 多選 Checkbox 的 layout primitive
18
+ *
19
+ * ── Canonical 鐵律(2026-04-21 user 明示 + codified)──
20
+ *
21
+ * **垂直 CheckboxGroup 的 item 之間沒有外部 gap**。間距完全靠每個 Checkbox 內部的
22
+ * `SelectionItem py = (field-height - 1lh) / 2` 公式生成 —— 單行高度對齊 field-height,
23
+ * 多 row stacked 時 row-to-row 自然有 py × 2 的呼吸空間。
24
+ *
25
+ * **禁止**外層加 `gap-y-*` / `space-y-*` / margin —— 會 double padding,違反 canonical。
26
+ *
27
+ * ── 對齊 RadioGroup canonical ──
28
+ * Radix `RadioGroup.Root` 預設用 `grid` (無 gap);本元件垂直也用 `grid`(無 gap),
29
+ * horizontal 用 `flex flex-wrap gap-4`(短 label 並排才需水平 gap)。
30
+ *
31
+ * ── 為什麼 vertical 不給 gap 也能好看 ──
32
+ * SelectionItem py 的公式讓每個 Checkbox row 的「單行高度 = field-height」。row 堆疊時
33
+ * 相鄰 row 的 py 相加 = 2×py ≈ 10-12px 真實 row-to-row 視覺呼吸空間。Atlassian / Ant /
34
+ * Chakra / GitHub CheckboxGroup 皆同流派 —— row 高度定義 gap,不加外部 gap。
35
+ *
36
+ * ── 本 session 曾經的錯誤 + 釐清 ──
37
+ * 早先曾以為「gap 要加」是因為「row 黏在一起」——但實際根因是 `Checkbox` 在 Field
38
+ * context 內誤吞自己的 label(見 checkbox.tsx 的 CheckboxGroupContext 修正)。
39
+ * label 回來後 row 自然撐開,不需要 gap。修正後的 canonical 鐵律:**zero gap,
40
+ * 間距由 SelectionItem py 獨家擁有**。
41
+ *
42
+ * ── fieldLayout:block ──
43
+ * 在 `<Field orientation="horizontal">` 內,control area auto `items-start` +
44
+ * padding-top 公式對齊第一個 item 的 label 第一行(見 field.spec.md)。
45
+ *
46
+ * ── 用法範例 ──
47
+ * <CheckboxGroup>
48
+ * <Checkbox label="待處理" defaultChecked />
49
+ * <Checkbox label="進行中" defaultChecked />
50
+ * <Checkbox label="已完成" />
51
+ * </CheckboxGroup>
52
+ *
53
+ * <CheckboxGroup orientation="horizontal">
54
+ * <Checkbox label="Email" />
55
+ * <Checkbox label="Push" />
56
+ * <Checkbox label="SMS" />
57
+ * </CheckboxGroup>
58
+ */
59
+
60
+ export interface CheckboxGroupProps extends React.HTMLAttributes<HTMLDivElement> {
61
+ /**
62
+ * 排列方向。
63
+ * - `vertical`(預設):多選項目堆疊,row 間距由 SelectionItem 擁有(外層 0 gap)
64
+ * - `horizontal`:選項並排,gap-4 分隔(短 label 場景,如「Email / Push / SMS」)
65
+ */
66
+ orientation?: 'vertical' | 'horizontal'
67
+ }
68
+
69
+ // Module-level 常數(2026-04-22 D3 perf audit):provider value 無狀態,hoist 避免 render 重建
70
+ const CHECKBOX_GROUP_CTX_VALUE = { inGroup: true } as const
71
+
72
+ const CheckboxGroup = React.forwardRef<HTMLDivElement, CheckboxGroupProps>(
73
+ ({ className, orientation = 'vertical', ...props }, ref) => (
74
+ <CheckboxGroupContext.Provider value={CHECKBOX_GROUP_CTX_VALUE}>
75
+ <div
76
+ ref={ref}
77
+ role="group"
78
+ className={cn(
79
+ // 垂直 CheckboxGroup:zero gap(間距由 SelectionItem py 獨家擁有,見 docblock canonical)
80
+ // 水平:短 label 並排需水平 gap-4(label 沒有 py 擴散,需要顯式 gap)
81
+ orientation === 'vertical' ? 'grid' : 'flex flex-wrap gap-4',
82
+ className
83
+ )}
84
+ {...props}
85
+ />
86
+ </CheckboxGroupContext.Provider>
87
+ )
88
+ )
89
+ CheckboxGroup.displayName = 'CheckboxGroup'
90
+ // Field layout declaration:block primitive(多項堆疊)——進入 <Field> 時
91
+ // control area 自動切 items-start + padding-top 公式。對齊 RadioGroup 做法。
92
+ ;(CheckboxGroup as unknown as { fieldLayout: 'block' }).fieldLayout = 'block'
93
+
94
+ export { CheckboxGroup }