@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3260.1.dc719cac84

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +46 -129
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +10 -3
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/EmptyState.js +1 -47
  7. package/dist/backend/EmptyState.js.map +2 -2
  8. package/dist/backend/FilterOverlay.js +31 -26
  9. package/dist/backend/FilterOverlay.js.map +2 -2
  10. package/dist/backend/FlashMessages.js +23 -25
  11. package/dist/backend/FlashMessages.js.map +2 -2
  12. package/dist/backend/JsonBuilder.js +4 -2
  13. package/dist/backend/JsonBuilder.js.map +2 -2
  14. package/dist/backend/LogList.js +49 -0
  15. package/dist/backend/LogList.js.map +7 -0
  16. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  17. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  18. package/dist/backend/detail/InlineEditors.js +80 -32
  19. package/dist/backend/detail/InlineEditors.js.map +2 -2
  20. package/dist/backend/inputs/ComboboxInput.js +60 -6
  21. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  22. package/dist/backend/inputs/DatePicker.js +6 -142
  23. package/dist/backend/inputs/DatePicker.js.map +2 -2
  24. package/dist/backend/inputs/DateTimePicker.js +6 -182
  25. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  26. package/dist/backend/inputs/TagsInput.js +16 -19
  27. package/dist/backend/inputs/TagsInput.js.map +2 -2
  28. package/dist/backend/inputs/TimeInput.js +1 -1
  29. package/dist/backend/inputs/TimeInput.js.map +2 -2
  30. package/dist/backend/inputs/TimePicker.js +83 -72
  31. package/dist/backend/inputs/TimePicker.js.map +2 -2
  32. package/dist/backend/utils/customFieldForms.js +2 -2
  33. package/dist/backend/utils/customFieldForms.js.map +1 -1
  34. package/dist/portal/components/PortalEmptyState.js +13 -9
  35. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  36. package/dist/primitives/accordion.js +137 -0
  37. package/dist/primitives/accordion.js.map +7 -0
  38. package/dist/primitives/alert.js +180 -12
  39. package/dist/primitives/alert.js.map +2 -2
  40. package/dist/primitives/calendar.js +122 -32
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/dist/primitives/compact-select.js +47 -0
  43. package/dist/primitives/compact-select.js.map +7 -0
  44. package/dist/primitives/counter-input.js +174 -0
  45. package/dist/primitives/counter-input.js.map +7 -0
  46. package/dist/primitives/date-picker-helpers.js +79 -0
  47. package/dist/primitives/date-picker-helpers.js.map +7 -0
  48. package/dist/primitives/date-picker.js +216 -0
  49. package/dist/primitives/date-picker.js.map +7 -0
  50. package/dist/primitives/date-range-picker.js +239 -0
  51. package/dist/primitives/date-range-picker.js.map +7 -0
  52. package/dist/primitives/digit-input.js +157 -0
  53. package/dist/primitives/digit-input.js.map +7 -0
  54. package/dist/primitives/empty-state.js +93 -0
  55. package/dist/primitives/empty-state.js.map +7 -0
  56. package/dist/primitives/inline-input.js +29 -0
  57. package/dist/primitives/inline-input.js.map +7 -0
  58. package/dist/primitives/inline-select.js +41 -0
  59. package/dist/primitives/inline-select.js.map +7 -0
  60. package/dist/primitives/notification-stack.js +116 -0
  61. package/dist/primitives/notification-stack.js.map +7 -0
  62. package/dist/primitives/notification.js +62 -0
  63. package/dist/primitives/notification.js.map +7 -0
  64. package/dist/primitives/rich-editor.js +1230 -0
  65. package/dist/primitives/rich-editor.js.map +7 -0
  66. package/dist/primitives/select.js +1 -0
  67. package/dist/primitives/select.js.map +2 -2
  68. package/dist/primitives/skeleton.js +55 -0
  69. package/dist/primitives/skeleton.js.map +7 -0
  70. package/dist/primitives/tag-input.js +186 -0
  71. package/dist/primitives/tag-input.js.map +7 -0
  72. package/dist/primitives/tag.js +66 -13
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/time-picker.js +793 -0
  75. package/dist/primitives/time-picker.js.map +7 -0
  76. package/package.json +4 -3
  77. package/src/backend/CrudForm.tsx +61 -113
  78. package/src/backend/DataTable.tsx +17 -5
  79. package/src/backend/EmptyState.tsx +13 -63
  80. package/src/backend/FilterOverlay.tsx +38 -22
  81. package/src/backend/FlashMessages.tsx +23 -24
  82. package/src/backend/JsonBuilder.tsx +4 -2
  83. package/src/backend/LogList.tsx +109 -0
  84. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  85. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  86. package/src/backend/__tests__/LogList.test.tsx +163 -0
  87. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  88. package/src/backend/detail/InlineEditors.tsx +78 -25
  89. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  90. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  91. package/src/backend/inputs/DatePicker.tsx +28 -155
  92. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  93. package/src/backend/inputs/TagsInput.tsx +10 -13
  94. package/src/backend/inputs/TimeInput.tsx +2 -1
  95. package/src/backend/inputs/TimePicker.tsx +99 -70
  96. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  97. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  98. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  99. package/src/backend/utils/customFieldForms.ts +2 -2
  100. package/src/portal/components/PortalEmptyState.tsx +27 -22
  101. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  102. package/src/primitives/__tests__/alert.test.tsx +204 -0
  103. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  104. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  105. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  106. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  107. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  108. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  109. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  110. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  111. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  112. package/src/primitives/__tests__/notification.test.tsx +119 -0
  113. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  114. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  115. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  116. package/src/primitives/__tests__/tag.test.tsx +123 -0
  117. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  118. package/src/primitives/accordion.tsx +225 -0
  119. package/src/primitives/alert.tsx +234 -24
  120. package/src/primitives/calendar.tsx +146 -38
  121. package/src/primitives/compact-select.tsx +59 -0
  122. package/src/primitives/counter-input.tsx +211 -0
  123. package/src/primitives/date-picker-helpers.ts +96 -0
  124. package/src/primitives/date-picker.tsx +290 -0
  125. package/src/primitives/date-range-picker.tsx +281 -0
  126. package/src/primitives/digit-input.tsx +208 -0
  127. package/src/primitives/empty-state.tsx +140 -0
  128. package/src/primitives/inline-input.tsx +55 -0
  129. package/src/primitives/inline-select.tsx +74 -0
  130. package/src/primitives/notification-stack.tsx +185 -0
  131. package/src/primitives/notification.tsx +136 -0
  132. package/src/primitives/rich-editor.tsx +1578 -0
  133. package/src/primitives/select.tsx +1 -0
  134. package/src/primitives/skeleton.tsx +70 -0
  135. package/src/primitives/tag-input.tsx +223 -0
  136. package/src/primitives/tag.tsx +69 -10
  137. package/src/primitives/time-picker.tsx +1059 -0
@@ -0,0 +1,1059 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Check, ChevronLeft, ChevronRight, Clock, X } from 'lucide-react'
5
+ import { cva, type VariantProps } from 'class-variance-authority'
6
+ import { cn } from '@open-mercato/shared/lib/utils'
7
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import { Button } from './button'
9
+ import { Popover, PopoverContent, PopoverTrigger } from './popover'
10
+
11
+ export type TimePickerValue = string | null
12
+
13
+ export type TimePickerStatusVariant = 'available' | 'busy' | 'in-meeting' | 'offline'
14
+
15
+ type SlotInteractiveState = 'default' | 'hover' | 'active' | 'disabled'
16
+ type DurationInteractiveState = SlotInteractiveState
17
+ type StatusInteractiveState = 'default' | 'hover' | 'selected' | 'disabled'
18
+
19
+ type TimeFormat = '12h' | '24h'
20
+
21
+ type LegacyFooterAction = {
22
+ label: string
23
+ onClick: () => void
24
+ variant?: 'link' | 'muted'
25
+ }
26
+
27
+ type PinnedTopAction = {
28
+ label: string
29
+ onClick: () => void
30
+ icon?: React.ReactNode
31
+ /** Optional secondary text rendered right-aligned (e.g. the current time next to `Now`). */
32
+ rightText?: string
33
+ }
34
+
35
+ type DurationOption = {
36
+ value: number
37
+ label?: string
38
+ disabled?: boolean
39
+ }
40
+
41
+ type StatusOption = {
42
+ variant: TimePickerStatusVariant
43
+ label?: string
44
+ disabled?: boolean
45
+ }
46
+
47
+ function padTwo(input: number): string {
48
+ return String(input).padStart(2, '0')
49
+ }
50
+
51
+ function parseTime(value: string | null | undefined): { hour: number; minute: number } | null {
52
+ if (!value) return null
53
+ const parts = value.split(':')
54
+ if (parts.length < 2) return null
55
+ const hour = parseInt(parts[0] ?? '0', 10)
56
+ const minute = parseInt(parts[1] ?? '0', 10)
57
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null
58
+ return {
59
+ hour: Math.max(0, Math.min(23, hour)),
60
+ minute: Math.max(0, Math.min(59, minute)),
61
+ }
62
+ }
63
+
64
+ function formatSlotDisplay(value: string, format: TimeFormat): { main: string; suffix: string | null } {
65
+ const parsed = parseTime(value)
66
+ if (!parsed) return { main: value, suffix: null }
67
+ if (format === '24h') {
68
+ return { main: `${padTwo(parsed.hour)}:${padTwo(parsed.minute)}`, suffix: null }
69
+ }
70
+ const hour12 = parsed.hour === 0 ? 12 : parsed.hour > 12 ? parsed.hour - 12 : parsed.hour
71
+ const suffix = parsed.hour < 12 ? 'AM' : 'PM'
72
+ return { main: `${padTwo(hour12)}:${padTwo(parsed.minute)}`, suffix }
73
+ }
74
+
75
+ function generateSlots(startTime: string, endTime: string, intervalMinutes: number): string[] {
76
+ const start = parseTime(startTime)
77
+ const end = parseTime(endTime)
78
+ if (!start || !end) return []
79
+ const step = intervalMinutes > 0 ? intervalMinutes : 30
80
+ const out: string[] = []
81
+ const startTotal = start.hour * 60 + start.minute
82
+ const endTotal = end.hour * 60 + end.minute
83
+ if (endTotal < startTotal) return []
84
+ for (let m = startTotal; m <= endTotal; m += step) {
85
+ const h = Math.floor(m / 60)
86
+ const min = m % 60
87
+ out.push(`${padTwo(h)}:${padTwo(min)}`)
88
+ }
89
+ return out
90
+ }
91
+
92
+ /**
93
+ * Format an HH:MM 24h string for display, splitting into the main digits and an
94
+ * optional AM/PM suffix. Exported so consumers (e.g. the legacy `backend/inputs/TimePicker`
95
+ * shim) can render their trigger label in the same format as the slot list.
96
+ */
97
+ export function formatTimePickerDisplay(
98
+ value: string,
99
+ format: '12h' | '24h' = '12h',
100
+ ): { main: string; suffix: string | null } {
101
+ return formatSlotDisplay(value, format)
102
+ }
103
+
104
+ export function formatDuration(minutes: number, options?: { short?: boolean }): string {
105
+ const short = options?.short ?? true
106
+ if (!Number.isFinite(minutes) || minutes <= 0) return short ? '0 min' : '0 minutes'
107
+ if (minutes < 60) return short ? `${minutes} min` : `${minutes} minutes`
108
+ if (minutes % (24 * 60) === 0) {
109
+ const days = minutes / (24 * 60)
110
+ return short ? (days === 1 ? '1 day' : `${days} days`) : days === 1 ? '1 day' : `${days} days`
111
+ }
112
+ if (minutes % 60 === 0) {
113
+ const hours = minutes / 60
114
+ return short ? (hours === 1 ? '1 hour' : `${hours} hours`) : hours === 1 ? '1 hour' : `${hours} hours`
115
+ }
116
+ const hours = Math.floor(minutes / 60)
117
+ const rest = minutes - hours * 60
118
+ return short
119
+ ? `${hours}h ${rest}m`
120
+ : `${hours} hour${hours === 1 ? '' : 's'} ${rest} minute${rest === 1 ? '' : 's'}`
121
+ }
122
+
123
+ const defaultStatusLabels: Record<TimePickerStatusVariant, string> = {
124
+ available: 'Available',
125
+ busy: 'Busy',
126
+ 'in-meeting': 'In meeting',
127
+ offline: 'Offline',
128
+ }
129
+
130
+ const statusColorMap: Record<
131
+ TimePickerStatusVariant,
132
+ { dot: string; bgSoft: string; border: string; text: string }
133
+ > = {
134
+ available: {
135
+ dot: 'bg-status-success-icon',
136
+ bgSoft: 'bg-status-success-bg',
137
+ border: 'border-status-success-border',
138
+ text: 'text-status-success-text',
139
+ },
140
+ busy: {
141
+ dot: 'bg-status-error-icon',
142
+ bgSoft: 'bg-status-error-bg',
143
+ border: 'border-status-error-border',
144
+ text: 'text-status-error-text',
145
+ },
146
+ 'in-meeting': {
147
+ dot: 'bg-status-warning-icon',
148
+ bgSoft: 'bg-status-warning-bg',
149
+ border: 'border-status-warning-border',
150
+ text: 'text-status-warning-text',
151
+ },
152
+ offline: {
153
+ dot: 'bg-muted-foreground',
154
+ bgSoft: 'bg-muted',
155
+ border: 'border-border',
156
+ text: 'text-muted-foreground',
157
+ },
158
+ }
159
+
160
+ const slotVariants = cva(
161
+ 'flex h-9 w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors text-left',
162
+ {
163
+ variants: {
164
+ state: {
165
+ default: 'bg-background hover:bg-muted/50 text-muted-foreground cursor-pointer',
166
+ hover: 'bg-muted/50 text-muted-foreground cursor-pointer',
167
+ active: 'bg-brand-violet/10 text-brand-violet cursor-pointer',
168
+ disabled: 'bg-background text-muted-foreground/40 cursor-not-allowed',
169
+ },
170
+ },
171
+ defaultVariants: { state: 'default' },
172
+ },
173
+ )
174
+
175
+ export type TimePickerSlotProps = {
176
+ value: string
177
+ label?: string
178
+ rightText?: string
179
+ state?: SlotInteractiveState
180
+ selected?: boolean
181
+ disabled?: boolean
182
+ format?: TimeFormat
183
+ onSelect?: (value: string) => void
184
+ className?: string
185
+ 'aria-label'?: string
186
+ }
187
+
188
+ export const TimePickerSlot = React.forwardRef<HTMLButtonElement, TimePickerSlotProps>(
189
+ (
190
+ {
191
+ value,
192
+ label,
193
+ rightText,
194
+ state,
195
+ selected,
196
+ disabled,
197
+ format = '12h',
198
+ onSelect,
199
+ className,
200
+ 'aria-label': ariaLabel,
201
+ },
202
+ ref,
203
+ ) => {
204
+ const effectiveState: SlotInteractiveState = disabled
205
+ ? 'disabled'
206
+ : selected
207
+ ? 'active'
208
+ : (state ?? 'default')
209
+ const display = label != null ? { main: label, suffix: null as string | null } : formatSlotDisplay(value, format)
210
+ const right = rightText ? formatSlotDisplay(rightText, format) : null
211
+ const isInteractive = effectiveState !== 'disabled'
212
+
213
+ return (
214
+ <button
215
+ ref={ref}
216
+ type="button"
217
+ className={cn(slotVariants({ state: effectiveState }), className)}
218
+ onClick={isInteractive ? () => onSelect?.(value) : undefined}
219
+ disabled={!isInteractive}
220
+ aria-label={ariaLabel ?? value}
221
+ aria-pressed={effectiveState === 'active'}
222
+ data-slot="time-picker-slot"
223
+ data-state={effectiveState}
224
+ >
225
+ <span className="flex min-w-0 flex-1 items-center gap-1">
226
+ <span>{display.main}</span>
227
+ {display.suffix ? (
228
+ <span className={effectiveState === 'active' ? 'text-brand-violet/60' : 'text-muted-foreground/60'}>
229
+ {display.suffix}
230
+ </span>
231
+ ) : null}
232
+ </span>
233
+ {right ? (
234
+ <span className="flex min-w-0 items-center justify-end gap-1">
235
+ <span>{right.main}</span>
236
+ {right.suffix ? (
237
+ <span className={effectiveState === 'active' ? 'text-brand-violet/60' : 'text-muted-foreground/60'}>
238
+ {right.suffix}
239
+ </span>
240
+ ) : null}
241
+ </span>
242
+ ) : null}
243
+ {effectiveState === 'active' ? (
244
+ <Check className="size-3.5 shrink-0 text-brand-violet" aria-hidden="true" />
245
+ ) : null}
246
+ </button>
247
+ )
248
+ },
249
+ )
250
+ TimePickerSlot.displayName = 'TimePickerSlot'
251
+
252
+ const durationChipVariants = cva(
253
+ 'inline-flex h-7 items-center justify-center gap-1.5 rounded-lg px-2.5 text-sm font-medium transition-colors whitespace-nowrap',
254
+ {
255
+ variants: {
256
+ state: {
257
+ default:
258
+ 'bg-background border border-border shadow-xs text-muted-foreground hover:bg-muted/40 cursor-pointer',
259
+ hover: 'bg-muted/40 border border-border text-muted-foreground cursor-pointer',
260
+ active: 'bg-brand-violet/10 text-brand-violet cursor-pointer',
261
+ disabled:
262
+ 'bg-background border border-border text-muted-foreground/40 cursor-not-allowed shadow-xs',
263
+ },
264
+ },
265
+ defaultVariants: { state: 'default' },
266
+ },
267
+ )
268
+
269
+ export type TimePickerDurationChipProps = {
270
+ value: number
271
+ label?: string
272
+ state?: DurationInteractiveState
273
+ selected?: boolean
274
+ disabled?: boolean
275
+ onSelect?: (value: number) => void
276
+ className?: string
277
+ }
278
+
279
+ export const TimePickerDurationChip = React.forwardRef<HTMLButtonElement, TimePickerDurationChipProps>(
280
+ (
281
+ { value, label, state, selected, disabled, onSelect, className },
282
+ ref,
283
+ ) => {
284
+ const effectiveState: DurationInteractiveState = disabled
285
+ ? 'disabled'
286
+ : selected
287
+ ? 'active'
288
+ : (state ?? 'default')
289
+ const isInteractive = effectiveState !== 'disabled'
290
+ const displayLabel = label ?? formatDuration(value)
291
+ return (
292
+ <button
293
+ ref={ref}
294
+ type="button"
295
+ className={cn(durationChipVariants({ state: effectiveState }), className)}
296
+ onClick={isInteractive ? () => onSelect?.(value) : undefined}
297
+ disabled={!isInteractive}
298
+ aria-pressed={effectiveState === 'active'}
299
+ data-slot="time-picker-duration-chip"
300
+ data-state={effectiveState}
301
+ >
302
+ {effectiveState === 'active' ? (
303
+ <Check className="size-3 shrink-0" aria-hidden="true" />
304
+ ) : null}
305
+ <span>{displayLabel}</span>
306
+ </button>
307
+ )
308
+ },
309
+ )
310
+ TimePickerDurationChip.displayName = 'TimePickerDurationChip'
311
+
312
+ const statusChipBaseVariants = cva(
313
+ 'inline-flex h-7 items-center justify-center gap-1.5 rounded-lg px-2.5 text-sm font-medium transition-colors whitespace-nowrap border',
314
+ {
315
+ variants: {
316
+ state: {
317
+ default: '',
318
+ hover: '',
319
+ selected: '',
320
+ disabled: 'cursor-not-allowed opacity-60',
321
+ },
322
+ },
323
+ defaultVariants: { state: 'default' },
324
+ },
325
+ )
326
+
327
+ export type TimePickerStatusChipProps = {
328
+ variant: TimePickerStatusVariant
329
+ label?: string
330
+ state?: StatusInteractiveState
331
+ selected?: boolean
332
+ disabled?: boolean
333
+ icon?: React.ReactNode
334
+ onSelect?: (variant: TimePickerStatusVariant) => void
335
+ className?: string
336
+ }
337
+
338
+ export const TimePickerStatusChip = React.forwardRef<HTMLButtonElement, TimePickerStatusChipProps>(
339
+ (
340
+ { variant, label, state, selected, disabled, icon, onSelect, className },
341
+ ref,
342
+ ) => {
343
+ const effectiveState: StatusInteractiveState = disabled
344
+ ? 'disabled'
345
+ : selected
346
+ ? 'selected'
347
+ : (state ?? 'default')
348
+ const colors = statusColorMap[variant]
349
+ const isInteractive = effectiveState !== 'disabled'
350
+ const displayLabel = label ?? defaultStatusLabels[variant]
351
+
352
+ const stateClasses =
353
+ effectiveState === 'selected'
354
+ ? cn(colors.bgSoft, colors.border, colors.text)
355
+ : effectiveState === 'hover'
356
+ ? cn('bg-muted/30 border-border', colors.text, 'cursor-pointer')
357
+ : effectiveState === 'disabled'
358
+ ? cn('bg-background border-border', colors.text)
359
+ : cn('bg-background border-border text-muted-foreground hover:bg-muted/30 cursor-pointer')
360
+
361
+ return (
362
+ <button
363
+ ref={ref}
364
+ type="button"
365
+ className={cn(statusChipBaseVariants({ state: effectiveState }), stateClasses, className)}
366
+ onClick={isInteractive ? () => onSelect?.(variant) : undefined}
367
+ disabled={!isInteractive}
368
+ aria-pressed={effectiveState === 'selected'}
369
+ data-slot="time-picker-status-chip"
370
+ data-state={effectiveState}
371
+ data-variant={variant}
372
+ >
373
+ {icon ?? (
374
+ <span
375
+ className={cn('inline-block size-1.5 shrink-0 rounded-full', colors.dot)}
376
+ aria-hidden="true"
377
+ />
378
+ )}
379
+ <span>{displayLabel}</span>
380
+ </button>
381
+ )
382
+ },
383
+ )
384
+ TimePickerStatusChip.displayName = 'TimePickerStatusChip'
385
+
386
+ /**
387
+ * Wraps a horizontally-scrollable row with prev/next arrow buttons that appear
388
+ * only when overflow is present. Hides the native scrollbar and reserves padding
389
+ * on each side proportional to the visible arrow so chips never sit underneath
390
+ * the arrow button. Exported for consumers that need the same UX (e.g. inline
391
+ * duration chip row inside a meeting/activity form).
392
+ */
393
+ export function HorizontalScrollRow({
394
+ className,
395
+ contentClassName,
396
+ children,
397
+ ariaLabel,
398
+ arrowSize = 'default',
399
+ scrollLeftAriaLabel = 'Scroll left',
400
+ scrollRightAriaLabel = 'Scroll right',
401
+ }: {
402
+ className?: string
403
+ /** Override classes for the inner scroll container (e.g. tighter padding when inlined into a form row). */
404
+ contentClassName?: string
405
+ children: React.ReactNode
406
+ ariaLabel?: string
407
+ /** Arrow button size. `default` = 28px circle, `sm` = 24px (denser rows). */
408
+ arrowSize?: 'default' | 'sm'
409
+ /** Override the left arrow's aria-label. Default is English 'Scroll left'. */
410
+ scrollLeftAriaLabel?: string
411
+ /** Override the right arrow's aria-label. Default is English 'Scroll right'. */
412
+ scrollRightAriaLabel?: string
413
+ }) {
414
+ const scrollRef = React.useRef<HTMLDivElement>(null)
415
+ const [canScrollLeft, setCanScrollLeft] = React.useState(false)
416
+ const [canScrollRight, setCanScrollRight] = React.useState(false)
417
+
418
+ const refreshScrollState = React.useCallback(() => {
419
+ const el = scrollRef.current
420
+ if (!el) return
421
+ const { scrollLeft, scrollWidth, clientWidth } = el
422
+ setCanScrollLeft(scrollLeft > 1)
423
+ setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 1)
424
+ }, [])
425
+
426
+ React.useEffect(() => {
427
+ refreshScrollState()
428
+ const el = scrollRef.current
429
+ if (!el) return
430
+ el.addEventListener('scroll', refreshScrollState, { passive: true })
431
+ const ro = new ResizeObserver(refreshScrollState)
432
+ ro.observe(el)
433
+ return () => {
434
+ el.removeEventListener('scroll', refreshScrollState)
435
+ ro.disconnect()
436
+ }
437
+ }, [refreshScrollState])
438
+
439
+ const scrollBy = React.useCallback((direction: 'left' | 'right') => {
440
+ const el = scrollRef.current
441
+ if (!el) return
442
+ const amount = Math.max(80, Math.floor(el.clientWidth * 0.6))
443
+ el.scrollBy({ left: direction === 'left' ? -amount : amount, behavior: 'smooth' })
444
+ }, [])
445
+
446
+ const arrowButtonClass =
447
+ arrowSize === 'sm' ? 'size-6 [&_svg]:size-3.5' : 'size-7 [&_svg]:size-4'
448
+
449
+ return (
450
+ <div className={cn('relative', className)} data-slot="time-picker-scroll-row">
451
+ {/* Outer gutter — reserves room for the absolute-positioned chevron buttons.
452
+ The scroll viewport lives INSIDE this gutter so chips can never slide
453
+ underneath the arrows — moving scroll offsets only shift content inside
454
+ the viewport, not the chevron clearance. */}
455
+ <div
456
+ className={cn(
457
+ 'transition-[padding]',
458
+ canScrollLeft ? 'pl-9' : 'pl-4',
459
+ canScrollRight ? 'pr-9' : 'pr-4',
460
+ )}
461
+ >
462
+ <div
463
+ ref={scrollRef}
464
+ className={cn(
465
+ 'flex items-center gap-2 overflow-x-auto py-3.5',
466
+ '[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden',
467
+ contentClassName,
468
+ )}
469
+ role="group"
470
+ aria-label={ariaLabel}
471
+ >
472
+ {children}
473
+ </div>
474
+ </div>
475
+ {/* Edge fades — soft gradient at the boundary between the gutter and the
476
+ scroll viewport, so chips sliding into the viewport dissolve cleanly
477
+ before reaching the arrow's edge. */}
478
+ {canScrollLeft ? (
479
+ <div
480
+ aria-hidden="true"
481
+ className="pointer-events-none absolute inset-y-0 left-9 w-6 bg-gradient-to-r from-background to-transparent"
482
+ />
483
+ ) : null}
484
+ {canScrollRight ? (
485
+ <div
486
+ aria-hidden="true"
487
+ className="pointer-events-none absolute inset-y-0 right-9 w-6 bg-gradient-to-l from-background to-transparent"
488
+ />
489
+ ) : null}
490
+ {canScrollLeft ? (
491
+ <button
492
+ type="button"
493
+ onClick={() => scrollBy('left')}
494
+ aria-label={scrollLeftAriaLabel}
495
+ className={cn(
496
+ 'absolute left-1 top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-muted-foreground shadow-xs transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
497
+ arrowButtonClass,
498
+ )}
499
+ data-slot="time-picker-scroll-left"
500
+ >
501
+ <ChevronLeft aria-hidden="true" />
502
+ </button>
503
+ ) : null}
504
+ {canScrollRight ? (
505
+ <button
506
+ type="button"
507
+ onClick={() => scrollBy('right')}
508
+ aria-label={scrollRightAriaLabel}
509
+ className={cn(
510
+ 'absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-muted-foreground shadow-xs transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
511
+ arrowButtonClass,
512
+ )}
513
+ data-slot="time-picker-scroll-right"
514
+ >
515
+ <ChevronRight aria-hidden="true" />
516
+ </button>
517
+ ) : null}
518
+ </div>
519
+ )
520
+ }
521
+
522
+ function useControllable<T>(controlled: T | undefined, defaultValue: T, onChange?: (next: T) => void) {
523
+ const [internal, setInternal] = React.useState<T>(defaultValue)
524
+ const isControlled = controlled !== undefined
525
+ const value = isControlled ? (controlled as T) : internal
526
+ const setValue = React.useCallback(
527
+ (next: T) => {
528
+ if (!isControlled) setInternal(next)
529
+ onChange?.(next)
530
+ },
531
+ [isControlled, onChange],
532
+ )
533
+ return [value, setValue] as const
534
+ }
535
+
536
+ export type TimePickerProps = {
537
+ value?: TimePickerValue
538
+ defaultValue?: TimePickerValue
539
+ onChange?: (value: TimePickerValue) => void
540
+
541
+ showHeader?: boolean
542
+ headerIcon?: React.ReactNode
543
+ headerTitle?: string
544
+ headerPlaceholder?: string
545
+ onClose?: () => void
546
+
547
+ durations?: DurationOption[]
548
+ activeDuration?: number
549
+ defaultActiveDuration?: number
550
+ onDurationChange?: (value: number) => void
551
+
552
+ statuses?: StatusOption[]
553
+ activeStatus?: TimePickerStatusVariant
554
+ defaultActiveStatus?: TimePickerStatusVariant
555
+ onStatusChange?: (variant: TimePickerStatusVariant) => void
556
+ statusLabel?: string
557
+
558
+ slots?: string[]
559
+ startTime?: string
560
+ endTime?: string
561
+ intervalMinutes?: number
562
+ format?: TimeFormat
563
+ slotRightText?: (slot: string) => string | undefined
564
+ slotLabel?: string
565
+ maxHeight?: number
566
+
567
+ showFooter?: boolean
568
+ cancelLabel?: string
569
+ applyLabel?: string
570
+ onCancel?: () => void
571
+ onApply?: (value: TimePickerValue) => void
572
+ legacyFooterActions?: LegacyFooterAction[]
573
+ /**
574
+ * Quick-action rows rendered above the scrollable slot list — used for
575
+ * "Now"-style shortcuts that stay visible regardless of scroll position.
576
+ * Styled like slots but with primary-tinted foreground.
577
+ */
578
+ pinnedTopActions?: PinnedTopAction[]
579
+
580
+ trigger?: React.ReactNode
581
+ open?: boolean
582
+ defaultOpen?: boolean
583
+ onOpenChange?: (open: boolean) => void
584
+ popoverAlign?: 'start' | 'center' | 'end'
585
+ popoverSide?: 'top' | 'right' | 'bottom' | 'left'
586
+
587
+ className?: string
588
+ disabled?: boolean
589
+ 'aria-label'?: string
590
+ }
591
+
592
+ function TimePickerCard({
593
+ value,
594
+ setValue,
595
+ showHeader,
596
+ headerIcon,
597
+ headerTitle,
598
+ headerPlaceholder,
599
+ onClose,
600
+ closeAriaLabel,
601
+ format,
602
+ durationOptions,
603
+ activeDuration,
604
+ setActiveDuration,
605
+ onDurationChange,
606
+ durationsAriaLabel,
607
+ scrollLeftAriaLabel,
608
+ scrollRightAriaLabel,
609
+ statusOptions,
610
+ activeStatus,
611
+ setActiveStatus,
612
+ onStatusChange,
613
+ statusLabel,
614
+ slotList,
615
+ slotLabel,
616
+ slotRightText,
617
+ maxHeight,
618
+ showFooter,
619
+ cancelLabel,
620
+ applyLabel,
621
+ handleCancel,
622
+ handleApply,
623
+ legacyFooterActions,
624
+ pinnedTopActions,
625
+ disabled,
626
+ className,
627
+ ariaLabel,
628
+ slotListRef,
629
+ }: {
630
+ value: TimePickerValue
631
+ setValue: (next: TimePickerValue) => void
632
+ showHeader: boolean
633
+ headerIcon: React.ReactNode
634
+ headerTitle: string | undefined
635
+ headerPlaceholder: string
636
+ onClose: (() => void) | undefined
637
+ closeAriaLabel: string
638
+ format: TimeFormat
639
+ durationOptions: DurationOption[] | undefined
640
+ activeDuration: number | undefined
641
+ setActiveDuration: (next: number) => void
642
+ onDurationChange: ((value: number) => void) | undefined
643
+ durationsAriaLabel: string
644
+ scrollLeftAriaLabel: string
645
+ scrollRightAriaLabel: string
646
+ statusOptions: StatusOption[] | undefined
647
+ activeStatus: TimePickerStatusVariant | undefined
648
+ setActiveStatus: (next: TimePickerStatusVariant) => void
649
+ onStatusChange: ((variant: TimePickerStatusVariant) => void) | undefined
650
+ statusLabel: string
651
+ slotList: string[]
652
+ slotLabel: string | undefined
653
+ slotRightText: ((slot: string) => string | undefined) | undefined
654
+ maxHeight: number
655
+ showFooter: boolean
656
+ cancelLabel: string
657
+ applyLabel: string
658
+ handleCancel: () => void
659
+ handleApply: () => void
660
+ legacyFooterActions: LegacyFooterAction[] | undefined
661
+ pinnedTopActions: PinnedTopAction[] | undefined
662
+ disabled: boolean
663
+ className: string | undefined
664
+ ariaLabel: string
665
+ slotListRef: React.RefObject<HTMLDivElement | null>
666
+ }) {
667
+ const headerDisplay = React.useMemo(() => {
668
+ if (headerTitle != null) return { main: headerTitle, suffix: null as string | null }
669
+ if (value) return formatSlotDisplay(value, format)
670
+ return { main: headerPlaceholder, suffix: null as string | null }
671
+ }, [headerTitle, value, format, headerPlaceholder])
672
+
673
+ const handleSlotKeyDown = React.useCallback(
674
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
675
+ if (disabled) return
676
+ const target = event.target as HTMLElement
677
+ const slotButton = target.closest<HTMLButtonElement>('[data-slot="time-picker-slot"]')
678
+ if (!slotButton) return
679
+ const container = slotListRef.current
680
+ if (!container) return
681
+ const all = Array.from(
682
+ container.querySelectorAll<HTMLButtonElement>('[data-slot="time-picker-slot"]:not(:disabled)'),
683
+ )
684
+ const currentIndex = all.indexOf(slotButton)
685
+ if (currentIndex === -1) return
686
+ if (event.key === 'ArrowDown') {
687
+ event.preventDefault()
688
+ const next = all[Math.min(all.length - 1, currentIndex + 1)]
689
+ next?.focus()
690
+ } else if (event.key === 'ArrowUp') {
691
+ event.preventDefault()
692
+ const prev = all[Math.max(0, currentIndex - 1)]
693
+ prev?.focus()
694
+ } else if (event.key === 'Home') {
695
+ event.preventDefault()
696
+ all[0]?.focus()
697
+ } else if (event.key === 'End') {
698
+ event.preventDefault()
699
+ all[all.length - 1]?.focus()
700
+ }
701
+ },
702
+ [disabled, slotListRef],
703
+ )
704
+
705
+ return (
706
+ <div
707
+ className={cn(
708
+ 'flex w-[348px] flex-col overflow-hidden rounded-[20px] border border-border bg-background',
709
+ 'shadow-[0px_1px_2px_0px_rgba(10,13,20,0.03),0px_24px_24px_-12px_rgba(51,51,51,0.04),0px_12px_12px_-6px_rgba(51,51,51,0.04)]',
710
+ className,
711
+ )}
712
+ role="dialog"
713
+ aria-label={ariaLabel}
714
+ aria-disabled={disabled || undefined}
715
+ data-slot="time-picker"
716
+ >
717
+ {showHeader ? (
718
+ <div
719
+ className="flex items-center gap-2 border-b border-border px-4 py-3.5"
720
+ data-slot="time-picker-header"
721
+ >
722
+ <span className="flex shrink-0 items-center text-muted-foreground" aria-hidden="true">
723
+ {headerIcon ?? <Clock className="size-5" aria-hidden="true" />}
724
+ </span>
725
+ <p className="flex-1 min-w-0 truncate text-base font-medium text-muted-foreground">
726
+ <span>{headerDisplay.main}</span>
727
+ {headerDisplay.suffix ? <span className="ml-1 text-muted-foreground/60">{headerDisplay.suffix}</span> : null}
728
+ </p>
729
+ {onClose ? (
730
+ <button
731
+ type="button"
732
+ onClick={onClose}
733
+ disabled={disabled}
734
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring/40 disabled:pointer-events-none disabled:opacity-60"
735
+ aria-label={closeAriaLabel}
736
+ data-slot="time-picker-close"
737
+ >
738
+ <X className="size-4" aria-hidden="true" />
739
+ </button>
740
+ ) : null}
741
+ </div>
742
+ ) : null}
743
+
744
+ {durationOptions && durationOptions.length > 0 ? (
745
+ <HorizontalScrollRow
746
+ className="border-b border-border"
747
+ ariaLabel={durationsAriaLabel}
748
+ scrollLeftAriaLabel={scrollLeftAriaLabel}
749
+ scrollRightAriaLabel={scrollRightAriaLabel}
750
+ >
751
+ {durationOptions.map((option) => (
752
+ <TimePickerDurationChip
753
+ key={option.value}
754
+ value={option.value}
755
+ label={option.label}
756
+ disabled={disabled || option.disabled}
757
+ selected={activeDuration === option.value}
758
+ onSelect={(next) => {
759
+ setActiveDuration(next)
760
+ onDurationChange?.(next)
761
+ }}
762
+ />
763
+ ))}
764
+ </HorizontalScrollRow>
765
+ ) : null}
766
+
767
+ {statusOptions && statusOptions.length > 0 ? (
768
+ <div
769
+ className="flex flex-col gap-2 border-b border-border px-4 py-3.5"
770
+ data-slot="time-picker-status-row"
771
+ >
772
+ <p className="text-sm font-medium text-muted-foreground">{statusLabel}</p>
773
+ <div className="flex flex-wrap items-center gap-2">
774
+ {statusOptions.map((option) => (
775
+ <TimePickerStatusChip
776
+ key={option.variant}
777
+ variant={option.variant}
778
+ label={option.label}
779
+ disabled={disabled || option.disabled}
780
+ selected={activeStatus === option.variant}
781
+ onSelect={(next) => {
782
+ setActiveStatus(next)
783
+ onStatusChange?.(next)
784
+ }}
785
+ />
786
+ ))}
787
+ </div>
788
+ </div>
789
+ ) : null}
790
+
791
+ {pinnedTopActions && pinnedTopActions.length > 0 ? (
792
+ <div
793
+ className="flex flex-col gap-0.5 border-b border-border p-2"
794
+ data-slot="time-picker-pinned-top"
795
+ >
796
+ {pinnedTopActions.map((action) => (
797
+ <button
798
+ key={action.label}
799
+ type="button"
800
+ onClick={action.onClick}
801
+ disabled={disabled}
802
+ className="flex h-9 w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium text-brand-violet transition-colors hover:bg-brand-violet/10 cursor-pointer disabled:pointer-events-none disabled:opacity-60"
803
+ data-slot="time-picker-pinned-action"
804
+ >
805
+ {action.icon ? (
806
+ <span className="flex shrink-0 items-center [&_svg]:size-4" aria-hidden="true">
807
+ {action.icon}
808
+ </span>
809
+ ) : null}
810
+ <span className="flex-1 text-left">{action.label}</span>
811
+ {action.rightText ? (
812
+ <span className="text-brand-violet/60">{action.rightText}</span>
813
+ ) : null}
814
+ </button>
815
+ ))}
816
+ </div>
817
+ ) : null}
818
+
819
+ <div
820
+ className="flex flex-col gap-0.5 overflow-y-auto p-2"
821
+ style={{ maxHeight }}
822
+ ref={slotListRef}
823
+ onKeyDown={handleSlotKeyDown}
824
+ data-slot="time-picker-slots"
825
+ >
826
+ {slotLabel ? (
827
+ <p className="px-3 pt-1 pb-2 text-sm font-medium text-muted-foreground">{slotLabel}</p>
828
+ ) : null}
829
+ {slotList.length === 0 ? (
830
+ <p className="px-3 py-4 text-center text-sm text-muted-foreground">No time slots available.</p>
831
+ ) : (
832
+ slotList.map((slot) => (
833
+ <TimePickerSlot
834
+ key={slot}
835
+ value={slot}
836
+ format={format}
837
+ rightText={slotRightText?.(slot)}
838
+ selected={value === slot}
839
+ disabled={disabled}
840
+ onSelect={(next) => setValue(next)}
841
+ />
842
+ ))
843
+ )}
844
+ </div>
845
+
846
+ {showFooter ? (
847
+ <div
848
+ className="flex flex-wrap items-center justify-end gap-2 border-t border-border px-4 py-3"
849
+ data-slot="time-picker-footer"
850
+ >
851
+ {legacyFooterActions && legacyFooterActions.length > 0 ? (
852
+ <div className="mr-auto flex items-center gap-1">
853
+ {legacyFooterActions.map((action) => (
854
+ <Button
855
+ key={action.label}
856
+ type="button"
857
+ variant={action.variant === 'muted' ? 'muted' : 'ghost'}
858
+ size="sm"
859
+ onClick={action.onClick}
860
+ disabled={disabled}
861
+ data-slot="time-picker-legacy-action"
862
+ >
863
+ {action.label}
864
+ </Button>
865
+ ))}
866
+ <span aria-hidden="true" className="ml-2 mr-1 h-5 w-px bg-border" />
867
+ </div>
868
+ ) : null}
869
+ <Button
870
+ type="button"
871
+ variant="outline"
872
+ size="sm"
873
+ onClick={handleCancel}
874
+ disabled={disabled}
875
+ data-slot="time-picker-cancel"
876
+ >
877
+ {cancelLabel}
878
+ </Button>
879
+ <Button
880
+ type="button"
881
+ variant="default"
882
+ size="sm"
883
+ onClick={handleApply}
884
+ disabled={disabled}
885
+ data-slot="time-picker-apply"
886
+ >
887
+ {applyLabel}
888
+ </Button>
889
+ </div>
890
+ ) : null}
891
+ </div>
892
+ )
893
+ }
894
+
895
+ export function TimePicker({
896
+ value: valueProp,
897
+ defaultValue = null,
898
+ onChange,
899
+ showHeader = true,
900
+ headerIcon,
901
+ headerTitle,
902
+ headerPlaceholder,
903
+ onClose,
904
+ durations,
905
+ activeDuration: activeDurationProp,
906
+ defaultActiveDuration,
907
+ onDurationChange,
908
+ statuses,
909
+ activeStatus: activeStatusProp,
910
+ defaultActiveStatus,
911
+ onStatusChange,
912
+ statusLabel,
913
+ slots: slotsProp,
914
+ startTime = '00:00',
915
+ endTime = '23:30',
916
+ intervalMinutes = 30,
917
+ format = '12h',
918
+ slotRightText,
919
+ slotLabel,
920
+ maxHeight = 280,
921
+ showFooter = true,
922
+ cancelLabel,
923
+ applyLabel,
924
+ onCancel,
925
+ onApply,
926
+ legacyFooterActions,
927
+ pinnedTopActions,
928
+ trigger,
929
+ open: openProp,
930
+ defaultOpen = false,
931
+ onOpenChange,
932
+ popoverAlign = 'start',
933
+ popoverSide = 'bottom',
934
+ className,
935
+ disabled = false,
936
+ 'aria-label': ariaLabel,
937
+ }: TimePickerProps) {
938
+ const t = useT()
939
+ const resolvedHeaderPlaceholder = headerPlaceholder ?? t('ui.timePicker.placeholder', 'Pick a time')
940
+ const resolvedStatusLabel = statusLabel ?? t('ui.timePicker.statusLabel', 'Select status')
941
+ const resolvedCancelLabel = cancelLabel ?? t('ui.timePicker.cancelButton', 'Cancel')
942
+ const resolvedApplyLabel = applyLabel ?? t('ui.timePicker.applyButton', 'Apply')
943
+ const resolvedAriaLabel = ariaLabel ?? t('ui.timePicker.label', 'Time picker')
944
+ const resolvedCloseAriaLabel = t('ui.timePicker.closeButton', 'Close')
945
+ const resolvedDurationsAriaLabel = t('ui.timePicker.durationsRowLabel', 'Quick duration')
946
+ const resolvedScrollLeftAriaLabel = t('ui.timePicker.scrollLeft', 'Scroll left')
947
+ const resolvedScrollRightAriaLabel = t('ui.timePicker.scrollRight', 'Scroll right')
948
+ const [value, setValue] = useControllable<TimePickerValue>(valueProp, defaultValue, onChange)
949
+ const [activeDuration, setActiveDuration] = useControllable<number | undefined>(
950
+ activeDurationProp,
951
+ defaultActiveDuration,
952
+ )
953
+ const [activeStatus, setActiveStatus] = useControllable<TimePickerStatusVariant | undefined>(
954
+ activeStatusProp,
955
+ defaultActiveStatus,
956
+ )
957
+ const [open, setOpen] = useControllable<boolean>(openProp, defaultOpen, onOpenChange)
958
+
959
+ const openSnapshotRef = React.useRef<TimePickerValue>(value)
960
+ const slotListRef = React.useRef<HTMLDivElement | null>(null)
961
+
962
+ const slotList = React.useMemo(() => {
963
+ if (slotsProp && slotsProp.length > 0) return slotsProp
964
+ return generateSlots(startTime, endTime, intervalMinutes)
965
+ }, [slotsProp, startTime, endTime, intervalMinutes])
966
+
967
+ const setOpenWithSnapshot = React.useCallback(
968
+ (next: boolean) => {
969
+ if (next && !open) {
970
+ openSnapshotRef.current = value
971
+ }
972
+ setOpen(next)
973
+ },
974
+ [open, setOpen, value],
975
+ )
976
+
977
+ const handleApply = React.useCallback(() => {
978
+ onApply?.(value)
979
+ if (trigger) setOpenWithSnapshot(false)
980
+ }, [onApply, setOpenWithSnapshot, trigger, value])
981
+
982
+ const handleCancel = React.useCallback(() => {
983
+ onCancel?.()
984
+ if (trigger) {
985
+ setValue(openSnapshotRef.current)
986
+ setOpenWithSnapshot(false)
987
+ }
988
+ }, [onCancel, setOpenWithSnapshot, setValue, trigger])
989
+
990
+ const handleKeyDown = React.useCallback(
991
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
992
+ if (event.key === 'Escape') {
993
+ handleCancel()
994
+ } else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
995
+ event.preventDefault()
996
+ handleApply()
997
+ }
998
+ },
999
+ [handleApply, handleCancel],
1000
+ )
1001
+
1002
+ const card = (
1003
+ <div onKeyDown={handleKeyDown}>
1004
+ <TimePickerCard
1005
+ value={value}
1006
+ setValue={setValue}
1007
+ showHeader={showHeader}
1008
+ headerIcon={headerIcon}
1009
+ headerTitle={headerTitle}
1010
+ headerPlaceholder={resolvedHeaderPlaceholder}
1011
+ onClose={onClose}
1012
+ closeAriaLabel={resolvedCloseAriaLabel}
1013
+ format={format}
1014
+ durationOptions={durations}
1015
+ activeDuration={activeDuration}
1016
+ setActiveDuration={setActiveDuration}
1017
+ onDurationChange={onDurationChange}
1018
+ durationsAriaLabel={resolvedDurationsAriaLabel}
1019
+ scrollLeftAriaLabel={resolvedScrollLeftAriaLabel}
1020
+ scrollRightAriaLabel={resolvedScrollRightAriaLabel}
1021
+ statusOptions={statuses}
1022
+ activeStatus={activeStatus}
1023
+ setActiveStatus={setActiveStatus}
1024
+ onStatusChange={onStatusChange}
1025
+ statusLabel={resolvedStatusLabel}
1026
+ slotList={slotList}
1027
+ slotLabel={slotLabel}
1028
+ slotRightText={slotRightText}
1029
+ maxHeight={maxHeight}
1030
+ showFooter={showFooter}
1031
+ cancelLabel={resolvedCancelLabel}
1032
+ applyLabel={resolvedApplyLabel}
1033
+ handleCancel={handleCancel}
1034
+ handleApply={handleApply}
1035
+ legacyFooterActions={legacyFooterActions}
1036
+ pinnedTopActions={pinnedTopActions}
1037
+ disabled={disabled}
1038
+ className={className}
1039
+ ariaLabel={resolvedAriaLabel}
1040
+ slotListRef={slotListRef}
1041
+ />
1042
+ </div>
1043
+ )
1044
+
1045
+ if (!trigger) return card
1046
+
1047
+ return (
1048
+ <Popover open={open} onOpenChange={setOpenWithSnapshot}>
1049
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
1050
+ <PopoverContent
1051
+ align={popoverAlign}
1052
+ side={popoverSide}
1053
+ className="w-auto border-none bg-transparent p-0 shadow-none"
1054
+ >
1055
+ {card}
1056
+ </PopoverContent>
1057
+ </Popover>
1058
+ )
1059
+ }