@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.
- package/package.json +93 -0
- package/src/README.md +32 -0
- package/src/components/Accordion/accordion.tsx +104 -0
- package/src/components/Alert/alert.tsx +188 -0
- package/src/components/AppShell/_demo-helpers.tsx +198 -0
- package/src/components/AppShell/app-shell.tsx +364 -0
- package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
- package/src/components/Avatar/avatar.tsx +368 -0
- package/src/components/Badge/badge.tsx +104 -0
- package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
- package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
- package/src/components/Button/button-group.tsx +96 -0
- package/src/components/Button/button.tsx +539 -0
- package/src/components/Calendar/calendar.tsx +411 -0
- package/src/components/Carousel/carousel.tsx +371 -0
- package/src/components/Chart/chart.tsx +376 -0
- package/src/components/Checkbox/checkbox-group.tsx +94 -0
- package/src/components/Checkbox/checkbox.tsx +237 -0
- package/src/components/Chip/chip.tsx +359 -0
- package/src/components/CircularProgress/circular-progress.tsx +204 -0
- package/src/components/Coachmark/coachmark.tsx +255 -0
- package/src/components/Combobox/combobox.tsx +826 -0
- package/src/components/Command/command.tsx +187 -0
- package/src/components/DataTable/active-editor-controller.ts +72 -0
- package/src/components/DataTable/cell-registry.tsx +520 -0
- package/src/components/DataTable/column-types.ts +180 -0
- package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
- package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
- package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
- package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
- package/src/components/DataTable/data-table.css +165 -0
- package/src/components/DataTable/data-table.tsx +2924 -0
- package/src/components/DataTable/filter-operators.ts +225 -0
- package/src/components/DataTable/filter-tree.ts +313 -0
- package/src/components/DataTable/lib/column-meta.ts +79 -0
- package/src/components/DateGrid/date-grid.tsx +209 -0
- package/src/components/DatePicker/date-picker.tsx +1114 -0
- package/src/components/DescriptionList/description-list.tsx +141 -0
- package/src/components/Dialog/dialog.tsx +267 -0
- package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
- package/src/components/Empty/empty.tsx +108 -0
- package/src/components/Field/field-context.ts +136 -0
- package/src/components/Field/field-types.ts +52 -0
- package/src/components/Field/field-wrapper.tsx +348 -0
- package/src/components/Field/field.tsx +535 -0
- package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
- package/src/components/FileItem/file-item.tsx +322 -0
- package/src/components/FileUpload/file-upload.tsx +326 -0
- package/src/components/FileViewer/file-viewer-types.ts +76 -0
- package/src/components/FileViewer/file-viewer.tsx +1065 -0
- package/src/components/FileViewer/image-renderer.tsx +256 -0
- package/src/components/HoverCard/hover-card.tsx +79 -0
- package/src/components/Input/input.tsx +233 -0
- package/src/components/LinkInput/link-input.tsx +304 -0
- package/src/components/Menu/menu-item.tsx +334 -0
- package/src/components/NameCard/name-card.tsx +319 -0
- package/src/components/Notice/notice.tsx +196 -0
- package/src/components/NumberInput/number-input.tsx +203 -0
- package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
- package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
- package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
- package/src/components/PeoplePicker/people-picker.tsx +455 -0
- package/src/components/PeoplePicker/person-display.tsx +358 -0
- package/src/components/Popover/popover.tsx +183 -0
- package/src/components/ProgressBar/progress-bar.tsx +157 -0
- package/src/components/README.md +58 -0
- package/src/components/RadioGroup/radio-group.tsx +261 -0
- package/src/components/Rating/rating.tsx +295 -0
- package/src/components/ScrollArea/scroll-area.tsx +110 -0
- package/src/components/SegmentedControl/segmented-control.tsx +304 -0
- package/src/components/Select/select.tsx +658 -0
- package/src/components/SelectMenu/select-menu.tsx +430 -0
- package/src/components/SelectionControl/selection-item.tsx +261 -0
- package/src/components/Separator/separator.tsx +48 -0
- package/src/components/Sheet/sheet.tsx +240 -0
- package/src/components/Sidebar/sidebar.tsx +1280 -0
- package/src/components/Skeleton/skeleton.tsx +35 -0
- package/src/components/Slider/slider.tsx +158 -0
- package/src/components/Steps/steps.tsx +850 -0
- package/src/components/Switch/switch.tsx +285 -0
- package/src/components/Tabs/tabs.tsx +515 -0
- package/src/components/Tag/tag.tsx +246 -0
- package/src/components/Textarea/textarea.tsx +280 -0
- package/src/components/TimePicker/time-columns.tsx +260 -0
- package/src/components/TimePicker/time-picker.tsx +419 -0
- package/src/components/Toast/toast.tsx +129 -0
- package/src/components/Tooltip/tooltip.tsx +68 -0
- package/src/components/TreeView/tree-view.tsx +1031 -0
- package/src/hooks/use-controllable.ts +40 -0
- package/src/hooks/use-is-narrow-viewport.ts +19 -0
- package/src/hooks/use-is-touch-device.ts +21 -0
- package/src/hooks/use-overflow-items.ts +256 -0
- package/src/index.ts +85 -0
- package/src/lib/README.md +82 -0
- package/src/lib/drag-visual.ts +272 -0
- package/src/lib/i18n/README.md +60 -0
- package/src/lib/i18n/i18n-context.tsx +129 -0
- package/src/lib/multi-select-ordering.ts +61 -0
- package/src/lib/utils.ts +93 -0
- package/src/patterns/README.md +67 -0
- package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
- package/src/patterns/header-canonical/chrome-header.tsx +175 -0
- package/src/patterns/header-canonical/header-canonical.css +27 -0
- package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
- package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
- package/src/patterns/resize-handle/resize-handle.tsx +188 -0
- package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
- package/src/tokens/README.md +53 -0
- package/src/tokens/color/primitives.css +429 -0
- package/src/tokens/color/semantic.css +539 -0
- package/src/tokens/elevation/overlay-geometry.ts +13 -0
- package/src/tokens/layoutSpace/layoutSpace.css +36 -0
- package/src/tokens/motion/motion.css +30 -0
- package/src/tokens/motion/motion.ts +17 -0
- package/src/tokens/opacity/opacity.css +23 -0
- package/src/tokens/radius/radius.css +19 -0
- package/src/tokens/typography/typography.css +118 -0
- package/src/tokens/uiSize/icon-size.ts +52 -0
- 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 }
|