@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,260 @@
|
|
|
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
|
+
/**
|
|
3
|
+
* TimeColumns — H/M/S scroll selector primitive(M17 Rule-of-3 SSOT)。
|
|
4
|
+
*
|
|
5
|
+
* 共用消費者:
|
|
6
|
+
* - `TimePicker`(本元件家)
|
|
7
|
+
* - `DatePicker showTime`(date + time)
|
|
8
|
+
* - `DatePickerRange showTime`(range with time)
|
|
9
|
+
*
|
|
10
|
+
* 抽出原因:三個 picker 共用 H/M/S column scroll-pick 行為,公式重覆 = M17 違反。
|
|
11
|
+
* 抽到 TimePicker/(time scroll selector 的 canonical 家)。
|
|
12
|
+
*
|
|
13
|
+
* ── 設計 ──
|
|
14
|
+
* - Value:`TimeParts { hours, minutes, seconds }`(對齊 date-fns / Date getHours()...)
|
|
15
|
+
* - Step:每欄獨立 `minuteStep` / `secondStep`(會議常用 15)
|
|
16
|
+
* - Disabled:`disabledHours / disabledMinutes / disabledSeconds`(動態根據已選其他欄位)
|
|
17
|
+
* - Visual:對齊 ref/timepicker.png — 多欄並排 + border-r 分隔
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as React from 'react'
|
|
21
|
+
import { ScrollArea } from '@/design-system/components/ScrollArea/scroll-area'
|
|
22
|
+
import { cn } from '@/lib/utils'
|
|
23
|
+
|
|
24
|
+
// ── Types ───────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface TimeParts {
|
|
27
|
+
hours: number
|
|
28
|
+
minutes: number
|
|
29
|
+
seconds: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type TimeStep = 1 | 5 | 10 | 15 | 30
|
|
33
|
+
|
|
34
|
+
export interface TimeColumnsDisabled {
|
|
35
|
+
hours?: number[]
|
|
36
|
+
minutes?: number[]
|
|
37
|
+
seconds?: number[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── ISO time parsing ────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Parse "HH:MM:SS" / "HH:MM" / full ISO datetime — returns time parts only */
|
|
43
|
+
export function isoToTimeParts(iso: string | null | undefined): TimeParts | undefined {
|
|
44
|
+
if (!iso) return undefined
|
|
45
|
+
const timeMatch = iso.match(/(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/)
|
|
46
|
+
if (!timeMatch) return undefined
|
|
47
|
+
const h = Number(timeMatch[1])
|
|
48
|
+
const m = Number(timeMatch[2])
|
|
49
|
+
const s = timeMatch[3] !== undefined ? Number(timeMatch[3]) : 0
|
|
50
|
+
if (
|
|
51
|
+
Number.isNaN(h) || h < 0 || h > 23 ||
|
|
52
|
+
Number.isNaN(m) || m < 0 || m > 59 ||
|
|
53
|
+
Number.isNaN(s) || s < 0 || s > 59
|
|
54
|
+
) return undefined
|
|
55
|
+
return { hours: h, minutes: m, seconds: s }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Format time parts → "HH:MM" or "HH:MM:SS" depending on showSeconds */
|
|
59
|
+
export function timePartsToString(parts: TimeParts, showSeconds = false): string {
|
|
60
|
+
const hh = String(parts.hours).padStart(2, '0')
|
|
61
|
+
const mm = String(parts.minutes).padStart(2, '0')
|
|
62
|
+
if (!showSeconds) return `${hh}:${mm}`
|
|
63
|
+
const ss = String(parts.seconds).padStart(2, '0')
|
|
64
|
+
return `${hh}:${mm}:${ss}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Range builder ───────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function buildRange(max: number, step: number): number[] {
|
|
70
|
+
const arr: number[] = []
|
|
71
|
+
for (let v = 0; v < max; v += step) arr.push(v)
|
|
72
|
+
return arr
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Single column ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
interface TimeColumnProps {
|
|
78
|
+
values: number[]
|
|
79
|
+
selected: number
|
|
80
|
+
/** disabled value set(動態根據其他欄位推) */
|
|
81
|
+
disabledSet?: Set<number>
|
|
82
|
+
label: string
|
|
83
|
+
onSelect: (value: number) => void
|
|
84
|
+
/** 右側分隔線(對齊 ref 多欄樣式) */
|
|
85
|
+
withDivider?: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// code-quality-allow: long-function — column 含 scroll-into-view useEffect / WAI-ARIA listbox 鍵盤 handler / 視覺 state 計算,拆 sub-fn 會切散 listbox accessibility 邏輯
|
|
89
|
+
function TimeColumn({ values, selected, disabledSet, label, onSelect, withDivider }: TimeColumnProps) {
|
|
90
|
+
const listRef = React.useRef<HTMLDivElement>(null)
|
|
91
|
+
|
|
92
|
+
// 開啟時跳到 selected 位置(置中);後續變更走 smooth(對齊 iOS / Material / Ant timepicker idiom)。
|
|
93
|
+
// 用 scrollIntoView({ block: 'center' }) 自動找最近的 scrollable ancestor —
|
|
94
|
+
// 比 manual scrollTop + parentElement 強健(Radix ScrollArea 結構為 Viewport > inner-div > content,
|
|
95
|
+
// listRef.parentElement 不是真正 scrollable 元素)。
|
|
96
|
+
// mount 用 'auto' 避免開啟瞬間出現飄移,後續 user 操作走 'smooth'(同 Tabs/Chip/FileViewer canonical)。
|
|
97
|
+
const isFirstRunRef = React.useRef(true)
|
|
98
|
+
React.useEffect(() => {
|
|
99
|
+
const list = listRef.current
|
|
100
|
+
if (!list) return
|
|
101
|
+
const idx = values.indexOf(selected)
|
|
102
|
+
if (idx < 0) return
|
|
103
|
+
const item = list.children[idx] as HTMLElement | undefined
|
|
104
|
+
if (!item) return
|
|
105
|
+
item.scrollIntoView({ block: 'center', behavior: isFirstRunRef.current ? 'auto' : 'smooth' })
|
|
106
|
+
isFirstRunRef.current = false
|
|
107
|
+
}, [values, selected])
|
|
108
|
+
|
|
109
|
+
// WAI-ARIA listbox keyboard pattern:ArrowUp/Down 切 option / Home / End 跳邊界。
|
|
110
|
+
// 對標 Ant TimePicker / Material TimePicker。Tab 跳離 listbox(走預設行為,不 stopPropagation)。
|
|
111
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
112
|
+
const idx = values.indexOf(selected)
|
|
113
|
+
if (e.key === 'ArrowDown') {
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
const next = values.find((_, i) => i > idx && !disabledSet?.has(values[i])) ?? values[idx]
|
|
116
|
+
onSelect(next)
|
|
117
|
+
} else if (e.key === 'ArrowUp') {
|
|
118
|
+
e.preventDefault()
|
|
119
|
+
// 反向找第一個 enabled
|
|
120
|
+
let i = idx - 1
|
|
121
|
+
while (i >= 0 && disabledSet?.has(values[i])) i--
|
|
122
|
+
if (i >= 0) onSelect(values[i])
|
|
123
|
+
} else if (e.key === 'Home') {
|
|
124
|
+
e.preventDefault()
|
|
125
|
+
const first = values.find((v) => !disabledSet?.has(v))
|
|
126
|
+
if (first !== undefined) onSelect(first)
|
|
127
|
+
} else if (e.key === 'End') {
|
|
128
|
+
e.preventDefault()
|
|
129
|
+
for (let i = values.length - 1; i >= 0; i--) {
|
|
130
|
+
if (!disabledSet?.has(values[i])) {
|
|
131
|
+
onSelect(values[i])
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// WAI-ARIA listbox pattern:role=listbox 直接包 role=option(button),不另用 li 包
|
|
139
|
+
// (li role=option + 內含 button 會被 axe 抓 nested-interactive)
|
|
140
|
+
// 高度策略:本 primitive 不鎖死 height(對齊 ScrollArea / Combobox 同 idiom)。
|
|
141
|
+
// ScrollArea 用 h-full,parent flex container 控高 — 讓 consumer:
|
|
142
|
+
// - TimePicker:wrap in h-[216px] container(預設 ~7 items)
|
|
143
|
+
// - DatePicker showTime / Range:flex-row items-stretch + calendar 一起決定高度(自動同高)
|
|
144
|
+
return (
|
|
145
|
+
<ScrollArea className={cn('flex-1 h-full', withDivider && 'border-r border-divider')}>
|
|
146
|
+
<div
|
|
147
|
+
ref={listRef}
|
|
148
|
+
role="listbox"
|
|
149
|
+
aria-label={label}
|
|
150
|
+
tabIndex={0}
|
|
151
|
+
onKeyDown={handleKeyDown}
|
|
152
|
+
className="flex flex-col py-2 focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-[-2px]"
|
|
153
|
+
>
|
|
154
|
+
{values.map((v) => {
|
|
155
|
+
const isSelected = v === selected
|
|
156
|
+
const isDisabled = disabledSet?.has(v) ?? false
|
|
157
|
+
return (
|
|
158
|
+
<button
|
|
159
|
+
key={v}
|
|
160
|
+
type="button"
|
|
161
|
+
role="option"
|
|
162
|
+
aria-selected={isSelected}
|
|
163
|
+
disabled={isDisabled}
|
|
164
|
+
// tabIndex=-1:listbox 自身 tabbable + 用 ArrowUp/Down 切 option(WAI-ARIA roving),
|
|
165
|
+
// 不讓每個 option 都進 Tab order(會 Tab 84 次過完 hours+minutes)
|
|
166
|
+
tabIndex={-1}
|
|
167
|
+
onClick={() => onSelect(v)}
|
|
168
|
+
className={cn(
|
|
169
|
+
'w-full h-field-sm text-body tabular-nums',
|
|
170
|
+
'flex items-center justify-center',
|
|
171
|
+
'cursor-pointer transition-colors',
|
|
172
|
+
'hover:bg-neutral-hover',
|
|
173
|
+
isSelected && 'bg-neutral-selected text-foreground hover:bg-neutral-selected',
|
|
174
|
+
isDisabled && 'text-fg-disabled cursor-not-allowed hover:bg-transparent',
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{String(v).padStart(2, '0')}
|
|
178
|
+
</button>
|
|
179
|
+
)
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
</ScrollArea>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Composite — H/M/S columns ──────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export interface TimeColumnsProps {
|
|
189
|
+
value?: TimeParts
|
|
190
|
+
onChange: (next: TimeParts) => void
|
|
191
|
+
showSeconds?: boolean
|
|
192
|
+
minuteStep?: TimeStep
|
|
193
|
+
secondStep?: TimeStep
|
|
194
|
+
/** 動態 disabled 各欄位 value 子集 */
|
|
195
|
+
disabled?: TimeColumnsDisabled
|
|
196
|
+
className?: string
|
|
197
|
+
/** 是否在最左側加 border-l(配 DatePicker showTime / Range date+time 拼接時用) */
|
|
198
|
+
leadingDivider?: boolean
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function TimeColumns({
|
|
202
|
+
value,
|
|
203
|
+
onChange,
|
|
204
|
+
showSeconds = false,
|
|
205
|
+
minuteStep = 1,
|
|
206
|
+
secondStep = 1,
|
|
207
|
+
disabled,
|
|
208
|
+
className,
|
|
209
|
+
leadingDivider = false,
|
|
210
|
+
}: TimeColumnsProps) {
|
|
211
|
+
const safeValue: TimeParts = value ?? { hours: 0, minutes: 0, seconds: 0 }
|
|
212
|
+
const hourValues = React.useMemo(() => buildRange(24, 1), [])
|
|
213
|
+
const minuteValues = React.useMemo(() => buildRange(60, minuteStep), [minuteStep])
|
|
214
|
+
const secondValues = React.useMemo(() => buildRange(60, secondStep), [secondStep])
|
|
215
|
+
|
|
216
|
+
const disabledSets = React.useMemo(() => ({
|
|
217
|
+
hours: disabled?.hours ? new Set(disabled.hours) : undefined,
|
|
218
|
+
minutes: disabled?.minutes ? new Set(disabled.minutes) : undefined,
|
|
219
|
+
seconds: disabled?.seconds ? new Set(disabled.seconds) : undefined,
|
|
220
|
+
}), [disabled?.hours, disabled?.minutes, disabled?.seconds])
|
|
221
|
+
|
|
222
|
+
const widthClass = showSeconds ? 'w-60' : 'w-40'
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div
|
|
226
|
+
className={cn(
|
|
227
|
+
'flex flex-row',
|
|
228
|
+
widthClass,
|
|
229
|
+
leadingDivider && 'border-l border-divider',
|
|
230
|
+
className,
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
233
|
+
<TimeColumn
|
|
234
|
+
values={hourValues}
|
|
235
|
+
selected={safeValue.hours}
|
|
236
|
+
disabledSet={disabledSets.hours}
|
|
237
|
+
label="hours"
|
|
238
|
+
onSelect={(h) => onChange({ ...safeValue, hours: h })}
|
|
239
|
+
withDivider
|
|
240
|
+
/>
|
|
241
|
+
<TimeColumn
|
|
242
|
+
values={minuteValues}
|
|
243
|
+
selected={safeValue.minutes}
|
|
244
|
+
disabledSet={disabledSets.minutes}
|
|
245
|
+
label="minutes"
|
|
246
|
+
onSelect={(m) => onChange({ ...safeValue, minutes: m })}
|
|
247
|
+
withDivider={showSeconds}
|
|
248
|
+
/>
|
|
249
|
+
{showSeconds && (
|
|
250
|
+
<TimeColumn
|
|
251
|
+
values={secondValues}
|
|
252
|
+
selected={safeValue.seconds}
|
|
253
|
+
disabledSet={disabledSets.seconds}
|
|
254
|
+
label="seconds"
|
|
255
|
+
onSelect={(s) => onChange({ ...safeValue, seconds: s })}
|
|
256
|
+
/>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
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 { X, Clock } from 'lucide-react'
|
|
4
|
+
import type { LucideIcon } from 'lucide-react'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import type { FieldMode, FieldVariant } from '@/design-system/components/Field/field-types'
|
|
7
|
+
import {
|
|
8
|
+
fieldWrapperStyles,
|
|
9
|
+
bareInputStyles,
|
|
10
|
+
EMPTY_DISPLAY,
|
|
11
|
+
fieldDisplayTextClass,
|
|
12
|
+
} from '@/design-system/components/Field/field-wrapper'
|
|
13
|
+
import { ItemInlineAction, ItemSuffix } from '@/design-system/patterns/element-anatomy/item-anatomy'
|
|
14
|
+
import { Popover, PopoverTrigger, PopoverContent } from '@/design-system/components/Popover/popover'
|
|
15
|
+
import { useFieldContext } from '@/design-system/components/Field/field-context'
|
|
16
|
+
import { Button } from '@/design-system/components/Button/button'
|
|
17
|
+
import {
|
|
18
|
+
TimeColumns,
|
|
19
|
+
isoToTimeParts,
|
|
20
|
+
timePartsToString,
|
|
21
|
+
type TimeParts,
|
|
22
|
+
type TimeStep,
|
|
23
|
+
type TimeColumnsDisabled,
|
|
24
|
+
} from '@/design-system/components/TimePicker/time-columns'
|
|
25
|
+
import { ICON_SIZE } from '@/design-system/tokens/uiSize/icon-size'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* TimePicker — 單一時間(時/分/秒)輸入與顯示元件
|
|
29
|
+
*
|
|
30
|
+
* ── 定位(同 DatePicker 家族)──
|
|
31
|
+
* Value 以 ISO time string 儲存("HH:mm" 或 "HH:mm:ss"),local-time 語義(不帶時區)。
|
|
32
|
+
* Edit 用本 DS 自建 time column panel + Popover 呈現,視覺與 DatePicker 一致。
|
|
33
|
+
* Display 用 Intl.DateTimeFormat 格式化(跨 locale / 12h-24h 統一經過此 API)。
|
|
34
|
+
*
|
|
35
|
+
* ── Layout Family ──
|
|
36
|
+
* CLAUDE.md 4-Family Model Family 4(Field control layout)消費者。結構繼承
|
|
37
|
+
* `fieldWrapperStyles + [<editable>] [endIcon=Clock]`,視覺對齊 DatePicker(同
|
|
38
|
+
* 「點擊觸發浮層」role:indicator 在 suffix slot,對齊 Material `endAdornment` /
|
|
39
|
+
* Ant DatePicker / Polaris Picker 共識)。
|
|
40
|
+
*
|
|
41
|
+
* ── 實作基礎 ──
|
|
42
|
+
* Trigger:`<button>` + `fieldWrapperStyles`(視覺仍是 Input wrapper,改為可點擊觸發浮層)
|
|
43
|
+
* Popup:`Popover`(消費 overlay-surface pattern)
|
|
44
|
+
* Panel 主體:自建 column picker(三欄 scrollable list),不引入第三方 time library
|
|
45
|
+
*
|
|
46
|
+
* ── 共用規則 ──
|
|
47
|
+
* Mode / size / disabled / error 等詳見 `../Field/field-controls.spec.md`。
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
// ── Time ISO <-> parts conversion ───────────────────────────────────────────
|
|
51
|
+
// Value 用 ISO time string(HH:mm 或 HH:mm:ss),local-time 語義(不帶時區/日期)。
|
|
52
|
+
// 跟 DatePicker 的 ISO date string 策略一致。
|
|
53
|
+
// `isoToTimeParts` / `timePartsToString` 改 import from time-columns(M17 SSOT)。
|
|
54
|
+
|
|
55
|
+
// ── Display formatting ──────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface TimeFormatOptions {
|
|
58
|
+
/** Intl.DateTimeFormat options(預設 { hour: '2-digit', minute: '2-digit', hour12: false }) */
|
|
59
|
+
formatOptions?: Intl.DateTimeFormatOptions
|
|
60
|
+
/** locale(預設 'en-US') */
|
|
61
|
+
locale?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatTime(
|
|
65
|
+
iso: string,
|
|
66
|
+
options: TimeFormatOptions = {},
|
|
67
|
+
): string {
|
|
68
|
+
const parts = isoToTimeParts(iso)
|
|
69
|
+
if (!parts) return iso
|
|
70
|
+
const {
|
|
71
|
+
formatOptions = { hour: '2-digit', minute: '2-digit', hour12: false },
|
|
72
|
+
locale = 'en-US',
|
|
73
|
+
} = options
|
|
74
|
+
// 借用 Date 讓 Intl.DateTimeFormat 處理 locale / 12h-24h
|
|
75
|
+
const d = new Date()
|
|
76
|
+
d.setHours(parts.hours, parts.minutes, parts.seconds, 0)
|
|
77
|
+
return new Intl.DateTimeFormat(locale, formatOptions).format(d)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Disabled time callback ──────────────────────────────────────────────────
|
|
81
|
+
// `Step` / `buildRange` / `TimeColumn`(內部欄位實作)拔掉,改 import `TimeColumns` primitive。
|
|
82
|
+
|
|
83
|
+
// code-quality-allow: dead-export — public API surface — consumer-exposed for future use
|
|
84
|
+
export interface DisabledTimeResult {
|
|
85
|
+
disabledHours?: number[]
|
|
86
|
+
disabledMinutes?: number[]
|
|
87
|
+
disabledSeconds?: number[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Component props ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export interface TimePickerProps
|
|
93
|
+
extends TimeFormatOptions,
|
|
94
|
+
Omit<
|
|
95
|
+
React.HTMLAttributes<HTMLDivElement>,
|
|
96
|
+
'onChange' | 'placeholder'
|
|
97
|
+
> {
|
|
98
|
+
mode?: FieldMode
|
|
99
|
+
/** Field chrome variant. Default = context.variant ?? 'default'. Per-prop override. */
|
|
100
|
+
variant?: FieldVariant
|
|
101
|
+
error?: boolean
|
|
102
|
+
size?: 'sm' | 'md' | 'lg'
|
|
103
|
+
/** ISO time string("HH:mm" 或 "HH:mm:ss") */
|
|
104
|
+
value?: string | null
|
|
105
|
+
onChange?: (value: string) => void
|
|
106
|
+
placeholder?: string
|
|
107
|
+
className?: string
|
|
108
|
+
disabled?: boolean
|
|
109
|
+
/** 允許清空已選值 */
|
|
110
|
+
clearable?: boolean
|
|
111
|
+
/**
|
|
112
|
+
* 是否顯示秒欄(三欄 picker)。預設 false(兩欄:時/分)。
|
|
113
|
+
* format 自動對應:false → "HH:mm",true → "HH:mm:ss"。
|
|
114
|
+
*/
|
|
115
|
+
showSeconds?: boolean
|
|
116
|
+
/** 分鐘步進(會議常用 15)。預設 1 */
|
|
117
|
+
minuteStep?: TimeStep
|
|
118
|
+
/** 秒步進。預設 1。僅 showSeconds=true 有效 */
|
|
119
|
+
secondStep?: TimeStep
|
|
120
|
+
/** 動態 disabled 某些時/分/秒(基於已選其他欄位)。 */
|
|
121
|
+
disabledTime?: (parts: TimeParts) => DisabledTimeResult
|
|
122
|
+
/**
|
|
123
|
+
* Suffix indicator(2026-05-05 v9 canonical fix):「點擊觸發浮層」indicator 一律 suffix
|
|
124
|
+
* (對齊 DatePicker calendar / Material endAdornment)。預設 Clock,傳 null 可關閉。
|
|
125
|
+
*/
|
|
126
|
+
endIcon?: LucideIcon | null
|
|
127
|
+
/**
|
|
128
|
+
* Display 是否渲 endIcon + Field naked wrapper(D-path opt-in,2026-05-08)
|
|
129
|
+
* — DataTable cell display↔edit 像素級對齊用。預設 false(裸 span,backward compat)。
|
|
130
|
+
* 設 true 時 display 也走 fieldWrapperStyles(naked variant)+ ItemSuffix Clock,
|
|
131
|
+
* 與 edit 同 DOM 結構,消除 Layer-B padding mismatch。
|
|
132
|
+
*/
|
|
133
|
+
showDisplayEndIcon?: boolean
|
|
134
|
+
/** Initial open state(uncontrolled)— DataTable cell-as-input 1-step open canonical */
|
|
135
|
+
defaultOpen?: boolean
|
|
136
|
+
/** open state 變更 callback。DataTable cell-as-input 用:open=false → cell exit edit */
|
|
137
|
+
onOpenChange?: (open: boolean) => void
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
|
|
141
|
+
const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
|
|
142
|
+
(
|
|
143
|
+
{
|
|
144
|
+
mode = 'edit',
|
|
145
|
+
variant: variantProp,
|
|
146
|
+
error: errorProp = false,
|
|
147
|
+
size = 'md',
|
|
148
|
+
value,
|
|
149
|
+
onChange,
|
|
150
|
+
placeholder,
|
|
151
|
+
className,
|
|
152
|
+
disabled: disabledProp,
|
|
153
|
+
clearable = false,
|
|
154
|
+
showSeconds = false,
|
|
155
|
+
minuteStep = 1,
|
|
156
|
+
secondStep = 1,
|
|
157
|
+
disabledTime,
|
|
158
|
+
endIcon,
|
|
159
|
+
showDisplayEndIcon = false,
|
|
160
|
+
formatOptions,
|
|
161
|
+
locale,
|
|
162
|
+
defaultOpen = false,
|
|
163
|
+
onOpenChange,
|
|
164
|
+
id: idProp,
|
|
165
|
+
'aria-describedby': ariaDescribedByProp,
|
|
166
|
+
'aria-errormessage': ariaErrorMessageProp,
|
|
167
|
+
...props
|
|
168
|
+
},
|
|
169
|
+
ref,
|
|
170
|
+
) => {
|
|
171
|
+
const fieldCtx = useFieldContext()
|
|
172
|
+
const error = errorProp || (fieldCtx?.invalid ?? false)
|
|
173
|
+
const disabled = disabledProp ?? fieldCtx?.disabled
|
|
174
|
+
const resolvedMode = disabled ? 'disabled' : mode
|
|
175
|
+
const variant: FieldVariant = variantProp ?? fieldCtx?.variant ?? 'default'
|
|
176
|
+
const isEditable = resolvedMode === 'edit'
|
|
177
|
+
// 2026-05-18 改 import ICON_SIZE SSOT(per user『做完』approval,消除 M17 違反 7+ 重複 ternary)
|
|
178
|
+
const iconSize = ICON_SIZE[size as 'sm' | 'md' | 'lg']
|
|
179
|
+
const EndIconCmp: LucideIcon | null =
|
|
180
|
+
endIcon === null ? null : (endIcon ?? Clock)
|
|
181
|
+
const defaultPlaceholder = showSeconds ? 'HH:MM:SS' : 'HH:MM'
|
|
182
|
+
const resolvedPlaceholder = placeholder ?? defaultPlaceholder
|
|
183
|
+
const showClear = clearable && !!value && isEditable
|
|
184
|
+
const [open, setOpenState] = React.useState(defaultOpen)
|
|
185
|
+
const setOpen = React.useCallback((next: boolean) => { setOpenState(next); onOpenChange?.(next) }, [onOpenChange])
|
|
186
|
+
|
|
187
|
+
const currentParts = React.useMemo(() => isoToTimeParts(value), [value])
|
|
188
|
+
// draft 僅在 panel 開啟時用來處理 commit(OK button)的暫存
|
|
189
|
+
const [draft, setDraft] = React.useState<TimeParts>(
|
|
190
|
+
() => currentParts ?? { hours: 0, minutes: 0, seconds: 0 },
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
// 每次 popover 開啟時以當前 value 初始化 draft
|
|
194
|
+
React.useEffect(() => {
|
|
195
|
+
if (open) {
|
|
196
|
+
setDraft(currentParts ?? { hours: 0, minutes: 0, seconds: 0 })
|
|
197
|
+
}
|
|
198
|
+
}, [open, currentParts])
|
|
199
|
+
|
|
200
|
+
const disabledForColumns: TimeColumnsDisabled | undefined = React.useMemo(() => {
|
|
201
|
+
if (!disabledTime) return undefined
|
|
202
|
+
const res = disabledTime(draft)
|
|
203
|
+
return {
|
|
204
|
+
hours: res.disabledHours,
|
|
205
|
+
minutes: res.disabledMinutes,
|
|
206
|
+
seconds: res.disabledSeconds,
|
|
207
|
+
}
|
|
208
|
+
}, [disabledTime, draft])
|
|
209
|
+
|
|
210
|
+
const commitDraft = (next: TimeParts) => {
|
|
211
|
+
setDraft(next)
|
|
212
|
+
onChange?.(timePartsToString(next, showSeconds))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const handleNow = () => {
|
|
216
|
+
const now = new Date()
|
|
217
|
+
// 按照 minuteStep / secondStep 對齊
|
|
218
|
+
const m = Math.round(now.getMinutes() / minuteStep) * minuteStep
|
|
219
|
+
const s = showSeconds
|
|
220
|
+
? Math.round(now.getSeconds() / secondStep) * secondStep
|
|
221
|
+
: 0
|
|
222
|
+
const next: TimeParts = {
|
|
223
|
+
hours: now.getHours(),
|
|
224
|
+
minutes: Math.min(m, 59),
|
|
225
|
+
seconds: Math.min(s, 59),
|
|
226
|
+
}
|
|
227
|
+
commitDraft(next)
|
|
228
|
+
setOpen(false)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// mode='display'(Phase B2 2026-05-05):純內容輸出 — 對齊原 TimePickerDisplay sub-component(retired)。
|
|
232
|
+
// Default(showDisplayEndIcon=false):無 Field wrapper / 無 Clock icon — backward compat 裸 span。
|
|
233
|
+
// Opt-in(showDisplayEndIcon=true,2026-05-08 D-path):Field naked wrapper + ItemSuffix Clock,
|
|
234
|
+
// 與 edit 同結構消除 cell display↔edit 像素偏移(Layer-B padding mismatch)。
|
|
235
|
+
if (resolvedMode === 'display') {
|
|
236
|
+
if (!showDisplayEndIcon) {
|
|
237
|
+
// 2026-05-14 I2 fix(spec contract (e) display typography canonical):bare span 套
|
|
238
|
+
// `fieldDisplayTextClass(size)`(sm/md→text-body,lg→text-body-lg)— 對齊 Field family 統一。
|
|
239
|
+
if (!value) return <span className={cn(fieldDisplayTextClass(size), 'text-fg-muted', className)}>{EMPTY_DISPLAY}</span>
|
|
240
|
+
return <span className={cn(fieldDisplayTextClass(size), 'truncate', className)}>{formatTime(value, { formatOptions, locale })}</span>
|
|
241
|
+
}
|
|
242
|
+
return (
|
|
243
|
+
<div
|
|
244
|
+
className={cn(fieldWrapperStyles({ mode: 'display', variant, size }), className)}
|
|
245
|
+
data-field-mode="display"
|
|
246
|
+
>
|
|
247
|
+
<span className={cn(bareInputStyles, 'flex-1 min-w-0 truncate', !value && 'text-fg-muted')}>
|
|
248
|
+
{value ? formatTime(value, { formatOptions, locale }) : EMPTY_DISPLAY}
|
|
249
|
+
</span>
|
|
250
|
+
{EndIconCmp && (
|
|
251
|
+
<ItemSuffix className="pointer-events-none">
|
|
252
|
+
<EndIconCmp size={iconSize} className="text-fg-muted" aria-hidden />
|
|
253
|
+
</ItemSuffix>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// readonly / disabled
|
|
260
|
+
if (!isEditable) {
|
|
261
|
+
return (
|
|
262
|
+
<div
|
|
263
|
+
className={cn(fieldWrapperStyles({ mode: resolvedMode, variant: variant, size }), className)}
|
|
264
|
+
data-field-mode={resolvedMode}
|
|
265
|
+
{...(props as React.HTMLAttributes<HTMLDivElement>)}
|
|
266
|
+
>
|
|
267
|
+
<span
|
|
268
|
+
className={cn(
|
|
269
|
+
'flex-1 min-w-0',
|
|
270
|
+
resolvedMode === 'disabled' && 'text-fg-disabled',
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
{value
|
|
274
|
+
? formatTime(value, { formatOptions, locale })
|
|
275
|
+
: <span className="text-fg-muted">{EMPTY_DISPLAY}</span>
|
|
276
|
+
}
|
|
277
|
+
</span>
|
|
278
|
+
{EndIconCmp && (
|
|
279
|
+
<ItemSuffix className="pointer-events-none">
|
|
280
|
+
<EndIconCmp
|
|
281
|
+
size={iconSize}
|
|
282
|
+
className={resolvedMode === 'disabled' ? 'text-fg-disabled' : 'text-fg-muted'}
|
|
283
|
+
aria-hidden
|
|
284
|
+
/>
|
|
285
|
+
</ItemSuffix>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const displayText = value
|
|
292
|
+
? formatTime(value, { formatOptions, locale })
|
|
293
|
+
: <span className="text-fg-muted">{resolvedPlaceholder}</span>
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
297
|
+
<PopoverTrigger asChild>
|
|
298
|
+
{/* a11y(2026-04-25 nested-interactive fix):trigger 改 <div role='combobox'>
|
|
299
|
+
(對齊 Select / Combobox 同 pattern),原 <button> 會與內層 ItemInlineAction
|
|
300
|
+
清除 button 構成 nested-interactive。Radix Popover 在 trigger asChild 下會
|
|
301
|
+
自動 inject keyboard handler(Enter / Space 開啟)+ 正確 aria attributes。 */}
|
|
302
|
+
<div
|
|
303
|
+
ref={ref}
|
|
304
|
+
id={idProp ?? fieldCtx?.id}
|
|
305
|
+
role="combobox"
|
|
306
|
+
tabIndex={disabled ? -1 : 0}
|
|
307
|
+
aria-disabled={disabled || undefined}
|
|
308
|
+
aria-labelledby={fieldCtx?.labelId}
|
|
309
|
+
aria-invalid={error || undefined}
|
|
310
|
+
aria-required={fieldCtx?.required || undefined}
|
|
311
|
+
aria-describedby={ariaDescribedByProp ?? fieldCtx?.descriptionId}
|
|
312
|
+
aria-errormessage={ariaErrorMessageProp ?? (error ? fieldCtx?.errorId : undefined)}
|
|
313
|
+
aria-haspopup="dialog"
|
|
314
|
+
aria-expanded={open}
|
|
315
|
+
data-field-mode="edit"
|
|
316
|
+
data-error={error ? '' : undefined}
|
|
317
|
+
className={cn(
|
|
318
|
+
fieldWrapperStyles({ mode: 'edit', variant: variant, size }),
|
|
319
|
+
'text-left cursor-pointer',
|
|
320
|
+
'focus-visible:outline-none',
|
|
321
|
+
error && [
|
|
322
|
+
'border-error hover:border-error-hover',
|
|
323
|
+
'focus-within:border-error focus-within:hover:border-error',
|
|
324
|
+
],
|
|
325
|
+
className,
|
|
326
|
+
)}
|
|
327
|
+
{...props}
|
|
328
|
+
>
|
|
329
|
+
<span className={cn(bareInputStyles, 'truncate', !value && 'text-fg-muted')}>
|
|
330
|
+
{displayText}
|
|
331
|
+
</span>
|
|
332
|
+
{showClear && (
|
|
333
|
+
<ItemInlineAction
|
|
334
|
+
size={size ?? 'md'}
|
|
335
|
+
action={{
|
|
336
|
+
icon: X,
|
|
337
|
+
label: '清除時間', // i18n-allow: DS default inline-action label
|
|
338
|
+
onClick: (e) => {
|
|
339
|
+
e?.stopPropagation()
|
|
340
|
+
onChange?.('')
|
|
341
|
+
},
|
|
342
|
+
}}
|
|
343
|
+
/>
|
|
344
|
+
)}
|
|
345
|
+
{EndIconCmp && (
|
|
346
|
+
<ItemSuffix className="pointer-events-none">
|
|
347
|
+
<EndIconCmp size={iconSize} className="text-fg-muted" aria-hidden />
|
|
348
|
+
</ItemSuffix>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
</PopoverTrigger>
|
|
352
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
353
|
+
{/* Panel 對齊 ref/timepicker.png:2-3 個 SelectMenu 式欄位並排,分隔線分開。
|
|
354
|
+
Width 依欄數由 TimeColumns 決定:2 欄 w-40 / 3 欄 w-60。
|
|
355
|
+
Height 由 wrapper 控:216px 預設(~7 items)。
|
|
356
|
+
TimeColumns 本身 h-full,parent 控 height — 讓 DatePicker showTime / Range 可
|
|
357
|
+
用 flex-row items-stretch 自動同 calendar 高。 */}
|
|
358
|
+
<div className="flex flex-col h-[216px]">
|
|
359
|
+
<TimeColumns
|
|
360
|
+
value={draft}
|
|
361
|
+
onChange={commitDraft}
|
|
362
|
+
showSeconds={showSeconds}
|
|
363
|
+
minuteStep={minuteStep}
|
|
364
|
+
secondStep={secondStep}
|
|
365
|
+
disabled={disabledForColumns}
|
|
366
|
+
// 2026-05-06 v9.1 M25 chain fix:TimeColumns 自然高 = 24 buttons × ~28.7px = 688px
|
|
367
|
+
// 會撐破 parent h-[216px]。flex-1 + min-h-0 讓 TimeColumns 取 parent 剩餘空間
|
|
368
|
+
// (216 - footer 40 = 176px)→ ScrollArea h-full 才能正確收斂 →
|
|
369
|
+
// listbox scrollIntoView 找對 nearest scrollable ancestor(內部 viewport),
|
|
370
|
+
// 不會走到 document body 把 popover 內容推出畫面(user 報「hours 欄空白」根因)。
|
|
371
|
+
className="flex-1 min-h-0"
|
|
372
|
+
/>
|
|
373
|
+
{/* Footer:Now + OK */}
|
|
374
|
+
<div
|
|
375
|
+
className={cn(
|
|
376
|
+
'flex items-center justify-between gap-2',
|
|
377
|
+
'border-t border-divider',
|
|
378
|
+
'px-[var(--layout-space-tight)] py-[var(--layout-space-tight)]',
|
|
379
|
+
)}
|
|
380
|
+
>
|
|
381
|
+
<Button variant="text" size="sm" onClick={handleNow}>
|
|
382
|
+
此刻
|
|
383
|
+
</Button>
|
|
384
|
+
<Button
|
|
385
|
+
variant="primary"
|
|
386
|
+
size="sm"
|
|
387
|
+
onClick={() => setOpen(false)}
|
|
388
|
+
>
|
|
389
|
+
確定
|
|
390
|
+
</Button>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</PopoverContent>
|
|
394
|
+
</Popover>
|
|
395
|
+
)
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
TimePicker.displayName = 'TimePicker'
|
|
399
|
+
|
|
400
|
+
// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
|
|
401
|
+
// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
|
|
402
|
+
export const timePickerMeta = {
|
|
403
|
+
component: 'TimePicker',
|
|
404
|
+
family: 4,
|
|
405
|
+
variants: {
|
|
406
|
+
|
|
407
|
+
},
|
|
408
|
+
sizes: {
|
|
409
|
+
|
|
410
|
+
},
|
|
411
|
+
states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
|
|
412
|
+
tokens: {
|
|
413
|
+
bg: ['bg-neutral-hover', 'bg-primary', 'bg-transparent'],
|
|
414
|
+
fg: ['text-fg-disabled', 'text-fg-muted', 'text-foreground'],
|
|
415
|
+
ring: [],
|
|
416
|
+
},
|
|
417
|
+
} as const
|
|
418
|
+
|
|
419
|
+
export { TimePicker }
|