@qijenchen/design-system 0.1.0-beta.64 → 0.1.0-beta.65
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/CLAUDE.md +1 -1
- package/dist/components/Checkbox/checkbox.d.ts.map +1 -1
- package/dist/components/Checkbox/checkbox.js +28 -3
- package/dist/components/Checkbox/checkbox.js.map +1 -1
- package/dist/components/PeoplePicker/person-display.d.ts.map +1 -1
- package/dist/components/PeoplePicker/person-display.js +1 -1
- package/dist/components/PeoplePicker/person-display.js.map +1 -1
- package/dist/components/RadioGroup/radio-group.d.ts +1 -1
- package/dist/components/RadioGroup/radio-group.d.ts.map +1 -1
- package/dist/components/RadioGroup/radio-group.js +46 -14
- package/dist/components/RadioGroup/radio-group.js.map +1 -1
- package/dist/components/Rating/rating.d.ts.map +1 -1
- package/dist/components/Rating/rating.js +5 -3
- package/dist/components/Rating/rating.js.map +1 -1
- package/dist/components/Slider/slider.d.ts +1 -1
- package/dist/components/Slider/slider.d.ts.map +1 -1
- package/dist/components/Slider/slider.js +11 -6
- package/dist/components/Slider/slider.js.map +1 -1
- package/dist/components/Switch/switch.d.ts +9 -7
- package/dist/components/Switch/switch.d.ts.map +1 -1
- package/dist/components/Switch/switch.js +30 -5
- package/dist/components/Switch/switch.js.map +1 -1
- package/dist/components/Tabs/tabs.d.ts.map +1 -1
- package/dist/components/Tabs/tabs.js +9 -3
- package/dist/components/Tabs/tabs.js.map +1 -1
- package/ds-canonical/hooks/check_consumer_app_invariants.sh +9 -0
- package/ds-canonical/references/story-baseline-registry.json +18 -2
- package/ds-canonical/references/ui-dev-rules.md +21 -0
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/components/Accordion/accordion.spec.md +1 -1
- package/src/components/AppShell/app-shell.stories.tsx +3 -3
- package/src/components/Carousel/carousel.principles.stories.tsx +3 -3
- package/src/components/Checkbox/checkbox.spec.md +9 -1
- package/src/components/Checkbox/checkbox.tsx +45 -3
- package/src/components/Field/field-controls.spec.md +3 -1
- package/src/components/Field/field.anatomy.stories.tsx +3 -1
- package/src/components/Field/field.stories.tsx +14 -1
- package/src/components/PeoplePicker/person-display.tsx +4 -3
- package/src/components/ProgressBar/progress-bar.anatomy.stories.tsx +2 -2
- package/src/components/RadioGroup/radio-group.anatomy.stories.tsx +1 -1
- package/src/components/RadioGroup/radio-group.spec.md +2 -0
- package/src/components/RadioGroup/radio-group.tsx +59 -15
- package/src/components/Rating/rating.tsx +7 -3
- package/src/components/Sidebar/sidebar.spec.md +2 -0
- package/src/components/Slider/slider.anatomy.stories.tsx +2 -1
- package/src/components/Slider/slider.spec.md +8 -7
- package/src/components/Slider/slider.tsx +24 -11
- package/src/components/Switch/switch.anatomy.stories.tsx +4 -4
- package/src/components/Switch/switch.principles.stories.tsx +3 -3
- package/src/components/Switch/switch.spec.md +10 -6
- package/src/components/Switch/switch.tsx +45 -12
- package/src/components/Tabs/tabs.anatomy.stories.tsx +3 -3
- package/src/components/Tabs/tabs.principles.stories.tsx +1 -1
- package/src/components/Tabs/tabs.spec.md +4 -0
- package/src/components/Tabs/tabs.stories.tsx +4 -4
- package/src/components/Tabs/tabs.tsx +9 -3
- package/src/patterns/header-canonical/header-canonical.spec.md +1 -1
- package/src/styles/base.css +9 -2
- package/src/tokens/color/semantic.css +5 -1
|
@@ -6,11 +6,11 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|
|
6
6
|
|
|
7
7
|
import { cn } from "@/lib/utils"
|
|
8
8
|
import type { FieldMode, FieldVariant } from "@/design-system/components/Field/field-types"
|
|
9
|
-
import { useResolvedFieldMode, useResolvedFieldDisabled } from "@/design-system/components/Field/field-context"
|
|
9
|
+
import { useFieldContext, useResolvedFieldMode, useResolvedFieldDisabled, useResolvedFieldSize } from "@/design-system/components/Field/field-context"
|
|
10
10
|
import { SelectionItem } from "@/design-system/components/SelectionControl/selection-item"
|
|
11
11
|
import type { LucideIcon } from "lucide-react"
|
|
12
12
|
import type { AvatarData } from "@/design-system/components/Avatar/avatar"
|
|
13
|
-
import { EMPTY_DISPLAY } from "@/design-system/components/Field/field-wrapper"
|
|
13
|
+
import { EMPTY_DISPLAY, fieldWrapperStyles } from "@/design-system/components/Field/field-wrapper"
|
|
14
14
|
|
|
15
15
|
// ── RadioGroup display mode ─────────────────────────────────────────────────
|
|
16
16
|
// RadioGroup mode='display' 時:Group 不渲染 Radix primitive(無 radio 視覺),
|
|
@@ -29,7 +29,7 @@ export interface RadioGroupProps
|
|
|
29
29
|
* display — **純展示**:不渲染 Radix Root / 任何 radio 視覺;RadioGroup 本體 walk
|
|
30
30
|
* children,僅 control.value === group.value 那筆把 label 渲染為純文字 span。
|
|
31
31
|
* 對齊 Carbon read-only / DataTable single-select cell read mode。
|
|
32
|
-
* readonly —
|
|
32
|
+
* readonly — standalone:ReadonlyContext 傳 items 鎖互動保留視覺;Field 內:灰框 + 選中項 label(不渲染 radio 群組)
|
|
33
33
|
* disabled — 同 RadioGroupPrimitive.Root disabled 屬性
|
|
34
34
|
*/
|
|
35
35
|
mode?: FieldMode
|
|
@@ -44,12 +44,38 @@ export interface RadioGroupProps
|
|
|
44
44
|
// (item 已支援 readOnly prop + data-[readonly] 樣式;Radix Root 無 readOnly,故用 context)。
|
|
45
45
|
const RadioGroupReadonlyContext = React.createContext(false)
|
|
46
46
|
|
|
47
|
+
// walk children 找 control.value === selectedValue 的 SelectionItem label(display / readonly-in-Field 共用)
|
|
48
|
+
function findSelectedRadioLabel(children: React.ReactNode, selectedValue: string | undefined): React.ReactNode {
|
|
49
|
+
let selectedLabel: React.ReactNode = null
|
|
50
|
+
React.Children.forEach(children, (child) => {
|
|
51
|
+
if (!React.isValidElement(child)) return
|
|
52
|
+
const cProps = child.props as { control?: unknown; label?: React.ReactNode; value?: unknown }
|
|
53
|
+
// 形狀 1:<RadioGroupItem value label>(主用法)— value/label 直掛 props
|
|
54
|
+
if (cProps.value === selectedValue && cProps.value !== undefined) {
|
|
55
|
+
selectedLabel = cProps.label ?? selectedValue
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
// 形狀 2:<SelectionItem control={<RadioGroupItem value/>} label>(組合用法)
|
|
59
|
+
const control = cProps.control
|
|
60
|
+
if (React.isValidElement(control)) {
|
|
61
|
+
const controlValue = (control.props as { value?: unknown }).value
|
|
62
|
+
if (controlValue === selectedValue) {
|
|
63
|
+
selectedLabel = cProps.label ?? selectedValue
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
return selectedLabel
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
const RadioGroup = React.forwardRef<
|
|
48
71
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
|
49
72
|
RadioGroupProps
|
|
50
73
|
>(({ className, mode, variant: _chrome, value, defaultValue, ...props }, ref) => {
|
|
51
74
|
// 2026-06-08 SSOT cascade:resolvedMode 經 resolver hook 讀 fieldCtx(原 root 完全不讀 → <Field disabled>/<Field mode> 失效)
|
|
52
75
|
const resolvedMode = useResolvedFieldMode({ mode, disabled: (props as { disabled?: boolean }).disabled })
|
|
76
|
+
const fieldCtx = useFieldContext()
|
|
77
|
+
// readonly 灰框 size:走 SSOT resolver(RadioGroup 無 size prop → ctx > 'md')
|
|
78
|
+
const resolvedBoxSize = useResolvedFieldSize(undefined, 'md') as 'sm' | 'md' | 'lg'
|
|
53
79
|
// mode='display' — 純展示 selected option 的 label,不渲染任何 radio control 視覺。
|
|
54
80
|
// 對齊 Carbon read-only single-select(只顯示 selected 內容)+ Airtable / Notion read-only。
|
|
55
81
|
// 實作:walk children 找 control.value === selectedValue 的 SelectionItem,render label plain text。
|
|
@@ -59,18 +85,7 @@ const RadioGroup = React.forwardRef<
|
|
|
59
85
|
if (!selectedValue) {
|
|
60
86
|
return <div role="group" className={cn('grid', className)}><span className="text-fg-muted">{EMPTY_DISPLAY}</span></div>
|
|
61
87
|
}
|
|
62
|
-
|
|
63
|
-
React.Children.forEach(props.children, (child) => {
|
|
64
|
-
if (!React.isValidElement(child)) return
|
|
65
|
-
const cProps = child.props as { control?: unknown; label?: React.ReactNode }
|
|
66
|
-
const control = cProps.control
|
|
67
|
-
if (React.isValidElement(control)) {
|
|
68
|
-
const controlValue = (control.props as { value?: unknown }).value
|
|
69
|
-
if (controlValue === selectedValue) {
|
|
70
|
-
selectedLabel = cProps.label ?? selectedValue
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
})
|
|
88
|
+
const selectedLabel = findSelectedRadioLabel(props.children, selectedValue)
|
|
74
89
|
return (
|
|
75
90
|
<div role="group" className={cn('grid', className)}>
|
|
76
91
|
<span className="text-foreground">{selectedLabel ?? selectedValue}</span>
|
|
@@ -78,6 +93,35 @@ const RadioGroup = React.forwardRef<
|
|
|
78
93
|
)
|
|
79
94
|
}
|
|
80
95
|
|
|
96
|
+
// ── mode='readonly' in Field(2026-06-12 user 拍板,與 Checkbox/Switch 灰框模型一致)──
|
|
97
|
+
// Field 內 readonly 單選 = fieldWrapperStyles readonly 灰框 + 選中項 label 文字
|
|
98
|
+
// (= Select readonly 同款呈現:同為「單選資料」,鎖定時呈現一致)。
|
|
99
|
+
// standalone readonly(無 Field)維持原樣鎖互動(ReadonlyContext 路徑)。
|
|
100
|
+
if (resolvedMode === 'readonly' && fieldCtx?.hasFieldWrapper === true) {
|
|
101
|
+
const selectedValue = (value ?? defaultValue) as string | undefined
|
|
102
|
+
const selectedLabel = selectedValue ? findSelectedRadioLabel(props.children, selectedValue) : null
|
|
103
|
+
const boxSize = resolvedBoxSize
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
role="radiogroup"
|
|
107
|
+
aria-readonly="true"
|
|
108
|
+
aria-labelledby={fieldCtx?.labelId}
|
|
109
|
+
aria-invalid={fieldCtx?.invalid || undefined}
|
|
110
|
+
data-readonly="true"
|
|
111
|
+
tabIndex={0}
|
|
112
|
+
className={cn(
|
|
113
|
+
fieldWrapperStyles({ size: boxSize, mode: 'readonly', variant: 'default' }),
|
|
114
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
{selectedValue
|
|
119
|
+
? <span className="text-foreground">{selectedLabel ?? selectedValue}</span>
|
|
120
|
+
: <span className="text-fg-muted">{EMPTY_DISPLAY}</span>}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
81
125
|
// mode='disabled' → Radix Root disabled(原生 propagate 給所有 item);
|
|
82
126
|
// mode='readonly' → context 傳 readOnly 給 items(item 渲染為 data-[readonly] 鎖互動 + aria-readonly)。
|
|
83
127
|
return (
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { Star, type LucideIcon } from 'lucide-react'
|
|
4
4
|
import { cn } from '@/lib/utils'
|
|
5
|
-
import { useFieldContext, useResolvedFieldSize, useResolvedFieldDisabled } from '@/design-system/components/Field/field-context'
|
|
5
|
+
import { useFieldContext, useResolvedFieldSize, useResolvedFieldDisabled, useResolvedFieldMode } from '@/design-system/components/Field/field-context'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Rating — 星星評分元件
|
|
@@ -98,7 +98,7 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
|
|
|
98
98
|
max = 5,
|
|
99
99
|
size: sizeProp,
|
|
100
100
|
precision = 'full',
|
|
101
|
-
readOnly = false,
|
|
101
|
+
readOnly: readOnlyProp = false,
|
|
102
102
|
disabled: disabledProp,
|
|
103
103
|
loading = false,
|
|
104
104
|
icon: Icon = Star,
|
|
@@ -115,6 +115,10 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
|
|
|
115
115
|
const fieldCtx = useFieldContext() // 保留:aria-labelledby 用 fieldCtx.labelId
|
|
116
116
|
// 2026-06-08 SSOT:<Field disabled> cascade(原 isInteractive 只看 local disabled prop)
|
|
117
117
|
const disabled = useResolvedFieldDisabled(disabledProp)
|
|
118
|
+
// <Field mode="readonly"> cascade(2026-06-12 補):Rating 的 readonly 呈現 = 星星本身
|
|
119
|
+
// (星星即值語言,role=img,全業界 review-stars canonical)——不包灰框,只鎖互動。
|
|
120
|
+
const resolvedMode = useResolvedFieldMode({ mode: undefined, disabled, readOnly: readOnlyProp })
|
|
121
|
+
const readOnly = readOnlyProp || resolvedMode === 'readonly'
|
|
118
122
|
const size = useResolvedFieldSize<'xs' | 'sm' | 'md' | 'lg'>(sizeProp, 'xs') // SSOT:統一 size resolution(Rating default 'xs')
|
|
119
123
|
const [internalValue, setInternalValue] = React.useState(defaultValue)
|
|
120
124
|
const [hoverValue, setHoverValue] = React.useState<number | null>(null)
|
|
@@ -157,7 +161,7 @@ const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
|
|
|
157
161
|
// Standalone → 仍需 consumer 傳 aria-label。對齊 TimePicker / DatePicker 同 canonical
|
|
158
162
|
// (time-picker.tsx:313 / date-picker.tsx:514:aria-labelledby={fieldCtx?.labelId})。
|
|
159
163
|
// 置於 {...props} 前,consumer 顯式傳的 aria-labelledby 仍可覆寫。
|
|
160
|
-
aria-labelledby={
|
|
164
|
+
aria-labelledby={fieldCtx?.labelId} // 2026-06-12 修:readonly/disabled(role=img)也需 accessible name,labelledby 對 img 合法
|
|
161
165
|
aria-valuenow={isInteractive ? currentValue : undefined}
|
|
162
166
|
aria-valuemin={isInteractive ? 0 : undefined}
|
|
163
167
|
aria-valuemax={isInteractive ? max : undefined}
|
|
@@ -130,6 +130,8 @@ SidebarProvider ← 全域 context(open 狀態、cookie、快捷
|
|
|
130
130
|
|
|
131
131
|
**Chrome header 不跟 size 變**:`SidebarHeader` / `SidebarFooter` 固定用 `var(--chrome-header-height)`,只跟 **density** 連動,不跟 `size` prop 變——因為 header 是結構槽位,不是 row。
|
|
132
132
|
|
|
133
|
+
**SidebarHeader brand 必單行(2026-06-12 明文,fork drift anchor)**:Avatar 24 + 名稱單行,**無副標 slot**——Fixed-h chrome 結構上不可成長;plan/org 等第二行資訊歸 workspace switcher dropdown row,對齊 Slack/Notion/Linear 現行版單行。shadcn TeamSwitcher demo 的「Acme Inc + Enterprise」兩行是 demo 慣例非本 DS canonical(fork repo AI 曾以訓練先驗在 spec 沉默處回填此副標)。Registry antiPattern `sidebar-header-subtitle-second-line` 機械攔(flex-col 直接子層簽名)。
|
|
134
|
+
|
|
133
135
|
---
|
|
134
136
|
|
|
135
137
|
## 內容形態選擇(核心設計決策)
|
|
@@ -104,7 +104,8 @@ export const ColorMatrix: Story = {
|
|
|
104
104
|
<tr><Td>Range 填滿色 default</Td><Td><TokenCell token="--primary" display="bg-primary" /></Td><Td><TokenCell token="--primary" /></Td></tr>
|
|
105
105
|
<tr><Td>Range 填滿色 disabled</Td><Td><TokenCell token="--border" display="bg-border" /></Td><Td><TokenCell token="--border" display="--border(neutral-5)" /></Td></tr>
|
|
106
106
|
<tr><Td>Thumb 直徑</Td><Td mono>16px(h-4 w-4)</Td><Td>—</Td></tr>
|
|
107
|
-
<tr><Td>Thumb
|
|
107
|
+
<tr><Td>Thumb 底色 rest</Td><Td><TokenCell token="--on-emphasis" display="bg-on-emphasis(白,深淺不反轉)" /></Td><Td><TokenCell token="--on-emphasis" /></Td></tr>
|
|
108
|
+
<tr><Td>Thumb 底色 disabled</Td><Td><TokenCell token="--canvas" display="bg-canvas(不透明背景色,沉回)" /></Td><Td><TokenCell token="--canvas" /></Td></tr>
|
|
108
109
|
<tr><Td>Thumb 邊框 default</Td><Td><TokenCell token="--primary" display="border-2 border-primary" /></Td><Td><TokenCell token="--primary" display="--primary(與 Range default 同色)" /></Td></tr>
|
|
109
110
|
<tr><Td>Thumb 邊框 disabled</Td><Td><TokenCell token="--border" display="border-border" /></Td><Td><TokenCell token="--border" display="--border(與 Range disabled 同色)" /></Td></tr>
|
|
110
111
|
<tr><Td>Thumb hover</Td><Td mono>border primary-hover + --elevation-100</Td><Td>—</Td></tr>
|
|
@@ -101,7 +101,8 @@ Slider 接收 `size?: 'sm' | 'md' | 'lg'` prop(預設 `md`),**這個 prop 只決
|
|
|
101
101
|
| **Range** 填滿色 rest | `bg-primary` | `--primary` |
|
|
102
102
|
| **Range** 填滿色 disabled | `bg-border`(neutral-5)| `--border` |
|
|
103
103
|
| **Thumb** 直徑 | 16px | — |
|
|
104
|
-
| **Thumb** 底色
|
|
104
|
+
| **Thumb** 底色 rest | `bg-on-emphasis`(白,深淺主題不反轉;對齊 Switch thumb / Radix Themes / iOS) | `--on-emphasis` |
|
|
105
|
+
| **Thumb** 底色 disabled | `bg-canvas`(不透明頁面背景色,沉回背景;= Radix disabled thumb gray-1 同款) | `--canvas` |
|
|
105
106
|
| **Thumb** 邊框 rest | 2px,色 = Range rest 同 token | `--primary` |
|
|
106
107
|
| **Thumb** 邊框 disabled | `border-border`(= Range disabled 同色)| `--border` |
|
|
107
108
|
| **Thumb** hover | border `primary-hover` + 陰影 `--elevation-100` | `--primary-hover` |
|
|
@@ -191,11 +192,11 @@ Radix Slider 原生支援多 thumb——只要 `value` / `defaultValue` 傳長
|
|
|
191
192
|
|
|
192
193
|
| State | 視覺 | 觸發 |
|
|
193
194
|
|---|---|---|
|
|
194
|
-
| Rest | track `bg-secondary`,range `bg-primary`,thumb `bg-
|
|
195
|
+
| Rest | track `bg-secondary`,range `bg-primary`,thumb `bg-on-emphasis + border-primary` | 預設 |
|
|
195
196
|
| Hover(thumb) | 加 `--elevation-100` 陰影 | 滑鼠 hover 在 thumb 上 |
|
|
196
197
|
| Active(拖曳中) | 加 `--elevation-200` 陰影 | 按住拖曳 |
|
|
197
198
|
| Focus | thumb border 加深成 `primary-hover`(跟 hover 同視覺,不加 ring / halo)| 鍵盤 Tab 聚焦 |
|
|
198
|
-
| Disabled | 灰階降級:range `bg-border`、thumb `border-border`(= range 同 token)、thumb bg
|
|
199
|
+
| Disabled | 灰階降級:range `bg-border`、thumb `border-border`(= range 同 token)、thumb bg 沉回 `bg-canvas`(不透明背景色)、`cursor-not-allowed`、hover 陰影關閉 | `disabled` prop 或 Field context disabled |
|
|
199
200
|
|
|
200
201
|
**Mode / readonly / dark mode / density** 詳見 `../Field/field-controls.spec.md`(Slider 作為 Field 家族整合時繼承其 canonical;semantic token 自動處理 dark mode,無需元件內特殊 handling)。
|
|
201
202
|
|
|
@@ -208,7 +209,7 @@ track (muted, n-2) < range = thumb border (border, n-5) < text (n-7+)
|
|
|
208
209
|
**三階**,不是四階。Range 和 Thumb border 刻意用同一個 token(見前面「Range 色 ↔ Thumb border 色的綁定規則」),不拆成兩階。
|
|
209
210
|
|
|
210
211
|
- **Track n-2**:最底的凹槽底線
|
|
211
|
-
- **Range + Thumb border n-5**:同層——thumb border 是 range 的連續視覺,兩者融為一體;thumb 的 fill
|
|
212
|
+
- **Range + Thumb border n-5**:同層——thumb border 是 range 的連續視覺,兩者融為一體;thumb 的 fill(rest 白 / disabled canvas)與 range fill 對比,位置由 range 的長度段決定
|
|
212
213
|
- **Text n-7+**:label / description 在所有視覺元件之上
|
|
213
214
|
|
|
214
215
|
### 為什麼 range disabled 不用 `--fg-disabled`
|
|
@@ -267,7 +268,7 @@ Switch 是**唯一需要 opacity 的特例**,因為它的 on/off 沒有任何形
|
|
|
267
268
|
|
|
268
269
|
### 常見錯誤(避免)
|
|
269
270
|
|
|
270
|
-
**不要把 thumb 的 disabled bg 改成 `bg-muted`**——`--muted` 和 `--bg-disabled` 在這個系統都等於 `var(--color-neutral-2)`,同一個顏色。Thumb `bg-muted` 會跟 track `bg-muted` 完全融色,只剩 border 可見,失去 thumb 形狀辨識(真實踩過的 bug)
|
|
271
|
+
**不要把 thumb 的 disabled bg 改成 `bg-muted`**——`--muted` 和 `--bg-disabled` 在這個系統都等於 `var(--color-neutral-2)`,同一個顏色。Thumb `bg-muted` 會跟 track `bg-muted` 完全融色,只剩 border 可見,失去 thumb 形狀辨識(真實踩過的 bug)。Disabled 用 **`bg-canvas`**(不透明頁面背景色,與 track 的 muted 不同值且隔 n-5 邊框,不融色)。2026-06-12 補:rest 態原為 `bg-surface`,但 `--surface` 深色 = 8% 白半透明 → thumb 在深色變破洞且 track 穿透,故改 `bg-on-emphasis`(固定白不反轉)。
|
|
271
272
|
|
|
272
273
|
**不要同時套 opacity + 灰階 swap**——兩個策略互斥,同時用會導致「灰階 swap 後再打 opacity 一層」,視覺雙重降級,整個 slider 褪色過度。選一條路走到底。
|
|
273
274
|
|
|
@@ -275,9 +276,9 @@ Switch 是**唯一需要 opacity 的特例**,因為它的 on/off 沒有任何形
|
|
|
275
276
|
|
|
276
277
|
Slider 沒有獨立的 error 視覺——拖曳選值本身不太會「無效」。如果業務邏輯需要限制範圍,用 `min` / `max` 直接限制使用者能拖到的範圍,不要讓他拖到再報錯。
|
|
277
278
|
|
|
278
|
-
###
|
|
279
|
+
### Readonly(僅 Field cascade,無獨立 prop)
|
|
279
280
|
|
|
280
|
-
Slider
|
|
281
|
+
Slider 無獨立 `readOnly` prop;但在 `<Field mode="readonly">` 內(2026-06-12 拍板)= **鎖互動、保留正常視覺**(pointer-events-none + thumb tabIndex=-1 + aria-readonly on thumb;值仍可讀、不降色——readonly ≠ disabled)。理由:readonly 表單中 Slider 的值(thumb 位置 + range 長度)本身就是 value 呈現。若要在非表單情境顯示「歷史值」,用純文字或另一個 display 元件。
|
|
281
282
|
|
|
282
283
|
---
|
|
283
284
|
|
|
@@ -2,7 +2,7 @@ import * as React from 'react'
|
|
|
2
2
|
import * as SliderPrimitive from '@radix-ui/react-slider'
|
|
3
3
|
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
4
|
import { cn } from '@/lib/utils'
|
|
5
|
-
import { useFieldContext, useResolvedFieldDisabled } from '@/design-system/components/Field/field-context'
|
|
5
|
+
import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode } from '@/design-system/components/Field/field-context'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Slider — 數值範圍選取器
|
|
@@ -61,7 +61,10 @@ const Slider = React.forwardRef<
|
|
|
61
61
|
// Field 家族整合:被 <Field mode="disabled"> 包裹時自動 disabled(per slider.spec.md「Slider 作為 Field
|
|
62
62
|
// 家族整合時繼承其 canonical」)。Slider 已有完整 data-[disabled] 視覺,故只需把 fieldCtx disabled 接上。
|
|
63
63
|
// 2026-06-08 SSOT:讀 useResolvedFieldDisabled()(fieldCtx.disabled)→ <Field disabled> 與 <Field mode="disabled"> 都生效
|
|
64
|
+
// 2026-06-12 補:<Field mode="readonly"> → 鎖互動保留視覺(readonly ≠ disabled,不降色)
|
|
64
65
|
const fieldDisabled = useResolvedFieldDisabled()
|
|
66
|
+
const fieldMode = useResolvedFieldMode({ mode: undefined, disabled: undefined, readOnly: undefined })
|
|
67
|
+
const fieldReadonly = fieldMode === 'readonly'
|
|
65
68
|
// 2026-06-10 a11y:Field 內 Slider thumb(role=slider)無 accessible name(deep-audit axe 抓 aria-input-field-name)
|
|
66
69
|
// → 預設接 FieldLabel(aria-labelledby),consumer ariaLabel 優先。對齊 rating/time-picker labelId 接線。
|
|
67
70
|
const fieldLabelId = useFieldContext()?.labelId
|
|
@@ -77,9 +80,12 @@ const Slider = React.forwardRef<
|
|
|
77
80
|
ref={ref}
|
|
78
81
|
value={value}
|
|
79
82
|
defaultValue={defaultValue}
|
|
80
|
-
className={cn(sliderRootVariants({ size }), className)}
|
|
83
|
+
className={cn(sliderRootVariants({ size }), fieldReadonly && 'pointer-events-none', className)}
|
|
81
84
|
{...props}
|
|
82
85
|
disabled={(props as { disabled?: boolean }).disabled || fieldDisabled}
|
|
86
|
+
// <Field mode="readonly"> cascade(2026-06-12 補):鎖互動、保留正常視覺(readonly
|
|
87
|
+
// 不降色;值仍可讀)。pointer-events-none 擋滑鼠,thumb tabIndex=-1 擋鍵盤。
|
|
88
|
+
data-readonly={fieldReadonly || undefined}
|
|
83
89
|
>
|
|
84
90
|
{/*
|
|
85
91
|
Track — rest 用 bg-secondary(n-3,「微淡可辨」),disabled 用 bg-muted(n-2,退化)。
|
|
@@ -115,27 +121,34 @@ const Slider = React.forwardRef<
|
|
|
115
121
|
- Rest: `border-primary` ↔ Range `bg-primary`
|
|
116
122
|
- Disabled: `border-border` ↔ Range `bg-border`
|
|
117
123
|
這個一致性讓 thumb border 跟 range 融為一體,看起來像「range 包住 thumb」
|
|
118
|
-
|
|
119
|
-
清楚浮出。不論 state,thumb border 跟 range 永遠同色。
|
|
124
|
+
的連續視覺。不論 state,thumb border 跟 range 永遠同色。
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
**Thumb bg 兩態(2026-06-12 user 拍板,深色模式破洞修正)**:
|
|
127
|
+
- Rest/hover/active:`bg-on-emphasis`(固定白、深淺主題不反轉)— 對齊自家
|
|
128
|
+
Switch thumb(switch.tsx bg-on-emphasis)+ Radix Themes(thumb 字面 white
|
|
129
|
+
無 dark override)+ Apple iOS(深色模式旋鈕仍白)。原 `bg-surface` 在深色
|
|
130
|
+
= 8% 白半透明 → thumb 變深色破洞且 track 穿透。
|
|
131
|
+
- Disabled:`bg-canvas`(不透明頁面背景色)— 沉回背景表達「不可動」,
|
|
132
|
+
= Radix disabled thumb 用 gray-1(app background)同款;不透明故 track
|
|
133
|
+
不穿透。不可用 bg-muted(曾踩 thumb 跟 track 同色融合 bug;canvas 與
|
|
134
|
+
track 的 muted 隔 n-5 邊框 + 不同值,不融)。
|
|
124
135
|
*/}
|
|
125
136
|
{Array.from({ length: thumbCount }).map((_, i) => (
|
|
126
137
|
<SliderPrimitive.Thumb
|
|
127
138
|
key={i}
|
|
139
|
+
tabIndex={fieldReadonly ? -1 : undefined}
|
|
140
|
+
aria-readonly={fieldReadonly || undefined}
|
|
128
141
|
className={cn(
|
|
129
142
|
'block h-4 w-4 shrink-0 rounded-full cursor-grab',
|
|
130
|
-
'bg-
|
|
143
|
+
'bg-on-emphasis border-2 border-primary',
|
|
131
144
|
'transition-all duration-150',
|
|
132
145
|
// Hover:border 加深到 primary-hover + elevation 陰影
|
|
133
146
|
'hover:border-primary-hover hover:[box-shadow:var(--elevation-100)]',
|
|
134
147
|
'active:cursor-grabbing active:border-primary-hover active:[box-shadow:var(--elevation-200)]',
|
|
135
148
|
// Focus:border 加深(跟 hover 同視覺),不加 ring 或 halo
|
|
136
149
|
'outline-none focus-visible:border-primary-hover',
|
|
137
|
-
// Disabled:border 跟 Range 一起退成 border(n-5),bg
|
|
138
|
-
'data-[disabled]:cursor-not-allowed data-[disabled]:border-border',
|
|
150
|
+
// Disabled:border 跟 Range 一起退成 border(n-5),bg 沉回 canvas(不透明背景色)
|
|
151
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:border-border data-[disabled]:bg-canvas',
|
|
139
152
|
'data-[disabled]:hover:[box-shadow:none]',
|
|
140
153
|
)}
|
|
141
154
|
// aria-label 策略(對齊 WAI-ARIA APG multi-thumb slider + Radix 原生語意標籤):
|
|
@@ -174,7 +187,7 @@ export const sliderMeta = {
|
|
|
174
187
|
},
|
|
175
188
|
states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
|
|
176
189
|
tokens: {
|
|
177
|
-
bg: ['bg-muted', 'bg-primary', 'bg-secondary', 'bg-
|
|
190
|
+
bg: ['bg-muted', 'bg-primary', 'bg-secondary', 'bg-on-emphasis', 'bg-canvas'],
|
|
178
191
|
fg: [],
|
|
179
192
|
ring: [],
|
|
180
193
|
},
|
|
@@ -45,7 +45,7 @@ export const Overview: Story = {
|
|
|
45
45
|
['label', 'ReactNode', '—', 'inline label(Field context 內會被忽略)'],
|
|
46
46
|
['description', 'ReactNode', '—', 'inline description(與 label 搭配)'],
|
|
47
47
|
['disabled', 'boolean', 'false', '停用(opacity-disabled 保留顏色,見 spec)'],
|
|
48
|
-
['readOnly', 'boolean', 'false', '
|
|
48
|
+
['readOnly', 'boolean', 'false', 'standalone:鎖定互動視覺正常;Field 內:灰框 + ✓/—(2026-06-12 拍板)'],
|
|
49
49
|
].map(([p, t, d, desc]) => (
|
|
50
50
|
<tr key={p}><Td mono>{p}</Td><Td mono>{t}</Td><Td mono>{d}</Td><Td>{desc}</Td></tr>
|
|
51
51
|
))}
|
|
@@ -87,7 +87,7 @@ export const Inspector: InspectorStory = {
|
|
|
87
87
|
},
|
|
88
88
|
defaultChecked: { control: 'boolean', description: '預設 ON / OFF' },
|
|
89
89
|
disabled: { control: 'boolean', description: '停用(opacity-disabled 保留顏色)' },
|
|
90
|
-
readOnly: { control: 'boolean', description: '唯讀(
|
|
90
|
+
readOnly: { control: 'boolean', description: '唯讀(standalone 視覺正常鎖互動;Field 內灰框 + ✓/—)' },
|
|
91
91
|
label: { control: 'text', description: 'inline label(留空 → 只渲染 switch 本體)' },
|
|
92
92
|
description: { control: 'text', description: 'inline description(需搭配 label)' },
|
|
93
93
|
},
|
|
@@ -135,7 +135,7 @@ export const StateBehavior: Story = {
|
|
|
135
135
|
<div className="flex flex-col gap-8">
|
|
136
136
|
<div>
|
|
137
137
|
<H3>視覺狀態對照</H3>
|
|
138
|
-
<Desc>disabled 用 opacity-disabled 保留顏色(Switch 特例,見下「Disabled 策略」)。readonly
|
|
138
|
+
<Desc>disabled 用 opacity-disabled 保留顏色(Switch 特例,見下「Disabled 策略」)。readonly:standalone 視覺正常鎖互動;Field 內灰框 + ✓/—。</Desc>
|
|
139
139
|
<div className="overflow-x-auto mb-4">
|
|
140
140
|
<table className="text-caption border-collapse">
|
|
141
141
|
<thead><tr><Th>State</Th><Th>Track</Th><Th>Thumb</Th><Th>Check icon</Th></tr></thead>
|
|
@@ -144,7 +144,7 @@ export const StateBehavior: Story = {
|
|
|
144
144
|
<tr><Td>ON</Td><Td><TokenCell token="--primary" display="bg-primary" /></Td><Td><span className="inline-flex items-center gap-1.5"><Swatch value="--primary" size="sm" /><span className="font-mono">白色 + 2px primary border</span></span></Td><Td><TokenCell token="--primary" display="primary check" /></Td></tr>
|
|
145
145
|
<tr><Td>Disabled OFF</Td><Td>opacity-disabled 套於整體</Td><Td>同 OFF(顏色保留)</Td><Td>—</Td></tr>
|
|
146
146
|
<tr><Td>Disabled ON</Td><Td>opacity-disabled 套於整體</Td><Td>同 ON(顏色保留)</Td><Td>同 ON</Td></tr>
|
|
147
|
-
<tr><Td>Readonly</Td><Td>正常顏色</Td><Td>正常視覺</Td><Td>視 on/off</Td></tr>
|
|
147
|
+
<tr><Td>Readonly(standalone)</Td><Td>正常顏色</Td><Td>正常視覺</Td><Td>視 on/off</Td></tr>
|
|
148
148
|
</tbody>
|
|
149
149
|
</table>
|
|
150
150
|
</div>
|
|
@@ -99,7 +99,7 @@ export const UsageGuidance: Story = {
|
|
|
99
99
|
{/* vs 近親 — ReadonlyVsDisabledRule — 原 ReadonlyVsDisabledRule */}
|
|
100
100
|
<div>
|
|
101
101
|
<Rule
|
|
102
|
-
title="Readonly
|
|
102
|
+
title="Readonly(standalone)保留正常顏色(可讀)/ Disabled 降透明度(弱化)"
|
|
103
103
|
note="兩者都鎖定互動,但視覺訊號不同:readonly 告訴使用者「這個值就是這樣,你看得清」;disabled 告訴使用者「這個 field 目前不可用」(弱化暗示低優先)"
|
|
104
104
|
>
|
|
105
105
|
<div className="grid grid-cols-2 gap-4">
|
|
@@ -116,9 +116,9 @@ export const UsageGuidance: Story = {
|
|
|
116
116
|
|
|
117
117
|
<Rule
|
|
118
118
|
title="使用場景對照"
|
|
119
|
-
note="Readonly
|
|
119
|
+
note="Readonly(standalone settings list):值重要、視覺不能弱化。表單 readonly = Field 內灰框 + ✓/—(2026-06-12 拍板);DataTable cell 非編輯態 = mode display。Disabled:外部條件造成暫時不可用(方案限制、權限不足)——傳達「現在用不到」"
|
|
120
120
|
>
|
|
121
|
-
<Label>兩者都不可切換、不在 tab order
|
|
121
|
+
<Label>兩者都不可切換、不在 tab order 內(standalone),機制不同:readonly 用 `pointer-events-none`(視覺正常);disabled 走 native `disabled` + `cursor-not-allowed`(降透明度)。Field 內 readonly 灰框則可聚焦(tabIndex=0)</Label>
|
|
122
122
|
</Rule>
|
|
123
123
|
</div>
|
|
124
124
|
</div>
|
|
@@ -102,7 +102,8 @@ sm 和 md 視覺相同(純粹命名 mapping,讓消費者可直接傳同一
|
|
|
102
102
|
| OFF | `bg-border`(neutral-5) | 白色 + 2px `border-border`(neutral-5,與 OFF track 同色) | 無 |
|
|
103
103
|
| ON | `bg-primary` | 白色 + 2px primary border | primary check |
|
|
104
104
|
| Disabled | 套 `opacity-disabled`(整體透明度降級) | 同 ON/OFF | 同 ON/OFF |
|
|
105
|
-
| Readonly
|
|
105
|
+
| Readonly(standalone)| 視覺同一般態 | 但 `pointer-events-none` + `aria-readonly` | — |
|
|
106
|
+
| Readonly(Field 內,2026-06-12 user 拍板)| 不渲染 toggle — 改渲染 `fieldWrapperStyles` readonly 灰框(= Input readonly 同源)+ ✓/—(display 同款值語言) | role="switch" + aria-checked + aria-readonly + 可 focus | — |
|
|
106
107
|
|
|
107
108
|
### Disabled 用 `opacity`
|
|
108
109
|
|
|
@@ -117,22 +118,25 @@ sm 和 md 視覺相同(純粹命名 mapping,讓消費者可直接傳同一
|
|
|
117
118
|
|
|
118
119
|
詳細對照見 `../Slider/slider.spec.md`「Disabled 策略」節。
|
|
119
120
|
|
|
120
|
-
### Readonly vs Disabled
|
|
121
|
+
### Readonly vs Disabled(standalone;Field 內 readonly = 灰框 + ✓/—,見上方狀態表)
|
|
121
122
|
|
|
122
|
-
| | Readonly | Disabled |
|
|
123
|
+
| | Readonly(standalone) | Disabled |
|
|
123
124
|
|---|---|---|
|
|
124
125
|
| 視覺 | 正常顏色(可讀) | 降透明度(弱化) |
|
|
125
126
|
| 互動 | 不可切換(pointer-events-none) | 不可切換(cursor-not-allowed) |
|
|
126
127
|
| aria | `aria-readonly` | `disabled` |
|
|
127
128
|
| Tab 焦點 | 不在 tab order | 不在 tab order |
|
|
128
|
-
| 用途 |
|
|
129
|
+
| 用途 | settings list 鎖定呈現 | 外部條件造成不可操作 |
|
|
130
|
+
|
|
131
|
+
表單 readonly 呈現 = Field 內灰框 + ✓/—(2026-06-12 拍板);DataTable cell 非編輯態 = `mode="display"`(✓/—),皆非本表 standalone readonly。
|
|
129
132
|
|
|
130
133
|
### `mode` prop(Field mode,正交於 size)
|
|
131
134
|
|
|
132
135
|
`mode?: 'edit' | 'display' | 'readonly' | 'disabled'`(默認 inherit Field context 或 `'edit'`),對齊 `field-types.ts` FieldMode:
|
|
133
136
|
- `edit`(預設)— 可切換的 Switch。
|
|
134
137
|
- `display` — **純展示**:渲染 ✓ / —(非互動 Switch),語意由 context(如 DataTable boolean cell 的行/列 header)提供,對齊 Carbon read-only / Input·Select·Textarea display mode 一致。
|
|
135
|
-
- `readonly`
|
|
138
|
+
- `readonly` — **Field 內** = 灰框 + ✓/—(消費 `fieldWrapperStyles` readonly,與 Input readonly 同一視覺語言;世界級:Salesforce output ✓ glyph / SAP 靜態文字 — readonly 下 boolean 是資料 value 非控件);**standalone** = 正常色鎖互動(settings list 場景)。
|
|
139
|
+
- `disabled` — 同上表「Readonly vs Disabled」(opacity 弱化);`mode='disabled'` 直傳與 `disabled` prop 等效(effectiveDisabled,2026-06-12)。
|
|
136
140
|
|
|
137
141
|
---
|
|
138
142
|
|
|
@@ -177,7 +181,7 @@ Switch 可透過 `label` / `description` props 內部直接渲染緊鄰文字:
|
|
|
177
181
|
|
|
178
182
|
## 邊界案例
|
|
179
183
|
|
|
180
|
-
- **Readonly 的滑鼠 /
|
|
184
|
+
- **Readonly 的滑鼠 / 鍵盤**(standalone):`pointer-events-none` 使點擊無反應;`tabIndex=-1` 不入 tab order,故無鍵盤互動(視覺正常、僅鎖互動)。Field 內 readonly 灰框則 tabIndex=0 可聚焦(對齊 readonly input 可聚焦慣例)
|
|
181
185
|
- **Disabled 的鍵盤**:native `disabled`——不可聚焦、無鍵盤行為
|
|
182
186
|
- **Label 過長**:自動換行(label 容器 `flex-1 min-w-0`),Switch 錨定第一行行高置中(`h-[1lh]`)且不被擠壓(`shrink-0`)
|
|
183
187
|
- **無 loading state**:Switch 無 loading prop——async 進度不屬 Switch(見「何時不用」,用 Button loading)
|
|
@@ -5,7 +5,8 @@ import { Check } from 'lucide-react'
|
|
|
5
5
|
import { cva, type VariantProps } from 'class-variance-authority'
|
|
6
6
|
import { cn } from '@/lib/utils'
|
|
7
7
|
import type { FieldMode, FieldVariant } from '@/design-system/components/Field/field-types'
|
|
8
|
-
import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode } from '@/design-system/components/Field/field-context'
|
|
8
|
+
import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode, useResolvedFieldSize } from '@/design-system/components/Field/field-context'
|
|
9
|
+
import { fieldWrapperStyles } from '@/design-system/components/Field/field-wrapper'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Switch — 開關控件
|
|
@@ -22,7 +23,8 @@ import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode } from
|
|
|
22
23
|
* OFF: track border (neutral-5), thumb 白色 + 2px border-border(neutral-5,與 OFF track 同色)無 check
|
|
23
24
|
* ON: track primary, thumb 白色 + 2px primary border + primary check icon
|
|
24
25
|
* disabled: opacity-disabled(整體透明度)
|
|
25
|
-
* readOnly: 視覺同一般態,但 pointer-events-none + aria-readonly
|
|
26
|
+
* readOnly(standalone): 視覺同一般態,但 pointer-events-none + aria-readonly
|
|
27
|
+
* readOnly(Field 內): 渲染 readonly 灰框 + ✓/—(2026-06-12 拍板,= Input readonly 同視覺語言)
|
|
26
28
|
*
|
|
27
29
|
* ── label / description / readOnly ──
|
|
28
30
|
* Switch 可以透過 `label` 和 `description` props 在元件內直接渲染緊鄰的文字,
|
|
@@ -42,7 +44,8 @@ import { useFieldContext, useResolvedFieldDisabled, useResolvedFieldMode } from
|
|
|
42
44
|
*
|
|
43
45
|
* readOnly 模式:
|
|
44
46
|
* <Switch readOnly checked={true} label="..." />
|
|
45
|
-
*
|
|
47
|
+
* standalone:視覺維持 ON/OFF 正確狀態,但無法互動、不在 tab order 內、寫入 aria-readonly。
|
|
48
|
+
* Field 內:渲染 readonly 灰框 + ✓/—(不渲染 toggle;見 forwardRef 內 readonly 分支)。
|
|
46
49
|
* 與 disabled 的差異:readonly 不降色(可讀),disabled 降色(弱化)。
|
|
47
50
|
*/
|
|
48
51
|
|
|
@@ -98,9 +101,9 @@ export interface SwitchProps
|
|
|
98
101
|
*/
|
|
99
102
|
description?: React.ReactNode
|
|
100
103
|
/**
|
|
101
|
-
* readonly
|
|
102
|
-
* 與 disabled
|
|
103
|
-
*
|
|
104
|
+
* readonly 模式:standalone = 鎖定互動但維持 ON/OFF 視覺;Field 內 = 灰框 + ✓/—。
|
|
105
|
+
* 與 disabled 的差異:readonly 不降色(可讀),disabled 降色(弱化)。
|
|
106
|
+
* DataTable cell 非編輯態用 mode="display"(✓/—),非 readOnly。
|
|
104
107
|
*/
|
|
105
108
|
readOnly?: boolean
|
|
106
109
|
/**
|
|
@@ -108,8 +111,8 @@ export interface SwitchProps
|
|
|
108
111
|
* edit — 一般可互動 Switch(預設)
|
|
109
112
|
* display — **純展示**:渲染 ✓ / —(無互動 primitive、無 input chrome);
|
|
110
113
|
* 對齊 Carbon read-only / DataTable boolean cell。
|
|
111
|
-
* `
|
|
112
|
-
* readonly — 同 readOnly prop
|
|
114
|
+
* `display` 完全無 toggle 形體;`readonly` 視場景(field-types.ts)。
|
|
115
|
+
* readonly — standalone 同 readOnly prop(保留視覺鎖互動);Field 內 = 灰框 + ✓/—
|
|
113
116
|
* disabled — 同 disabled prop
|
|
114
117
|
*/
|
|
115
118
|
mode?: FieldMode
|
|
@@ -151,6 +154,10 @@ const Switch = React.forwardRef<
|
|
|
151
154
|
const disabled = useResolvedFieldDisabled(disabledProp)
|
|
152
155
|
const resolvedMode = useResolvedFieldMode({ mode, disabled, readOnly })
|
|
153
156
|
const effectiveReadOnly = readOnly || resolvedMode === 'readonly'
|
|
157
|
+
// mode='disabled' 直傳(無 Field ctx 的 cell 場景)必須落到真 disabled chrome(同 Checkbox 2026-06-12 修)
|
|
158
|
+
const effectiveDisabled = disabled || resolvedMode === 'disabled'
|
|
159
|
+
// readonly 灰框 size:走 SSOT resolver(prop > ctx > 'md',field-context.ts:150-161)
|
|
160
|
+
const resolvedBoxSize = useResolvedFieldSize(size ?? undefined, 'md') as 'sm' | 'md' | 'lg'
|
|
154
161
|
const insideField = fieldCtx?.hasFieldWrapper === true
|
|
155
162
|
const effectiveLabel = insideField ? undefined : label
|
|
156
163
|
const effectiveDescription = insideField ? undefined : description
|
|
@@ -176,12 +183,38 @@ const Switch = React.forwardRef<
|
|
|
176
183
|
: <span className="text-fg-muted">—</span>
|
|
177
184
|
}
|
|
178
185
|
|
|
186
|
+
// ── mode='readonly' in Field(2026-06-12 user 拍板「灰框 + ✓/—」,與 Checkbox 同款)──
|
|
187
|
+
// Field 內 readonly boolean = fieldWrapperStyles readonly 灰框(= Input readonly 同源)
|
|
188
|
+
// + ✓/— 值語言;standalone readOnly(settings list)維持原樣鎖互動。詳 checkbox.tsx 同段註解。
|
|
189
|
+
if (effectiveReadOnly && insideField) {
|
|
190
|
+
const isChecked = (props.checked ?? props.defaultChecked) === true
|
|
191
|
+
const boxSize = resolvedBoxSize
|
|
192
|
+
return (
|
|
193
|
+
<div
|
|
194
|
+
role="switch"
|
|
195
|
+
aria-checked={isChecked}
|
|
196
|
+
aria-readonly="true"
|
|
197
|
+
aria-labelledby={fieldCtx?.labelId}
|
|
198
|
+
aria-invalid={fieldCtx?.invalid || undefined}
|
|
199
|
+
data-readonly="true"
|
|
200
|
+
tabIndex={0}
|
|
201
|
+
className={cn(
|
|
202
|
+
fieldWrapperStyles({ size: boxSize, mode: 'readonly', variant: 'default' }),
|
|
203
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
204
|
+
className,
|
|
205
|
+
)}
|
|
206
|
+
>
|
|
207
|
+
{isChecked ? <span className="text-foreground">✓</span> : <span className="text-fg-muted">—</span>}
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
179
212
|
const rootEl = (
|
|
180
213
|
<SwitchPrimitives.Root
|
|
181
214
|
id={inputId}
|
|
182
215
|
className={cn(switchVariants({ size }), alignRightInField, className)}
|
|
183
216
|
ref={ref}
|
|
184
|
-
disabled={
|
|
217
|
+
disabled={effectiveDisabled}
|
|
185
218
|
aria-readonly={effectiveReadOnly || undefined}
|
|
186
219
|
data-readonly={effectiveReadOnly || undefined}
|
|
187
220
|
tabIndex={effectiveReadOnly ? -1 : undefined}
|
|
@@ -220,7 +253,7 @@ const Switch = React.forwardRef<
|
|
|
220
253
|
htmlFor={inputId}
|
|
221
254
|
className={cn(
|
|
222
255
|
'inline-flex items-start gap-3 select-none',
|
|
223
|
-
|
|
256
|
+
effectiveDisabled ? 'cursor-not-allowed' : readOnly ? 'cursor-default' : 'cursor-pointer'
|
|
224
257
|
)}
|
|
225
258
|
>
|
|
226
259
|
{/* Label↔desc gap typography-mode-aware:
|
|
@@ -237,7 +270,7 @@ const Switch = React.forwardRef<
|
|
|
237
270
|
className={cn(
|
|
238
271
|
// Reading mode 字級:lg → text-body-lg (16px),sm/md → text-body (14px)
|
|
239
272
|
sizeKey === 'lg' ? 'text-body-lg' : 'text-body',
|
|
240
|
-
|
|
273
|
+
effectiveDisabled ? 'text-fg-disabled' : 'text-foreground'
|
|
241
274
|
)}
|
|
242
275
|
>
|
|
243
276
|
{effectiveLabel}
|
|
@@ -245,7 +278,7 @@ const Switch = React.forwardRef<
|
|
|
245
278
|
{effectiveDescription != null && (
|
|
246
279
|
<span
|
|
247
280
|
className={cn(
|
|
248
|
-
|
|
281
|
+
effectiveDisabled ? 'text-fg-disabled' : 'text-fg-secondary'
|
|
249
282
|
)}
|
|
250
283
|
// Reading mode description:**最小 14px**(spec 14→14px, 16→14px),lh 預設 1.5。
|
|
251
284
|
// 用 inline style 直接繞過 tailwind-merge 對 text-body / text-fg-* 的潛在衝突。
|
|
@@ -166,13 +166,13 @@ export const Inspector: Story = {
|
|
|
166
166
|
</>
|
|
167
167
|
)}
|
|
168
168
|
</TabsList>
|
|
169
|
-
<TabsContent value="overview" className="
|
|
169
|
+
<TabsContent value="overview" className=" text-body text-fg-secondary">
|
|
170
170
|
專案的總覽資訊(KPI / 最近活動 / 團隊成員簡介)
|
|
171
171
|
</TabsContent>
|
|
172
|
-
<TabsContent value="members" className="
|
|
172
|
+
<TabsContent value="members" className=" text-body text-fg-secondary">
|
|
173
173
|
專案成員列表(3 人待邀請)
|
|
174
174
|
</TabsContent>
|
|
175
|
-
<TabsContent value="settings" className="
|
|
175
|
+
<TabsContent value="settings" className=" text-body text-fg-secondary">
|
|
176
176
|
專案設定(12 個通知待閱讀)
|
|
177
177
|
</TabsContent>
|
|
178
178
|
</Tabs>
|
|
@@ -120,7 +120,7 @@ export const UsageGuidance: Story = {
|
|
|
120
120
|
<TabsTrigger value="products">產品</TabsTrigger>
|
|
121
121
|
<TabsTrigger value="settings" startIcon={Settings}>設定</TabsTrigger>
|
|
122
122
|
</TabsList>
|
|
123
|
-
<TabsContent value="orders" className="
|
|
123
|
+
<TabsContent value="orders" className="text-body text-fg-muted">(訂單頁的 toolbar、filters、table…)</TabsContent>
|
|
124
124
|
</Tabs>
|
|
125
125
|
</div>
|
|
126
126
|
<div>
|
|
@@ -181,6 +181,10 @@ Tabs 是 **navigation anchor**,不是 compact control:
|
|
|
181
181
|
|
|
182
182
|
**Triggers 之間 gap**:使用 `--layout-space-loose`(md density 16px / lg density 24px),與其他「pattern 層級的鬆散留白」共用同一 token。Tabs 的 gap 不是裝飾性空白,而是告訴使用者「每個 tab 是獨立的視圖入口」的視覺分隔。
|
|
183
183
|
|
|
184
|
+
**TabsList 容器寬度(2026-06-12 user 拍板,解 L58 spec≠code)**:**跨滿父容器**(none 模式 `w-full`;scroll/menu 模式 inner list `min-w-full` 保留溢出成長)——底線連成整條、右端達內容區邊緣,與「跨父容器寬度、與 header border 對齊」的角色定義一致(對齊 Ant nav `left:0;right:0` 底線 / Primer UnderlineNav;shadcn 為 pill 式不同型不適用)。Trigger 本身仍 hug content(上段不變)。**左右邊距 ownership**:TabsList **不自加水平 padding**——邊距由容器的 `--layout-space-loose` gutter 提供(standalone = 父容器 padding;withTabs = 繼承 header padding,W2 lockstep),故 tab 左緣永遠對齊兄弟內容左緣。
|
|
185
|
+
|
|
186
|
+
**TabsContent 與 TabsList 間距(2026-06-12 user 拍板)**:`--layout-space-tight`(md 12px,density 連動),**內建於 TabsContent**(`mt-[var(--layout-space-tight)]`,className 可覆寫;full-height 佈局用 `mt-0`)。依 `layoutSpace.spec`「親疏 3 級」第一級(同 bundle,元件 spec own)+ 規則 3「直接功能依賴 = tight」精神;**不**取 Ant 的固定 16px(M23 自家 token 優先)。內容本身的排版歸 item-anatomy / 各內容元件管,本條只管 list↔content gap。
|
|
187
|
+
|
|
184
188
|
---
|
|
185
189
|
|
|
186
190
|
## Underline 與 TabsList border 的視覺關係
|
|
@@ -32,10 +32,10 @@ export const Default: Story = {
|
|
|
32
32
|
<TabsTrigger value="notifications" badge={<Badge count={3} />}>通知</TabsTrigger>
|
|
33
33
|
<TabsTrigger value="settings">設定</TabsTrigger>
|
|
34
34
|
</TabsList>
|
|
35
|
-
<TabsContent value="overview" className="
|
|
36
|
-
<TabsContent value="members" className="
|
|
37
|
-
<TabsContent value="notifications" className="
|
|
38
|
-
<TabsContent value="settings" className="
|
|
35
|
+
<TabsContent value="overview" className="text-body text-fg-secondary">專案的總覽資訊(KPI、最近活動、團隊成員簡介)</TabsContent>
|
|
36
|
+
<TabsContent value="members" className="text-body text-fg-secondary">專案成員列表(3 人待邀請)</TabsContent>
|
|
37
|
+
<TabsContent value="notifications" className="text-body text-fg-secondary">3 則未讀通知(提及、指派、留言回覆)</TabsContent>
|
|
38
|
+
<TabsContent value="settings" className="text-body text-fg-secondary">專案設定(一般、權限、整合)</TabsContent>
|
|
39
39
|
</Tabs>
|
|
40
40
|
),
|
|
41
41
|
}
|