@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,475 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
|
3
|
+
import { ChevronRight, type LucideIcon } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
import type { AvatarData } from "@/design-system/components/Avatar/avatar"
|
|
7
|
+
import { MenuItem } from "@/design-system/components/Menu/menu-item"
|
|
8
|
+
import { ScrollArea } from "@/design-system/components/ScrollArea/scroll-area"
|
|
9
|
+
import { OVERLAY_SIDE_OFFSET, OVERLAY_COLLISION_PADDING } from "@/design-system/tokens/elevation/overlay-geometry"
|
|
10
|
+
import {
|
|
11
|
+
RowSizeProvider,
|
|
12
|
+
useRowSize,
|
|
13
|
+
ICON_SIZE as ROW_ICON_SIZE,
|
|
14
|
+
type RowSize,
|
|
15
|
+
} from "@/design-system/patterns/element-anatomy/item-anatomy"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* DropdownMenu — Radix DropdownMenu + MenuItem visual layer
|
|
19
|
+
*
|
|
20
|
+
* 架構分工:
|
|
21
|
+
* - Radix primitives:behavior(keyboard nav, focus management, aria roles)
|
|
22
|
+
* - MenuItem:visual(layout, padding, icon alignment, typography)
|
|
23
|
+
*
|
|
24
|
+
* Radix primitive 是外層容器,控制 `data-[highlighted]:bg-neutral-hover`。
|
|
25
|
+
* MenuItem 內層只負責佈局,不加互動樣式。
|
|
26
|
+
*
|
|
27
|
+
* ── Hover / highlight canonical(2026-04-22 修正)──
|
|
28
|
+
* 用 Radix 官方的 `data-[highlighted]` attribute,**不用 `:focus-visible` / `:hover` /
|
|
29
|
+
* `:focus`**:
|
|
30
|
+
* - Radix 在 **mouse hover、keyboard arrow nav、focus move in** 時自動 set `data-highlighted`
|
|
31
|
+
* - mouse leave / focus move out / menu close 時自動清掉
|
|
32
|
+
* - 不會在 click 後留殘影(Radix 內部已處理)
|
|
33
|
+
* - 跨瀏覽器一致(不依賴 `:focus-visible` 的 heuristic)
|
|
34
|
+
*
|
|
35
|
+
* 曾經用過 `focus-visible:bg-neutral-hover` 的理由:避免 click 後殘影。但實測:mouse hover
|
|
36
|
+
* 觸發 Radix 程式化 `.focus()`,Chromium / Safari / Firefox 對 programmatic focus 是否 fire
|
|
37
|
+
* `:focus-visible` 行為不一致,導致 mouse hover 有時無 bg。改用 `data-[highlighted]:` 後行為
|
|
38
|
+
* 一致 —— 世界級 canonical(shadcn / Radix docs / Ariakit 皆此)。
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
// ── Floating layer 共用樣式 ──
|
|
42
|
+
const floatingLayerClass = [
|
|
43
|
+
'z-50 overflow-hidden rounded-lg border border-border bg-surface-raised',
|
|
44
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out motion-reduce:animate-none',
|
|
45
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
46
|
+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
47
|
+
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
|
48
|
+
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
|
49
|
+
'origin-[var(--radix-dropdown-menu-content-transform-origin)]',
|
|
50
|
+
].join(' ')
|
|
51
|
+
|
|
52
|
+
// ── Size:統一用 RowSizeContext(item-layout module),消除本地 SizeContext 漂移 ──
|
|
53
|
+
type SizeKey = RowSize
|
|
54
|
+
// Re-export for backward compat(內部命名)
|
|
55
|
+
const ICON_SIZE = ROW_ICON_SIZE
|
|
56
|
+
|
|
57
|
+
// ── Shared item classes on Radix primitive ──
|
|
58
|
+
// Highlight(hover + keyboard nav): 用 Radix `data-[highlighted]` canonical(見 docblock)
|
|
59
|
+
const radixItemClass = [
|
|
60
|
+
'relative cursor-pointer select-none outline-none',
|
|
61
|
+
'transition-colors duration-150',
|
|
62
|
+
'data-[highlighted]:bg-neutral-hover',
|
|
63
|
+
'data-[disabled]:pointer-events-none data-[disabled]:text-fg-disabled data-[disabled]:cursor-default',
|
|
64
|
+
].join(' ')
|
|
65
|
+
|
|
66
|
+
// ── Root ──
|
|
67
|
+
const DropdownMenu = DropdownMenuPrimitive.Root
|
|
68
|
+
const DropdownMenuTrigger = React.forwardRef<
|
|
69
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
|
70
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
|
71
|
+
>(({ className, ...props }, ref) => (
|
|
72
|
+
<DropdownMenuPrimitive.Trigger
|
|
73
|
+
ref={ref}
|
|
74
|
+
className={cn('outline-none', className)}
|
|
75
|
+
{...props}
|
|
76
|
+
/>
|
|
77
|
+
))
|
|
78
|
+
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName
|
|
79
|
+
// DropdownMenuGroup — 對齊 MenuGroup 的 group separation 設計語言
|
|
80
|
+
//
|
|
81
|
+
// 設計語言(跨 Menu-like 元件統一,SSOT 見 item-anatomy.spec.md
|
|
82
|
+
// 「Group auto-separation」):
|
|
83
|
+
// 每個 group 上下各 8px padding,相鄰 group 之間用 border-divider 分隔
|
|
84
|
+
// 兩個 group 之間視覺 gap = 8px(上一個 bottom)+ 8px(下一個 top)= 16px + border
|
|
85
|
+
//
|
|
86
|
+
// MenuGroup(menu-item.tsx)實作:`py-2 [&+&]:border-t [&+&]:border-divider`
|
|
87
|
+
// (在 Command.List 下提供 Content 邊界 8px + group 間 16px gap)
|
|
88
|
+
//
|
|
89
|
+
// DropdownMenuGroup(本元件)實作:`[&+&]:mt-2 [&+&]:pt-2 [&+&]:border-t
|
|
90
|
+
// [&+&]:border-divider`(因為 DropdownMenuContent 已有 py-2 提供 Content 邊界
|
|
91
|
+
// 的 8px,只需在第二個起的 group 加 8+8 = 16px gap + border)
|
|
92
|
+
//
|
|
93
|
+
// **視覺結果等同**:兩種實作的 visual output 一致,只是「padding 住在哪層」
|
|
94
|
+
// 不同。不強制統一 CSS 表達式——DropdownMenuContent 的 py-2 是既有 Radix
|
|
95
|
+
// 期望的行為,移除會影響 trigger 鍵盤導覽的 focus offset。
|
|
96
|
+
const DropdownMenuGroup = React.forwardRef<
|
|
97
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Group>,
|
|
98
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group>
|
|
99
|
+
>(({ className, ...props }, ref) => (
|
|
100
|
+
<DropdownMenuPrimitive.Group
|
|
101
|
+
ref={ref}
|
|
102
|
+
className={cn('[&+&]:border-t [&+&]:border-divider [&+&]:mt-2 [&+&]:pt-2', className)}
|
|
103
|
+
{...props}
|
|
104
|
+
/>
|
|
105
|
+
))
|
|
106
|
+
DropdownMenuGroup.displayName = 'DropdownMenuGroup'
|
|
107
|
+
|
|
108
|
+
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
|
109
|
+
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
|
110
|
+
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
|
111
|
+
|
|
112
|
+
// ── Content ──
|
|
113
|
+
interface DropdownMenuContentProps
|
|
114
|
+
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
|
|
115
|
+
size?: SizeKey
|
|
116
|
+
/** 最小寬度(px),預設跟隨觸發元件寬度 */
|
|
117
|
+
minWidth?: number
|
|
118
|
+
/** 最大高度(px),超過時捲動 */
|
|
119
|
+
maxHeight?: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const DropdownMenuContent = React.forwardRef<
|
|
123
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
124
|
+
DropdownMenuContentProps
|
|
125
|
+
>(({ className, size = 'md', sideOffset = OVERLAY_SIDE_OFFSET, collisionPadding = OVERLAY_COLLISION_PADDING, align = 'start', minWidth, maxHeight, children, ...props }, ref) => (
|
|
126
|
+
<DropdownMenuPrimitive.Portal>
|
|
127
|
+
<DropdownMenuPrimitive.Content
|
|
128
|
+
ref={ref}
|
|
129
|
+
sideOffset={sideOffset}
|
|
130
|
+
collisionPadding={collisionPadding}
|
|
131
|
+
align={align}
|
|
132
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
133
|
+
className={cn(floatingLayerClass, !maxHeight && 'py-2', className)}
|
|
134
|
+
style={{
|
|
135
|
+
boxShadow: 'var(--elevation-200)',
|
|
136
|
+
minWidth: minWidth ?? 'max(180px, var(--radix-dropdown-menu-trigger-width))',
|
|
137
|
+
maxHeight,
|
|
138
|
+
}}
|
|
139
|
+
{...props}
|
|
140
|
+
>
|
|
141
|
+
<RowSizeProvider value={size}>
|
|
142
|
+
{maxHeight ? (
|
|
143
|
+
// 長選單用 ScrollArea 跨 OS 一致捲動(不吃寬度,macOS/Windows 視覺一致)
|
|
144
|
+
// py-2 移到內層,ScrollArea Viewport 才能 scroll 整個 padded 區
|
|
145
|
+
<ScrollArea className="max-h-[inherit]">
|
|
146
|
+
<div className="py-2">{children}</div>
|
|
147
|
+
</ScrollArea>
|
|
148
|
+
) : (
|
|
149
|
+
children
|
|
150
|
+
)}
|
|
151
|
+
</RowSizeProvider>
|
|
152
|
+
</DropdownMenuPrimitive.Content>
|
|
153
|
+
</DropdownMenuPrimitive.Portal>
|
|
154
|
+
))
|
|
155
|
+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
|
156
|
+
|
|
157
|
+
// ── SubContent ──
|
|
158
|
+
const DropdownMenuSubContent = React.forwardRef<
|
|
159
|
+
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
160
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
161
|
+
>(({ className, children, ...props }, ref) => {
|
|
162
|
+
const size = useRowSize()
|
|
163
|
+
return (
|
|
164
|
+
<DropdownMenuPrimitive.SubContent
|
|
165
|
+
ref={ref}
|
|
166
|
+
sideOffset={OVERLAY_SIDE_OFFSET}
|
|
167
|
+
className={cn(floatingLayerClass, 'py-2', className)}
|
|
168
|
+
style={{ boxShadow: 'var(--elevation-200)', minWidth: 180 }}
|
|
169
|
+
{...props}
|
|
170
|
+
>
|
|
171
|
+
<RowSizeProvider value={size}>
|
|
172
|
+
{children}
|
|
173
|
+
</RowSizeProvider>
|
|
174
|
+
</DropdownMenuPrimitive.SubContent>
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
|
178
|
+
|
|
179
|
+
// ── Helper: build endContent from badge + endIcon + shortcut ──
|
|
180
|
+
function buildEndContent(
|
|
181
|
+
size: SizeKey,
|
|
182
|
+
badge?: React.ReactNode,
|
|
183
|
+
endIcon?: LucideIcon,
|
|
184
|
+
shortcut?: string,
|
|
185
|
+
): React.ReactNode | undefined {
|
|
186
|
+
const EndIcon = endIcon
|
|
187
|
+
if (!badge && !EndIcon && !shortcut) return undefined
|
|
188
|
+
const iconPx = ICON_SIZE[size]
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
{badge}
|
|
192
|
+
{EndIcon && <EndIcon size={iconPx} className="text-fg-muted" aria-hidden />}
|
|
193
|
+
{shortcut && <span className="text-caption text-fg-muted">{shortcut}</span>}
|
|
194
|
+
</>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Item ──
|
|
199
|
+
interface DropdownMenuItemProps
|
|
200
|
+
extends Omit<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>, 'children'> {
|
|
201
|
+
children: React.ReactNode
|
|
202
|
+
/** 左側 icon */
|
|
203
|
+
startIcon?: LucideIcon
|
|
204
|
+
/** 左側頭像資料(AvatarData),與 startIcon 互斥 */
|
|
205
|
+
avatar?: AvatarData
|
|
206
|
+
/** 次要說明文字 */
|
|
207
|
+
description?: React.ReactNode
|
|
208
|
+
/** 後綴 Tag(ReactNode) */
|
|
209
|
+
tag?: React.ReactNode
|
|
210
|
+
/** 後綴 Badge(ReactNode) */
|
|
211
|
+
badge?: React.ReactNode
|
|
212
|
+
/** 後綴指示型 icon(LucideIcon),fg-muted */
|
|
213
|
+
endIcon?: LucideIcon
|
|
214
|
+
/** 鍵盤快捷鍵 */
|
|
215
|
+
shortcut?: string
|
|
216
|
+
/** 單選選中(bg-neutral-selected,持續選中狀態)*/
|
|
217
|
+
selected?: boolean
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const DropdownMenuItem = React.forwardRef<
|
|
221
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
|
222
|
+
DropdownMenuItemProps
|
|
223
|
+
>(({ className, children, startIcon, avatar, description, tag, badge, endIcon, shortcut, selected, disabled, ...props }, ref) => {
|
|
224
|
+
const size = useRowSize()
|
|
225
|
+
const endContent = buildEndContent(size, badge, endIcon, shortcut)
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<DropdownMenuPrimitive.Item
|
|
229
|
+
ref={ref}
|
|
230
|
+
disabled={disabled}
|
|
231
|
+
className={cn(
|
|
232
|
+
radixItemClass,
|
|
233
|
+
selected && 'bg-neutral-selected',
|
|
234
|
+
className,
|
|
235
|
+
)}
|
|
236
|
+
{...props}
|
|
237
|
+
>
|
|
238
|
+
<MenuItem
|
|
239
|
+
size={size}
|
|
240
|
+
startIcon={startIcon}
|
|
241
|
+
avatar={avatar}
|
|
242
|
+
description={description}
|
|
243
|
+
tag={tag}
|
|
244
|
+
endContent={endContent}
|
|
245
|
+
disabled={disabled}
|
|
246
|
+
// Pure visual — Radix parent handles role/aria/interaction
|
|
247
|
+
role="presentation"
|
|
248
|
+
className="!bg-transparent hover:!bg-transparent pointer-events-none"
|
|
249
|
+
>
|
|
250
|
+
{children}
|
|
251
|
+
</MenuItem>
|
|
252
|
+
</DropdownMenuPrimitive.Item>
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
|
256
|
+
|
|
257
|
+
// ── SubTrigger(子選單觸發器,自動附加 ChevronRight)──
|
|
258
|
+
interface DropdownMenuSubTriggerProps
|
|
259
|
+
extends Omit<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>, 'children'> {
|
|
260
|
+
children: React.ReactNode
|
|
261
|
+
/** 左側 icon */
|
|
262
|
+
startIcon?: LucideIcon
|
|
263
|
+
/** 子選單目前狀態值文字(如 "深色") */
|
|
264
|
+
value?: string
|
|
265
|
+
/** 子選單狀態 badge */
|
|
266
|
+
badge?: React.ReactNode
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const DropdownMenuSubTrigger = React.forwardRef<
|
|
270
|
+
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
|
271
|
+
DropdownMenuSubTriggerProps
|
|
272
|
+
>(({ className, children, startIcon, value, badge, ...props }, ref) => {
|
|
273
|
+
const size = useRowSize()
|
|
274
|
+
const iconPx = ICON_SIZE[size]
|
|
275
|
+
|
|
276
|
+
// SubTrigger suffix: [value?] [badge?] [ChevronRight] with gap-1
|
|
277
|
+
const endContent = (
|
|
278
|
+
<div className="flex items-center gap-1">
|
|
279
|
+
{value && <span className="text-fg-muted">{value}</span>}
|
|
280
|
+
{badge}
|
|
281
|
+
<ChevronRight size={iconPx} className="text-fg-muted" />
|
|
282
|
+
</div>
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<DropdownMenuPrimitive.SubTrigger
|
|
287
|
+
ref={ref}
|
|
288
|
+
className={cn(
|
|
289
|
+
radixItemClass,
|
|
290
|
+
'data-[state=open]:bg-neutral-hover',
|
|
291
|
+
className,
|
|
292
|
+
)}
|
|
293
|
+
{...props}
|
|
294
|
+
>
|
|
295
|
+
<MenuItem
|
|
296
|
+
size={size}
|
|
297
|
+
startIcon={startIcon}
|
|
298
|
+
endContent={endContent}
|
|
299
|
+
role="presentation"
|
|
300
|
+
className="!bg-transparent hover:!bg-transparent pointer-events-none"
|
|
301
|
+
>
|
|
302
|
+
{children}
|
|
303
|
+
</MenuItem>
|
|
304
|
+
</DropdownMenuPrimitive.SubTrigger>
|
|
305
|
+
)
|
|
306
|
+
})
|
|
307
|
+
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
|
308
|
+
|
|
309
|
+
// ── CheckboxItem ──
|
|
310
|
+
interface DropdownMenuCheckboxItemProps
|
|
311
|
+
extends Omit<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>, 'children'> {
|
|
312
|
+
children: React.ReactNode
|
|
313
|
+
/** 左側 icon */
|
|
314
|
+
startIcon?: LucideIcon
|
|
315
|
+
/** 次要說明文字 */
|
|
316
|
+
description?: React.ReactNode
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const DropdownMenuCheckboxItem = React.forwardRef<
|
|
320
|
+
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
321
|
+
DropdownMenuCheckboxItemProps
|
|
322
|
+
>(({ className, children, startIcon, description, checked, disabled, ...props }, ref) => {
|
|
323
|
+
const size = useRowSize()
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<DropdownMenuPrimitive.CheckboxItem
|
|
327
|
+
ref={ref}
|
|
328
|
+
checked={checked}
|
|
329
|
+
disabled={disabled}
|
|
330
|
+
onSelect={(e) => e.preventDefault()}
|
|
331
|
+
className={cn(radixItemClass, className)}
|
|
332
|
+
{...props}
|
|
333
|
+
>
|
|
334
|
+
<MenuItem
|
|
335
|
+
size={size}
|
|
336
|
+
checkbox
|
|
337
|
+
checked={!!checked}
|
|
338
|
+
startIcon={startIcon}
|
|
339
|
+
description={description}
|
|
340
|
+
disabled={disabled}
|
|
341
|
+
role="presentation"
|
|
342
|
+
className="!bg-transparent hover:!bg-transparent pointer-events-none"
|
|
343
|
+
>
|
|
344
|
+
{children}
|
|
345
|
+
</MenuItem>
|
|
346
|
+
</DropdownMenuPrimitive.CheckboxItem>
|
|
347
|
+
)
|
|
348
|
+
})
|
|
349
|
+
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
|
350
|
+
|
|
351
|
+
// ── Label(群組標題)──
|
|
352
|
+
const DropdownMenuLabel = React.forwardRef<
|
|
353
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
|
354
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
|
|
355
|
+
>(({ className, children, ...props }, ref) => {
|
|
356
|
+
const size = useRowSize()
|
|
357
|
+
return (
|
|
358
|
+
<DropdownMenuPrimitive.Label
|
|
359
|
+
ref={ref}
|
|
360
|
+
className={cn('outline-none', className)}
|
|
361
|
+
{...props}
|
|
362
|
+
>
|
|
363
|
+
<MenuItem
|
|
364
|
+
size={size}
|
|
365
|
+
header
|
|
366
|
+
role="presentation"
|
|
367
|
+
className="pointer-events-none"
|
|
368
|
+
>
|
|
369
|
+
{children}
|
|
370
|
+
</MenuItem>
|
|
371
|
+
</DropdownMenuPrimitive.Label>
|
|
372
|
+
)
|
|
373
|
+
})
|
|
374
|
+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
|
375
|
+
|
|
376
|
+
// ── Separator ──
|
|
377
|
+
const DropdownMenuSeparator = React.forwardRef<
|
|
378
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
|
379
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
|
380
|
+
>(({ className, ...props }, ref) => (
|
|
381
|
+
<DropdownMenuPrimitive.Separator
|
|
382
|
+
ref={ref}
|
|
383
|
+
className={cn("my-2 h-px bg-divider", className)}
|
|
384
|
+
{...props}
|
|
385
|
+
/>
|
|
386
|
+
))
|
|
387
|
+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
|
388
|
+
|
|
389
|
+
// ── RadioItem(單選,排序方式等)──
|
|
390
|
+
// Radix handles checked state; visual用 MenuItem 的 selected highlight。
|
|
391
|
+
interface DropdownMenuRadioItemProps
|
|
392
|
+
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> {
|
|
393
|
+
/** Prefix icon(LucideIcon) */
|
|
394
|
+
startIcon?: LucideIcon
|
|
395
|
+
/** 次要說明文字 */
|
|
396
|
+
description?: React.ReactNode
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const DropdownMenuRadioItem = React.forwardRef<
|
|
400
|
+
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
401
|
+
DropdownMenuRadioItemProps
|
|
402
|
+
>(({ className, children, startIcon, description, disabled, ...props }, ref) => {
|
|
403
|
+
const size = useRowSize()
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<DropdownMenuPrimitive.RadioItem
|
|
407
|
+
ref={ref}
|
|
408
|
+
disabled={disabled}
|
|
409
|
+
onSelect={(e) => e.preventDefault()}
|
|
410
|
+
className={cn(radixItemClass, 'data-[state=checked]:[&>*]:bg-neutral-selected', className)}
|
|
411
|
+
{...props}
|
|
412
|
+
>
|
|
413
|
+
<MenuItem
|
|
414
|
+
size={size}
|
|
415
|
+
startIcon={startIcon}
|
|
416
|
+
description={description}
|
|
417
|
+
disabled={disabled}
|
|
418
|
+
role="presentation"
|
|
419
|
+
className="!bg-transparent hover:!bg-transparent pointer-events-none"
|
|
420
|
+
>
|
|
421
|
+
{children}
|
|
422
|
+
</MenuItem>
|
|
423
|
+
</DropdownMenuPrimitive.RadioItem>
|
|
424
|
+
)
|
|
425
|
+
})
|
|
426
|
+
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
|
427
|
+
|
|
428
|
+
// ── Shortcut(鍵盤快捷鍵提示,ml-auto 靠右)──
|
|
429
|
+
// 作為 MenuItem children 的後綴,視覺為 fg-muted 小字。
|
|
430
|
+
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
|
431
|
+
<span
|
|
432
|
+
className={cn('ml-auto text-footnote text-fg-muted tracking-shortcut', className)}
|
|
433
|
+
{...props}
|
|
434
|
+
/>
|
|
435
|
+
)
|
|
436
|
+
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
|
437
|
+
|
|
438
|
+
// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
|
|
439
|
+
// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
|
|
440
|
+
export const dropdownMenuMeta = {
|
|
441
|
+
component: 'DropdownMenu',
|
|
442
|
+
family: null, // non-family composite / overlay / layout
|
|
443
|
+
variants: {
|
|
444
|
+
|
|
445
|
+
},
|
|
446
|
+
sizes: {
|
|
447
|
+
|
|
448
|
+
},
|
|
449
|
+
states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
|
|
450
|
+
tokens: {
|
|
451
|
+
bg: ['bg-neutral-hover', 'bg-surface-raised', 'bg-transparent'],
|
|
452
|
+
fg: ['text-fg-disabled', 'text-fg-muted'],
|
|
453
|
+
ring: [],
|
|
454
|
+
},
|
|
455
|
+
} as const
|
|
456
|
+
|
|
457
|
+
export {
|
|
458
|
+
DropdownMenu,
|
|
459
|
+
DropdownMenuTrigger,
|
|
460
|
+
DropdownMenuContent,
|
|
461
|
+
DropdownMenuItem,
|
|
462
|
+
DropdownMenuCheckboxItem,
|
|
463
|
+
DropdownMenuLabel,
|
|
464
|
+
DropdownMenuSeparator,
|
|
465
|
+
DropdownMenuGroup,
|
|
466
|
+
DropdownMenuPortal,
|
|
467
|
+
DropdownMenuSub,
|
|
468
|
+
DropdownMenuSubContent,
|
|
469
|
+
DropdownMenuSubTrigger,
|
|
470
|
+
DropdownMenuRadioGroup,
|
|
471
|
+
DropdownMenuRadioItem,
|
|
472
|
+
DropdownMenuShortcut,
|
|
473
|
+
floatingLayerClass,
|
|
474
|
+
}
|
|
475
|
+
export type { SizeKey, DropdownMenuItemProps }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import type { LucideIcon } from 'lucide-react'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { Avatar } from '@/design-system/components/Avatar/avatar'
|
|
5
|
+
import { useRowSize } from '@/design-system/patterns/element-anatomy/item-anatomy'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Empty — 空狀態視覺元件
|
|
9
|
+
*
|
|
10
|
+
* 居中垂直堆疊:icon(Avatar) → title → description → action。
|
|
11
|
+
* 所有 slot 皆可選,預設只需 description。
|
|
12
|
+
*
|
|
13
|
+
* 間距固定,不隨 density 變(Empty 是展示性元件,不是工作區域元件):
|
|
14
|
+
* icon → text = mb-4(16px)
|
|
15
|
+
* desc → action = mt-6(24px)
|
|
16
|
+
* title → desc = `var(--item-gap-label-desc)`(token,預設 2px,item-anatomy SSOT)
|
|
17
|
+
*
|
|
18
|
+
* Outer padding 由 consumer 容器決定(py-12 / py-6 / py-16 等)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface EmptyProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
22
|
+
/** LucideIcon → 自動包 Avatar 48px neutral;ReactElement → 原樣渲染 */
|
|
23
|
+
icon?: LucideIcon | React.ReactElement
|
|
24
|
+
/** 標題(可選,font-medium,適用於首次引導) */
|
|
25
|
+
title?: string
|
|
26
|
+
/** 說明文字 */
|
|
27
|
+
description?: string
|
|
28
|
+
/** 行動按鈕(可選) */
|
|
29
|
+
action?: React.ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const Empty = React.forwardRef<HTMLDivElement, EmptyProps>(
|
|
33
|
+
({ icon, title, description, action, className, ...props }, ref) => {
|
|
34
|
+
// 字體 tier:讀 RowSizeContext(menu 內自動對齊 menu items 的字體)
|
|
35
|
+
// 沒有 context(standalone)→ fallback 'md' → text-body (14px)
|
|
36
|
+
const rowSize = useRowSize('md')
|
|
37
|
+
const descFont = rowSize === 'lg' ? 'text-body-lg' : 'text-body'
|
|
38
|
+
|
|
39
|
+
// Icon rendering: ReactElement → as-is;LucideIcon(component,包括 forwardRef 物件)→ 包 Avatar
|
|
40
|
+
// 注意:Lucide v0.577+ icons 是 forwardRef 物件(`typeof === 'object'`),不是 function。
|
|
41
|
+
// 必用 React.isValidElement 判斷 element vs component(typeof 會把 forwardRef 物件誤歸 object)。
|
|
42
|
+
let iconElement: React.ReactNode = null
|
|
43
|
+
if (icon) {
|
|
44
|
+
if (React.isValidElement(icon)) {
|
|
45
|
+
iconElement = icon
|
|
46
|
+
} else {
|
|
47
|
+
const Icon = icon as LucideIcon
|
|
48
|
+
iconElement = <Avatar icon={Icon} size={48} color="neutral" />
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
ref={ref}
|
|
55
|
+
className={cn('flex flex-col items-center text-center', className)}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
{iconElement && (
|
|
59
|
+
<div className="mb-4">{iconElement}</div>
|
|
60
|
+
)}
|
|
61
|
+
{title && (
|
|
62
|
+
<span className="text-body-lg font-medium text-foreground">
|
|
63
|
+
{title}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
{description && (
|
|
67
|
+
<span
|
|
68
|
+
className={cn(
|
|
69
|
+
// 字體跟 RowSizeContext 對齊:sm/md = text-body (14px),lg = text-body-lg (16px)
|
|
70
|
+
// 在 menu 內自動對齊 menu items;standalone 時 fallback text-body
|
|
71
|
+
descFont,
|
|
72
|
+
(title || action) ? 'text-fg-secondary' : 'text-fg-muted',
|
|
73
|
+
// Empty title 永遠 body-lg(16)→ 用 reading-lg token(label tier 決定)
|
|
74
|
+
title && 'mt-[var(--item-gap-label-desc-reading-lg)]',
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{description}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
{action && (
|
|
81
|
+
<div className="mt-6">{action}</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
Empty.displayName = 'Empty'
|
|
88
|
+
|
|
89
|
+
// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
|
|
90
|
+
// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
|
|
91
|
+
export const emptyMeta = {
|
|
92
|
+
component: 'Empty',
|
|
93
|
+
family: null, // non-family composite / overlay / layout
|
|
94
|
+
variants: {
|
|
95
|
+
|
|
96
|
+
},
|
|
97
|
+
sizes: {
|
|
98
|
+
|
|
99
|
+
},
|
|
100
|
+
states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
|
|
101
|
+
tokens: {
|
|
102
|
+
bg: [],
|
|
103
|
+
fg: ['text-fg-muted', 'text-fg-secondary', 'text-foreground'],
|
|
104
|
+
ring: [],
|
|
105
|
+
},
|
|
106
|
+
} as const
|
|
107
|
+
|
|
108
|
+
export { Empty }
|