@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,246 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { X } from "lucide-react"
|
|
4
|
+
import type { LucideIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
import { Tooltip, TooltipTrigger, TooltipContent } from "@/design-system/components/Tooltip/tooltip"
|
|
8
|
+
import { ItemInlineActionButton } from "@/design-system/patterns/element-anatomy/item-anatomy"
|
|
9
|
+
|
|
10
|
+
// ── Tag(inline label)─────────────────────────────────────────────────────
|
|
11
|
+
// 用於分類標籤、狀態標記、多選已選值。
|
|
12
|
+
//
|
|
13
|
+
// 三種尺寸(子元件補齊原則——消費端直接透傳 size,不做 mapping):
|
|
14
|
+
// sm — 20px 高, 12px 字, 4px tag-px, font-medium(配 field sm)
|
|
15
|
+
// md — 24px 高, 14px 字, 4px tag-px, font-normal(配 field md)— 預設
|
|
16
|
+
// lg — 24px = md alias(配 field lg,子元件補齊原則)
|
|
17
|
+
//
|
|
18
|
+
// 截斷:max-w-40(160px),超出時文字 truncate + 自動 tooltip。
|
|
19
|
+
// 用 Canvas measureText 偵測截斷(scrollWidth 在 flex 內不可靠)。
|
|
20
|
+
|
|
21
|
+
let _measureCtx: CanvasRenderingContext2D | null = null
|
|
22
|
+
function getMeasureCtx() {
|
|
23
|
+
if (!_measureCtx) _measureCtx = document.createElement('canvas').getContext('2d')
|
|
24
|
+
return _measureCtx
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tagVariants = cva(
|
|
28
|
+
"inline-flex items-center rounded-md border border-transparent transition-colors cursor-text",
|
|
29
|
+
{
|
|
30
|
+
variants: {
|
|
31
|
+
color: {
|
|
32
|
+
// 直接引用 primitive(bg=step-1, text=step-7),不經過語義層
|
|
33
|
+
// step-1 在 dark mode 用 alpha 公式,step-7 用 lighter 公式——兩個 mode 都正確
|
|
34
|
+
neutral: "bg-secondary text-foreground",
|
|
35
|
+
blue: "bg-[var(--color-blue-1)] text-[var(--color-blue-7)]",
|
|
36
|
+
red: "bg-[var(--color-deep-orange-1)] text-[var(--color-deep-orange-7)]",
|
|
37
|
+
green: "bg-[var(--color-green-1)] text-[var(--color-green-7)]",
|
|
38
|
+
yellow: "bg-[var(--color-yellow-1)] text-[var(--color-yellow-7)]",
|
|
39
|
+
turquoise: "bg-[var(--color-turquoise-1)] text-[var(--color-turquoise-7)]",
|
|
40
|
+
purple: "bg-[var(--color-purple-1)] text-[var(--color-purple-7)]",
|
|
41
|
+
magenta: "bg-[var(--color-magenta-1)] text-[var(--color-magenta-7)]",
|
|
42
|
+
indigo: "bg-[var(--color-indigo-1)] text-[var(--color-indigo-7)]",
|
|
43
|
+
},
|
|
44
|
+
size: {
|
|
45
|
+
sm: "h-5 px-1 text-caption font-medium",
|
|
46
|
+
md: "h-6 px-1 text-body font-normal",
|
|
47
|
+
lg: "h-6 px-1 text-body font-normal",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
defaultVariants: {
|
|
51
|
+
color: "neutral",
|
|
52
|
+
size: "md",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// ── Solid variant 色彩(step-6 底 + 白字,warning 用 --warning-foreground)──
|
|
58
|
+
// 直接引用 primitive step-6,不經過語義層
|
|
59
|
+
// neutral 用 neutral-9 + --inverse-fg(light=白字, dark=深字,自動反轉)
|
|
60
|
+
const SOLID_CLASSES: Record<string, string> = {
|
|
61
|
+
neutral: 'bg-[var(--color-neutral-9)] text-inverse-fg',
|
|
62
|
+
blue: 'bg-[var(--color-blue-6)] text-on-emphasis',
|
|
63
|
+
red: 'bg-[var(--color-deep-orange-6)] text-on-emphasis',
|
|
64
|
+
green: 'bg-[var(--color-green-6)] text-on-emphasis',
|
|
65
|
+
yellow: 'bg-[var(--color-yellow-6)] text-[var(--warning-foreground)]',
|
|
66
|
+
turquoise: 'bg-[var(--color-turquoise-6)] text-on-emphasis',
|
|
67
|
+
purple: 'bg-[var(--color-purple-6)] text-on-emphasis',
|
|
68
|
+
magenta: 'bg-[var(--color-magenta-6)] text-on-emphasis',
|
|
69
|
+
indigo: 'bg-[var(--color-indigo-6)] text-on-emphasis',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TagProps
|
|
73
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'prefix' | 'color'>,
|
|
74
|
+
VariantProps<typeof tagVariants> {
|
|
75
|
+
/** 左側 icon(LucideIcon),由 Tag 統一 16px。與 avatar 互斥。 */
|
|
76
|
+
icon?: LucideIcon
|
|
77
|
+
/** 左側 avatar(ReactNode),與 icon 互斥。 */
|
|
78
|
+
avatar?: React.ReactNode
|
|
79
|
+
/** 可移除——Tag 自動渲染 dismiss 按鈕並控制尺寸與互動樣式 */
|
|
80
|
+
onDismiss?: () => void
|
|
81
|
+
/** 深底白字模式(step-6 背景 + 白色前景,warning 例外) */
|
|
82
|
+
solid?: boolean
|
|
83
|
+
/**
|
|
84
|
+
* 2026-05-15 Q3 真 SSOT fix(per user verbatim「同空間兩判斷點」+「不要冰山一角」):
|
|
85
|
+
* Tag 寬度由 parent constrain,不套預設 max-w-40(160px)。用於 cell-as-input narrow cell
|
|
86
|
+
* (< 160px)時 Tag fit cell 寬度 + truncate ellipsis,而非 160px 後被 cell `overflow-hidden`
|
|
87
|
+
* 硬切。對齊「同 cell width → 同 overflow 判斷」SSOT。Default false 保 backward compat
|
|
88
|
+
* (wrap layout / pill rail 等仍受 160px 保護)。
|
|
89
|
+
*/
|
|
90
|
+
unbounded?: boolean
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Solid dismiss hover/active bg ──
|
|
94
|
+
// 用 semantic --{hue}-hover/active token,跟 --primary-hover/active 同模式:
|
|
95
|
+
// solid 色彩 shade change(hover 較亮 step、active 較暗 step),跟 Button 等
|
|
96
|
+
// 互動元件視覺一致。在 semantic 層做 dark mode swap 確保方向跨 mode 一致。
|
|
97
|
+
// neutral 特例:bg 是 neutral-9 隨 mode 反轉,用 --inverse-neutral-* 鏡射
|
|
98
|
+
const SOLID_DISMISS_HOVER: Record<string, { hover: string; active: string }> = {
|
|
99
|
+
neutral: { hover: 'var(--inverse-neutral-hover)', active: 'var(--inverse-neutral-active)' },
|
|
100
|
+
blue: { hover: 'var(--blue-hover)', active: 'var(--blue-active)' },
|
|
101
|
+
red: { hover: 'var(--red-hover)', active: 'var(--red-active)' },
|
|
102
|
+
green: { hover: 'var(--green-hover)', active: 'var(--green-active)' },
|
|
103
|
+
yellow: { hover: 'var(--yellow-hover)', active: 'var(--yellow-active)' },
|
|
104
|
+
turquoise: { hover: 'var(--turquoise-hover)', active: 'var(--turquoise-active)' },
|
|
105
|
+
purple: { hover: 'var(--purple-hover)', active: 'var(--purple-active)' },
|
|
106
|
+
magenta: { hover: 'var(--magenta-hover)', active: 'var(--magenta-active)' },
|
|
107
|
+
indigo: { hover: 'var(--indigo-hover)', active: 'var(--indigo-active)' },
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Dismiss(internal)────────────────────────────────────────────────────
|
|
111
|
+
// 走 `ItemInlineActionButton`(item-anatomy SSOT)+ `hoverBgClassName` override prop
|
|
112
|
+
// (2026-05-01 整合,消除原 Tag 自刻 `<button>` 繞 DS infra 的 tech debt)。
|
|
113
|
+
//
|
|
114
|
+
// 視覺對齊:`size="md"` → icon 16 / hover-bg 18,跟 Tag 既有手刻幾何完全相等。
|
|
115
|
+
// Solid variant(blue/green/red 等)透過 `hoverBgClassName` 套色相 override token;
|
|
116
|
+
// Subtle variant 落用 ItemInlineActionButton 預設 neutral-hover。
|
|
117
|
+
// 圖標色繼承 Tag 文字色 → `text-current` 三態覆寫。
|
|
118
|
+
|
|
119
|
+
function TagDismiss({ onDismiss, label, solid, color }: { onDismiss: () => void; label: string; solid?: boolean; color?: string }) {
|
|
120
|
+
const solidColors = solid && color ? SOLID_DISMISS_HOVER[color] : undefined
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<ItemInlineActionButton
|
|
124
|
+
icon={X}
|
|
125
|
+
size="md"
|
|
126
|
+
onClick={(e) => { e.stopPropagation(); onDismiss() }}
|
|
127
|
+
aria-label={`移除 ${label}`}
|
|
128
|
+
style={solidColors ? ({ '--dismiss-hover': solidColors.hover, '--dismiss-active': solidColors.active } as React.CSSProperties) : undefined}
|
|
129
|
+
hoverBgClassName={
|
|
130
|
+
solidColors
|
|
131
|
+
? 'group-hover/action:bg-[var(--dismiss-hover)] group-active/action:bg-[var(--dismiss-active)]'
|
|
132
|
+
: undefined
|
|
133
|
+
}
|
|
134
|
+
// Override default fg-muted → 繼承 Tag 文字色(label 同色)
|
|
135
|
+
className="text-current hover:text-current active:text-current"
|
|
136
|
+
/>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function TagInner(
|
|
141
|
+
{ className, color, size, icon: Icon, avatar, onDismiss, solid, unbounded = false, children, style, ...props }: TagProps,
|
|
142
|
+
forwardedRef: React.ForwardedRef<HTMLDivElement>,
|
|
143
|
+
) {
|
|
144
|
+
const solidClass = solid ? SOLID_CLASSES[color ?? 'neutral'] : undefined
|
|
145
|
+
const ownRef = React.useRef<HTMLDivElement | null>(null)
|
|
146
|
+
const [isTruncated, setIsTruncated] = React.useState(false)
|
|
147
|
+
|
|
148
|
+
React.useLayoutEffect(() => {
|
|
149
|
+
const el = ownRef.current
|
|
150
|
+
if (!el) return
|
|
151
|
+
const ctx = getMeasureCtx()
|
|
152
|
+
const check = () => {
|
|
153
|
+
const textSpan = el.querySelector('[data-tag-text]')
|
|
154
|
+
if (!textSpan || !ctx) return
|
|
155
|
+
const text = textSpan.textContent || ''
|
|
156
|
+
const cs = getComputedStyle(textSpan)
|
|
157
|
+
ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
|
|
158
|
+
const textWidth = ctx.measureText(text).width
|
|
159
|
+
const padL = parseFloat(cs.paddingLeft) || 0
|
|
160
|
+
const padR = parseFloat(cs.paddingRight) || 0
|
|
161
|
+
const needed = textWidth + padL + padR
|
|
162
|
+
setIsTruncated(needed > (textSpan as HTMLElement).clientWidth + 1)
|
|
163
|
+
}
|
|
164
|
+
check()
|
|
165
|
+
const obs = new ResizeObserver(check)
|
|
166
|
+
obs.observe(el)
|
|
167
|
+
return () => obs.disconnect()
|
|
168
|
+
}, [children])
|
|
169
|
+
|
|
170
|
+
const label = typeof children === 'string' ? children : ''
|
|
171
|
+
|
|
172
|
+
const tag = (
|
|
173
|
+
<div
|
|
174
|
+
ref={(el) => {
|
|
175
|
+
ownRef.current = el
|
|
176
|
+
if (typeof forwardedRef === 'function') forwardedRef(el)
|
|
177
|
+
else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = el
|
|
178
|
+
}}
|
|
179
|
+
data-tag-root=""
|
|
180
|
+
className={cn(tagVariants({ color, size }), solidClass, 'w-fit min-w-0 overflow-hidden', className)}
|
|
181
|
+
// 2026-05-18 Round 5 fix(per Codex M31 Round 5 verdict + user 拍板「那就開始做」):
|
|
182
|
+
// 用 CSS var `--combobox-tag-area-inline-size`(由 Combobox useOverflowCount JS-injected)取代
|
|
183
|
+
// `min(100%, 10rem)` cyclic percentage。CSS Sizing 3 §5.2.1:percentage 在 indefinite containing
|
|
184
|
+
// block 退化為 initial value → Round 4 的 100% 沒 enforce。改 explicit px 值(JS measured)避此 trap。
|
|
185
|
+
// unbounded=true:cap = inject 寬(回 cell-as-input narrow cell 原 behavior)
|
|
186
|
+
// default:cap = min(inject 寬, 160px)— 兩 cap 取小。fallback(無 var,Form context 等)= 100% / 10rem。
|
|
187
|
+
style={{
|
|
188
|
+
maxWidth: unbounded
|
|
189
|
+
? 'var(--combobox-tag-area-inline-size, 100%)'
|
|
190
|
+
: 'min(var(--combobox-tag-area-inline-size, 10rem), 10rem)',
|
|
191
|
+
...style,
|
|
192
|
+
}}
|
|
193
|
+
{...props}
|
|
194
|
+
>
|
|
195
|
+
{Icon && <Icon size={16} aria-hidden />}
|
|
196
|
+
{avatar && <span className="shrink-0 w-4 h-4 rounded-full overflow-hidden inline-grid place-content-center [&>*]:w-full [&>*]:h-full">{avatar}</span>}
|
|
197
|
+
<span data-tag-text="" className="px-1 truncate min-w-0">{children}</span>
|
|
198
|
+
{onDismiss && <TagDismiss onDismiss={onDismiss} label={label} solid={solid} color={color ?? 'neutral'} />}
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if (!isTruncated) return tag
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<Tooltip>
|
|
206
|
+
<TooltipTrigger asChild>{tag}</TooltipTrigger>
|
|
207
|
+
<TooltipContent>{children}</TooltipContent>
|
|
208
|
+
</Tooltip>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const Tag = React.forwardRef<HTMLDivElement, TagProps>(TagInner)
|
|
213
|
+
Tag.displayName = 'Tag'
|
|
214
|
+
|
|
215
|
+
// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
|
|
216
|
+
// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
|
|
217
|
+
export const tagMeta = {
|
|
218
|
+
component: 'Tag',
|
|
219
|
+
family: 3,
|
|
220
|
+
variants: {
|
|
221
|
+
neutral: { purpose: '通用分類、草稿、無特定語義' },
|
|
222
|
+
blue: { purpose: '進行中、資訊提示、active 狀態(對應 --info)' },
|
|
223
|
+
red: { purpose: '錯誤、已封鎖、危險(對應 --error)' },
|
|
224
|
+
green: { purpose: '成功、已完成、已核准(對應 --success)' },
|
|
225
|
+
yellow: { purpose: '警告、待審核、注意(對應 --warning)' },
|
|
226
|
+
turquoise: { purpose: '分類色(無固定語義)' },
|
|
227
|
+
purple: { purpose: '分類色(無固定語義)' },
|
|
228
|
+
magenta: { purpose: '分類色(無固定語義)' },
|
|
229
|
+
indigo: { purpose: '分類色(無固定語義)' },
|
|
230
|
+
},
|
|
231
|
+
sizes: {
|
|
232
|
+
sm: { fieldHeight: 28, iconSize: 16, typography: 'body' },
|
|
233
|
+
md: { fieldHeight: 32, iconSize: 16, typography: 'body' },
|
|
234
|
+
lg: { fieldHeight: 40, iconSize: 20, typography: 'body' },
|
|
235
|
+
},
|
|
236
|
+
states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
|
|
237
|
+
tokens: {
|
|
238
|
+
bg: ['bg-neutral-active', 'bg-neutral-hover', 'bg-secondary', 'bg-transparent'],
|
|
239
|
+
fg: ['text-foreground', 'text-inverse-fg'],
|
|
240
|
+
ring: [],
|
|
241
|
+
},
|
|
242
|
+
defaultVariant: 'neutral',
|
|
243
|
+
defaultSize: 'md',
|
|
244
|
+
} as const
|
|
245
|
+
|
|
246
|
+
export { Tag, tagVariants }
|
|
@@ -0,0 +1,280 @@
|
|
|
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 { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import type { FieldMode, FieldVariant } from '@/design-system/components/Field/field-types'
|
|
6
|
+
import { useFieldContext } from '@/design-system/components/Field/field-context'
|
|
7
|
+
import { EMPTY_DISPLAY } from '@/design-system/components/Field/field-wrapper'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Textarea — 多行文字輸入
|
|
11
|
+
*
|
|
12
|
+
* ── 定位 ────────────────────────────────────────────────────────────────
|
|
13
|
+
* 多行版本的 Input,edit / display / readonly / disabled 四態與 Input 邏輯一致(Phase B1 2026-05-05)。
|
|
14
|
+
* 不同於 Input:
|
|
15
|
+
* - 沒有固定 field-height(高度由 rows 或 min-h 決定)
|
|
16
|
+
* - 沒有 startIcon / endAction(textarea 慣例不放 icon)
|
|
17
|
+
* - readonly 呈現保留邊框與 padding,只改底色,讓多行文字有合理閱讀區
|
|
18
|
+
* - display 渲染 <div> + white-space:pre-wrap 保留多行文本
|
|
19
|
+
*
|
|
20
|
+
* ── Padding 規則 ───────────────────────────────────────────────────────
|
|
21
|
+
* 多行內容必須有上下內距才能閱讀舒適。不沿用 Input 的 items-center,
|
|
22
|
+
* 改用 py-2(8px)固定上下內距 + px-3 左右內距(與 Input 一致)。
|
|
23
|
+
*
|
|
24
|
+
* ── Size ────────────────────────────────────────────────────────────────
|
|
25
|
+
* sm / md → text-body(14px)
|
|
26
|
+
* lg → text-body-lg(16px)
|
|
27
|
+
*
|
|
28
|
+
* ── rows / min-h ───────────────────────────────────────────────────────
|
|
29
|
+
* 預設 rows={3}。消費者可透過 rows prop 調整,或透過 min-h-* className 覆寫。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// Phase B1(2026-05-05):新增 chrome variant(default / bare),mode×chrome 的 chrome 規則由
|
|
33
|
+
// compoundVariants 決定,鏡射 fieldWrapperStyles 對齊 canonical(Phase D 將整併進 fieldWrapperStyles)。
|
|
34
|
+
const textareaVariants = cva(
|
|
35
|
+
[
|
|
36
|
+
'w-full rounded-md',
|
|
37
|
+
'text-foreground font-normal',
|
|
38
|
+
'outline-none resize-y',
|
|
39
|
+
'placeholder:text-fg-muted',
|
|
40
|
+
// K10 fix(2026-05-04):disabled 時 placeholder + text 切 fg-disabled(parallel 到 bareInputStyles)
|
|
41
|
+
// Textarea 自身 `<textarea disabled>` 帶 disabled HTML attribute,用 `disabled:` variant 直接命中
|
|
42
|
+
'disabled:placeholder:text-fg-disabled disabled:text-fg-disabled',
|
|
43
|
+
'px-3 py-2',
|
|
44
|
+
'transition-colors duration-150',
|
|
45
|
+
],
|
|
46
|
+
{
|
|
47
|
+
variants: {
|
|
48
|
+
mode: {
|
|
49
|
+
edit: '',
|
|
50
|
+
display: '',
|
|
51
|
+
readonly: '',
|
|
52
|
+
disabled: '',
|
|
53
|
+
},
|
|
54
|
+
// chrome 對齊 fieldWrapperStyles.variant(default / bare / naked)。
|
|
55
|
+
variant: {
|
|
56
|
+
default: '',
|
|
57
|
+
bare: '',
|
|
58
|
+
naked: '',
|
|
59
|
+
},
|
|
60
|
+
size: {
|
|
61
|
+
sm: 'text-body',
|
|
62
|
+
md: 'text-body',
|
|
63
|
+
lg: 'text-body-lg',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
compoundVariants: [
|
|
67
|
+
// default chrome × mode
|
|
68
|
+
{
|
|
69
|
+
mode: 'edit',
|
|
70
|
+
variant: 'default',
|
|
71
|
+
className: [
|
|
72
|
+
'bg-surface border border-border',
|
|
73
|
+
'hover:border-border-hover',
|
|
74
|
+
'focus-visible:!border-primary focus-visible:hover:!border-primary',
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
mode: 'display',
|
|
79
|
+
variant: 'default',
|
|
80
|
+
// 2026-05-13 Q3 Path Ⅰ:Textarea default display zero chrome,!px-0 !py-0 override base `px-3 py-2`
|
|
81
|
+
// (跟 Input 同 SSOT,per field-controls.spec.md (d))
|
|
82
|
+
className: 'bg-transparent border border-transparent !px-0 !py-0',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
mode: 'readonly',
|
|
86
|
+
variant: 'default',
|
|
87
|
+
className: 'bg-disabled border border-transparent',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
mode: 'disabled',
|
|
91
|
+
variant: 'default',
|
|
92
|
+
className: 'bg-disabled border border-transparent cursor-not-allowed text-fg-disabled',
|
|
93
|
+
},
|
|
94
|
+
// bare chrome × mode(對齊 fieldWrapperStyles bare 規則)
|
|
95
|
+
{
|
|
96
|
+
mode: 'edit',
|
|
97
|
+
variant: 'bare',
|
|
98
|
+
className: [
|
|
99
|
+
'bg-transparent border border-transparent',
|
|
100
|
+
'hover:border-border',
|
|
101
|
+
'focus-visible:!border-primary focus-visible:hover:!border-primary',
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
mode: 'display',
|
|
106
|
+
variant: 'bare',
|
|
107
|
+
className: 'bg-transparent border border-transparent',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
mode: 'readonly',
|
|
111
|
+
variant: 'bare',
|
|
112
|
+
className: 'bg-transparent border border-transparent',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
mode: 'disabled',
|
|
116
|
+
variant: 'bare',
|
|
117
|
+
className: 'bg-transparent border border-transparent cursor-not-allowed opacity-disabled text-fg-disabled',
|
|
118
|
+
},
|
|
119
|
+
// naked chrome × mode — cell-as-input substrate(2026-05-06 v14 revert v12)。
|
|
120
|
+
// v12 `!absolute -inset-px` autoRowHeight 不相容 → revert v9 baseline + 保留 v13.3
|
|
121
|
+
// focus !important。focus-visible 用 textarea 自身 selector(focusable element)。
|
|
122
|
+
{
|
|
123
|
+
mode: 'edit',
|
|
124
|
+
variant: 'naked',
|
|
125
|
+
className: [
|
|
126
|
+
'bg-transparent !rounded-none !resize-none !h-full',
|
|
127
|
+
'!px-[var(--table-cell-px)] !py-[var(--table-cell-py)]',
|
|
128
|
+
'border border-border',
|
|
129
|
+
'hover:border-border-hover',
|
|
130
|
+
'focus-visible:!border-primary focus-visible:hover:!border-primary',
|
|
131
|
+
// textarea UA stylesheet 預設 line-height: normal(1.2-1.5 不定),會跟 display
|
|
132
|
+
// `<div>` text-body line-height: 1.5(21px @ 14px)不一致 → cell 進 edit 後 height
|
|
133
|
+
// shift。顯式 leading 對齊 div 行為。
|
|
134
|
+
'!leading-[1.5]',
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
// 2026-05-13 Q1 R4 verify(per codex Q1 verdict 補 Textarea nuance):
|
|
138
|
+
// Textarea naked display/readonly/disabled 用 `!h-full`,**不**對齊 Field wrapper 的 `!h-auto`。
|
|
139
|
+
// Why divergence:textarea 是 native form element 帶 intrinsic rows-based height,且 cell 內
|
|
140
|
+
// multi-line text 需要撐滿 cell 而非依 line-height intrinsic。`!h-full` 讓 textarea 填滿 cell
|
|
141
|
+
// 高度,文字 anchored to cell.top + cell padding(同視覺結果 Field wrapper autoRow !h-auto)。
|
|
142
|
+
// 此 divergence intentional + documented;非 SSOT violation。
|
|
143
|
+
{ mode: 'display', variant: 'naked', className: 'bg-transparent !rounded-none !h-full !resize-none !px-0 !py-0 border border-transparent !leading-[1.5]' },
|
|
144
|
+
{ mode: 'readonly', variant: 'naked', className: 'bg-transparent !rounded-none !h-full !resize-none !px-0 !py-0 border border-transparent !leading-[1.5]' },
|
|
145
|
+
// 2026-05-13 codex V2 fix:移除 `opacity-disabled` blanket(對齊 field-wrapper.tsx naked R3 fix +
|
|
146
|
+
// color.spec.md:729 逃生艙 rule)。Textarea 已用具體 `text-fg-disabled` token,不需要 wrapper opacity。
|
|
147
|
+
{ mode: 'disabled', variant: 'naked', className: 'bg-transparent !rounded-none cursor-not-allowed text-fg-disabled !h-full !resize-none !px-0 !py-0 border border-transparent !leading-[1.5]' },
|
|
148
|
+
],
|
|
149
|
+
defaultVariants: {
|
|
150
|
+
mode: 'edit',
|
|
151
|
+
variant: 'default',
|
|
152
|
+
size: 'md',
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
export interface TextareaProps
|
|
158
|
+
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'size'>,
|
|
159
|
+
Omit<VariantProps<typeof textareaVariants>, 'mode' | 'variant'> {
|
|
160
|
+
/** Field display mode */
|
|
161
|
+
mode?: FieldMode
|
|
162
|
+
/**
|
|
163
|
+
* Visual chrome(正交於 mode);Phase B1(2026-05-05)新增。透傳自 FieldContext.variant,per-prop override。
|
|
164
|
+
* - `'default'` — 完整 chrome(form 場景)
|
|
165
|
+
* - `'bare'` — 透明 variant,hover/focus reveal(toolbar / cell-as-input)
|
|
166
|
+
*/
|
|
167
|
+
variant?: FieldVariant
|
|
168
|
+
/** Error 狀態(正交於 mode)。border-error + aria-invalid。 */
|
|
169
|
+
error?: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// code-quality-allow: long-function — Textarea forwardRef body 含 mode×size×variant×error 4 軸 prop + autoFocus + aria 完整覆蓋,拆 sub-fn 會把 useFieldContext / fieldWrapperStyles 跨檔 drilling
|
|
173
|
+
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
174
|
+
(
|
|
175
|
+
{
|
|
176
|
+
mode: modeProp,
|
|
177
|
+
variant: variantProp,
|
|
178
|
+
error: errorProp = false,
|
|
179
|
+
size: sizeProp,
|
|
180
|
+
className,
|
|
181
|
+
disabled,
|
|
182
|
+
readOnly,
|
|
183
|
+
rows = 3,
|
|
184
|
+
value,
|
|
185
|
+
id: idProp,
|
|
186
|
+
'aria-describedby': ariaDescribedByProp,
|
|
187
|
+
'aria-errormessage': ariaErrorMessageProp,
|
|
188
|
+
...props
|
|
189
|
+
},
|
|
190
|
+
ref
|
|
191
|
+
) => {
|
|
192
|
+
// Field context 整合:disabled / mode / chrome / invalid / size / id 都能從 context 繼承
|
|
193
|
+
const fieldCtx = useFieldContext()
|
|
194
|
+
// chrome 透傳:per-prop override context
|
|
195
|
+
const variant: FieldVariant = variantProp ?? fieldCtx?.variant ?? 'default'
|
|
196
|
+
const error = errorProp || (fieldCtx?.invalid ?? false)
|
|
197
|
+
const size = sizeProp ?? fieldCtx?.size ?? 'md'
|
|
198
|
+
// mode resolve order(Phase B1 2026-05-05):prop > fieldCtx > readOnly/disabled fallback > 'edit'
|
|
199
|
+
const resolvedMode: FieldMode = modeProp
|
|
200
|
+
?? fieldCtx?.mode
|
|
201
|
+
?? (readOnly ? 'readonly' : (disabled ?? fieldCtx?.disabled) ? 'disabled' : 'edit')
|
|
202
|
+
const isEditable = resolvedMode === 'edit'
|
|
203
|
+
const isDisplay = resolvedMode === 'display'
|
|
204
|
+
const inputId = idProp ?? fieldCtx?.id
|
|
205
|
+
const ariaDescribedBy = ariaDescribedByProp ?? fieldCtx?.descriptionId
|
|
206
|
+
const ariaErrorMessage = ariaErrorMessageProp ?? (error ? fieldCtx?.errorId : undefined)
|
|
207
|
+
|
|
208
|
+
// ── display mode:純展示,渲染 <div> 取代 <textarea>(white-space:pre-wrap 保留多行) ──
|
|
209
|
+
// 對齊 Carbon read-only / Cloudscape display-mode
|
|
210
|
+
if (isDisplay) {
|
|
211
|
+
const displayValue = value != null && value !== '' ? String(value) : null
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
id={inputId}
|
|
215
|
+
data-field-mode="display"
|
|
216
|
+
aria-describedby={ariaDescribedBy}
|
|
217
|
+
className={cn(
|
|
218
|
+
textareaVariants({ mode: 'display', variant: variant, size }),
|
|
219
|
+
'whitespace-pre-wrap break-words',
|
|
220
|
+
displayValue == null && 'text-fg-muted',
|
|
221
|
+
className,
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{displayValue ?? EMPTY_DISPLAY}
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<textarea
|
|
231
|
+
ref={ref}
|
|
232
|
+
id={inputId}
|
|
233
|
+
rows={rows}
|
|
234
|
+
value={value as string | number | readonly string[] | undefined}
|
|
235
|
+
disabled={resolvedMode === 'disabled'}
|
|
236
|
+
readOnly={resolvedMode === 'readonly'}
|
|
237
|
+
aria-invalid={error || undefined}
|
|
238
|
+
aria-required={fieldCtx?.required || undefined}
|
|
239
|
+
aria-describedby={ariaDescribedBy}
|
|
240
|
+
aria-errormessage={ariaErrorMessage}
|
|
241
|
+
data-field-mode={resolvedMode}
|
|
242
|
+
data-error={isEditable && error ? '' : undefined}
|
|
243
|
+
className={cn(
|
|
244
|
+
textareaVariants({ mode: resolvedMode, variant: variant, size }),
|
|
245
|
+
isEditable && error && [
|
|
246
|
+
'border-error hover:border-error-hover',
|
|
247
|
+
'focus-visible:border-error focus-visible:hover:border-error',
|
|
248
|
+
],
|
|
249
|
+
className
|
|
250
|
+
)}
|
|
251
|
+
{...props}
|
|
252
|
+
/>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
Textarea.displayName = 'Textarea'
|
|
257
|
+
|
|
258
|
+
// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
|
|
259
|
+
// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
|
|
260
|
+
export const textareaMeta = {
|
|
261
|
+
component: 'Textarea',
|
|
262
|
+
family: 4,
|
|
263
|
+
variants: {
|
|
264
|
+
|
|
265
|
+
},
|
|
266
|
+
sizes: {
|
|
267
|
+
sm: { fieldHeight: 28, iconSize: 16, typography: 'body' },
|
|
268
|
+
md: { fieldHeight: 32, iconSize: 16, typography: 'body' },
|
|
269
|
+
lg: { fieldHeight: 40, iconSize: 20, typography: 'body' },
|
|
270
|
+
},
|
|
271
|
+
states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
|
|
272
|
+
tokens: {
|
|
273
|
+
bg: ['bg-disabled', 'bg-surface'],
|
|
274
|
+
fg: ['text-fg-disabled', 'text-fg-muted', 'text-foreground'],
|
|
275
|
+
ring: [],
|
|
276
|
+
},
|
|
277
|
+
defaultSize: 'md',
|
|
278
|
+
} as const
|
|
279
|
+
|
|
280
|
+
export { Textarea, textareaVariants }
|