@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3276.1.1fcd145078
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +46 -129
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +10 -3
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/EmptyState.js +1 -47
- package/dist/backend/EmptyState.js.map +2 -2
- package/dist/backend/FilterOverlay.js +31 -26
- package/dist/backend/FilterOverlay.js.map +2 -2
- package/dist/backend/FlashMessages.js +23 -25
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +4 -2
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/LogList.js +49 -0
- package/dist/backend/LogList.js.map +7 -0
- package/dist/backend/crud/CollapsibleGroup.js +13 -3
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +80 -32
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +60 -6
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/DatePicker.js +6 -142
- package/dist/backend/inputs/DatePicker.js.map +2 -2
- package/dist/backend/inputs/DateTimePicker.js +6 -182
- package/dist/backend/inputs/DateTimePicker.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +16 -19
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/TimeInput.js +1 -1
- package/dist/backend/inputs/TimeInput.js.map +2 -2
- package/dist/backend/inputs/TimePicker.js +83 -72
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +2 -2
- package/dist/backend/utils/customFieldForms.js.map +1 -1
- package/dist/portal/components/PortalEmptyState.js +13 -9
- package/dist/portal/components/PortalEmptyState.js.map +2 -2
- package/dist/primitives/accordion.js +137 -0
- package/dist/primitives/accordion.js.map +7 -0
- package/dist/primitives/alert.js +180 -12
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/calendar.js +122 -32
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/compact-select.js +47 -0
- package/dist/primitives/compact-select.js.map +7 -0
- package/dist/primitives/counter-input.js +174 -0
- package/dist/primitives/counter-input.js.map +7 -0
- package/dist/primitives/date-picker-helpers.js +79 -0
- package/dist/primitives/date-picker-helpers.js.map +7 -0
- package/dist/primitives/date-picker.js +216 -0
- package/dist/primitives/date-picker.js.map +7 -0
- package/dist/primitives/date-range-picker.js +239 -0
- package/dist/primitives/date-range-picker.js.map +7 -0
- package/dist/primitives/digit-input.js +157 -0
- package/dist/primitives/digit-input.js.map +7 -0
- package/dist/primitives/empty-state.js +93 -0
- package/dist/primitives/empty-state.js.map +7 -0
- package/dist/primitives/inline-input.js +29 -0
- package/dist/primitives/inline-input.js.map +7 -0
- package/dist/primitives/inline-select.js +41 -0
- package/dist/primitives/inline-select.js.map +7 -0
- package/dist/primitives/notification-stack.js +116 -0
- package/dist/primitives/notification-stack.js.map +7 -0
- package/dist/primitives/notification.js +62 -0
- package/dist/primitives/notification.js.map +7 -0
- package/dist/primitives/rich-editor.js +1230 -0
- package/dist/primitives/rich-editor.js.map +7 -0
- package/dist/primitives/select.js +1 -0
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/skeleton.js +55 -0
- package/dist/primitives/skeleton.js.map +7 -0
- package/dist/primitives/tag-input.js +186 -0
- package/dist/primitives/tag-input.js.map +7 -0
- package/dist/primitives/tag.js +66 -13
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/time-picker.js +793 -0
- package/dist/primitives/time-picker.js.map +7 -0
- package/package.json +4 -3
- package/src/backend/CrudForm.tsx +61 -113
- package/src/backend/DataTable.tsx +17 -5
- package/src/backend/EmptyState.tsx +13 -63
- package/src/backend/FilterOverlay.tsx +38 -22
- package/src/backend/FlashMessages.tsx +23 -24
- package/src/backend/JsonBuilder.tsx +4 -2
- package/src/backend/LogList.tsx +109 -0
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
- package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
- package/src/backend/__tests__/LogList.test.tsx +163 -0
- package/src/backend/crud/CollapsibleGroup.tsx +17 -3
- package/src/backend/detail/InlineEditors.tsx +78 -25
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
- package/src/backend/inputs/ComboboxInput.tsx +81 -9
- package/src/backend/inputs/DatePicker.tsx +28 -155
- package/src/backend/inputs/DateTimePicker.tsx +25 -193
- package/src/backend/inputs/TagsInput.tsx +10 -13
- package/src/backend/inputs/TimeInput.tsx +2 -1
- package/src/backend/inputs/TimePicker.tsx +99 -70
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
- package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
- package/src/backend/utils/customFieldForms.ts +2 -2
- package/src/portal/components/PortalEmptyState.tsx +27 -22
- package/src/primitives/__tests__/accordion.test.tsx +197 -0
- package/src/primitives/__tests__/alert.test.tsx +204 -0
- package/src/primitives/__tests__/compact-select.test.tsx +98 -0
- package/src/primitives/__tests__/counter-input.test.tsx +165 -0
- package/src/primitives/__tests__/date-picker.test.tsx +221 -0
- package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
- package/src/primitives/__tests__/digit-input.test.tsx +168 -0
- package/src/primitives/__tests__/empty-state.test.tsx +154 -0
- package/src/primitives/__tests__/inline-input.test.tsx +100 -0
- package/src/primitives/__tests__/inline-select.test.tsx +103 -0
- package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
- package/src/primitives/__tests__/notification.test.tsx +119 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
- package/src/primitives/__tests__/skeleton.test.tsx +76 -0
- package/src/primitives/__tests__/tag-input.test.tsx +198 -0
- package/src/primitives/__tests__/tag.test.tsx +123 -0
- package/src/primitives/__tests__/time-picker.test.tsx +392 -0
- package/src/primitives/accordion.tsx +225 -0
- package/src/primitives/alert.tsx +234 -24
- package/src/primitives/calendar.tsx +146 -38
- package/src/primitives/compact-select.tsx +59 -0
- package/src/primitives/counter-input.tsx +211 -0
- package/src/primitives/date-picker-helpers.ts +96 -0
- package/src/primitives/date-picker.tsx +290 -0
- package/src/primitives/date-range-picker.tsx +281 -0
- package/src/primitives/digit-input.tsx +208 -0
- package/src/primitives/empty-state.tsx +140 -0
- package/src/primitives/inline-input.tsx +55 -0
- package/src/primitives/inline-select.tsx +74 -0
- package/src/primitives/notification-stack.tsx +185 -0
- package/src/primitives/notification.tsx +136 -0
- package/src/primitives/rich-editor.tsx +1578 -0
- package/src/primitives/select.tsx +1 -0
- package/src/primitives/skeleton.tsx +70 -0
- package/src/primitives/tag-input.tsx +223 -0
- package/src/primitives/tag.tsx +69 -10
- 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
|
+
}
|