@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.
Files changed (119) hide show
  1. package/package.json +93 -0
  2. package/src/README.md +32 -0
  3. package/src/components/Accordion/accordion.tsx +104 -0
  4. package/src/components/Alert/alert.tsx +188 -0
  5. package/src/components/AppShell/_demo-helpers.tsx +198 -0
  6. package/src/components/AppShell/app-shell.tsx +364 -0
  7. package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
  8. package/src/components/Avatar/avatar.tsx +368 -0
  9. package/src/components/Badge/badge.tsx +104 -0
  10. package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
  11. package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
  12. package/src/components/Button/button-group.tsx +96 -0
  13. package/src/components/Button/button.tsx +539 -0
  14. package/src/components/Calendar/calendar.tsx +411 -0
  15. package/src/components/Carousel/carousel.tsx +371 -0
  16. package/src/components/Chart/chart.tsx +376 -0
  17. package/src/components/Checkbox/checkbox-group.tsx +94 -0
  18. package/src/components/Checkbox/checkbox.tsx +237 -0
  19. package/src/components/Chip/chip.tsx +359 -0
  20. package/src/components/CircularProgress/circular-progress.tsx +204 -0
  21. package/src/components/Coachmark/coachmark.tsx +255 -0
  22. package/src/components/Combobox/combobox.tsx +826 -0
  23. package/src/components/Command/command.tsx +187 -0
  24. package/src/components/DataTable/active-editor-controller.ts +72 -0
  25. package/src/components/DataTable/cell-registry.tsx +520 -0
  26. package/src/components/DataTable/column-types.ts +180 -0
  27. package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
  28. package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
  29. package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
  30. package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
  31. package/src/components/DataTable/data-table.css +165 -0
  32. package/src/components/DataTable/data-table.tsx +2924 -0
  33. package/src/components/DataTable/filter-operators.ts +225 -0
  34. package/src/components/DataTable/filter-tree.ts +313 -0
  35. package/src/components/DataTable/lib/column-meta.ts +79 -0
  36. package/src/components/DateGrid/date-grid.tsx +209 -0
  37. package/src/components/DatePicker/date-picker.tsx +1114 -0
  38. package/src/components/DescriptionList/description-list.tsx +141 -0
  39. package/src/components/Dialog/dialog.tsx +267 -0
  40. package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
  41. package/src/components/Empty/empty.tsx +108 -0
  42. package/src/components/Field/field-context.ts +136 -0
  43. package/src/components/Field/field-types.ts +52 -0
  44. package/src/components/Field/field-wrapper.tsx +348 -0
  45. package/src/components/Field/field.tsx +535 -0
  46. package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
  47. package/src/components/FileItem/file-item.tsx +322 -0
  48. package/src/components/FileUpload/file-upload.tsx +326 -0
  49. package/src/components/FileViewer/file-viewer-types.ts +76 -0
  50. package/src/components/FileViewer/file-viewer.tsx +1065 -0
  51. package/src/components/FileViewer/image-renderer.tsx +256 -0
  52. package/src/components/HoverCard/hover-card.tsx +79 -0
  53. package/src/components/Input/input.tsx +233 -0
  54. package/src/components/LinkInput/link-input.tsx +304 -0
  55. package/src/components/Menu/menu-item.tsx +334 -0
  56. package/src/components/NameCard/name-card.tsx +319 -0
  57. package/src/components/Notice/notice.tsx +196 -0
  58. package/src/components/NumberInput/number-input.tsx +203 -0
  59. package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
  60. package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
  61. package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
  62. package/src/components/PeoplePicker/people-picker.tsx +455 -0
  63. package/src/components/PeoplePicker/person-display.tsx +358 -0
  64. package/src/components/Popover/popover.tsx +183 -0
  65. package/src/components/ProgressBar/progress-bar.tsx +157 -0
  66. package/src/components/README.md +58 -0
  67. package/src/components/RadioGroup/radio-group.tsx +261 -0
  68. package/src/components/Rating/rating.tsx +295 -0
  69. package/src/components/ScrollArea/scroll-area.tsx +110 -0
  70. package/src/components/SegmentedControl/segmented-control.tsx +304 -0
  71. package/src/components/Select/select.tsx +658 -0
  72. package/src/components/SelectMenu/select-menu.tsx +430 -0
  73. package/src/components/SelectionControl/selection-item.tsx +261 -0
  74. package/src/components/Separator/separator.tsx +48 -0
  75. package/src/components/Sheet/sheet.tsx +240 -0
  76. package/src/components/Sidebar/sidebar.tsx +1280 -0
  77. package/src/components/Skeleton/skeleton.tsx +35 -0
  78. package/src/components/Slider/slider.tsx +158 -0
  79. package/src/components/Steps/steps.tsx +850 -0
  80. package/src/components/Switch/switch.tsx +285 -0
  81. package/src/components/Tabs/tabs.tsx +515 -0
  82. package/src/components/Tag/tag.tsx +246 -0
  83. package/src/components/Textarea/textarea.tsx +280 -0
  84. package/src/components/TimePicker/time-columns.tsx +260 -0
  85. package/src/components/TimePicker/time-picker.tsx +419 -0
  86. package/src/components/Toast/toast.tsx +129 -0
  87. package/src/components/Tooltip/tooltip.tsx +68 -0
  88. package/src/components/TreeView/tree-view.tsx +1031 -0
  89. package/src/hooks/use-controllable.ts +40 -0
  90. package/src/hooks/use-is-narrow-viewport.ts +19 -0
  91. package/src/hooks/use-is-touch-device.ts +21 -0
  92. package/src/hooks/use-overflow-items.ts +256 -0
  93. package/src/index.ts +85 -0
  94. package/src/lib/README.md +82 -0
  95. package/src/lib/drag-visual.ts +272 -0
  96. package/src/lib/i18n/README.md +60 -0
  97. package/src/lib/i18n/i18n-context.tsx +129 -0
  98. package/src/lib/multi-select-ordering.ts +61 -0
  99. package/src/lib/utils.ts +93 -0
  100. package/src/patterns/README.md +67 -0
  101. package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
  102. package/src/patterns/header-canonical/chrome-header.tsx +175 -0
  103. package/src/patterns/header-canonical/header-canonical.css +27 -0
  104. package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
  105. package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
  106. package/src/patterns/resize-handle/resize-handle.tsx +188 -0
  107. package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
  108. package/src/tokens/README.md +53 -0
  109. package/src/tokens/color/primitives.css +429 -0
  110. package/src/tokens/color/semantic.css +539 -0
  111. package/src/tokens/elevation/overlay-geometry.ts +13 -0
  112. package/src/tokens/layoutSpace/layoutSpace.css +36 -0
  113. package/src/tokens/motion/motion.css +30 -0
  114. package/src/tokens/motion/motion.ts +17 -0
  115. package/src/tokens/opacity/opacity.css +23 -0
  116. package/src/tokens/radius/radius.css +19 -0
  117. package/src/tokens/typography/typography.css +118 -0
  118. package/src/tokens/uiSize/icon-size.ts +52 -0
  119. 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 }