@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,304 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Pencil } from 'lucide-react'
|
|
3
|
+
import 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 { fieldWrapperStyles, bareInputStyles, EMPTY_DISPLAY, fieldDisplayTextClass } from '@/design-system/components/Field/field-wrapper'
|
|
7
|
+
import { useFieldContext } from '@/design-system/components/Field/field-context'
|
|
8
|
+
import { ItemInlineAction } from '@/design-system/patterns/element-anatomy/item-anatomy'
|
|
9
|
+
|
|
10
|
+
// ── URL Validation ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function isValidUrl(value: string): boolean {
|
|
13
|
+
if (!value) return true
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(value)
|
|
16
|
+
return url.protocol === 'http:' || url.protocol === 'https:'
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatHostname(url: string): string {
|
|
23
|
+
try {
|
|
24
|
+
return new URL(url).hostname.replace(/^www\./, '')
|
|
25
|
+
} catch {
|
|
26
|
+
return url
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Display rendering(inline,2026-05-05 Phase B3 retire LinkInputDisplay)──
|
|
31
|
+
// 取代 LinkInputDisplay sub-component:純展示 a tag,無 input chrome、無 hover affordance。
|
|
32
|
+
// edit mode 內 link state(showLink branch)也共用此 helper,確保「編輯態的 link 顯示」與
|
|
33
|
+
// display mode 的視覺完全一致(SSOT)。
|
|
34
|
+
function renderLinkAnchor(value: string, label?: string) {
|
|
35
|
+
const displayText = label || formatHostname(value)
|
|
36
|
+
return (
|
|
37
|
+
<a
|
|
38
|
+
href={value}
|
|
39
|
+
target="_blank"
|
|
40
|
+
rel="noopener noreferrer"
|
|
41
|
+
className="block truncate min-w-0 text-primary hover:text-primary-hover hover:underline transition-colors"
|
|
42
|
+
>
|
|
43
|
+
{displayText}
|
|
44
|
+
</a>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Component ───────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export interface LinkInputProps
|
|
51
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'value' | 'onChange'>,
|
|
52
|
+
Omit<VariantProps<typeof fieldWrapperStyles>, 'mode' | 'variant'> {
|
|
53
|
+
mode?: FieldMode
|
|
54
|
+
/**
|
|
55
|
+
* Visual chrome(2026-05-05 Phase B3)。對齊 FieldContext.variant 透傳。
|
|
56
|
+
* - `'default'`(預設)— Field wrapper 完整 chrome(form / Field 內嵌)。
|
|
57
|
+
* - `'bare'` — 透明 variant,hover/focus 才現 border(Toolbar inline edit / DataTable cell-as-input)。
|
|
58
|
+
*
|
|
59
|
+
* mode='display' 時 chrome 無視覺意義(display 完全無 wrapper);chrome 僅作用於 edit / readonly / disabled。
|
|
60
|
+
*/
|
|
61
|
+
variant?: FieldVariant
|
|
62
|
+
error?: boolean
|
|
63
|
+
value?: string | null
|
|
64
|
+
onChange?: (value: string) => void
|
|
65
|
+
placeholder?: string
|
|
66
|
+
className?: string
|
|
67
|
+
disabled?: boolean
|
|
68
|
+
/** 自訂顯示文字(非編輯時) */
|
|
69
|
+
label?: string
|
|
70
|
+
/**
|
|
71
|
+
* Display 是否包 Field naked wrapper(D-path opt-in,2026-05-08)
|
|
72
|
+
* — DataTable cell display↔edit 像素級對齊用。預設 false(裸 anchor,backward compat)。
|
|
73
|
+
* 設 true 時 display 走 fieldWrapperStyles(naked variant)包覆 anchor,
|
|
74
|
+
* 與 cell edit (`<Input naked>`) 同 DOM 結構,消除 Layer-B padding mismatch。
|
|
75
|
+
* **本元件 edit 無 endIcon(UrlCell 用 plain Input edit)→ display 也無 ItemSuffix**(僅 wrapper)。
|
|
76
|
+
*/
|
|
77
|
+
showDisplayEndIcon?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
|
|
81
|
+
const LinkInput = React.forwardRef<HTMLInputElement, LinkInputProps>(
|
|
82
|
+
(
|
|
83
|
+
{
|
|
84
|
+
mode: modeProp,
|
|
85
|
+
variant: variantProp,
|
|
86
|
+
error: errorProp = false,
|
|
87
|
+
size: sizeProp = 'md',
|
|
88
|
+
value,
|
|
89
|
+
onChange,
|
|
90
|
+
placeholder = 'https://',
|
|
91
|
+
className,
|
|
92
|
+
disabled: disabledProp,
|
|
93
|
+
label,
|
|
94
|
+
showDisplayEndIcon = false,
|
|
95
|
+
id: idProp,
|
|
96
|
+
'aria-describedby': ariaDescribedByProp,
|
|
97
|
+
'aria-errormessage': ariaErrorMessageProp,
|
|
98
|
+
...props
|
|
99
|
+
},
|
|
100
|
+
ref
|
|
101
|
+
) => {
|
|
102
|
+
const fieldCtx = useFieldContext()
|
|
103
|
+
const size = sizeProp ?? fieldCtx?.size ?? 'md'
|
|
104
|
+
const disabled = disabledProp ?? fieldCtx?.disabled
|
|
105
|
+
// mode resolution:disabled prop 一律覆蓋;否則 prop > context.mode > default 'edit'
|
|
106
|
+
const mode: FieldMode = modeProp ?? fieldCtx?.mode ?? 'edit'
|
|
107
|
+
const resolvedMode: FieldMode = disabled ? 'disabled' : mode
|
|
108
|
+
const isEditable = resolvedMode === 'edit'
|
|
109
|
+
// chrome resolution:per-prop > context > 'default'
|
|
110
|
+
const resolvedVariant: FieldVariant = variantProp ?? fieldCtx?.variant ?? 'default'
|
|
111
|
+
|
|
112
|
+
// ── mode='display' ─────────────────────────────────────────────────────
|
|
113
|
+
// 純展示:無 input chrome / 無 hover affordance / 無 Pencil edit 入口。
|
|
114
|
+
// 取代既有 LinkInputDisplay sub-component(2026-05-05 Phase B3 retire)。
|
|
115
|
+
// Default(showDisplayEndIcon=false):無 wrapper 裸 anchor — backward compat。
|
|
116
|
+
// Opt-in(showDisplayEndIcon=true,2026-05-08 D-path):Field naked wrapper 包覆 anchor,
|
|
117
|
+
// 與 cell edit (`<Input naked>`) 同 DOM 結構消除像素偏移(無 ItemSuffix,因 edit 也無 endIcon)。
|
|
118
|
+
if (resolvedMode === 'display') {
|
|
119
|
+
if (!showDisplayEndIcon) {
|
|
120
|
+
// 2026-05-14 I2 fix(spec contract (e) display typography canonical):非 D-path bare
|
|
121
|
+
// anchor / span 必套 `fieldDisplayTextClass(size)`(sm/md→text-body,lg→text-body-lg)
|
|
122
|
+
// — 對齊跨 Field family display 視覺尺寸統一。原無 font-size class → 用 browser default
|
|
123
|
+
// 字體 → 跟其他 Field display 不一致(user 抓 I2)。truncate 同需,長 URL ellipsis(I1)。
|
|
124
|
+
if (!value) return <span className={cn(fieldDisplayTextClass(size), 'text-fg-muted block truncate')}>{EMPTY_DISPLAY}</span>
|
|
125
|
+
return <span className={cn(fieldDisplayTextClass(size), 'block truncate')}>{renderLinkAnchor(value, label)}</span>
|
|
126
|
+
}
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
className={cn(fieldWrapperStyles({ mode: 'display', variant: resolvedVariant, size }), className)}
|
|
130
|
+
data-field-mode="display"
|
|
131
|
+
>
|
|
132
|
+
<span className="flex-1 min-w-0 truncate">
|
|
133
|
+
{value
|
|
134
|
+
? renderLinkAnchor(value, label)
|
|
135
|
+
: <span className="text-fg-muted">{EMPTY_DISPLAY}</span>
|
|
136
|
+
}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const [editing, setEditing] = React.useState(false)
|
|
143
|
+
const [localValue, setLocalValue] = React.useState(value ?? '')
|
|
144
|
+
const [localError, setLocalError] = React.useState(false)
|
|
145
|
+
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
|
146
|
+
|
|
147
|
+
// Sync external value → local
|
|
148
|
+
React.useEffect(() => {
|
|
149
|
+
if (!editing) setLocalValue(value ?? '')
|
|
150
|
+
}, [value, editing])
|
|
151
|
+
|
|
152
|
+
// Merge refs
|
|
153
|
+
const setRef = React.useCallback((el: HTMLInputElement | null) => {
|
|
154
|
+
inputRef.current = el
|
|
155
|
+
if (typeof ref === 'function') ref(el)
|
|
156
|
+
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el
|
|
157
|
+
}, [ref])
|
|
158
|
+
|
|
159
|
+
const hasValidValue = !!value && isValidUrl(value)
|
|
160
|
+
const showLink = isEditable && hasValidValue && !editing && !localError
|
|
161
|
+
const error = errorProp || localError
|
|
162
|
+
|
|
163
|
+
// 2026-05-16 audit codex Round 6:capture rAF + cancel on unmount(defensive hygiene)
|
|
164
|
+
const focusRafIdRef = React.useRef<number>(0)
|
|
165
|
+
React.useEffect(() => () => { if (focusRafIdRef.current) cancelAnimationFrame(focusRafIdRef.current) }, [])
|
|
166
|
+
|
|
167
|
+
const handleEdit = () => {
|
|
168
|
+
setEditing(true)
|
|
169
|
+
if (focusRafIdRef.current) cancelAnimationFrame(focusRafIdRef.current)
|
|
170
|
+
focusRafIdRef.current = requestAnimationFrame(() => {
|
|
171
|
+
focusRafIdRef.current = 0
|
|
172
|
+
inputRef.current?.focus()
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const handleBlur = () => {
|
|
177
|
+
setEditing(false)
|
|
178
|
+
const trimmed = localValue.trim()
|
|
179
|
+
if (!trimmed) {
|
|
180
|
+
// Empty is OK — clear value
|
|
181
|
+
setLocalError(false)
|
|
182
|
+
onChange?.('')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (isValidUrl(trimmed)) {
|
|
186
|
+
setLocalError(false)
|
|
187
|
+
onChange?.(trimmed)
|
|
188
|
+
} else {
|
|
189
|
+
setLocalError(true)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
194
|
+
setLocalValue(e.target.value)
|
|
195
|
+
// Clear error on edit (blur validation)
|
|
196
|
+
if (localError) setLocalError(false)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
200
|
+
if (e.key === 'Enter') inputRef.current?.blur()
|
|
201
|
+
if (e.key === 'Escape') {
|
|
202
|
+
setLocalValue(value ?? '')
|
|
203
|
+
setLocalError(false)
|
|
204
|
+
setEditing(false)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// readonly — 顯示藍色連結(可點擊)
|
|
209
|
+
// disabled — 顯示純文字 fg-disabled(不可點擊)
|
|
210
|
+
if (!isEditable) {
|
|
211
|
+
const displayText = value ? (label || formatHostname(value)) : null
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
className={cn(fieldWrapperStyles({ mode: resolvedMode, variant: resolvedVariant, size }), className)}
|
|
215
|
+
data-field-mode={resolvedMode}
|
|
216
|
+
>
|
|
217
|
+
<span className="flex-1 min-w-0 truncate">
|
|
218
|
+
{resolvedMode === 'disabled'
|
|
219
|
+
? (displayText
|
|
220
|
+
? <span className="text-fg-disabled">{displayText}</span>
|
|
221
|
+
: <span className="text-fg-muted">{EMPTY_DISPLAY}</span>)
|
|
222
|
+
: (value
|
|
223
|
+
? renderLinkAnchor(value, label)
|
|
224
|
+
: <span className="text-fg-muted">{EMPTY_DISPLAY}</span>)
|
|
225
|
+
}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// edit — link display mode(有合法 URL 且未在編輯中)
|
|
232
|
+
if (showLink) {
|
|
233
|
+
return (
|
|
234
|
+
<div
|
|
235
|
+
className={cn(fieldWrapperStyles({ mode: 'edit', variant: resolvedVariant, size }), className)}
|
|
236
|
+
data-field-mode="edit"
|
|
237
|
+
>
|
|
238
|
+
<span className="flex-1 min-w-0">
|
|
239
|
+
{value && renderLinkAnchor(value, label)}
|
|
240
|
+
</span>
|
|
241
|
+
<ItemInlineAction
|
|
242
|
+
size={size ?? 'md'}
|
|
243
|
+
action={{ icon: Pencil, label: '編輯連結', onClick: handleEdit }} // i18n-allow: DS default inline-action label
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// edit — text input mode(正在編輯、無值、或格式錯誤)
|
|
250
|
+
return (
|
|
251
|
+
<div
|
|
252
|
+
className={cn(
|
|
253
|
+
fieldWrapperStyles({ mode: 'edit', variant: resolvedVariant, size }),
|
|
254
|
+
error && [
|
|
255
|
+
'border-error hover:border-error-hover',
|
|
256
|
+
'focus-within:border-error focus-within:hover:border-error',
|
|
257
|
+
],
|
|
258
|
+
className,
|
|
259
|
+
)}
|
|
260
|
+
data-field-mode="edit"
|
|
261
|
+
data-error={error ? '' : undefined}
|
|
262
|
+
>
|
|
263
|
+
<input
|
|
264
|
+
ref={setRef}
|
|
265
|
+
type="url"
|
|
266
|
+
id={idProp ?? fieldCtx?.id}
|
|
267
|
+
value={localValue}
|
|
268
|
+
onChange={handleChange}
|
|
269
|
+
onBlur={handleBlur}
|
|
270
|
+
onKeyDown={handleKeyDown}
|
|
271
|
+
placeholder={placeholder}
|
|
272
|
+
aria-invalid={(error || fieldCtx?.invalid) || undefined}
|
|
273
|
+
aria-required={fieldCtx?.required || undefined}
|
|
274
|
+
aria-describedby={ariaDescribedByProp ?? fieldCtx?.descriptionId}
|
|
275
|
+
aria-errormessage={ariaErrorMessageProp ?? ((error || fieldCtx?.invalid) ? fieldCtx?.errorId : undefined)}
|
|
276
|
+
className={bareInputStyles}
|
|
277
|
+
{...props}
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
LinkInput.displayName = 'LinkInput'
|
|
284
|
+
|
|
285
|
+
// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
|
|
286
|
+
// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
|
|
287
|
+
export const linkInputMeta = {
|
|
288
|
+
component: 'LinkInput',
|
|
289
|
+
family: 4,
|
|
290
|
+
variants: {
|
|
291
|
+
|
|
292
|
+
},
|
|
293
|
+
sizes: {
|
|
294
|
+
|
|
295
|
+
},
|
|
296
|
+
states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
|
|
297
|
+
tokens: {
|
|
298
|
+
bg: [],
|
|
299
|
+
fg: ['text-fg-disabled', 'text-fg-muted', 'text-primary'],
|
|
300
|
+
ring: [],
|
|
301
|
+
},
|
|
302
|
+
} as const
|
|
303
|
+
|
|
304
|
+
export { LinkInput }
|
|
@@ -0,0 +1,334 @@
|
|
|
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 type { LucideIcon } from 'lucide-react'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import { Checkbox } from '@/design-system/components/Checkbox/checkbox'
|
|
7
|
+
import { Avatar, type AvatarData } from '@/design-system/components/Avatar/avatar'
|
|
8
|
+
// Row primitive 共用常數——統一從 item-layout pattern module 引入
|
|
9
|
+
import { ICON_SIZE, AVATAR_SIZE, ItemContent, itemPrefixAlignVariants, ROW_PADDING_BY_SIZE } from '@/design-system/patterns/element-anatomy/item-anatomy'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MenuItem — 所有 menu 類元件的共用視覺佈局層
|
|
13
|
+
*
|
|
14
|
+
* SelectMenu、DropdownMenu、未來的 ContextMenu 等都消費這個元件。
|
|
15
|
+
* 它只負責 layout(padding、gap、prefix alignment、typography),
|
|
16
|
+
* 互動行為由各 menu 的 Radix primitive 外層控制。
|
|
17
|
+
*
|
|
18
|
+
* ── 結構 ──
|
|
19
|
+
* [checkbox?] [startIcon? | avatar?] [label + description?]
|
|
20
|
+
*
|
|
21
|
+
* ── Prefix 對齊規則(24px 閾值)──
|
|
22
|
+
* prefix ≤ 24px → h-[1lh],對齊第一行 label
|
|
23
|
+
* prefix > 24px → h-[calc(1lh+var(--item-gap-label-desc)+desc_1lh)],對齊文字區塊
|
|
24
|
+
* 無 description → prefix 上限 24px
|
|
25
|
+
*
|
|
26
|
+
* ── Size ──
|
|
27
|
+
* sm / md / lg — 單行高度對齊 field-height token
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const CHECKBOX_SIZE: Record<string, 'sm' | 'md' | 'lg'> = { sm: 'sm', md: 'md', lg: 'lg' }
|
|
31
|
+
|
|
32
|
+
// ── Item variants ──
|
|
33
|
+
const menuItemVariants = cva(
|
|
34
|
+
[
|
|
35
|
+
'flex items-start gap-2 px-3 w-full',
|
|
36
|
+
'cursor-pointer select-none',
|
|
37
|
+
'transition-colors duration-150',
|
|
38
|
+
'outline-none',
|
|
39
|
+
'focus-visible:bg-neutral-hover',
|
|
40
|
+
],
|
|
41
|
+
{
|
|
42
|
+
variants: {
|
|
43
|
+
// 消費 ROW_PADDING_BY_SIZE SSOT(item-anatomy.tsx 導出)
|
|
44
|
+
// 改一處 → 全 row primitive 自動同步(消除前 SidebarMenuButton 獨立實作 risk)
|
|
45
|
+
size: ROW_PADDING_BY_SIZE,
|
|
46
|
+
},
|
|
47
|
+
defaultVariants: {
|
|
48
|
+
size: 'md',
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// ── Prefix alignment container ──
|
|
54
|
+
// 消費 `itemPrefixAlignVariants`(SSOT from patterns/element-anatomy/item-anatomy.tsx)
|
|
55
|
+
// 原本 MenuItem 自管 cva 造成 drift 風險(block formula 改動需同步);改為共用 SSOT
|
|
56
|
+
// 讓公式只有一份,改 item-anatomy 一處 → MenuItem 自動同步。
|
|
57
|
+
|
|
58
|
+
// ── Component ──
|
|
59
|
+
|
|
60
|
+
export interface MenuItemProps
|
|
61
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>,
|
|
62
|
+
VariantProps<typeof menuItemVariants> {
|
|
63
|
+
/** Label 文字 */
|
|
64
|
+
children: React.ReactNode
|
|
65
|
+
/** 次要說明文字,顯示在 label 下方 */
|
|
66
|
+
description?: React.ReactNode
|
|
67
|
+
/** 左側 icon(LucideIcon),與 avatar 互斥 */
|
|
68
|
+
startIcon?: LucideIcon
|
|
69
|
+
/** 左側頭像資料(AvatarData),元件內部渲染 Avatar。與 startIcon 互斥 */
|
|
70
|
+
avatar?: AvatarData
|
|
71
|
+
/** 顯示 checkbox(多選模式由父層控制) */
|
|
72
|
+
checkbox?: boolean
|
|
73
|
+
/** Checkbox 選中狀態 */
|
|
74
|
+
checked?: boolean | 'indeterminate'
|
|
75
|
+
/** 單選選中(bg-neutral-selected 背景高亮,持續選中狀態) */
|
|
76
|
+
selected?: boolean
|
|
77
|
+
/** 後綴 Tag(ReactNode),靠右對齊 */
|
|
78
|
+
tag?: React.ReactNode
|
|
79
|
+
/** 後綴自訂內容(ReactNode),用於 DropdownMenu 的 badge/endIcon/shortcut 等 */
|
|
80
|
+
endContent?: React.ReactNode
|
|
81
|
+
/** 停用 */
|
|
82
|
+
disabled?: boolean
|
|
83
|
+
/** 作為群組標題(不可選,font-medium,fg-muted) */
|
|
84
|
+
header?: boolean
|
|
85
|
+
/**
|
|
86
|
+
* Label 最大行數(line-clamp 截斷,超過顯示 ellipsis)。
|
|
87
|
+
*
|
|
88
|
+
* - `undefined`(預設 prop 值未傳)→ 套用元件預設 `1`(單行截斷,符合選單快速掃視需求)
|
|
89
|
+
* - 數字 → 截斷到該行數
|
|
90
|
+
* - `'none'` → **明確**不截斷,自然 wrap 任意行數
|
|
91
|
+
*
|
|
92
|
+
* 為什麼用 `'none'` 而不是 `undefined` 表達不截斷?React props 的 destructure default
|
|
93
|
+
* 在 `undefined` 時會接管,所以 `<MenuItem labelMaxLines={undefined}>` 等同沒傳,
|
|
94
|
+
* 會 fallback 到預設 `1`。要明確覆寫成「不截斷」,必須用一個非 undefined 的 sentinel。
|
|
95
|
+
*/
|
|
96
|
+
labelMaxLines?: number | 'none'
|
|
97
|
+
/**
|
|
98
|
+
* Description 最大行數。
|
|
99
|
+
*
|
|
100
|
+
* - `undefined`(預設 prop 值未傳)→ 套用元件預設 `1`(跟 label 對稱,維持掃視節奏)
|
|
101
|
+
* - 數字 → 截斷到該行數
|
|
102
|
+
* - `'none'` → 明確不截斷
|
|
103
|
+
*
|
|
104
|
+
* 為什麼預設 1?Menu 的設計目的是「快速掃視多個選項挑一個」,垂直空間是
|
|
105
|
+
* 寶貴的——一個過高的 item 會破壞 row rhythm,讓使用者眼睛重新校準。description
|
|
106
|
+
* 跟 label 對稱地截到 1 行,確保所有 item 高度一致(無 desc / 有 desc 兩種高度)。
|
|
107
|
+
* Consumer 若有合理理由要 2 行 description,可顯式 override。
|
|
108
|
+
*/
|
|
109
|
+
descMaxLines?: number | 'none'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 把 maxLines 轉成 line-clamp class;'none' / 0 → 空字串 */
|
|
113
|
+
function lineClampClass(maxLines: number | 'none'): string {
|
|
114
|
+
if (maxLines === 'none' || !maxLines) return ''
|
|
115
|
+
if (maxLines === 1) return 'line-clamp-1'
|
|
116
|
+
if (maxLines === 2) return 'line-clamp-2'
|
|
117
|
+
if (maxLines === 3) return 'line-clamp-3'
|
|
118
|
+
if (maxLines === 4) return 'line-clamp-4'
|
|
119
|
+
if (maxLines === 5) return 'line-clamp-5'
|
|
120
|
+
if (maxLines === 6) return 'line-clamp-6'
|
|
121
|
+
return ''
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
|
|
125
|
+
const MenuItem = React.forwardRef<HTMLDivElement, MenuItemProps>(
|
|
126
|
+
(
|
|
127
|
+
{
|
|
128
|
+
children,
|
|
129
|
+
description,
|
|
130
|
+
startIcon: StartIcon,
|
|
131
|
+
avatar,
|
|
132
|
+
checkbox,
|
|
133
|
+
checked,
|
|
134
|
+
selected,
|
|
135
|
+
tag,
|
|
136
|
+
endContent,
|
|
137
|
+
disabled,
|
|
138
|
+
header,
|
|
139
|
+
size,
|
|
140
|
+
labelMaxLines = 1,
|
|
141
|
+
descMaxLines = 1,
|
|
142
|
+
className,
|
|
143
|
+
...props
|
|
144
|
+
},
|
|
145
|
+
ref
|
|
146
|
+
) => {
|
|
147
|
+
const sizeKey = size ?? 'md'
|
|
148
|
+
const labelClampClass = lineClampClass(labelMaxLines)
|
|
149
|
+
const descClampClass = lineClampClass(descMaxLines)
|
|
150
|
+
const iconPx = ICON_SIZE[sizeKey]
|
|
151
|
+
|
|
152
|
+
// ── 決定 avatar 容器尺寸 + 對齊模式 ──
|
|
153
|
+
// 有 description → 使用 block 尺寸(32/40px),block 對齊
|
|
154
|
+
// 無 description → 使用 inline 尺寸(20/24px),inline 對齊
|
|
155
|
+
const avatarPx = avatar
|
|
156
|
+
? (description ? AVATAR_SIZE.block[sizeKey] : AVATAR_SIZE.inline[sizeKey])
|
|
157
|
+
: 0
|
|
158
|
+
const isBlockAlign = avatarPx > 24 && !!description
|
|
159
|
+
|
|
160
|
+
const prefixAlign = isBlockAlign
|
|
161
|
+
? (`block-${sizeKey}` as const)
|
|
162
|
+
: 'inline'
|
|
163
|
+
|
|
164
|
+
const hasPrefix = !!StartIcon || !!avatar || checkbox
|
|
165
|
+
|
|
166
|
+
// ── Header variant ──
|
|
167
|
+
if (header) {
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
ref={ref}
|
|
171
|
+
className={cn(
|
|
172
|
+
menuItemVariants({ size }),
|
|
173
|
+
'font-medium text-fg-muted cursor-default pointer-events-none',
|
|
174
|
+
className,
|
|
175
|
+
)}
|
|
176
|
+
role="presentation"
|
|
177
|
+
{...props}
|
|
178
|
+
>
|
|
179
|
+
{children}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
ref={ref}
|
|
187
|
+
role="option"
|
|
188
|
+
aria-selected={selected || (checked === true) || undefined}
|
|
189
|
+
aria-disabled={disabled || undefined}
|
|
190
|
+
data-selected={selected ? '' : undefined}
|
|
191
|
+
data-disabled={disabled ? '' : undefined}
|
|
192
|
+
className={cn(
|
|
193
|
+
menuItemVariants({ size }),
|
|
194
|
+
!disabled && !selected && 'hover:bg-neutral-hover',
|
|
195
|
+
!disabled && selected && 'bg-neutral-selected',
|
|
196
|
+
// disabled 用 cursor-not-allowed(對齊 Button + Material/Polaris/Atlassian);
|
|
197
|
+
// pointer-events-none 會讓 cursor 失效,改用 aria-disabled + onClick guard
|
|
198
|
+
disabled && 'text-fg-disabled cursor-not-allowed',
|
|
199
|
+
className,
|
|
200
|
+
)}
|
|
201
|
+
onClick={disabled ? undefined : props.onClick}
|
|
202
|
+
onKeyDown={disabled ? undefined : props.onKeyDown}
|
|
203
|
+
{...Object.fromEntries(Object.entries(props).filter(([k]) => k !== 'onClick' && k !== 'onKeyDown'))}
|
|
204
|
+
>
|
|
205
|
+
{/* Prefix 對齊容器 */}
|
|
206
|
+
{hasPrefix && (
|
|
207
|
+
<div className={cn(itemPrefixAlignVariants({ align: prefixAlign }))}>
|
|
208
|
+
{checkbox && (
|
|
209
|
+
<Checkbox
|
|
210
|
+
size={CHECKBOX_SIZE[sizeKey]}
|
|
211
|
+
checked={checked === true ? true : checked === 'indeterminate' ? 'indeterminate' : false}
|
|
212
|
+
disabled={disabled}
|
|
213
|
+
tabIndex={-1}
|
|
214
|
+
className="pointer-events-none"
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
{StartIcon && (
|
|
218
|
+
<StartIcon
|
|
219
|
+
data-prefix-type="icon"
|
|
220
|
+
size={iconPx}
|
|
221
|
+
className={cn('shrink-0', disabled && 'text-fg-disabled')}
|
|
222
|
+
aria-hidden
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
{avatar && (
|
|
226
|
+
<Avatar
|
|
227
|
+
data-prefix-type="avatar"
|
|
228
|
+
src={avatar.src}
|
|
229
|
+
alt={avatar.alt}
|
|
230
|
+
color={avatar.color}
|
|
231
|
+
hoverCard={avatar.hoverCard}
|
|
232
|
+
size={avatarPx}
|
|
233
|
+
/>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* Content — 消費 ItemContent primitive(SSOT)。
|
|
239
|
+
scanning mode 跟隨 size:sm/md = caption,lg = body(desc 比 label lg 16 小 1 tier)。
|
|
240
|
+
labelClampClass / descClampClass 透過 className escape hatch 傳入(MenuItem 特化 labelMaxLines / descMaxLines 語意)。 */}
|
|
241
|
+
<ItemContent
|
|
242
|
+
label={children}
|
|
243
|
+
description={description}
|
|
244
|
+
mode="scanning"
|
|
245
|
+
size={sizeKey === 'lg' ? 'lg' : 'md'}
|
|
246
|
+
descriptionTone={disabled ? 'disabled' : 'secondary'}
|
|
247
|
+
labelTruncate={false}
|
|
248
|
+
labelClassName={cn(labelClampClass || 'break-words', disabled && 'text-fg-disabled')}
|
|
249
|
+
descriptionClassName={cn(descClampClass || 'break-words')}
|
|
250
|
+
/>
|
|
251
|
+
|
|
252
|
+
{(tag || endContent) && (
|
|
253
|
+
<div className={cn(
|
|
254
|
+
'flex items-center gap-2 shrink-0 h-[1lh] ml-auto',
|
|
255
|
+
disabled && 'opacity-disabled',
|
|
256
|
+
)}>
|
|
257
|
+
{tag}
|
|
258
|
+
{endContent}
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
MenuItem.displayName = 'MenuItem'
|
|
266
|
+
|
|
267
|
+
// ── Group ──
|
|
268
|
+
// MenuGroup — Menu-like group primitive(跨元件設計語言統一,SSOT 見
|
|
269
|
+
// `patterns/element-anatomy/item-anatomy.spec.md`「Group auto-separation」)
|
|
270
|
+
//
|
|
271
|
+
// 設計語言:每個 group 上下 8px + 相鄰 group 間 border-divider + 視覺 gap 16px
|
|
272
|
+
//
|
|
273
|
+
// 本元件用 Pattern A:`py-2 [&+&]:border-t [&+&]:border-divider`
|
|
274
|
+
// (適用外層容器無 py-2 的情境,例 Command.List——Group 自帶邊界 padding)
|
|
275
|
+
//
|
|
276
|
+
// **視覺必須跟 DropdownMenuGroup 等價**(見 dropdown-menu.tsx Pattern B 對照)。
|
|
277
|
+
// 改動前先讀 item-anatomy.spec.md「Group auto-separation」確認視覺不漂移。
|
|
278
|
+
|
|
279
|
+
export interface MenuGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
280
|
+
children: React.ReactNode
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const MenuGroup = React.forwardRef<HTMLDivElement, MenuGroupProps>(
|
|
284
|
+
({ children, className, ...props }, ref) => (
|
|
285
|
+
<div
|
|
286
|
+
ref={ref}
|
|
287
|
+
role="group"
|
|
288
|
+
className={cn('py-2 [&+&]:border-t [&+&]:border-divider', className)}
|
|
289
|
+
{...props}
|
|
290
|
+
>
|
|
291
|
+
{children}
|
|
292
|
+
</div>
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
MenuGroup.displayName = 'MenuGroup'
|
|
296
|
+
|
|
297
|
+
// ── Footer ──
|
|
298
|
+
|
|
299
|
+
export interface MenuFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
300
|
+
children: React.ReactNode
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const MenuFooter = React.forwardRef<HTMLDivElement, MenuFooterProps>(
|
|
304
|
+
({ children, className, ...props }, ref) => (
|
|
305
|
+
<div
|
|
306
|
+
ref={ref}
|
|
307
|
+
className={cn('py-2 border-t border-divider', className)}
|
|
308
|
+
{...props}
|
|
309
|
+
>
|
|
310
|
+
{children}
|
|
311
|
+
</div>
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
MenuFooter.displayName = 'MenuFooter'
|
|
315
|
+
|
|
316
|
+
// Story auto-compile metadata — Phase 1+2 migration
|
|
317
|
+
export const menuItemMeta = {
|
|
318
|
+
component: 'MenuItem',
|
|
319
|
+
family: 1,
|
|
320
|
+
variants: {},
|
|
321
|
+
sizes: {
|
|
322
|
+
sm: {},
|
|
323
|
+
md: {},
|
|
324
|
+
lg: {},
|
|
325
|
+
},
|
|
326
|
+
defaultSize: 'md',
|
|
327
|
+
states: ['default', 'hover', 'selected', 'focus-visible', 'disabled'],
|
|
328
|
+
tokens: {
|
|
329
|
+
bg: ['bg-neutral-hover', 'bg-neutral-selected'],
|
|
330
|
+
fg: ['text-fg-muted', 'text-fg-disabled'],
|
|
331
|
+
},
|
|
332
|
+
} as const
|
|
333
|
+
|
|
334
|
+
export { MenuItem, MenuGroup, MenuFooter, menuItemVariants }
|