@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,535 @@
1
+ // code-quality-allow: file-size — foundational composite(Field + FieldLabel + FieldDescription + FieldError + context + 8 layout variants),拆檔會讓 Field 家族互相 import 循環
2
+ import * as React from 'react'
3
+ import { Info as InfoIcon } from 'lucide-react'
4
+ import { cn } from '@/lib/utils'
5
+ import { Tooltip, TooltipTrigger, TooltipContent } from '@/design-system/components/Tooltip/tooltip'
6
+
7
+ /**
8
+ * Field — 表單欄位佈局容器(shadcn Field 風格)
9
+ *
10
+ * ── 定位 ────────────────────────────────────────────────────────────────
11
+ * Field 只負責 **佈局 + 狀態 context**,不擁有任何資料型別邏輯。
12
+ * 每個資料型別對應的 Control(Input、NumberInput、Checkbox、Switch 等)
13
+ * 維持自己的 edit / readonly / disabled 三態,Field 透過 context 把
14
+ * mode / disabled / required / invalid / id 傳給子元件,由子元件決定
15
+ * 如何反映。
16
+ *
17
+ * ── 結構 ────────────────────────────────────────────────────────────────
18
+ * <Field orientation="vertical | horizontal" labelWidth="120px">
19
+ * <FieldLabel>姓名</FieldLabel>
20
+ * <Input value={...} onChange={...} /> ← Control(任何非 label/desc/error 的 child)
21
+ * <FieldDescription>...</FieldDescription>
22
+ * <FieldError>{errors.name}</FieldError>
23
+ * </Field>
24
+ *
25
+ * Control 會自動包在 control area slot(min-h-field-* + items-center),
26
+ * 確保 Checkbox / Switch / Radio 等高度 < field-height 的 primitive
27
+ * 垂直對齊 Input 中線;Input 等自身為 field-height 的 primitive 填滿。
28
+ *
29
+ * ── Horizontal mode 的 label 垂直對齊 ───────────────────────────────────
30
+ * FieldLabel 在 horizontal 模式下使用公式:
31
+ * padding-top: calc((var(--field-height-{size}) - 1lh) / 2)
32
+ *
33
+ * 單行 label → 文字第一行與 input 中線對齊(視覺置中)
34
+ * 多行 label → 第一行仍與 input 中線對齊,其餘行往下流(label 高度超過
35
+ * input 時視覺上仍保持與 input 內容同一基準線)
36
+ *
37
+ * 此公式 tracks field-height 和 line-height 的變動,size 切換或字體
38
+ * 調整時自動連動,不需 JS 測量。
39
+ *
40
+ * ── Horizontal mode 的 label 寬度 ───────────────────────────────────────
41
+ * 透過 labelWidth prop → --field-label-width CSS variable,可以是任何
42
+ * CSS length("120px"、"10rem"、"30%" 等)。預設 "auto" 由 label 內容撐開。
43
+ *
44
+ * ── Required 星號 ──────────────────────────────────────────────────────
45
+ * Field 的 required prop 會透過 context 傳給 FieldLabel 自動渲染 *,
46
+ * 星號為 neutral-7(fg-muted),貼齊 label 文字(無 gap),disabled
47
+ * 時降為 fg-disabled。也可在個別 FieldLabel 覆寫。
48
+ */
49
+
50
+ // ── Types & Context ──
51
+ // Context 定義在 field-context.ts(打斷 circular import)。
52
+ // field.tsx 只 import 不 re-export——consumer 直接從 field-context.ts import useFieldContext。
53
+
54
+ import type { FieldMode, FieldVariant, FieldOrientation, FieldSize, FieldControlLayout, FieldContextValue } from './field-context'
55
+ import { FieldContext, useFieldContext } from './field-context'
56
+
57
+ // ── Internal helpers ────────────────────────────────────────────────────────
58
+
59
+ const MIN_H_CLASS: Record<FieldSize, string> = {
60
+ sm: 'min-h-field-sm',
61
+ md: 'min-h-field-md',
62
+ lg: 'min-h-field-lg',
63
+ }
64
+
65
+ const FIELD_HEIGHT_VAR: Record<FieldSize, string> = {
66
+ sm: 'var(--field-height-sm)',
67
+ md: 'var(--field-height-md)',
68
+ lg: 'var(--field-height-lg)',
69
+ }
70
+
71
+ // Label / Description / Error 的字體固定 text-body (14px),不隨 field size 變。
72
+ // 世界級共識:field size 只影響 input 高度,不影響表單佈局元素的 typography。
73
+ const FIELD_TEXT_CLASS = 'text-body'
74
+
75
+ type SlotKind = 'label' | 'description' | 'error' | 'control'
76
+
77
+ function resolveSlotKind(node: React.ReactNode): SlotKind {
78
+ if (!React.isValidElement(node)) return 'control'
79
+ const displayName = (node.type as { displayName?: string } | null | undefined)?.displayName
80
+ if (displayName === 'FieldLabel') return 'label'
81
+ if (displayName === 'FieldDescription') return 'description'
82
+ if (displayName === 'FieldError') return 'error'
83
+ return 'control'
84
+ }
85
+
86
+ /**
87
+ * 偵測 control children 的 fieldLayout——任一 control 宣告為 'block' 即整個 area 切 block 模式。
88
+ *
89
+ * Convention:block primitive 在自己的元件檔案掛 static `fieldLayout = 'block'` 屬性,
90
+ * Field 在 render 時讀 `child.type.fieldLayout`。預設 'inline'。
91
+ *
92
+ * 為什麼是「任一」而非「全部」:實務上 Field 一個 control area 通常只有一個 control,
93
+ * 但若 consumer 同時放多個 child(例如 RadioGroup + 一段補充文字節點),只要其中有 block
94
+ * primitive,整個 area 就應該以 block 模式佈局,確保第一行對齊正確。
95
+ */
96
+ function detectControlLayout(controlNodes: React.ReactNode[]): FieldControlLayout {
97
+ for (const node of controlNodes) {
98
+ if (!React.isValidElement(node)) continue
99
+ const layout = (node.type as { fieldLayout?: FieldControlLayout } | null | undefined)?.fieldLayout
100
+ if (layout === 'block') return 'block'
101
+ }
102
+ return 'inline'
103
+ }
104
+
105
+ // ── Field ───────────────────────────────────────────────────────────────────
106
+
107
+ export interface FieldProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'id'> {
108
+ id?: string
109
+ mode?: FieldMode
110
+ /**
111
+ * 視覺外殼(2026-05-05)。
112
+ * - `default`(預設)— 含 border + bg(一般 form input)
113
+ * - `bare` — 透明 variant,hover/focus reveal(cell-as-input substrate;VS Code/Figma toolbar idiom)
114
+ *
115
+ * 透傳機制:Field 一次宣告,所有 child Field control 自動繼承(per-control prop override 可覆寫)。
116
+ */
117
+ variant?: FieldVariant
118
+ orientation?: FieldOrientation
119
+ size?: FieldSize
120
+ required?: boolean
121
+ disabled?: boolean
122
+ invalid?: boolean
123
+ /**
124
+ * Horizontal mode 的 label 欄寬度。支援任何 CSS length 值("120px"、"10rem"、"30%"...)。
125
+ * 預設 'auto' 由 label 內容撐開。
126
+ */
127
+ labelWidth?: string
128
+ /**
129
+ * Control area 佈局模型(逃生艙)。
130
+ *
131
+ * 預設由 Field 自動偵測——讀第一個 control child 的 `type.fieldLayout` static 屬性,
132
+ * primitive 沒宣告時視為 `'inline'`。
133
+ *
134
+ * 只有兩種情況需要手動指定:
135
+ * 1. consumer 把自己手寫的 JSX(`<div>` / 函式元件)當 control,系統無法偵測——強制 `'block'`
136
+ * 2. 想覆寫 primitive 的預設(如把 RadioGroup 強制 inline 呈現,罕見)
137
+ */
138
+ controlLayout?: FieldControlLayout
139
+ }
140
+
141
+ // ── FieldGroup Context(cascade horizontal labelWidth)──
142
+ // 同一畫面多個 horizontal Field,label 寬度應統一對齊 → FieldGroup 提供 SSOT。
143
+ // 下面 Field 組件自動 consume,consumer 可用 Field 的 labelWidth prop 覆寫單行。
144
+ interface FieldGroupContextValue {
145
+ horizontalLabelWidth?: string
146
+ }
147
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
148
+ const FieldGroupContext = React.createContext<FieldGroupContextValue>({})
149
+
150
+ const Field = React.forwardRef<HTMLDivElement, FieldProps>(
151
+ (
152
+ {
153
+ id: idProp,
154
+ mode = 'edit',
155
+ variant = 'default',
156
+ orientation = 'vertical',
157
+ size = 'md',
158
+ required = false,
159
+ disabled: disabledProp = false,
160
+ invalid = false,
161
+ labelWidth,
162
+ controlLayout: controlLayoutProp,
163
+ className,
164
+ style,
165
+ children,
166
+ ...props
167
+ },
168
+ ref
169
+ ) => {
170
+ const generatedId = React.useId()
171
+ const id = idProp ?? generatedId
172
+ const labelId = `${id}-label`
173
+ const descriptionId = `${id}-description`
174
+ const errorId = `${id}-error`
175
+
176
+ // FieldGroup cascade:group 的 horizontalLabelWidth 是 fallback,單行 labelWidth 覆寫
177
+ const groupCtx = React.useContext(FieldGroupContext)
178
+ const effectiveLabelWidth = labelWidth ?? groupCtx.horizontalLabelWidth
179
+
180
+ // mode=disabled 與 disabled prop 任一為 true 即視為 disabled
181
+ const disabled = disabledProp || mode === 'disabled'
182
+
183
+ // 把 children 依 slot 類型分組
184
+ const labelNodes: React.ReactNode[] = []
185
+ const controlNodes: React.ReactNode[] = []
186
+ const descriptionNodes: React.ReactNode[] = []
187
+ const errorNodes: React.ReactNode[] = []
188
+
189
+ React.Children.forEach(children, (child) => {
190
+ const slot = resolveSlotKind(child)
191
+ if (slot === 'label') labelNodes.push(child)
192
+ else if (slot === 'description') descriptionNodes.push(child)
193
+ else if (slot === 'error') errorNodes.push(child)
194
+ else controlNodes.push(child)
195
+ })
196
+
197
+ // 解析 control layout:consumer 顯式指定 > primitive 自我宣告 > 預設 inline
198
+ const controlLayout: FieldControlLayout =
199
+ controlLayoutProp ?? detectControlLayout(controlNodes)
200
+
201
+ const contextValue = React.useMemo<FieldContextValue>(
202
+ () => ({
203
+ id,
204
+ labelId,
205
+ descriptionId,
206
+ errorId,
207
+ mode,
208
+ variant,
209
+ disabled,
210
+ required,
211
+ invalid,
212
+ size,
213
+ orientation,
214
+ controlLayout,
215
+ hasFieldWrapper: true,
216
+ }),
217
+ [id, labelId, descriptionId, errorId, mode, variant, disabled, required, invalid, size, orientation, controlLayout]
218
+ )
219
+
220
+ // Control area:兩種佈局模型,「第一行內容中線」都錨在 field-height/2,
221
+ // 跟 FieldLabel 在 horizontal 模式下的 padding-top 公式自然對齊。
222
+ //
223
+ // - inline: min-h-field-{size} + items-center
224
+ // 單行 control(Input、Button 等)中線置中於 min-h box。
225
+ //
226
+ // - block: flex-col + items-start + padding-top: calc((field-height - 1lh) / 2)
227
+ // 多行 control(RadioGroup 等),第一行往下推到 field-height 中線,
228
+ // 後續 item 自然往下流。不設 min-h(內容自己決定高度)。
229
+ // Block control area 不加額外 paddingTop——block primitive(RadioGroup 等)
230
+ // 的子元件(SelectionItem)已自帶 py = calc((field-height - 1lh) / 2),
231
+ // 第一個 item 的文字自然落在 field-height/2。額外加 paddingTop 會 double padding。
232
+ const controlArea =
233
+ controlLayout === 'block' ? (
234
+ <div
235
+ className="flex flex-col items-start min-w-0"
236
+ data-field-slot="control"
237
+ data-field-control-layout="block"
238
+ >
239
+ {controlNodes}
240
+ </div>
241
+ ) : (
242
+ <div
243
+ className={cn('flex items-center min-w-0', MIN_H_CLASS[size])}
244
+ data-field-slot="control"
245
+ data-field-control-layout="inline"
246
+ >
247
+ {controlNodes}
248
+ </div>
249
+ )
250
+
251
+ // Horizontal:grid 兩欄,label 在左、content 欄堆疊(control → description → error)
252
+ if (orientation === 'horizontal') {
253
+ return (
254
+ <FieldContext.Provider value={contextValue}>
255
+ <div
256
+ ref={ref}
257
+ className={cn('grid gap-x-3 items-start', className)}
258
+ style={{
259
+ gridTemplateColumns: 'var(--field-label-width, auto) minmax(0, 1fr)',
260
+ ...(effectiveLabelWidth !== undefined
261
+ ? ({ ['--field-label-width' as string]: effectiveLabelWidth } as React.CSSProperties)
262
+ : undefined),
263
+ ...style,
264
+ }}
265
+ data-field-orientation="horizontal"
266
+ data-field-mode={mode}
267
+ data-field-size={size}
268
+ data-field-disabled={disabled ? '' : undefined}
269
+ data-field-invalid={invalid ? '' : undefined}
270
+ {...props}
271
+ >
272
+ {labelNodes}
273
+ <div className="flex flex-col gap-1 min-w-0">
274
+ {controlArea}
275
+ {descriptionNodes}
276
+ {errorNodes}
277
+ </div>
278
+ </div>
279
+ </FieldContext.Provider>
280
+ )
281
+ }
282
+
283
+ // Vertical(預設):單欄 flex-col
284
+ return (
285
+ <FieldContext.Provider value={contextValue}>
286
+ <div
287
+ ref={ref}
288
+ className={cn('flex flex-col gap-1 min-w-0', className)}
289
+ style={style}
290
+ data-field-orientation="vertical"
291
+ data-field-mode={mode}
292
+ data-field-size={size}
293
+ data-field-disabled={disabled ? '' : undefined}
294
+ data-field-invalid={invalid ? '' : undefined}
295
+ {...props}
296
+ >
297
+ {labelNodes}
298
+ {controlArea}
299
+ {descriptionNodes}
300
+ {errorNodes}
301
+ </div>
302
+ </FieldContext.Provider>
303
+ )
304
+ }
305
+ )
306
+ Field.displayName = 'Field'
307
+
308
+ // ── FieldLabel ──────────────────────────────────────────────────────────────
309
+
310
+ export interface FieldLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
311
+ /**
312
+ * 強制渲染 required 星號(覆寫 Field context 的 required)。
313
+ * 若未設定,預設讀 context。
314
+ */
315
+ required?: boolean
316
+ /**
317
+ * 在 label 文字後方顯示 info icon (ℹ),hover 出現 tooltip 說明。
318
+ * 傳 string → tooltip 內容。
319
+ *
320
+ * Info icon 用 inline action pattern(補充工具,視覺退後),
321
+ * 因為 label 的 primary interaction 是 input,info 是補充說明。
322
+ */
323
+ info?: string
324
+ }
325
+
326
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
327
+ const FieldLabel = React.forwardRef<HTMLLabelElement, FieldLabelProps>(
328
+ ({ className, required: requiredProp, info, htmlFor: htmlForProp, style, children, ...props }, ref) => {
329
+ const ctx = useFieldContext()
330
+ const required = requiredProp ?? ctx?.required ?? false
331
+ const disabled = ctx?.disabled ?? false
332
+ const htmlFor = htmlForProp ?? ctx?.id
333
+ const isHorizontal = ctx?.orientation === 'horizontal'
334
+ const controlLayout = ctx?.controlLayout ?? 'inline'
335
+ const size: FieldSize = ctx?.size ?? 'md'
336
+
337
+ // Horizontal 模式對齊策略 — 依 controlLayout 分兩套 (CSS-only, 不需 JS 測量)
338
+ //
339
+ // ── Inline control (Input / Button / Switch / SegmentedControl) ──
340
+ // Control 有固定單行高度 = field-height,可以對齊中線。
341
+ // 策略: min-h-field-{size} + flex flex-col + justify-content: center
342
+ //
343
+ // 1) 短 label (總高 ≤ field-height):
344
+ // min-h 生效 → 容器 = field-height → justify-center 把 label 垂直置中
345
+ // 第一行 top = (field-height - 1lh)/2 → 第一行中線對齊 control 中線 ✓
346
+ // 2) 長 label (總高 > field-height):
347
+ // min-h 被內容撐大 → 容器 = label 總高 → justify-center 無作用(內容已填滿)
348
+ // 第一行 top = 0 → label top 對齊 control top ✓
349
+ //
350
+ // ── Block control (RadioGroup / CheckboxGroup) ──
351
+ // Control 是多行群組,沒有「整體中線」可以對齊;錨點是「第一個 item 的第一行
352
+ // 中線永遠在 field-height/2」,由 SelectionItem 的 py 維持。
353
+ // 策略: padding-top = (field-height - 1lh)/2 — 把 label 第一行推到同樣位置。
354
+ //
355
+ // 這個策略對任何 label 長度都正確:label 第一行永遠與第一個 item 第一行對齊,
356
+ // label 超出 control 時往下流(因為 block control 通常本來就很高,不會有
357
+ // inline 模式那種「label 比 control 高」的視覺問題)。
358
+ //
359
+ // 內層 <span>: 只有 inline 策略需要(flex-col 會把 * 星號和 label 文字縱向堆疊,
360
+ // 必須包一層讓兩者 inline 同行)。block 策略可以不包,但為了 DOM 一致性一律包。
361
+ const horizontalInlineClass =
362
+ isHorizontal && controlLayout === 'inline'
363
+ ? cn('flex flex-col justify-center', MIN_H_CLASS[size])
364
+ : undefined
365
+
366
+ const horizontalBlockStyle: React.CSSProperties | undefined =
367
+ isHorizontal && controlLayout === 'block'
368
+ ? { paddingTop: `calc((${FIELD_HEIGHT_VAR[size]} - 1lh) / 2)` }
369
+ : undefined
370
+
371
+ return (
372
+ <label
373
+ ref={ref}
374
+ id={ctx?.labelId}
375
+ htmlFor={htmlFor}
376
+ className={cn(
377
+ FIELD_TEXT_CLASS,
378
+ 'font-normal select-none',
379
+ disabled ? 'text-fg-disabled' : 'text-foreground',
380
+ horizontalInlineClass,
381
+ className
382
+ )}
383
+ style={{ ...horizontalBlockStyle, ...style }}
384
+ data-field-slot="label"
385
+ data-field-disabled={disabled ? '' : undefined}
386
+ {...props}
387
+ >
388
+ <span className="inline-flex items-center gap-1">
389
+ <span>
390
+ {required && (
391
+ <span
392
+ aria-hidden="true"
393
+ className={disabled ? 'text-fg-disabled' : 'text-fg-muted'}
394
+ >
395
+ *
396
+ </span>
397
+ )}
398
+ {children}
399
+ </span>
400
+ {info && !disabled && (
401
+ <Tooltip>
402
+ <TooltipTrigger asChild>
403
+ <button
404
+ type="button"
405
+ aria-label={info}
406
+ className="inline-flex items-center text-fg-muted hover:text-fg-secondary bg-transparent border-0 p-0 cursor-pointer"
407
+ >
408
+ <InfoIcon size={16} aria-hidden />
409
+ </button>
410
+ </TooltipTrigger>
411
+ <TooltipContent>{info}</TooltipContent>
412
+ </Tooltip>
413
+ )}
414
+ </span>
415
+ </label>
416
+ )
417
+ }
418
+ )
419
+ FieldLabel.displayName = 'FieldLabel'
420
+
421
+ // ── FieldDescription ────────────────────────────────────────────────────────
422
+
423
+ const FieldDescription = React.forwardRef<
424
+ HTMLParagraphElement,
425
+ React.HTMLAttributes<HTMLParagraphElement>
426
+ >(({ className, children, id: idProp, ...props }, ref) => {
427
+ const ctx = useFieldContext()
428
+ const disabled = ctx?.disabled ?? false
429
+
430
+ return (
431
+ <p
432
+ ref={ref}
433
+ id={idProp ?? ctx?.descriptionId}
434
+ className={cn(
435
+ FIELD_TEXT_CLASS,
436
+ disabled ? 'text-fg-disabled' : 'text-fg-secondary',
437
+ className
438
+ )}
439
+ data-field-slot="description"
440
+ {...props}
441
+ >
442
+ {children}
443
+ </p>
444
+ )
445
+ })
446
+ FieldDescription.displayName = 'FieldDescription'
447
+
448
+ // ── FieldError ──────────────────────────────────────────────────────────────
449
+
450
+ const FieldError = React.forwardRef<
451
+ HTMLParagraphElement,
452
+ React.HTMLAttributes<HTMLParagraphElement>
453
+ >(({ className, children, id: idProp, ...props }, ref) => {
454
+ const ctx = useFieldContext()
455
+
456
+ // 無內容不渲染,避免空殼佔位
457
+ if (children == null || children === false || children === '') return null
458
+
459
+ return (
460
+ <p
461
+ ref={ref}
462
+ id={idProp ?? ctx?.errorId}
463
+ className={cn(FIELD_TEXT_CLASS, 'text-error-text', className)}
464
+ data-field-slot="error"
465
+ role="alert"
466
+ {...props}
467
+ >
468
+ {children}
469
+ </p>
470
+ )
471
+ })
472
+ FieldError.displayName = 'FieldError'
473
+
474
+ // ── FieldGroup ──────────────────────────────────────────────────────────────
475
+ // 垂直堆疊多個 Field,共用 gap 節奏。
476
+ // 用於表單中多個欄位排列。
477
+
478
+ export interface FieldGroupProps extends React.HTMLAttributes<HTMLDivElement> {
479
+ /** Field 之間的垂直間距,預設 'normal'(gap-4) */
480
+ gap?: 'compact' | 'normal' | 'loose'
481
+ /**
482
+ * 同一 group 內所有 horizontal Field 共用的 label 欄寬度。
483
+ *
484
+ * 支援任何 CSS length(`"140px"` / `"10rem"` / `"30%"` 等)。預設不指定——
485
+ * 每個 Field 自動以 label 內容撐開(容易歪七扭八)。
486
+ *
487
+ * 世界級 idiom:macOS System Settings / iOS Settings / GitHub Settings 的
488
+ * setting list 一律 label 固定寬、control 右對齊,列與列整齊對齊。
489
+ *
490
+ * 單一 Field 可以用自己的 `labelWidth` prop 覆寫 cascade 值。
491
+ */
492
+ horizontalLabelWidth?: string
493
+ }
494
+
495
+ const FieldGroup = React.forwardRef<HTMLDivElement, FieldGroupProps>(
496
+ ({ className, gap = 'normal', horizontalLabelWidth, ...props }, ref) => {
497
+ const gapClass = gap === 'compact' ? 'gap-3' : gap === 'loose' ? 'gap-6' : 'gap-4'
498
+ const groupCtxValue = React.useMemo(
499
+ () => ({ horizontalLabelWidth }),
500
+ [horizontalLabelWidth],
501
+ )
502
+ return (
503
+ <FieldGroupContext.Provider value={groupCtxValue}>
504
+ <div
505
+ ref={ref}
506
+ className={cn('flex flex-col min-w-0', gapClass, className)}
507
+ data-field-group=""
508
+ {...props}
509
+ />
510
+ </FieldGroupContext.Provider>
511
+ )
512
+ }
513
+ )
514
+ FieldGroup.displayName = 'FieldGroup'
515
+
516
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
517
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
518
+ export const fieldMeta = {
519
+ component: 'Field',
520
+ family: null, // non-family composite / overlay / layout
521
+ variants: {
522
+
523
+ },
524
+ sizes: {
525
+
526
+ },
527
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
528
+ tokens: {
529
+ bg: [],
530
+ fg: ['text-error-text', 'text-fg-disabled', 'text-fg-muted', 'text-fg-secondary', 'text-foreground'],
531
+ ring: [],
532
+ },
533
+ } as const
534
+
535
+ export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup }
@@ -0,0 +1,136 @@
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
+ // ── 消費的 SSOT ──
3
+ // - patterns/element-anatomy/element-anatomy.spec.md(Field 家族邊界)
4
+ // - components/Field/field-wrapper.tsx(field-height token via h-field-{sm,md,lg})
5
+ // - components/Field/field-context.ts(useFieldContext / size cascade)
6
+ // - tokens/uiSize/uiSize.spec.md(--field-height-{sm,md,lg})
7
+ // - 世界級對照:Ant Space.Compact compact-item.ts(verified github.com/ant-design/ant-design/blob/master/components/style/compact-item.ts)
8
+ // Bootstrap input-group.scss(verified github.com/twbs/bootstrap/blob/v5.3.3/scss/forms/_input-group.scss)
9
+ //
10
+ import * as React from 'react'
11
+ import { cn } from '@/lib/utils'
12
+ import type { FieldSize } from '@/design-system/components/Field/field-context'
13
+
14
+ /**
15
+ * FieldControlGroup — 多個 Field controls 視覺接合成一個 input frame
16
+ *
17
+ * **Naming rationale**(2026-05-04):
18
+ * - Taxonomic 一致:FieldGroup(多 Field 堆疊)/ FieldControlGroup(多 control 接合)— scope 區分
19
+ * - Idiom 一致:ButtonGroup(多 Button 接合)同 pattern,只是 X = field control
20
+ * - 不撞 RadioGroup/CheckboxGroup(那是 1-question 多 options semantic group)
21
+ *
22
+ * **Behavior canonical**(verified Ant compact-item.ts L21-58):
23
+ * - 子 controls 保留自身 border + radius;不 strip
24
+ * - 鄰接子用負 margin 重疊 border(Bootstrap 也用此手法 但用 z-index 確保 focus 在最上層)
25
+ * - first child 只留左 radii / last child 只留右 radii / middle 全 0 radii
26
+ * - z-index:default 2 / hover|focus|focus-within 3 / disabled 0
27
+ * - Container `display: inline-flex`(Ant default)/ `block` prop → `display: flex; width: 100%`
28
+ *
29
+ * **Size cascade**(對齊 Field family):
30
+ * - `size` prop default = md;cascade 到 children via implicit context inheritance(children 自管 size 或繼承 useFieldContext)
31
+ * - Mode A:整個 FieldControlGroup 包進 Field 當 control slot,size 自動從 Field context 來
32
+ * - Mode B:standalone 用,size 由 prop 控制
33
+ *
34
+ * **Width 配置**(Ant Space.Compact W-A canonical):
35
+ * - 子 controls 自管 width(`className="w-[140px]"` / `style={{width:120}}` / `flex-1` etc.)
36
+ * - FieldControlGroup 不另開 Cell wrapper(避免 indirection,符合 Ant idiom)
37
+ *
38
+ * **使用範例**:
39
+ * ```tsx
40
+ * // Filter row: 2 fixed select + 1 flex value
41
+ * <FieldControlGroup>
42
+ * <Select className="w-[140px]" options={fields} />
43
+ * <Select className="w-[120px]" options={ops} />
44
+ * <Input className="flex-1" placeholder="輸入值..." />
45
+ * </FieldControlGroup>
46
+ *
47
+ * // Phone: country code + number
48
+ * <Field>
49
+ * <FieldLabel>電話</FieldLabel>
50
+ * <FieldControlGroup>
51
+ * <Select className="w-[80px]" options={codes} />
52
+ * <Input className="flex-1" />
53
+ * </FieldControlGroup>
54
+ * </Field>
55
+ *
56
+ * // Search + button
57
+ * <FieldControlGroup>
58
+ * <Input className="flex-1" />
59
+ * <Button variant="primary">搜尋</Button>
60
+ * </FieldControlGroup>
61
+ * ```
62
+ */
63
+
64
+ export interface FieldControlGroupProps extends React.HTMLAttributes<HTMLDivElement> {
65
+ /** Children size cascade(Mode B);Mode A 從 Field context 來 */
66
+ size?: FieldSize
67
+ /** Block 模式:width 100% 撐滿 parent(對齊 Ant Space.Compact `block` prop) */
68
+ block?: boolean
69
+ }
70
+
71
+ const FieldControlGroup = React.forwardRef<HTMLDivElement, FieldControlGroupProps>(
72
+ ({ size: _size = 'md', block = false, className, children, ...props }, ref) => {
73
+ return (
74
+ <div
75
+ ref={ref}
76
+ // ── Ant compact-item 機制(verified):
77
+ // [&>*]:relative — 子套 relative 才能 z-index 生效
78
+ // [&>*]:z-[2] — default z 2(Ant 同值)
79
+ // [&>*+*]:-ml-px — 鄰接子 margin-left -1px 重疊 border
80
+ // hover/focus/focus-within → z-3(active border 在最上層,Bootstrap 用 z-5,Ant 用 z-3 我們對齊 Ant)
81
+ // first/middle/last radii:對齊 Ant compactItemBorderRadius L67-92
82
+ // ── Children 自管 width(W-A,Ant idiom);Group 自身僅控制 border/radius/z-index 接合機制
83
+ className={cn(
84
+ block ? 'flex w-full' : 'inline-flex',
85
+ 'items-stretch',
86
+ // z-index baseline + active layer
87
+ '[&>*]:relative [&>*]:z-[2]',
88
+ '[&>*:hover]:z-[3] [&>*:focus]:z-[3] [&>*:focus-within]:z-[3]',
89
+ '[&>*[disabled]]:z-0 [&>*:has([disabled])]:z-0',
90
+ // border overlap
91
+ '[&>*+*]:-ml-px',
92
+ // border radius — 中間 0,首/尾保留外側 radii
93
+ '[&>*:not(:first-child):not(:last-child)]:rounded-none',
94
+ '[&>*:first-child:not(:last-child)]:rounded-r-none',
95
+ '[&>*:last-child:not(:first-child)]:rounded-l-none',
96
+ // K12 fix(2026-05-04 v7 — semantic token):FCG 內 disabled cell border 用 `--border-opaque`:
97
+ // ✓ 保留 global `bg-disabled`(neutral-2 灰底)— disabled state 視覺主要承載
98
+ // ✓ 用 SEMANTIC `--border-opaque`(視覺等同 --border 但不跟 bg compositing)
99
+ // v6 直接消費 primitive `--color-neutral-5-opaque` 違反 token 4 規則「禁 primitive 色名作 utility」,
100
+ // v7 升 semantic alias `--border-opaque` 在 semantic.css(其 primitive 後盾仍是 neutral-5-opaque)
101
+ // → 對齊 Ant Design colorBorderSecondary solid idiom(table 外框 / row divider 用 solid,跟 input alpha border 視覺層級分)
102
+ // → 解決 alpha border 在 grey bg 上 composite 略深 perception(physical 對比問題)
103
+ '[&>*[data-field-mode="disabled"]]:border-[var(--border-opaque)]',
104
+ className,
105
+ )}
106
+ data-field-control-group=""
107
+ {...props}
108
+ >
109
+ {children}
110
+ </div>
111
+ )
112
+ },
113
+ )
114
+ FieldControlGroup.displayName = 'FieldControlGroup'
115
+
116
+ // Story auto-compile metadata — Phase 4 migration(2026-05-10 #12 task complete)
117
+ // Per scripts/compile-stories.mjs --check。FieldControlGroup is self-contained
118
+ // structural wrapper(border-collapse pattern for Field controls)。
119
+ // **No own sizes** — size prop is cascade-only(passes through to children Field controls,
120
+ // not own visual variants),so sizes:{} matches spec frontmatter (no sizes declared)。
121
+ export const fieldControlGroupMeta = {
122
+ component: 'FieldControlGroup',
123
+ family: 'self-contained',
124
+ variants: {},
125
+ sizes: {}, // self-contained wrapper, sizes cascade to children only
126
+ states: ['default', 'children-hover', 'children-focus', 'children-disabled'],
127
+ tokens: {
128
+ bg: [], // structural wrapper has no own bg
129
+ fg: [],
130
+ border: ['var(--border-opaque)'], // K12 disabled cell border
131
+ },
132
+ defaultVariant: undefined,
133
+ defaultSize: undefined,
134
+ } as const
135
+
136
+ export { FieldControlGroup }