@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,157 @@
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 ProgressPrimitive from '@radix-ui/react-progress'
4
+ import { CircleCheck, XCircle } from 'lucide-react'
5
+ import { cn } from '@/lib/utils'
6
+
7
+ /**
8
+ * ProgressBar — 水平進度條(determinate)
9
+ *
10
+ * 世界級對照:Material `LinearProgress` / Ant `Progress` / Polaris `ProgressBar` /
11
+ * shadcn `Progress`(皆為 Radix Progress primitive 的包裝)。本 DS 命名為
12
+ * `ProgressBar`(linear)以和 `CircularProgress`(circular)做清楚區分。
13
+ *
14
+ * ── 與 CircularProgress 的分界 ──
15
+ * CircularProgress = circular 兩態(indeterminate 旋轉 / determinate arc),inline 小空間
16
+ * ProgressBar = linear determinate(0-100% 已知量化),頁面級大區塊 / 表單 wizard / quota
17
+ * 兩者視覺與語意都不同,consumer 依「形狀(linear / circular)+ 是否量化」擇一。
18
+ *
19
+ * ── 與 FileItem 的分界(2026-04-20 user 決策) ──
20
+ * **檔案上傳 UI 走 `FileItem`,不直接消費 ProgressBar**。FileItem 是檔案上傳情境的
21
+ * canonical consumer-facing primitive(檔名 / icon / 進度 / status / actions 一條龍);
22
+ * FileItem 內部可能消費 ProgressBar(engineering 實作細節),consumer 不用也不該自己
23
+ * 組合 raw ProgressBar + 檔名 + action 來做上傳列表。世界級對照:Ant Design 的 `Upload`
24
+ * vs `Progress`(Upload.List 內部用 Progress,consumer 不直接用 Progress 做上傳 UI)。
25
+ *
26
+ * ── 3 狀態,單一 size ──
27
+ * status: inProgress(進行中藍) / success(完成綠) / error(失敗紅)
28
+ * ^ 命名理由:`status` 是 lifecycle(在途 / 終態),不是視覺 emphasis 階。前身
29
+ * `primary` 會撞 Button `variant="primary"`(emphasis 最高階),改用世界級
30
+ * lifecycle 慣例(Polaris `inProgress` / Ant Progress `active`)。
31
+ *
32
+ * **單一高度 4px**(2026-04-20 user 決策):對齊 Material 3 / Carbon / Ant 慣例
33
+ * (固定 4dp/px 不給 size 選項)。4px 是 linear progress 的業界 canonical;若需
34
+ * 視覺強調改用 CircularProgress + label 或改做 full-width hero 版型,不靠 size
35
+ * 階梯撐。移除前的 sm(2)/ md(4)/ lg(6)刻度差太小,無實質視覺區分。
36
+ *
37
+ * ── affix(右側附加) ──
38
+ * `affix="value"` → 顯示 `{value}%` 文字
39
+ * `affix="status-icon"` → 顯示狀態 icon(success ✓ / error ✗;inProgress 時無 icon)
40
+ * `affix={<custom />}` → consumer 客製
41
+ * 不傳 → 純 bar
42
+ */
43
+
44
+ const DEFAULT_TRACK_H = 4 // 預設 4px(見 docblock「單一高度」)
45
+ const STATUS_FILL = {
46
+ inProgress: 'bg-primary',
47
+ success: 'bg-success',
48
+ error: 'bg-error',
49
+ } as const
50
+ const STATUS_ICON = {
51
+ success: { Icon: CircleCheck, className: 'text-success' },
52
+ error: { Icon: XCircle, className: 'text-error' },
53
+ } as const
54
+
55
+ export interface ProgressBarProps extends Omit<React.ComponentProps<typeof ProgressPrimitive.Root>, 'value'> {
56
+ /** 當前進度 0-100 */
57
+ value: number
58
+ /** 狀態色(lifecycle,非 emphasis 階) */
59
+ status?: 'inProgress' | 'success' | 'error'
60
+ /** 右側附加 */
61
+ affix?: 'value' | 'status-icon' | React.ReactNode
62
+ /**
63
+ * Track 高度(px)override。**預設 4**(對齊 Material / Carbon / Ant canonical)。
64
+ *
65
+ * **非公開 size 階**——這不是給 consumer 自由選擇粗細的 API,而是給**內部另一個
66
+ * DS 元件(FileItem)在極密集 row layout 下壓低到 2px** 的逃生艙。Consumer 端
67
+ * 一律走預設 4px。未來若新 primitive(例如 health-bar 型 hero progress)需要
68
+ * 較高的 track,再評估是否擴公開 API。
69
+ *
70
+ * 世界級對照:Ant `<Progress>` 有 `strokeWidth` 原生 prop;本 DS 只暴露給內部
71
+ * 元件使用,不在 public API 宣傳,避免 consumer 重新陷入「選哪個 size」的判斷負擔。
72
+ */
73
+ height?: number
74
+ }
75
+
76
+ const ProgressBar = React.forwardRef<HTMLDivElement, ProgressBarProps>(
77
+ (
78
+ {
79
+ value,
80
+ status = 'inProgress',
81
+ affix,
82
+ height,
83
+ className,
84
+ ...props
85
+ },
86
+ ref,
87
+ ) => {
88
+ const clampedValue = Math.max(0, Math.min(100, value))
89
+ const fillColor = STATUS_FILL[status]
90
+ const trackH = height ?? DEFAULT_TRACK_H
91
+
92
+ // Affix 渲染
93
+ let affixNode: React.ReactNode = null
94
+ if (affix === 'value') {
95
+ affixNode = (
96
+ <span className="text-caption text-foreground tabular-nums shrink-0">
97
+ {Math.round(clampedValue)}%
98
+ </span>
99
+ )
100
+ } else if (affix === 'status-icon') {
101
+ const s = status !== 'inProgress' ? STATUS_ICON[status] : null
102
+ if (s) affixNode = <s.Icon size={16} className={cn('shrink-0', s.className)} aria-hidden />
103
+ } else if (React.isValidElement(affix) || typeof affix === 'string' || typeof affix === 'number') {
104
+ affixNode = affix
105
+ }
106
+
107
+ const bar = (
108
+ <ProgressPrimitive.Root
109
+ ref={ref}
110
+ value={clampedValue}
111
+ max={100}
112
+ className={cn(
113
+ 'relative overflow-hidden rounded-full bg-secondary w-full',
114
+ className,
115
+ )}
116
+ style={{ height: trackH }}
117
+ {...props}
118
+ >
119
+ <ProgressPrimitive.Indicator
120
+ className={cn('h-full rounded-full transition-all duration-300', fillColor)}
121
+ style={{ width: `${clampedValue}%` }}
122
+ />
123
+ </ProgressPrimitive.Root>
124
+ )
125
+
126
+ if (!affixNode) return bar
127
+
128
+ return (
129
+ <div className="flex items-center gap-2 w-full">
130
+ <div className="flex-1 min-w-0">{bar}</div>
131
+ {affixNode}
132
+ </div>
133
+ )
134
+ },
135
+ )
136
+ ProgressBar.displayName = 'ProgressBar'
137
+
138
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
139
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
140
+ export const progressBarMeta = {
141
+ component: 'ProgressBar',
142
+ family: null, // non-family composite / overlay / layout
143
+ variants: {
144
+
145
+ },
146
+ sizes: {
147
+
148
+ },
149
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
150
+ tokens: {
151
+ bg: ['bg-error', 'bg-primary', 'bg-secondary', 'bg-success'],
152
+ fg: ['text-foreground'],
153
+ ring: [],
154
+ },
155
+ } as const
156
+
157
+ export { ProgressBar }
@@ -0,0 +1,58 @@
1
+ # components/ Charter
2
+
3
+ ## 這裡只收:元件家族的 folder
4
+
5
+ 每個元件家族一個 PascalCase folder,內含:
6
+ - `{name}.tsx` — 元件本體
7
+ - `{name}.spec.md` — 設計原則
8
+ - `{name}.stories.tsx` — 展示
9
+ - `{name}.anatomy.stories.tsx` — 設計規格
10
+ - `{name}.principles.stories.tsx` — 設計原則 stories
11
+ - 子檔案視元件需要(`{name}-context.ts` / `{name}-types.ts` 等)
12
+
13
+ **folder 名**: PascalCase(`Button/` / `DatePicker/`)
14
+ **file 名**: kebab-case(`button.tsx` / `date-picker.tsx`)
15
+
16
+ ## Compound component family(多元件 + 共享規則)
17
+
18
+ 部分 folder 是**元件家族 home**,houses 多個緊耦合 primitive + 共享規則 spec。對齊 Ant Design `Checkbox.Group` / Mantine `Checkbox.Group` / Chakra compound pattern 世界級慣例:tightly coupled primitives 同資料夾而非拆分。
19
+
20
+ 現有 compound folders:
21
+ - `Field/` — `field.tsx` + `field-wrapper.tsx` + `field-context.ts` + `field-types.ts` + `field-controls.spec.md`(跨 form 元件共享 mode/disabled/readonly 規則)+ `form-validation.spec.md`(表單驗證跨 primitive 規則,**無 Layout Family — behavior spec 非元件**)
22
+ - `Checkbox/` — `checkbox.tsx` + `checkbox-group.tsx`(2026-04-21 CheckboxGroup merge 自 separate folder,對齊 standalone + group 世界級慣例)+ `boolean-display.tsx`(table cell 顯示)
23
+ - `Menu/` — `menu-item.tsx`(家族預留位)
24
+ - `SelectionControl/` — `selection-item.tsx`(Checkbox/Radio 共享的 row layout primitive home)
25
+
26
+ **判斷 compound vs 單獨 folder**:
27
+ - 元件能**獨立 lifecycle / 獨立使用** → 各自獨立 folder(Button / Input / Select)
28
+ - 元件**同家族共享 context / 命名 / 規則** → compound folder(Field / Checkbox / Menu)
29
+ - 新元件若模糊,先問「能不能不依賴同家族其他元件獨立使用」— 能 → 獨立 folder;不能 → compound family
30
+
31
+ ## 這裡**不收**(反例)
32
+
33
+ | 疑似要放這但其實不是 | 實際應去 | 為什麼 |
34
+ |-------------------|---------|--------|
35
+ | 平坦 `.md` 檔(`components/foo.md`) | Skill / CLAUDE.md / spec | 本 dir 慣例是 PascalCase folder,平坦 md 破壞結構 |
36
+ | 跨元件共用的 checklist | `.claude/skills/component-quality-gate/` | 是 invoke-time workflow |
37
+ | 跨元件 runtime primitive | `../patterns/` | 本 dir 是單元件,跨元件去 patterns |
38
+ | 命名規則 / Props 命名慣例 | `CLAUDE.md` | 每 session signal |
39
+
40
+ ## 新元件進來的條件
41
+
42
+ 進 `components/` 前:
43
+ 1. 走 `.claude/skills/component-quality-gate/`(完整 checklist)
44
+ 2. 聲明 Layout Family(第一段 spec 必含,見 `patterns/element-anatomy/element-anatomy.spec.md`「4-Family Model Taxonomy」)
45
+ 3. 對標至少 2 個世界級 DS 的相似元件
46
+ 4. spec 第一段寫明實作基礎(Radix X / cmdk / native / 自建+理由)
47
+
48
+ ## Internal primitive vs Public 元件
49
+
50
+ 兩類都住同一個 folder,差別在:
51
+ - **Public**(Components/):有預設視覺,consumer 直接 `<X>` 就能用 — Storybook title `Design System/Components/{Name}`
52
+ - **Internal primitive**(Internal/):無預設視覺,必被 wrapper 元件包 — Storybook title `Design System/Internal/{Name}`
53
+
54
+ 判斷 test 見 `.claude/rules/story-rules.md` → 「Internal vs Components 三 test」。
55
+
56
+ ## 建立前必 Read
57
+
58
+ 本 README + 該元件所在 pattern spec(若屬 Family) + `.claude/rules/ui-development.md`「元件 Props 命名」 + `.claude/skills/component-quality-gate/`。
@@ -0,0 +1,261 @@
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 RadioGroupPrimitive from "@radix-ui/react-radio-group"
4
+ import { Circle } 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 { EMPTY_DISPLAY } from "@/design-system/components/Field/field-wrapper"
12
+
13
+ // ── RadioGroup display context ──────────────────────────────────────────────
14
+ // RadioGroup mode='display' 時:Group 不渲染 Radix primitive(無 radio 視覺),
15
+ // 改透過 Context 通知 child RadioGroupItem「我在 display mode、selected value 是 X」;
16
+ // item 自決 — 命中 selected 渲染 label 純文字,未命中 render null。
17
+ // 為什麼用 context(不是 RadioGroup 自己解析 children):children 可能是任意巢狀
18
+ // (Field 包 RadioGroup,內含 RadioGroupItem 散在 fragment / 條件式內),強行 walk children
19
+ // 會破壞 React composability;用 context 讓 item 自己判定是 idiom 對齊 Radix 原生模型。
20
+ interface RadioGroupDisplayContextValue {
21
+ displayMode: true
22
+ selectedValue?: string
23
+ }
24
+ const RadioGroupDisplayContext = React.createContext<RadioGroupDisplayContextValue | null>(null)
25
+
26
+ // ── RadioGroup ──────────────────────────────────────────────────────────────
27
+
28
+ export interface RadioGroupProps
29
+ extends React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> {
30
+ /**
31
+ * Field mode(2026-05-05 Phase B3 align):
32
+ * edit — 一般可互動 RadioGroup(預設)
33
+ * display — **純展示**:不渲染 Radix Root / 任何 radio 視覺,僅 child item 中
34
+ * value === group.value 那筆把 label 渲染為純文字 span;其他 item render null。
35
+ * 對齊 Carbon read-only / DataTable single-select cell read mode。
36
+ * readonly — 同 child item 各自 readOnly:radio 視覺保留 + 鎖互動
37
+ * disabled — 同 RadioGroupPrimitive.Root disabled 屬性
38
+ */
39
+ mode?: FieldMode
40
+ /**
41
+ * Visual chrome — RadioGroup 本體無 input wrapper variant,本 prop 對主體無視覺影響;
42
+ * 為對齊 Field 4-mode + chrome 透傳契約而保留(M19 一致性)。
43
+ */
44
+ variant?: FieldVariant
45
+ }
46
+
47
+ const RadioGroup = React.forwardRef<
48
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
49
+ RadioGroupProps
50
+ >(({ className, mode, variant: _chrome, value, defaultValue, ...props }, ref) => {
51
+ // mode='display' — 純展示 selected option 的 label,不渲染任何 radio control 視覺。
52
+ // 對齊 Carbon read-only single-select(只顯示 selected 內容)+ Airtable / Notion read-only。
53
+ // 實作:walk children 找 control.value === selectedValue 的 SelectionItem,render label plain text。
54
+ // (不用 context dispatch 給 RadioGroupItem — SelectionItem layout wrapper 仍會渲染所有 item label)
55
+ if (mode === 'display') {
56
+ const selectedValue = (value ?? defaultValue) as string | undefined
57
+ if (!selectedValue) {
58
+ return <div role="group" className={cn('grid', className)}><span className="text-fg-muted">{EMPTY_DISPLAY}</span></div>
59
+ }
60
+ let selectedLabel: React.ReactNode = null
61
+ React.Children.forEach(props.children, (child) => {
62
+ if (!React.isValidElement(child)) return
63
+ const cProps = child.props as { control?: unknown; label?: React.ReactNode }
64
+ const control = cProps.control
65
+ if (React.isValidElement(control)) {
66
+ const controlValue = (control.props as { value?: unknown }).value
67
+ if (controlValue === selectedValue) {
68
+ selectedLabel = cProps.label ?? selectedValue
69
+ }
70
+ }
71
+ })
72
+ return (
73
+ <div role="group" className={cn('grid', className)}>
74
+ <span className="text-foreground">{selectedLabel ?? selectedValue}</span>
75
+ </div>
76
+ )
77
+ }
78
+
79
+ return (
80
+ <RadioGroupPrimitive.Root
81
+ className={cn("grid", className)}
82
+ value={value}
83
+ defaultValue={defaultValue}
84
+ {...props}
85
+ ref={ref}
86
+ />
87
+ )
88
+ })
89
+ RadioGroup.displayName = 'RadioGroup'
90
+ // Field layout 宣告:RadioGroup 是 block primitive(多項堆疊),
91
+ // 進入 <Field> 時 control area 自動切 items-start + padding-top 公式對齊。
92
+ // Convention 詳見 components/Field/field.spec.md「Control area:Inline vs Block」段落。
93
+ ;(RadioGroup as unknown as { fieldLayout: 'block' }).fieldLayout = 'block'
94
+
95
+ // ── RadioGroupItem Variants ─────────────────────────────────────────────────
96
+ // 與 Checkbox 完全對齊:sm/md=16px, lg=20px。差異只有形狀(rounded-full)和指示器(filled dot)。
97
+
98
+ const radioItemVariants = cva(
99
+ [
100
+ 'grid place-content-center shrink-0 rounded-full',
101
+ 'border border-border bg-surface',
102
+ 'transition-colors duration-150',
103
+ 'hover:border-border-hover',
104
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
105
+ 'data-[state=checked]:border-primary data-[state=checked]:text-primary',
106
+ 'data-[state=checked]:hover:border-primary-hover data-[state=checked]:hover:text-primary-hover',
107
+ 'disabled:cursor-not-allowed disabled:bg-disabled disabled:border-transparent disabled:hover:border-transparent',
108
+ 'disabled:data-[state=checked]:bg-disabled disabled:data-[state=checked]:border-transparent disabled:data-[state=checked]:text-fg-disabled',
109
+ // readOnly:鎖定互動但維持 checked/unchecked 視覺
110
+ 'data-[readonly=true]:pointer-events-none data-[readonly=true]:cursor-default',
111
+ 'data-[readonly=true]:hover:border-border',
112
+ ],
113
+ {
114
+ variants: {
115
+ size: {
116
+ sm: 'h-4 w-4',
117
+ md: 'h-4 w-4',
118
+ lg: 'h-5 w-5',
119
+ },
120
+ },
121
+ defaultVariants: {
122
+ size: 'md',
123
+ },
124
+ }
125
+ )
126
+
127
+ // ── Dot Size ────────────────────────────────────────────────────────────────
128
+ const dotSize: Record<string, number> = { sm: 8, md: 8, lg: 10 }
129
+
130
+ // ── Types ───────────────────────────────────────────────────────────────────
131
+
132
+ type RadioItemPrimitiveProps = React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
133
+
134
+ export interface RadioGroupItemProps
135
+ extends RadioItemPrimitiveProps,
136
+ VariantProps<typeof radioItemVariants> {
137
+ /**
138
+ * Inline label。提供時 RadioGroupItem 自動透過 SelectionItem 包裝,
139
+ * 套用 codified 樣式(text-body / text-foreground / disabled 色)。
140
+ * 在 <Field> context 內時此 prop 仍然生效(Radio 的 label 是每個 item
141
+ * 各自的,不是整組 Field 的;FieldLabel 是 RadioGroup 整體的 label)。
142
+ */
143
+ label?: React.ReactNode
144
+ /**
145
+ * Inline description(secondary 文字)。須與 label 搭配使用。
146
+ * 套用 text-body / text-fg-secondary 樣式。
147
+ */
148
+ description?: React.ReactNode
149
+ /**
150
+ * readonly 模式:鎖定互動但維持 checked/unchecked 視覺正確。
151
+ * 通常整個 RadioGroup 一起設 readonly(由 parent RadioGroup 的 disabled
152
+ * 或 readonly 行為決定),個別 item 也可設。
153
+ */
154
+ readOnly?: boolean
155
+ }
156
+
157
+ // ── RadioGroupItem ──────────────────────────────────────────────────────────
158
+
159
+ const RadioGroupItem = React.forwardRef<
160
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
161
+ RadioGroupItemProps
162
+ >(
163
+ (
164
+ {
165
+ className,
166
+ size,
167
+ label,
168
+ description,
169
+ readOnly = false,
170
+ disabled,
171
+ id: idProp,
172
+ ...props
173
+ },
174
+ ref
175
+ ) => {
176
+ const sizeKey = size ?? 'md'
177
+ const dotPx = dotSize[sizeKey]
178
+
179
+ // ── RadioGroup mode='display' branch ──────────────────────────────────
180
+ // 命中 selected → 渲染 label 純文字 span;未命中 → render null(group 內只剩 selected 的 label)。
181
+ // 設計理由:對齊 Carbon read-only single-select 「只顯示 selected 內容、不列其他選項」原則。
182
+ const displayCtx = React.useContext(RadioGroupDisplayContext)
183
+ if (displayCtx?.displayMode) {
184
+ if (displayCtx.selectedValue !== props.value) return null
185
+ return <span className="text-foreground">{label ?? props.value}</span>
186
+ }
187
+
188
+ // 注意:Radio 的 label 語意與 Checkbox/Switch 不同——
189
+ // Checkbox/Switch 的 label 就是該 control 的唯一 label(被 Field context 接管),
190
+ // RadioGroupItem 的 label 是「該選項」的 label(每 item 各自擁有),
191
+ // FieldLabel 則是整個 RadioGroup 的 label。
192
+ // 因此 RadioGroupItem 的 label 不因 Field context 被忽略。
193
+ const fieldCtx = useFieldContext()
194
+
195
+ const generatedId = React.useId()
196
+ const inputId = idProp ?? generatedId
197
+
198
+ const rootEl = (
199
+ <RadioGroupPrimitive.Item
200
+ id={inputId}
201
+ ref={ref}
202
+ disabled={disabled}
203
+ aria-readonly={readOnly || undefined}
204
+ data-readonly={readOnly || undefined}
205
+ tabIndex={readOnly ? -1 : undefined}
206
+ className={cn(radioItemVariants({ size }), className)}
207
+ {...props}
208
+ >
209
+ <RadioGroupPrimitive.Indicator className="grid place-content-center">
210
+ <Circle
211
+ style={{ width: dotPx, height: dotPx }}
212
+ className="fill-current text-current"
213
+ />
214
+ </RadioGroupPrimitive.Indicator>
215
+ </RadioGroupPrimitive.Item>
216
+ )
217
+
218
+ // 無 label → 只渲染 radio 本體
219
+ if (label == null) return rootEl
220
+
221
+ // 有 label → 透過 SelectionItem 包裝,與 Checkbox 一致
222
+ // 同時繼承 Field context 的 disabled(若 RadioGroup 在 Field disabled 內)
223
+ const resolvedDisabled = disabled ?? fieldCtx?.disabled ?? false
224
+
225
+ return (
226
+ <SelectionItem
227
+ control={rootEl}
228
+ label={label}
229
+ description={description}
230
+ htmlFor={inputId}
231
+ disabled={resolvedDisabled}
232
+ size={sizeKey}
233
+ />
234
+ )
235
+ }
236
+ )
237
+ RadioGroupItem.displayName = 'RadioGroupItem'
238
+
239
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
240
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
241
+ export const radioGroupMeta = {
242
+ component: 'RadioGroup',
243
+ family: 4,
244
+ variants: {
245
+
246
+ },
247
+ sizes: {
248
+ sm: { fieldHeight: 28, iconSize: 16, typography: 'body' },
249
+ md: { fieldHeight: 32, iconSize: 16, typography: 'body' },
250
+ lg: { fieldHeight: 40, iconSize: 20, typography: 'body' },
251
+ },
252
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
253
+ tokens: {
254
+ bg: ['bg-disabled', 'bg-surface'],
255
+ fg: ['text-fg-disabled', 'text-fg-secondary', 'text-foreground', 'text-primary'],
256
+ ring: ['ring-ring'],
257
+ },
258
+ defaultSize: 'md',
259
+ } as const
260
+
261
+ export { RadioGroup, RadioGroupItem, radioItemVariants }