@open-mercato/ui 0.8.1-develop.7215.1.60786cf836 → 0.8.1-develop.7219.1.f96cb57e70
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/build.mjs +34 -7
- package/dist/backend/inputs/TimePicker.js +8 -3
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/primitives/calendar.js +5 -3
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/date-locale.js +47 -0
- package/dist/primitives/date-locale.js.map +7 -0
- package/dist/primitives/date-picker.js +4 -1
- package/dist/primitives/date-picker.js.map +2 -2
- package/dist/primitives/time-picker.js.map +1 -1
- package/package.json +3 -3
- package/scripts/lucideRegistrySource.cjs +16 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +62 -0
- package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +27 -1
- package/src/backend/inputs/TimePicker.tsx +17 -4
- package/src/backend/inputs/__tests__/DatePicker.test.tsx +1 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +31 -0
- package/src/primitives/__tests__/date-locale.test.ts +53 -0
- package/src/primitives/__tests__/date-picker.test.tsx +54 -2
- package/src/primitives/calendar.tsx +11 -3
- package/src/primitives/date-locale.ts +80 -0
- package/src/primitives/date-picker.tsx +16 -1
- package/src/primitives/time-picker.tsx +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/primitives/time-picker.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Check, CircleCheck, ChevronLeft, ChevronRight, Clock, X } from 'lucide-react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from './button'\nimport { Popover, PopoverContent, PopoverTrigger } from './popover'\n\nexport type TimePickerValue = string | null\n\nexport type TimePickerStatusVariant = 'available' | 'busy' | 'in-meeting' | 'offline'\n\ntype SlotInteractiveState = 'default' | 'hover' | 'active' | 'disabled'\ntype DurationInteractiveState = SlotInteractiveState\ntype StatusInteractiveState = 'default' | 'hover' | 'selected' | 'disabled'\n\ntype TimeFormat = '12h' | '24h'\n\ntype LegacyFooterAction = {\n label: string\n onClick: () => void\n variant?: 'link' | 'muted'\n}\n\ntype PinnedTopAction = {\n label: string\n onClick: () => void\n icon?: React.ReactNode\n /** Optional secondary text rendered right-aligned (e.g. the current time next to `Now`). */\n rightText?: string\n}\n\ntype DurationOption = {\n value: number\n label?: string\n disabled?: boolean\n}\n\ntype StatusOption = {\n variant: TimePickerStatusVariant\n label?: string\n disabled?: boolean\n}\n\nfunction padTwo(input: number): string {\n return String(input).padStart(2, '0')\n}\n\nfunction parseTime(value: string | null | undefined): { hour: number; minute: number } | null {\n if (!value) return null\n const parts = value.split(':')\n if (parts.length < 2) return null\n const hour = parseInt(parts[0] ?? '0', 10)\n const minute = parseInt(parts[1] ?? '0', 10)\n if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null\n return {\n hour: Math.max(0, Math.min(23, hour)),\n minute: Math.max(0, Math.min(59, minute)),\n }\n}\n\nfunction formatSlotDisplay(value: string, format: TimeFormat): { main: string; suffix: string | null } {\n const parsed = parseTime(value)\n if (!parsed) return { main: value, suffix: null }\n if (format === '24h') {\n return { main: `${padTwo(parsed.hour)}:${padTwo(parsed.minute)}`, suffix: null }\n }\n const hour12 = parsed.hour === 0 ? 12 : parsed.hour > 12 ? parsed.hour - 12 : parsed.hour\n const suffix = parsed.hour < 12 ? 'AM' : 'PM'\n return { main: `${padTwo(hour12)}:${padTwo(parsed.minute)}`, suffix }\n}\n\nfunction generateSlots(startTime: string, endTime: string, intervalMinutes: number): string[] {\n const start = parseTime(startTime)\n const end = parseTime(endTime)\n if (!start || !end) return []\n const step = intervalMinutes > 0 ? intervalMinutes : 30\n const out: string[] = []\n const startTotal = start.hour * 60 + start.minute\n const endTotal = end.hour * 60 + end.minute\n if (endTotal < startTotal) return []\n for (let m = startTotal; m <= endTotal; m += step) {\n const h = Math.floor(m / 60)\n const min = m % 60\n out.push(`${padTwo(h)}:${padTwo(min)}`)\n }\n return out\n}\n\n/**\n * Format an HH:MM 24h string for display, splitting into the main digits and an\n * optional AM/PM suffix. Exported so consumers (e.g. the legacy `backend/inputs/TimePicker`\n * shim) can render their trigger label in the same format as the slot list.\n */\nexport function formatTimePickerDisplay(\n value: string,\n format: '12h' | '24h' = '12h',\n): { main: string; suffix: string | null } {\n return formatSlotDisplay(value, format)\n}\n\nexport function formatDuration(minutes: number, options?: { short?: boolean }): string {\n const short = options?.short ?? true\n if (!Number.isFinite(minutes) || minutes <= 0) return short ? '0 min' : '0 minutes'\n if (minutes < 60) return short ? `${minutes} min` : `${minutes} minutes`\n if (minutes % (24 * 60) === 0) {\n const days = minutes / (24 * 60)\n return short ? (days === 1 ? '1 day' : `${days} days`) : days === 1 ? '1 day' : `${days} days`\n }\n if (minutes % 60 === 0) {\n const hours = minutes / 60\n return short ? (hours === 1 ? '1 hour' : `${hours} hours`) : hours === 1 ? '1 hour' : `${hours} hours`\n }\n const hours = Math.floor(minutes / 60)\n const rest = minutes - hours * 60\n return short\n ? `${hours}h ${rest}m`\n : `${hours} hour${hours === 1 ? '' : 's'} ${rest} minute${rest === 1 ? '' : 's'}`\n}\n\nconst defaultStatusLabels: Record<TimePickerStatusVariant, string> = {\n available: 'Available',\n busy: 'Busy',\n 'in-meeting': 'In meeting',\n offline: 'Offline',\n}\n\nconst statusColorMap: Record<\n TimePickerStatusVariant,\n { dot: string; bgSoft: string; border: string; text: string }\n> = {\n available: {\n dot: 'bg-status-success-icon',\n bgSoft: 'bg-status-success-bg',\n border: 'border-status-success-border',\n text: 'text-status-success-text',\n },\n busy: {\n dot: 'bg-status-error-icon',\n bgSoft: 'bg-status-error-bg',\n border: 'border-status-error-border',\n text: 'text-status-error-text',\n },\n 'in-meeting': {\n dot: 'bg-status-warning-icon',\n bgSoft: 'bg-status-warning-bg',\n border: 'border-status-warning-border',\n text: 'text-status-warning-text',\n },\n offline: {\n dot: 'bg-muted-foreground',\n bgSoft: 'bg-muted',\n border: 'border-border',\n text: 'text-muted-foreground',\n },\n}\n\nconst slotVariants = cva(\n 'flex h-9 w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors text-left',\n {\n variants: {\n state: {\n default: 'bg-background hover:bg-muted/50 text-muted-foreground cursor-pointer',\n hover: 'bg-muted/50 text-muted-foreground cursor-pointer',\n active: 'bg-muted text-foreground cursor-pointer',\n disabled: 'bg-background text-muted-foreground/40 cursor-not-allowed',\n },\n },\n defaultVariants: { state: 'default' },\n },\n)\n\nexport type TimePickerSlotProps = {\n value: string\n label?: string\n rightText?: string\n /** Position of the selection indicator between or after the two time labels. */\n checkPosition?: 'right' | 'center'\n state?: SlotInteractiveState\n selected?: boolean\n disabled?: boolean\n format?: TimeFormat\n onSelect?: (value: string) => void\n className?: string\n 'aria-label'?: string\n}\n\nexport const TimePickerSlot = React.forwardRef<HTMLButtonElement, TimePickerSlotProps>(\n (\n {\n value,\n label,\n rightText,\n checkPosition = 'right',\n state,\n selected,\n disabled,\n format = '12h',\n onSelect,\n className,\n 'aria-label': ariaLabel,\n },\n ref,\n ) => {\n const effectiveState: SlotInteractiveState = disabled\n ? 'disabled'\n : selected\n ? 'active'\n : (state ?? 'default')\n const display = label != null ? { main: label, suffix: null as string | null } : formatSlotDisplay(value, format)\n const right = rightText ? formatSlotDisplay(rightText, format) : null\n const isInteractive = effectiveState !== 'disabled'\n\n return (\n <button\n ref={ref}\n type=\"button\"\n className={cn(slotVariants({ state: effectiveState }), className)}\n onClick={isInteractive ? () => onSelect?.(value) : undefined}\n disabled={!isInteractive}\n aria-label={ariaLabel ?? value}\n aria-pressed={effectiveState === 'active'}\n data-slot=\"time-picker-slot\"\n data-state={effectiveState}\n >\n <span className=\"flex min-w-0 flex-1 items-center gap-1\">\n <span>{display.main}</span>\n {display.suffix ? (\n <span className=\"text-muted-foreground\">\n {display.suffix}\n </span>\n ) : null}\n </span>\n {effectiveState === 'active' && checkPosition === 'center' ? (\n <CircleCheck data-slot=\"time-picker-slot-check\" className=\"size-3.5 shrink-0 fill-accent-indigo text-background\" aria-hidden=\"true\" />\n ) : null}\n {right ? (\n <span className={cn('flex min-w-0 items-center justify-end gap-1', checkPosition === 'center' && 'flex-1')}>\n <span>{right.main}</span>\n {right.suffix ? (\n <span className=\"text-muted-foreground\">\n {right.suffix}\n </span>\n ) : null}\n </span>\n ) : null}\n {effectiveState === 'active' && checkPosition === 'right' ? (\n <CircleCheck data-slot=\"time-picker-slot-check\" className=\"size-3.5 shrink-0 fill-accent-indigo text-background\" aria-hidden=\"true\" />\n ) : null}\n </button>\n )\n },\n)\nTimePickerSlot.displayName = 'TimePickerSlot'\n\nconst durationChipVariants = cva(\n 'inline-flex h-7 items-center justify-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors whitespace-nowrap',\n {\n variants: {\n state: {\n default:\n 'bg-background border border-border shadow-xs text-muted-foreground hover:bg-muted/40 cursor-pointer',\n hover: 'bg-muted/40 border border-border text-muted-foreground cursor-pointer',\n active: 'bg-status-info-bg text-status-info-text cursor-pointer',\n disabled:\n 'bg-background border border-border text-muted-foreground/40 cursor-not-allowed shadow-xs',\n },\n },\n defaultVariants: { state: 'default' },\n },\n)\n\nexport type TimePickerDurationChipProps = {\n value: number\n label?: string\n state?: DurationInteractiveState\n selected?: boolean\n disabled?: boolean\n onSelect?: (value: number) => void\n className?: string\n}\n\nexport const TimePickerDurationChip = React.forwardRef<HTMLButtonElement, TimePickerDurationChipProps>(\n (\n { value, label, state, selected, disabled, onSelect, className },\n ref,\n ) => {\n const effectiveState: DurationInteractiveState = disabled\n ? 'disabled'\n : selected\n ? 'active'\n : (state ?? 'default')\n const isInteractive = effectiveState !== 'disabled'\n const displayLabel = label ?? formatDuration(value)\n return (\n <button\n ref={ref}\n type=\"button\"\n className={cn(durationChipVariants({ state: effectiveState }), className)}\n onClick={isInteractive ? () => onSelect?.(value) : undefined}\n disabled={!isInteractive}\n aria-pressed={effectiveState === 'active'}\n data-slot=\"time-picker-duration-chip\"\n data-state={effectiveState}\n >\n {effectiveState === 'active' ? (\n <Check className=\"size-3 shrink-0\" aria-hidden=\"true\" />\n ) : null}\n <span>{displayLabel}</span>\n </button>\n )\n },\n)\nTimePickerDurationChip.displayName = 'TimePickerDurationChip'\n\nconst statusChipBaseVariants = cva(\n 'inline-flex h-7 items-center justify-center gap-1 rounded-md pl-1.5 pr-2.5 text-sm font-medium transition-colors whitespace-nowrap border',\n {\n variants: {\n state: {\n default: '',\n hover: '',\n selected: '',\n disabled: 'cursor-not-allowed opacity-60',\n },\n },\n defaultVariants: { state: 'default' },\n },\n)\n\nexport type TimePickerStatusChipProps = {\n variant: TimePickerStatusVariant\n label?: string\n state?: StatusInteractiveState\n selected?: boolean\n disabled?: boolean\n icon?: React.ReactNode\n onSelect?: (variant: TimePickerStatusVariant) => void\n className?: string\n}\n\nexport const TimePickerStatusChip = React.forwardRef<HTMLButtonElement, TimePickerStatusChipProps>(\n (\n { variant, label, state, selected, disabled, icon, onSelect, className },\n ref,\n ) => {\n const effectiveState: StatusInteractiveState = disabled\n ? 'disabled'\n : selected\n ? 'selected'\n : (state ?? 'default')\n const colors = statusColorMap[variant]\n const isInteractive = effectiveState !== 'disabled'\n const displayLabel = label ?? defaultStatusLabels[variant]\n\n const stateClasses =\n effectiveState === 'selected'\n ? cn(colors.bgSoft, colors.border, colors.text)\n : effectiveState === 'hover'\n ? cn('bg-muted/30 border-border', colors.text, 'cursor-pointer')\n : effectiveState === 'disabled'\n ? cn('bg-background border-border', colors.text)\n : cn('bg-background border-border text-muted-foreground hover:bg-muted/30 cursor-pointer')\n\n return (\n <button\n ref={ref}\n type=\"button\"\n className={cn(statusChipBaseVariants({ state: effectiveState }), stateClasses, className)}\n onClick={isInteractive ? () => onSelect?.(variant) : undefined}\n disabled={!isInteractive}\n aria-pressed={effectiveState === 'selected'}\n data-slot=\"time-picker-status-chip\"\n data-state={effectiveState}\n data-variant={variant}\n >\n {icon ?? (\n <span\n className={cn('inline-block size-1.5 shrink-0 rounded-full', colors.dot)}\n aria-hidden=\"true\"\n />\n )}\n <span>{displayLabel}</span>\n </button>\n )\n },\n)\nTimePickerStatusChip.displayName = 'TimePickerStatusChip'\n\n/**\n * Wraps a horizontally-scrollable row with prev/next arrow buttons that appear\n * only when overflow is present. Hides the native scrollbar and reserves padding\n * on each side proportional to the visible arrow so chips never sit underneath\n * the arrow button. Exported for consumers that need the same UX (e.g. inline\n * duration chip row inside a meeting/activity form).\n */\nexport function HorizontalScrollRow({\n className,\n contentClassName,\n children,\n ariaLabel,\n arrowSize = 'default',\n scrollLeftAriaLabel = 'Scroll left',\n scrollRightAriaLabel = 'Scroll right',\n}: {\n className?: string\n /** Override classes for the inner scroll container (e.g. tighter padding when inlined into a form row). */\n contentClassName?: string\n children: React.ReactNode\n ariaLabel?: string\n /** Arrow button size. `default` = 28px circle, `sm` = 24px (denser rows). */\n arrowSize?: 'default' | 'sm'\n /** Override the left arrow's aria-label. Default is English 'Scroll left'. */\n scrollLeftAriaLabel?: string\n /** Override the right arrow's aria-label. Default is English 'Scroll right'. */\n scrollRightAriaLabel?: string\n}) {\n const scrollRef = React.useRef<HTMLDivElement>(null)\n const [canScrollLeft, setCanScrollLeft] = React.useState(false)\n const [canScrollRight, setCanScrollRight] = React.useState(false)\n\n const refreshScrollState = React.useCallback(() => {\n const el = scrollRef.current\n if (!el) return\n const { scrollLeft, scrollWidth, clientWidth } = el\n setCanScrollLeft(scrollLeft > 1)\n setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 1)\n }, [])\n\n React.useEffect(() => {\n refreshScrollState()\n const el = scrollRef.current\n if (!el) return\n el.addEventListener('scroll', refreshScrollState, { passive: true })\n const ro = new ResizeObserver(refreshScrollState)\n ro.observe(el)\n return () => {\n el.removeEventListener('scroll', refreshScrollState)\n ro.disconnect()\n }\n }, [refreshScrollState])\n\n const scrollBy = React.useCallback((direction: 'left' | 'right') => {\n const el = scrollRef.current\n if (!el) return\n const amount = Math.max(80, Math.floor(el.clientWidth * 0.6))\n el.scrollBy({ left: direction === 'left' ? -amount : amount, behavior: 'smooth' })\n }, [])\n\n const arrowButtonClass =\n arrowSize === 'sm' ? 'size-6 [&_svg]:size-3.5' : 'size-7 [&_svg]:size-4'\n\n return (\n <div className={cn('relative', className)} data-slot=\"time-picker-scroll-row\">\n {/* Outer gutter \u2014 reserves room for the absolute-positioned chevron buttons.\n The scroll viewport lives INSIDE this gutter so chips can never slide\n underneath the arrows \u2014 moving scroll offsets only shift content inside\n the viewport, not the chevron clearance. */}\n <div\n className={cn(\n 'transition-[padding]',\n canScrollLeft ? 'pl-9' : 'pl-4',\n canScrollRight ? 'pr-9' : 'pr-4',\n )}\n >\n <div\n ref={scrollRef}\n className={cn(\n 'scrollbar-hide flex items-center gap-2 overflow-x-auto py-3.5',\n contentClassName,\n )}\n role=\"group\"\n aria-label={ariaLabel}\n >\n {children}\n </div>\n </div>\n {/* Edge fades \u2014 soft gradient at the boundary between the gutter and the\n scroll viewport, so chips sliding into the viewport dissolve cleanly\n before reaching the arrow's edge. */}\n {canScrollLeft ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-y-0 left-9 w-6 bg-gradient-to-r from-background to-transparent\"\n />\n ) : null}\n {canScrollRight ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-y-0 right-9 w-6 bg-gradient-to-l from-background to-transparent\"\n />\n ) : null}\n {canScrollLeft ? (\n <button\n type=\"button\"\n onClick={() => scrollBy('left')}\n aria-label={scrollLeftAriaLabel}\n className={cn(\n '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',\n arrowButtonClass,\n )}\n data-slot=\"time-picker-scroll-left\"\n >\n <ChevronLeft aria-hidden=\"true\" />\n </button>\n ) : null}\n {canScrollRight ? (\n <button\n type=\"button\"\n onClick={() => scrollBy('right')}\n aria-label={scrollRightAriaLabel}\n className={cn(\n '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',\n arrowButtonClass,\n )}\n data-slot=\"time-picker-scroll-right\"\n >\n <ChevronRight aria-hidden=\"true\" />\n </button>\n ) : null}\n </div>\n )\n}\n\nfunction useControllable<T>(controlled: T | undefined, defaultValue: T, onChange?: (next: T) => void) {\n const [internal, setInternal] = React.useState<T>(defaultValue)\n const isControlled = controlled !== undefined\n const value = isControlled ? (controlled as T) : internal\n const setValue = React.useCallback(\n (next: T) => {\n if (!isControlled) setInternal(next)\n onChange?.(next)\n },\n [isControlled, onChange],\n )\n return [value, setValue] as const\n}\n\nexport type TimePickerProps = {\n value?: TimePickerValue\n defaultValue?: TimePickerValue\n onChange?: (value: TimePickerValue) => void\n\n showHeader?: boolean\n headerIcon?: React.ReactNode\n headerTitle?: string\n headerPlaceholder?: string\n onClose?: () => void\n\n durations?: DurationOption[]\n activeDuration?: number\n defaultActiveDuration?: number\n onDurationChange?: (value: number) => void\n\n statuses?: StatusOption[]\n activeStatus?: TimePickerStatusVariant\n defaultActiveStatus?: TimePickerStatusVariant\n onStatusChange?: (variant: TimePickerStatusVariant) => void\n statusLabel?: string\n\n slots?: string[]\n startTime?: string\n endTime?: string\n intervalMinutes?: number\n format?: TimeFormat\n slotRightText?: (slot: string) => string | undefined\n slotLabel?: string\n maxHeight?: number\n\n showFooter?: boolean\n cancelLabel?: string\n applyLabel?: string\n onCancel?: () => void\n onApply?: (value: TimePickerValue) => void\n legacyFooterActions?: LegacyFooterAction[]\n /**\n * Quick-action rows rendered above the scrollable slot list \u2014 used for\n * \"Now\"-style shortcuts that stay visible regardless of scroll position.\n * Styled like slots but with primary-tinted foreground.\n */\n pinnedTopActions?: PinnedTopAction[]\n\n trigger?: React.ReactNode\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n popoverAlign?: 'start' | 'center' | 'end'\n popoverSide?: 'top' | 'right' | 'bottom' | 'left'\n\n className?: string\n disabled?: boolean\n 'aria-label'?: string\n}\n\nfunction TimePickerCard({\n value,\n setValue,\n showHeader,\n headerIcon,\n headerTitle,\n headerPlaceholder,\n onClose,\n closeAriaLabel,\n format,\n durationOptions,\n activeDuration,\n setActiveDuration,\n onDurationChange,\n durationsAriaLabel,\n scrollLeftAriaLabel,\n scrollRightAriaLabel,\n statusOptions,\n activeStatus,\n setActiveStatus,\n onStatusChange,\n statusLabel,\n slotList,\n slotLabel,\n slotRightText,\n maxHeight,\n showFooter,\n cancelLabel,\n applyLabel,\n handleCancel,\n handleApply,\n legacyFooterActions,\n pinnedTopActions,\n disabled,\n className,\n ariaLabel,\n slotListRef,\n}: {\n value: TimePickerValue\n setValue: (next: TimePickerValue) => void\n showHeader: boolean\n headerIcon: React.ReactNode\n headerTitle: string | undefined\n headerPlaceholder: string\n onClose: (() => void) | undefined\n closeAriaLabel: string\n format: TimeFormat\n durationOptions: DurationOption[] | undefined\n activeDuration: number | undefined\n setActiveDuration: (next: number) => void\n onDurationChange: ((value: number) => void) | undefined\n durationsAriaLabel: string\n scrollLeftAriaLabel: string\n scrollRightAriaLabel: string\n statusOptions: StatusOption[] | undefined\n activeStatus: TimePickerStatusVariant | undefined\n setActiveStatus: (next: TimePickerStatusVariant) => void\n onStatusChange: ((variant: TimePickerStatusVariant) => void) | undefined\n statusLabel: string\n slotList: string[]\n slotLabel: string | undefined\n slotRightText: ((slot: string) => string | undefined) | undefined\n maxHeight: number\n showFooter: boolean\n cancelLabel: string\n applyLabel: string\n handleCancel: () => void\n handleApply: () => void\n legacyFooterActions: LegacyFooterAction[] | undefined\n pinnedTopActions: PinnedTopAction[] | undefined\n disabled: boolean\n className: string | undefined\n ariaLabel: string\n slotListRef: React.RefObject<HTMLDivElement | null>\n}) {\n const headerDisplay = React.useMemo(() => {\n if (headerTitle != null) return { main: headerTitle, suffix: null as string | null }\n if (value) return formatSlotDisplay(value, format)\n return { main: headerPlaceholder, suffix: null as string | null }\n }, [headerTitle, value, format, headerPlaceholder])\n\n const handleSlotKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (disabled) return\n const target = event.target as HTMLElement\n const slotButton = target.closest<HTMLButtonElement>('[data-slot=\"time-picker-slot\"]')\n if (!slotButton) return\n const container = slotListRef.current\n if (!container) return\n const all = Array.from(\n container.querySelectorAll<HTMLButtonElement>('[data-slot=\"time-picker-slot\"]:not(:disabled)'),\n )\n const currentIndex = all.indexOf(slotButton)\n if (currentIndex === -1) return\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n const next = all[Math.min(all.length - 1, currentIndex + 1)]\n next?.focus()\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n const prev = all[Math.max(0, currentIndex - 1)]\n prev?.focus()\n } else if (event.key === 'Home') {\n event.preventDefault()\n all[0]?.focus()\n } else if (event.key === 'End') {\n event.preventDefault()\n all[all.length - 1]?.focus()\n }\n },\n [disabled, slotListRef],\n )\n\n return (\n <div\n className={cn(\n 'flex w-80 flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl',\n className,\n )}\n role=\"dialog\"\n aria-label={ariaLabel}\n aria-disabled={disabled || undefined}\n data-slot=\"time-picker\"\n >\n {showHeader ? (\n <div\n className=\"flex items-center gap-2 border-b border-border px-4 py-3.5\"\n data-slot=\"time-picker-header\"\n >\n <span className=\"flex shrink-0 items-center text-muted-foreground\" aria-hidden=\"true\">\n {headerIcon ?? <Clock className=\"size-5\" aria-hidden=\"true\" />}\n </span>\n <p className=\"flex-1 min-w-0 truncate text-base font-medium text-muted-foreground\">\n <span>{headerDisplay.main}</span>\n {headerDisplay.suffix ? <span className=\"ml-1 text-muted-foreground/60\">{headerDisplay.suffix}</span> : null}\n </p>\n {onClose ? (\n <button\n type=\"button\"\n onClick={onClose}\n disabled={disabled}\n 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\"\n aria-label={closeAriaLabel}\n data-slot=\"time-picker-close\"\n >\n <X className=\"size-4\" aria-hidden=\"true\" />\n </button>\n ) : null}\n </div>\n ) : null}\n\n {durationOptions && durationOptions.length > 0 ? (\n <HorizontalScrollRow\n className=\"border-b border-border\"\n ariaLabel={durationsAriaLabel}\n scrollLeftAriaLabel={scrollLeftAriaLabel}\n scrollRightAriaLabel={scrollRightAriaLabel}\n >\n {durationOptions.map((option) => (\n <TimePickerDurationChip\n key={option.value}\n value={option.value}\n label={option.label}\n disabled={disabled || option.disabled}\n selected={activeDuration === option.value}\n onSelect={(next) => {\n setActiveDuration(next)\n onDurationChange?.(next)\n }}\n />\n ))}\n </HorizontalScrollRow>\n ) : null}\n\n {statusOptions && statusOptions.length > 0 ? (\n <div\n className=\"flex flex-col gap-2 border-b border-border px-4 py-3.5\"\n data-slot=\"time-picker-status-row\"\n >\n <p className=\"text-sm font-medium text-muted-foreground\">{statusLabel}</p>\n <div className=\"flex flex-wrap items-center gap-2\">\n {statusOptions.map((option) => (\n <TimePickerStatusChip\n key={option.variant}\n variant={option.variant}\n label={option.label}\n disabled={disabled || option.disabled}\n selected={activeStatus === option.variant}\n onSelect={(next) => {\n setActiveStatus(next)\n onStatusChange?.(next)\n }}\n />\n ))}\n </div>\n </div>\n ) : null}\n\n {pinnedTopActions && pinnedTopActions.length > 0 ? (\n <div\n className=\"flex flex-col gap-0.5 border-b border-border p-2\"\n data-slot=\"time-picker-pinned-top\"\n >\n {pinnedTopActions.map((action) => (\n <button\n key={action.label}\n type=\"button\"\n onClick={action.onClick}\n disabled={disabled}\n className=\"flex h-9 w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary/10 cursor-pointer disabled:pointer-events-none disabled:opacity-60\"\n data-slot=\"time-picker-pinned-action\"\n >\n {action.icon ? (\n <span className=\"flex shrink-0 items-center [&_svg]:size-4\" aria-hidden=\"true\">\n {action.icon}\n </span>\n ) : null}\n <span className=\"flex-1 text-left\">{action.label}</span>\n {action.rightText ? (\n <span className=\"text-primary/60\">{action.rightText}</span>\n ) : null}\n </button>\n ))}\n </div>\n ) : null}\n\n <div\n className=\"flex flex-col gap-0.5 overflow-y-auto p-2\"\n style={{ maxHeight }}\n ref={slotListRef}\n onKeyDown={handleSlotKeyDown}\n data-slot=\"time-picker-slots\"\n >\n {slotLabel ? (\n <p className=\"px-3 pt-1 pb-2 text-sm font-medium text-muted-foreground\">{slotLabel}</p>\n ) : null}\n {slotList.length === 0 ? (\n <p className=\"px-3 py-4 text-center text-sm text-muted-foreground\">No time slots available.</p>\n ) : (\n slotList.map((slot) => (\n <TimePickerSlot\n key={slot}\n value={slot}\n format={format}\n rightText={slotRightText?.(slot)}\n selected={value === slot}\n disabled={disabled}\n onSelect={(next) => setValue(next)}\n />\n ))\n )}\n </div>\n\n {showFooter ? (\n <div\n className=\"flex flex-wrap items-center justify-end gap-2 border-t border-border px-4 py-3\"\n data-slot=\"time-picker-footer\"\n >\n {legacyFooterActions && legacyFooterActions.length > 0 ? (\n <div className=\"mr-auto flex items-center gap-1\">\n {legacyFooterActions.map((action) => (\n <Button\n key={action.label}\n type=\"button\"\n variant={action.variant === 'muted' ? 'muted' : 'ghost'}\n size=\"sm\"\n onClick={action.onClick}\n disabled={disabled}\n data-slot=\"time-picker-legacy-action\"\n >\n {action.label}\n </Button>\n ))}\n <span aria-hidden=\"true\" className=\"ml-2 mr-1 h-5 w-px bg-border\" />\n </div>\n ) : null}\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={handleCancel}\n disabled={disabled}\n data-slot=\"time-picker-cancel\"\n >\n {cancelLabel}\n </Button>\n <Button\n type=\"button\"\n variant=\"default\"\n size=\"sm\"\n onClick={handleApply}\n disabled={disabled}\n data-slot=\"time-picker-apply\"\n >\n {applyLabel}\n </Button>\n </div>\n ) : null}\n </div>\n )\n}\n\nexport function TimePicker({\n value: valueProp,\n defaultValue = null,\n onChange,\n showHeader = true,\n headerIcon,\n headerTitle,\n headerPlaceholder,\n onClose,\n durations,\n activeDuration: activeDurationProp,\n defaultActiveDuration,\n onDurationChange,\n statuses,\n activeStatus: activeStatusProp,\n defaultActiveStatus,\n onStatusChange,\n statusLabel,\n slots: slotsProp,\n startTime = '00:00',\n endTime = '23:30',\n intervalMinutes = 30,\n format = '12h',\n slotRightText,\n slotLabel,\n maxHeight = 280,\n showFooter = true,\n cancelLabel,\n applyLabel,\n onCancel,\n onApply,\n legacyFooterActions,\n pinnedTopActions,\n trigger,\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n popoverAlign = 'start',\n popoverSide = 'bottom',\n className,\n disabled = false,\n 'aria-label': ariaLabel,\n}: TimePickerProps) {\n const t = useT()\n const resolvedHeaderPlaceholder = headerPlaceholder ?? t('ui.timePicker.placeholder', 'Pick a time')\n const resolvedStatusLabel = statusLabel ?? t('ui.timePicker.statusLabel', 'Select status')\n const resolvedCancelLabel = cancelLabel ?? t('ui.timePicker.cancelButton', 'Cancel')\n const resolvedApplyLabel = applyLabel ?? t('ui.timePicker.applyButton', 'Apply')\n const resolvedAriaLabel = ariaLabel ?? t('ui.timePicker.label', 'Time picker')\n const resolvedCloseAriaLabel = t('ui.timePicker.closeButton', 'Close')\n const resolvedDurationsAriaLabel = t('ui.timePicker.durationsRowLabel', 'Quick duration')\n const resolvedScrollLeftAriaLabel = t('ui.timePicker.scrollLeft', 'Scroll left')\n const resolvedScrollRightAriaLabel = t('ui.timePicker.scrollRight', 'Scroll right')\n const [value, setValue] = useControllable<TimePickerValue>(valueProp, defaultValue, onChange)\n const [activeDuration, setActiveDuration] = useControllable<number | undefined>(\n activeDurationProp,\n defaultActiveDuration,\n )\n const [activeStatus, setActiveStatus] = useControllable<TimePickerStatusVariant | undefined>(\n activeStatusProp,\n defaultActiveStatus,\n )\n const [open, setOpen] = useControllable<boolean>(openProp, defaultOpen, onOpenChange)\n\n const openSnapshotRef = React.useRef<TimePickerValue>(value)\n const slotListRef = React.useRef<HTMLDivElement | null>(null)\n\n const slotList = React.useMemo(() => {\n if (slotsProp && slotsProp.length > 0) return slotsProp\n return generateSlots(startTime, endTime, intervalMinutes)\n }, [slotsProp, startTime, endTime, intervalMinutes])\n\n const setOpenWithSnapshot = React.useCallback(\n (next: boolean) => {\n if (next && !open) {\n openSnapshotRef.current = value\n }\n setOpen(next)\n },\n [open, setOpen, value],\n )\n\n const handleApply = React.useCallback(() => {\n onApply?.(value)\n if (trigger) setOpenWithSnapshot(false)\n }, [onApply, setOpenWithSnapshot, trigger, value])\n\n const handleCancel = React.useCallback(() => {\n onCancel?.()\n if (trigger) {\n setValue(openSnapshotRef.current)\n setOpenWithSnapshot(false)\n }\n }, [onCancel, setOpenWithSnapshot, setValue, trigger])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key === 'Escape') {\n handleCancel()\n } else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n handleApply()\n }\n },\n [handleApply, handleCancel],\n )\n\n const card = (\n <div onKeyDown={handleKeyDown}>\n <TimePickerCard\n value={value}\n setValue={setValue}\n showHeader={showHeader}\n headerIcon={headerIcon}\n headerTitle={headerTitle}\n headerPlaceholder={resolvedHeaderPlaceholder}\n onClose={onClose}\n closeAriaLabel={resolvedCloseAriaLabel}\n format={format}\n durationOptions={durations}\n activeDuration={activeDuration}\n setActiveDuration={setActiveDuration}\n onDurationChange={onDurationChange}\n durationsAriaLabel={resolvedDurationsAriaLabel}\n scrollLeftAriaLabel={resolvedScrollLeftAriaLabel}\n scrollRightAriaLabel={resolvedScrollRightAriaLabel}\n statusOptions={statuses}\n activeStatus={activeStatus}\n setActiveStatus={setActiveStatus}\n onStatusChange={onStatusChange}\n statusLabel={resolvedStatusLabel}\n slotList={slotList}\n slotLabel={slotLabel}\n slotRightText={slotRightText}\n maxHeight={maxHeight}\n showFooter={showFooter}\n cancelLabel={resolvedCancelLabel}\n applyLabel={resolvedApplyLabel}\n handleCancel={handleCancel}\n handleApply={handleApply}\n legacyFooterActions={legacyFooterActions}\n pinnedTopActions={pinnedTopActions}\n disabled={disabled}\n className={className}\n ariaLabel={resolvedAriaLabel}\n slotListRef={slotListRef}\n />\n </div>\n )\n\n if (!trigger) return card\n\n return (\n <Popover open={open} onOpenChange={setOpenWithSnapshot}>\n <PopoverTrigger asChild>{trigger}</PopoverTrigger>\n <PopoverContent\n align={popoverAlign}\n side={popoverSide}\n className=\"w-auto border-none bg-transparent p-0 shadow-none\"\n >\n {card}\n </PopoverContent>\n </Popover>\n )\n}\n"],
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Check, CircleCheck, ChevronLeft, ChevronRight, Clock, X } from 'lucide-react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from './button'\nimport { Popover, PopoverContent, PopoverTrigger } from './popover'\n\nexport type TimePickerValue = string | null\n\nexport type TimePickerStatusVariant = 'available' | 'busy' | 'in-meeting' | 'offline'\n\ntype SlotInteractiveState = 'default' | 'hover' | 'active' | 'disabled'\ntype DurationInteractiveState = SlotInteractiveState\ntype StatusInteractiveState = 'default' | 'hover' | 'selected' | 'disabled'\n\nexport type TimeFormat = '12h' | '24h'\n\ntype LegacyFooterAction = {\n label: string\n onClick: () => void\n variant?: 'link' | 'muted'\n}\n\ntype PinnedTopAction = {\n label: string\n onClick: () => void\n icon?: React.ReactNode\n /** Optional secondary text rendered right-aligned (e.g. the current time next to `Now`). */\n rightText?: string\n}\n\ntype DurationOption = {\n value: number\n label?: string\n disabled?: boolean\n}\n\ntype StatusOption = {\n variant: TimePickerStatusVariant\n label?: string\n disabled?: boolean\n}\n\nfunction padTwo(input: number): string {\n return String(input).padStart(2, '0')\n}\n\nfunction parseTime(value: string | null | undefined): { hour: number; minute: number } | null {\n if (!value) return null\n const parts = value.split(':')\n if (parts.length < 2) return null\n const hour = parseInt(parts[0] ?? '0', 10)\n const minute = parseInt(parts[1] ?? '0', 10)\n if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null\n return {\n hour: Math.max(0, Math.min(23, hour)),\n minute: Math.max(0, Math.min(59, minute)),\n }\n}\n\nfunction formatSlotDisplay(value: string, format: TimeFormat): { main: string; suffix: string | null } {\n const parsed = parseTime(value)\n if (!parsed) return { main: value, suffix: null }\n if (format === '24h') {\n return { main: `${padTwo(parsed.hour)}:${padTwo(parsed.minute)}`, suffix: null }\n }\n const hour12 = parsed.hour === 0 ? 12 : parsed.hour > 12 ? parsed.hour - 12 : parsed.hour\n const suffix = parsed.hour < 12 ? 'AM' : 'PM'\n return { main: `${padTwo(hour12)}:${padTwo(parsed.minute)}`, suffix }\n}\n\nfunction generateSlots(startTime: string, endTime: string, intervalMinutes: number): string[] {\n const start = parseTime(startTime)\n const end = parseTime(endTime)\n if (!start || !end) return []\n const step = intervalMinutes > 0 ? intervalMinutes : 30\n const out: string[] = []\n const startTotal = start.hour * 60 + start.minute\n const endTotal = end.hour * 60 + end.minute\n if (endTotal < startTotal) return []\n for (let m = startTotal; m <= endTotal; m += step) {\n const h = Math.floor(m / 60)\n const min = m % 60\n out.push(`${padTwo(h)}:${padTwo(min)}`)\n }\n return out\n}\n\n/**\n * Format an HH:MM 24h string for display, splitting into the main digits and an\n * optional AM/PM suffix. Exported so consumers (e.g. the legacy `backend/inputs/TimePicker`\n * shim) can render their trigger label in the same format as the slot list.\n */\nexport function formatTimePickerDisplay(\n value: string,\n format: '12h' | '24h' = '12h',\n): { main: string; suffix: string | null } {\n return formatSlotDisplay(value, format)\n}\n\nexport function formatDuration(minutes: number, options?: { short?: boolean }): string {\n const short = options?.short ?? true\n if (!Number.isFinite(minutes) || minutes <= 0) return short ? '0 min' : '0 minutes'\n if (minutes < 60) return short ? `${minutes} min` : `${minutes} minutes`\n if (minutes % (24 * 60) === 0) {\n const days = minutes / (24 * 60)\n return short ? (days === 1 ? '1 day' : `${days} days`) : days === 1 ? '1 day' : `${days} days`\n }\n if (minutes % 60 === 0) {\n const hours = minutes / 60\n return short ? (hours === 1 ? '1 hour' : `${hours} hours`) : hours === 1 ? '1 hour' : `${hours} hours`\n }\n const hours = Math.floor(minutes / 60)\n const rest = minutes - hours * 60\n return short\n ? `${hours}h ${rest}m`\n : `${hours} hour${hours === 1 ? '' : 's'} ${rest} minute${rest === 1 ? '' : 's'}`\n}\n\nconst defaultStatusLabels: Record<TimePickerStatusVariant, string> = {\n available: 'Available',\n busy: 'Busy',\n 'in-meeting': 'In meeting',\n offline: 'Offline',\n}\n\nconst statusColorMap: Record<\n TimePickerStatusVariant,\n { dot: string; bgSoft: string; border: string; text: string }\n> = {\n available: {\n dot: 'bg-status-success-icon',\n bgSoft: 'bg-status-success-bg',\n border: 'border-status-success-border',\n text: 'text-status-success-text',\n },\n busy: {\n dot: 'bg-status-error-icon',\n bgSoft: 'bg-status-error-bg',\n border: 'border-status-error-border',\n text: 'text-status-error-text',\n },\n 'in-meeting': {\n dot: 'bg-status-warning-icon',\n bgSoft: 'bg-status-warning-bg',\n border: 'border-status-warning-border',\n text: 'text-status-warning-text',\n },\n offline: {\n dot: 'bg-muted-foreground',\n bgSoft: 'bg-muted',\n border: 'border-border',\n text: 'text-muted-foreground',\n },\n}\n\nconst slotVariants = cva(\n 'flex h-9 w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors text-left',\n {\n variants: {\n state: {\n default: 'bg-background hover:bg-muted/50 text-muted-foreground cursor-pointer',\n hover: 'bg-muted/50 text-muted-foreground cursor-pointer',\n active: 'bg-muted text-foreground cursor-pointer',\n disabled: 'bg-background text-muted-foreground/40 cursor-not-allowed',\n },\n },\n defaultVariants: { state: 'default' },\n },\n)\n\nexport type TimePickerSlotProps = {\n value: string\n label?: string\n rightText?: string\n /** Position of the selection indicator between or after the two time labels. */\n checkPosition?: 'right' | 'center'\n state?: SlotInteractiveState\n selected?: boolean\n disabled?: boolean\n format?: TimeFormat\n onSelect?: (value: string) => void\n className?: string\n 'aria-label'?: string\n}\n\nexport const TimePickerSlot = React.forwardRef<HTMLButtonElement, TimePickerSlotProps>(\n (\n {\n value,\n label,\n rightText,\n checkPosition = 'right',\n state,\n selected,\n disabled,\n format = '12h',\n onSelect,\n className,\n 'aria-label': ariaLabel,\n },\n ref,\n ) => {\n const effectiveState: SlotInteractiveState = disabled\n ? 'disabled'\n : selected\n ? 'active'\n : (state ?? 'default')\n const display = label != null ? { main: label, suffix: null as string | null } : formatSlotDisplay(value, format)\n const right = rightText ? formatSlotDisplay(rightText, format) : null\n const isInteractive = effectiveState !== 'disabled'\n\n return (\n <button\n ref={ref}\n type=\"button\"\n className={cn(slotVariants({ state: effectiveState }), className)}\n onClick={isInteractive ? () => onSelect?.(value) : undefined}\n disabled={!isInteractive}\n aria-label={ariaLabel ?? value}\n aria-pressed={effectiveState === 'active'}\n data-slot=\"time-picker-slot\"\n data-state={effectiveState}\n >\n <span className=\"flex min-w-0 flex-1 items-center gap-1\">\n <span>{display.main}</span>\n {display.suffix ? (\n <span className=\"text-muted-foreground\">\n {display.suffix}\n </span>\n ) : null}\n </span>\n {effectiveState === 'active' && checkPosition === 'center' ? (\n <CircleCheck data-slot=\"time-picker-slot-check\" className=\"size-3.5 shrink-0 fill-accent-indigo text-background\" aria-hidden=\"true\" />\n ) : null}\n {right ? (\n <span className={cn('flex min-w-0 items-center justify-end gap-1', checkPosition === 'center' && 'flex-1')}>\n <span>{right.main}</span>\n {right.suffix ? (\n <span className=\"text-muted-foreground\">\n {right.suffix}\n </span>\n ) : null}\n </span>\n ) : null}\n {effectiveState === 'active' && checkPosition === 'right' ? (\n <CircleCheck data-slot=\"time-picker-slot-check\" className=\"size-3.5 shrink-0 fill-accent-indigo text-background\" aria-hidden=\"true\" />\n ) : null}\n </button>\n )\n },\n)\nTimePickerSlot.displayName = 'TimePickerSlot'\n\nconst durationChipVariants = cva(\n 'inline-flex h-7 items-center justify-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors whitespace-nowrap',\n {\n variants: {\n state: {\n default:\n 'bg-background border border-border shadow-xs text-muted-foreground hover:bg-muted/40 cursor-pointer',\n hover: 'bg-muted/40 border border-border text-muted-foreground cursor-pointer',\n active: 'bg-status-info-bg text-status-info-text cursor-pointer',\n disabled:\n 'bg-background border border-border text-muted-foreground/40 cursor-not-allowed shadow-xs',\n },\n },\n defaultVariants: { state: 'default' },\n },\n)\n\nexport type TimePickerDurationChipProps = {\n value: number\n label?: string\n state?: DurationInteractiveState\n selected?: boolean\n disabled?: boolean\n onSelect?: (value: number) => void\n className?: string\n}\n\nexport const TimePickerDurationChip = React.forwardRef<HTMLButtonElement, TimePickerDurationChipProps>(\n (\n { value, label, state, selected, disabled, onSelect, className },\n ref,\n ) => {\n const effectiveState: DurationInteractiveState = disabled\n ? 'disabled'\n : selected\n ? 'active'\n : (state ?? 'default')\n const isInteractive = effectiveState !== 'disabled'\n const displayLabel = label ?? formatDuration(value)\n return (\n <button\n ref={ref}\n type=\"button\"\n className={cn(durationChipVariants({ state: effectiveState }), className)}\n onClick={isInteractive ? () => onSelect?.(value) : undefined}\n disabled={!isInteractive}\n aria-pressed={effectiveState === 'active'}\n data-slot=\"time-picker-duration-chip\"\n data-state={effectiveState}\n >\n {effectiveState === 'active' ? (\n <Check className=\"size-3 shrink-0\" aria-hidden=\"true\" />\n ) : null}\n <span>{displayLabel}</span>\n </button>\n )\n },\n)\nTimePickerDurationChip.displayName = 'TimePickerDurationChip'\n\nconst statusChipBaseVariants = cva(\n 'inline-flex h-7 items-center justify-center gap-1 rounded-md pl-1.5 pr-2.5 text-sm font-medium transition-colors whitespace-nowrap border',\n {\n variants: {\n state: {\n default: '',\n hover: '',\n selected: '',\n disabled: 'cursor-not-allowed opacity-60',\n },\n },\n defaultVariants: { state: 'default' },\n },\n)\n\nexport type TimePickerStatusChipProps = {\n variant: TimePickerStatusVariant\n label?: string\n state?: StatusInteractiveState\n selected?: boolean\n disabled?: boolean\n icon?: React.ReactNode\n onSelect?: (variant: TimePickerStatusVariant) => void\n className?: string\n}\n\nexport const TimePickerStatusChip = React.forwardRef<HTMLButtonElement, TimePickerStatusChipProps>(\n (\n { variant, label, state, selected, disabled, icon, onSelect, className },\n ref,\n ) => {\n const effectiveState: StatusInteractiveState = disabled\n ? 'disabled'\n : selected\n ? 'selected'\n : (state ?? 'default')\n const colors = statusColorMap[variant]\n const isInteractive = effectiveState !== 'disabled'\n const displayLabel = label ?? defaultStatusLabels[variant]\n\n const stateClasses =\n effectiveState === 'selected'\n ? cn(colors.bgSoft, colors.border, colors.text)\n : effectiveState === 'hover'\n ? cn('bg-muted/30 border-border', colors.text, 'cursor-pointer')\n : effectiveState === 'disabled'\n ? cn('bg-background border-border', colors.text)\n : cn('bg-background border-border text-muted-foreground hover:bg-muted/30 cursor-pointer')\n\n return (\n <button\n ref={ref}\n type=\"button\"\n className={cn(statusChipBaseVariants({ state: effectiveState }), stateClasses, className)}\n onClick={isInteractive ? () => onSelect?.(variant) : undefined}\n disabled={!isInteractive}\n aria-pressed={effectiveState === 'selected'}\n data-slot=\"time-picker-status-chip\"\n data-state={effectiveState}\n data-variant={variant}\n >\n {icon ?? (\n <span\n className={cn('inline-block size-1.5 shrink-0 rounded-full', colors.dot)}\n aria-hidden=\"true\"\n />\n )}\n <span>{displayLabel}</span>\n </button>\n )\n },\n)\nTimePickerStatusChip.displayName = 'TimePickerStatusChip'\n\n/**\n * Wraps a horizontally-scrollable row with prev/next arrow buttons that appear\n * only when overflow is present. Hides the native scrollbar and reserves padding\n * on each side proportional to the visible arrow so chips never sit underneath\n * the arrow button. Exported for consumers that need the same UX (e.g. inline\n * duration chip row inside a meeting/activity form).\n */\nexport function HorizontalScrollRow({\n className,\n contentClassName,\n children,\n ariaLabel,\n arrowSize = 'default',\n scrollLeftAriaLabel = 'Scroll left',\n scrollRightAriaLabel = 'Scroll right',\n}: {\n className?: string\n /** Override classes for the inner scroll container (e.g. tighter padding when inlined into a form row). */\n contentClassName?: string\n children: React.ReactNode\n ariaLabel?: string\n /** Arrow button size. `default` = 28px circle, `sm` = 24px (denser rows). */\n arrowSize?: 'default' | 'sm'\n /** Override the left arrow's aria-label. Default is English 'Scroll left'. */\n scrollLeftAriaLabel?: string\n /** Override the right arrow's aria-label. Default is English 'Scroll right'. */\n scrollRightAriaLabel?: string\n}) {\n const scrollRef = React.useRef<HTMLDivElement>(null)\n const [canScrollLeft, setCanScrollLeft] = React.useState(false)\n const [canScrollRight, setCanScrollRight] = React.useState(false)\n\n const refreshScrollState = React.useCallback(() => {\n const el = scrollRef.current\n if (!el) return\n const { scrollLeft, scrollWidth, clientWidth } = el\n setCanScrollLeft(scrollLeft > 1)\n setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 1)\n }, [])\n\n React.useEffect(() => {\n refreshScrollState()\n const el = scrollRef.current\n if (!el) return\n el.addEventListener('scroll', refreshScrollState, { passive: true })\n const ro = new ResizeObserver(refreshScrollState)\n ro.observe(el)\n return () => {\n el.removeEventListener('scroll', refreshScrollState)\n ro.disconnect()\n }\n }, [refreshScrollState])\n\n const scrollBy = React.useCallback((direction: 'left' | 'right') => {\n const el = scrollRef.current\n if (!el) return\n const amount = Math.max(80, Math.floor(el.clientWidth * 0.6))\n el.scrollBy({ left: direction === 'left' ? -amount : amount, behavior: 'smooth' })\n }, [])\n\n const arrowButtonClass =\n arrowSize === 'sm' ? 'size-6 [&_svg]:size-3.5' : 'size-7 [&_svg]:size-4'\n\n return (\n <div className={cn('relative', className)} data-slot=\"time-picker-scroll-row\">\n {/* Outer gutter \u2014 reserves room for the absolute-positioned chevron buttons.\n The scroll viewport lives INSIDE this gutter so chips can never slide\n underneath the arrows \u2014 moving scroll offsets only shift content inside\n the viewport, not the chevron clearance. */}\n <div\n className={cn(\n 'transition-[padding]',\n canScrollLeft ? 'pl-9' : 'pl-4',\n canScrollRight ? 'pr-9' : 'pr-4',\n )}\n >\n <div\n ref={scrollRef}\n className={cn(\n 'scrollbar-hide flex items-center gap-2 overflow-x-auto py-3.5',\n contentClassName,\n )}\n role=\"group\"\n aria-label={ariaLabel}\n >\n {children}\n </div>\n </div>\n {/* Edge fades \u2014 soft gradient at the boundary between the gutter and the\n scroll viewport, so chips sliding into the viewport dissolve cleanly\n before reaching the arrow's edge. */}\n {canScrollLeft ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-y-0 left-9 w-6 bg-gradient-to-r from-background to-transparent\"\n />\n ) : null}\n {canScrollRight ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-y-0 right-9 w-6 bg-gradient-to-l from-background to-transparent\"\n />\n ) : null}\n {canScrollLeft ? (\n <button\n type=\"button\"\n onClick={() => scrollBy('left')}\n aria-label={scrollLeftAriaLabel}\n className={cn(\n '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',\n arrowButtonClass,\n )}\n data-slot=\"time-picker-scroll-left\"\n >\n <ChevronLeft aria-hidden=\"true\" />\n </button>\n ) : null}\n {canScrollRight ? (\n <button\n type=\"button\"\n onClick={() => scrollBy('right')}\n aria-label={scrollRightAriaLabel}\n className={cn(\n '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',\n arrowButtonClass,\n )}\n data-slot=\"time-picker-scroll-right\"\n >\n <ChevronRight aria-hidden=\"true\" />\n </button>\n ) : null}\n </div>\n )\n}\n\nfunction useControllable<T>(controlled: T | undefined, defaultValue: T, onChange?: (next: T) => void) {\n const [internal, setInternal] = React.useState<T>(defaultValue)\n const isControlled = controlled !== undefined\n const value = isControlled ? (controlled as T) : internal\n const setValue = React.useCallback(\n (next: T) => {\n if (!isControlled) setInternal(next)\n onChange?.(next)\n },\n [isControlled, onChange],\n )\n return [value, setValue] as const\n}\n\nexport type TimePickerProps = {\n value?: TimePickerValue\n defaultValue?: TimePickerValue\n onChange?: (value: TimePickerValue) => void\n\n showHeader?: boolean\n headerIcon?: React.ReactNode\n headerTitle?: string\n headerPlaceholder?: string\n onClose?: () => void\n\n durations?: DurationOption[]\n activeDuration?: number\n defaultActiveDuration?: number\n onDurationChange?: (value: number) => void\n\n statuses?: StatusOption[]\n activeStatus?: TimePickerStatusVariant\n defaultActiveStatus?: TimePickerStatusVariant\n onStatusChange?: (variant: TimePickerStatusVariant) => void\n statusLabel?: string\n\n slots?: string[]\n startTime?: string\n endTime?: string\n intervalMinutes?: number\n format?: TimeFormat\n slotRightText?: (slot: string) => string | undefined\n slotLabel?: string\n maxHeight?: number\n\n showFooter?: boolean\n cancelLabel?: string\n applyLabel?: string\n onCancel?: () => void\n onApply?: (value: TimePickerValue) => void\n legacyFooterActions?: LegacyFooterAction[]\n /**\n * Quick-action rows rendered above the scrollable slot list \u2014 used for\n * \"Now\"-style shortcuts that stay visible regardless of scroll position.\n * Styled like slots but with primary-tinted foreground.\n */\n pinnedTopActions?: PinnedTopAction[]\n\n trigger?: React.ReactNode\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n popoverAlign?: 'start' | 'center' | 'end'\n popoverSide?: 'top' | 'right' | 'bottom' | 'left'\n\n className?: string\n disabled?: boolean\n 'aria-label'?: string\n}\n\nfunction TimePickerCard({\n value,\n setValue,\n showHeader,\n headerIcon,\n headerTitle,\n headerPlaceholder,\n onClose,\n closeAriaLabel,\n format,\n durationOptions,\n activeDuration,\n setActiveDuration,\n onDurationChange,\n durationsAriaLabel,\n scrollLeftAriaLabel,\n scrollRightAriaLabel,\n statusOptions,\n activeStatus,\n setActiveStatus,\n onStatusChange,\n statusLabel,\n slotList,\n slotLabel,\n slotRightText,\n maxHeight,\n showFooter,\n cancelLabel,\n applyLabel,\n handleCancel,\n handleApply,\n legacyFooterActions,\n pinnedTopActions,\n disabled,\n className,\n ariaLabel,\n slotListRef,\n}: {\n value: TimePickerValue\n setValue: (next: TimePickerValue) => void\n showHeader: boolean\n headerIcon: React.ReactNode\n headerTitle: string | undefined\n headerPlaceholder: string\n onClose: (() => void) | undefined\n closeAriaLabel: string\n format: TimeFormat\n durationOptions: DurationOption[] | undefined\n activeDuration: number | undefined\n setActiveDuration: (next: number) => void\n onDurationChange: ((value: number) => void) | undefined\n durationsAriaLabel: string\n scrollLeftAriaLabel: string\n scrollRightAriaLabel: string\n statusOptions: StatusOption[] | undefined\n activeStatus: TimePickerStatusVariant | undefined\n setActiveStatus: (next: TimePickerStatusVariant) => void\n onStatusChange: ((variant: TimePickerStatusVariant) => void) | undefined\n statusLabel: string\n slotList: string[]\n slotLabel: string | undefined\n slotRightText: ((slot: string) => string | undefined) | undefined\n maxHeight: number\n showFooter: boolean\n cancelLabel: string\n applyLabel: string\n handleCancel: () => void\n handleApply: () => void\n legacyFooterActions: LegacyFooterAction[] | undefined\n pinnedTopActions: PinnedTopAction[] | undefined\n disabled: boolean\n className: string | undefined\n ariaLabel: string\n slotListRef: React.RefObject<HTMLDivElement | null>\n}) {\n const headerDisplay = React.useMemo(() => {\n if (headerTitle != null) return { main: headerTitle, suffix: null as string | null }\n if (value) return formatSlotDisplay(value, format)\n return { main: headerPlaceholder, suffix: null as string | null }\n }, [headerTitle, value, format, headerPlaceholder])\n\n const handleSlotKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (disabled) return\n const target = event.target as HTMLElement\n const slotButton = target.closest<HTMLButtonElement>('[data-slot=\"time-picker-slot\"]')\n if (!slotButton) return\n const container = slotListRef.current\n if (!container) return\n const all = Array.from(\n container.querySelectorAll<HTMLButtonElement>('[data-slot=\"time-picker-slot\"]:not(:disabled)'),\n )\n const currentIndex = all.indexOf(slotButton)\n if (currentIndex === -1) return\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n const next = all[Math.min(all.length - 1, currentIndex + 1)]\n next?.focus()\n } else if (event.key === 'ArrowUp') {\n event.preventDefault()\n const prev = all[Math.max(0, currentIndex - 1)]\n prev?.focus()\n } else if (event.key === 'Home') {\n event.preventDefault()\n all[0]?.focus()\n } else if (event.key === 'End') {\n event.preventDefault()\n all[all.length - 1]?.focus()\n }\n },\n [disabled, slotListRef],\n )\n\n return (\n <div\n className={cn(\n 'flex w-80 flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl',\n className,\n )}\n role=\"dialog\"\n aria-label={ariaLabel}\n aria-disabled={disabled || undefined}\n data-slot=\"time-picker\"\n >\n {showHeader ? (\n <div\n className=\"flex items-center gap-2 border-b border-border px-4 py-3.5\"\n data-slot=\"time-picker-header\"\n >\n <span className=\"flex shrink-0 items-center text-muted-foreground\" aria-hidden=\"true\">\n {headerIcon ?? <Clock className=\"size-5\" aria-hidden=\"true\" />}\n </span>\n <p className=\"flex-1 min-w-0 truncate text-base font-medium text-muted-foreground\">\n <span>{headerDisplay.main}</span>\n {headerDisplay.suffix ? <span className=\"ml-1 text-muted-foreground/60\">{headerDisplay.suffix}</span> : null}\n </p>\n {onClose ? (\n <button\n type=\"button\"\n onClick={onClose}\n disabled={disabled}\n 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\"\n aria-label={closeAriaLabel}\n data-slot=\"time-picker-close\"\n >\n <X className=\"size-4\" aria-hidden=\"true\" />\n </button>\n ) : null}\n </div>\n ) : null}\n\n {durationOptions && durationOptions.length > 0 ? (\n <HorizontalScrollRow\n className=\"border-b border-border\"\n ariaLabel={durationsAriaLabel}\n scrollLeftAriaLabel={scrollLeftAriaLabel}\n scrollRightAriaLabel={scrollRightAriaLabel}\n >\n {durationOptions.map((option) => (\n <TimePickerDurationChip\n key={option.value}\n value={option.value}\n label={option.label}\n disabled={disabled || option.disabled}\n selected={activeDuration === option.value}\n onSelect={(next) => {\n setActiveDuration(next)\n onDurationChange?.(next)\n }}\n />\n ))}\n </HorizontalScrollRow>\n ) : null}\n\n {statusOptions && statusOptions.length > 0 ? (\n <div\n className=\"flex flex-col gap-2 border-b border-border px-4 py-3.5\"\n data-slot=\"time-picker-status-row\"\n >\n <p className=\"text-sm font-medium text-muted-foreground\">{statusLabel}</p>\n <div className=\"flex flex-wrap items-center gap-2\">\n {statusOptions.map((option) => (\n <TimePickerStatusChip\n key={option.variant}\n variant={option.variant}\n label={option.label}\n disabled={disabled || option.disabled}\n selected={activeStatus === option.variant}\n onSelect={(next) => {\n setActiveStatus(next)\n onStatusChange?.(next)\n }}\n />\n ))}\n </div>\n </div>\n ) : null}\n\n {pinnedTopActions && pinnedTopActions.length > 0 ? (\n <div\n className=\"flex flex-col gap-0.5 border-b border-border p-2\"\n data-slot=\"time-picker-pinned-top\"\n >\n {pinnedTopActions.map((action) => (\n <button\n key={action.label}\n type=\"button\"\n onClick={action.onClick}\n disabled={disabled}\n className=\"flex h-9 w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary/10 cursor-pointer disabled:pointer-events-none disabled:opacity-60\"\n data-slot=\"time-picker-pinned-action\"\n >\n {action.icon ? (\n <span className=\"flex shrink-0 items-center [&_svg]:size-4\" aria-hidden=\"true\">\n {action.icon}\n </span>\n ) : null}\n <span className=\"flex-1 text-left\">{action.label}</span>\n {action.rightText ? (\n <span className=\"text-primary/60\">{action.rightText}</span>\n ) : null}\n </button>\n ))}\n </div>\n ) : null}\n\n <div\n className=\"flex flex-col gap-0.5 overflow-y-auto p-2\"\n style={{ maxHeight }}\n ref={slotListRef}\n onKeyDown={handleSlotKeyDown}\n data-slot=\"time-picker-slots\"\n >\n {slotLabel ? (\n <p className=\"px-3 pt-1 pb-2 text-sm font-medium text-muted-foreground\">{slotLabel}</p>\n ) : null}\n {slotList.length === 0 ? (\n <p className=\"px-3 py-4 text-center text-sm text-muted-foreground\">No time slots available.</p>\n ) : (\n slotList.map((slot) => (\n <TimePickerSlot\n key={slot}\n value={slot}\n format={format}\n rightText={slotRightText?.(slot)}\n selected={value === slot}\n disabled={disabled}\n onSelect={(next) => setValue(next)}\n />\n ))\n )}\n </div>\n\n {showFooter ? (\n <div\n className=\"flex flex-wrap items-center justify-end gap-2 border-t border-border px-4 py-3\"\n data-slot=\"time-picker-footer\"\n >\n {legacyFooterActions && legacyFooterActions.length > 0 ? (\n <div className=\"mr-auto flex items-center gap-1\">\n {legacyFooterActions.map((action) => (\n <Button\n key={action.label}\n type=\"button\"\n variant={action.variant === 'muted' ? 'muted' : 'ghost'}\n size=\"sm\"\n onClick={action.onClick}\n disabled={disabled}\n data-slot=\"time-picker-legacy-action\"\n >\n {action.label}\n </Button>\n ))}\n <span aria-hidden=\"true\" className=\"ml-2 mr-1 h-5 w-px bg-border\" />\n </div>\n ) : null}\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={handleCancel}\n disabled={disabled}\n data-slot=\"time-picker-cancel\"\n >\n {cancelLabel}\n </Button>\n <Button\n type=\"button\"\n variant=\"default\"\n size=\"sm\"\n onClick={handleApply}\n disabled={disabled}\n data-slot=\"time-picker-apply\"\n >\n {applyLabel}\n </Button>\n </div>\n ) : null}\n </div>\n )\n}\n\nexport function TimePicker({\n value: valueProp,\n defaultValue = null,\n onChange,\n showHeader = true,\n headerIcon,\n headerTitle,\n headerPlaceholder,\n onClose,\n durations,\n activeDuration: activeDurationProp,\n defaultActiveDuration,\n onDurationChange,\n statuses,\n activeStatus: activeStatusProp,\n defaultActiveStatus,\n onStatusChange,\n statusLabel,\n slots: slotsProp,\n startTime = '00:00',\n endTime = '23:30',\n intervalMinutes = 30,\n format = '12h',\n slotRightText,\n slotLabel,\n maxHeight = 280,\n showFooter = true,\n cancelLabel,\n applyLabel,\n onCancel,\n onApply,\n legacyFooterActions,\n pinnedTopActions,\n trigger,\n open: openProp,\n defaultOpen = false,\n onOpenChange,\n popoverAlign = 'start',\n popoverSide = 'bottom',\n className,\n disabled = false,\n 'aria-label': ariaLabel,\n}: TimePickerProps) {\n const t = useT()\n const resolvedHeaderPlaceholder = headerPlaceholder ?? t('ui.timePicker.placeholder', 'Pick a time')\n const resolvedStatusLabel = statusLabel ?? t('ui.timePicker.statusLabel', 'Select status')\n const resolvedCancelLabel = cancelLabel ?? t('ui.timePicker.cancelButton', 'Cancel')\n const resolvedApplyLabel = applyLabel ?? t('ui.timePicker.applyButton', 'Apply')\n const resolvedAriaLabel = ariaLabel ?? t('ui.timePicker.label', 'Time picker')\n const resolvedCloseAriaLabel = t('ui.timePicker.closeButton', 'Close')\n const resolvedDurationsAriaLabel = t('ui.timePicker.durationsRowLabel', 'Quick duration')\n const resolvedScrollLeftAriaLabel = t('ui.timePicker.scrollLeft', 'Scroll left')\n const resolvedScrollRightAriaLabel = t('ui.timePicker.scrollRight', 'Scroll right')\n const [value, setValue] = useControllable<TimePickerValue>(valueProp, defaultValue, onChange)\n const [activeDuration, setActiveDuration] = useControllable<number | undefined>(\n activeDurationProp,\n defaultActiveDuration,\n )\n const [activeStatus, setActiveStatus] = useControllable<TimePickerStatusVariant | undefined>(\n activeStatusProp,\n defaultActiveStatus,\n )\n const [open, setOpen] = useControllable<boolean>(openProp, defaultOpen, onOpenChange)\n\n const openSnapshotRef = React.useRef<TimePickerValue>(value)\n const slotListRef = React.useRef<HTMLDivElement | null>(null)\n\n const slotList = React.useMemo(() => {\n if (slotsProp && slotsProp.length > 0) return slotsProp\n return generateSlots(startTime, endTime, intervalMinutes)\n }, [slotsProp, startTime, endTime, intervalMinutes])\n\n const setOpenWithSnapshot = React.useCallback(\n (next: boolean) => {\n if (next && !open) {\n openSnapshotRef.current = value\n }\n setOpen(next)\n },\n [open, setOpen, value],\n )\n\n const handleApply = React.useCallback(() => {\n onApply?.(value)\n if (trigger) setOpenWithSnapshot(false)\n }, [onApply, setOpenWithSnapshot, trigger, value])\n\n const handleCancel = React.useCallback(() => {\n onCancel?.()\n if (trigger) {\n setValue(openSnapshotRef.current)\n setOpenWithSnapshot(false)\n }\n }, [onCancel, setOpenWithSnapshot, setValue, trigger])\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key === 'Escape') {\n handleCancel()\n } else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n handleApply()\n }\n },\n [handleApply, handleCancel],\n )\n\n const card = (\n <div onKeyDown={handleKeyDown}>\n <TimePickerCard\n value={value}\n setValue={setValue}\n showHeader={showHeader}\n headerIcon={headerIcon}\n headerTitle={headerTitle}\n headerPlaceholder={resolvedHeaderPlaceholder}\n onClose={onClose}\n closeAriaLabel={resolvedCloseAriaLabel}\n format={format}\n durationOptions={durations}\n activeDuration={activeDuration}\n setActiveDuration={setActiveDuration}\n onDurationChange={onDurationChange}\n durationsAriaLabel={resolvedDurationsAriaLabel}\n scrollLeftAriaLabel={resolvedScrollLeftAriaLabel}\n scrollRightAriaLabel={resolvedScrollRightAriaLabel}\n statusOptions={statuses}\n activeStatus={activeStatus}\n setActiveStatus={setActiveStatus}\n onStatusChange={onStatusChange}\n statusLabel={resolvedStatusLabel}\n slotList={slotList}\n slotLabel={slotLabel}\n slotRightText={slotRightText}\n maxHeight={maxHeight}\n showFooter={showFooter}\n cancelLabel={resolvedCancelLabel}\n applyLabel={resolvedApplyLabel}\n handleCancel={handleCancel}\n handleApply={handleApply}\n legacyFooterActions={legacyFooterActions}\n pinnedTopActions={pinnedTopActions}\n disabled={disabled}\n className={className}\n ariaLabel={resolvedAriaLabel}\n slotListRef={slotListRef}\n />\n </div>\n )\n\n if (!trigger) return card\n\n return (\n <Popover open={open} onOpenChange={setOpenWithSnapshot}>\n <PopoverTrigger asChild>{trigger}</PopoverTrigger>\n <PopoverContent\n align={popoverAlign}\n side={popoverSide}\n className=\"w-auto border-none bg-transparent p-0 shadow-none\"\n >\n {card}\n </PopoverContent>\n </Popover>\n )\n}\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": ["hours"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7219.1.f96cb57e70",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.8.1-develop.7219.1.f96cb57e70",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.8.1-develop.7219.1.f96cb57e70",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -130,4 +130,19 @@ function buildLucideRegistrySource(resolvedIcons) {
|
|
|
130
130
|
return normalizeSourceText(sourceFile.getFullText())
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// Drift decision for the committed registry, kept here rather than inline in
|
|
134
|
+
// build.mjs so it is reachable from Jest: build.mjs's top level awaits a full
|
|
135
|
+
// package build, so nothing can import it to exercise this comparison. The
|
|
136
|
+
// caller owns reporting and the exit code; this only decides.
|
|
137
|
+
function checkLucideRegistryDrift(committedSource, generatedSource, registryPath = GENERATED_FILE_NAME) {
|
|
138
|
+
if (committedSource === generatedSource) return { drifted: false }
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
drifted: true,
|
|
142
|
+
message: `Lucide icon registry is out of date: ${registryPath}\n`
|
|
143
|
+
+ 'A referenced icon name was added or removed without regenerating it.\n'
|
|
144
|
+
+ 'Run `yarn workspace @open-mercato/ui build` and commit the result.',
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { buildLucideRegistrySource, checkLucideRegistryDrift }
|
|
@@ -7,9 +7,38 @@ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
|
7
7
|
|
|
8
8
|
import * as React from 'react'
|
|
9
9
|
import { renderToString } from 'react-dom/server'
|
|
10
|
+
import { pl } from 'date-fns/locale/pl'
|
|
10
11
|
import { CrudForm, type CrudField } from '../CrudForm'
|
|
11
12
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
12
13
|
|
|
14
|
+
function renderFormWithLocale(
|
|
15
|
+
fields: CrudField[],
|
|
16
|
+
locale: string,
|
|
17
|
+
initialValues?: Record<string, unknown>,
|
|
18
|
+
): string {
|
|
19
|
+
return renderToString(
|
|
20
|
+
React.createElement(
|
|
21
|
+
I18nProvider as React.ComponentType<{
|
|
22
|
+
locale: string
|
|
23
|
+
dict: Record<string, string>
|
|
24
|
+
children: React.ReactNode
|
|
25
|
+
}>,
|
|
26
|
+
{ locale, dict: {} },
|
|
27
|
+
React.createElement(CrudForm as React.ComponentType<{
|
|
28
|
+
title: string
|
|
29
|
+
fields: CrudField[]
|
|
30
|
+
initialValues?: Record<string, unknown>
|
|
31
|
+
onSubmit: () => void
|
|
32
|
+
}>, {
|
|
33
|
+
title: 'Form',
|
|
34
|
+
fields,
|
|
35
|
+
initialValues,
|
|
36
|
+
onSubmit: () => {},
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
function renderForm(
|
|
14
43
|
fields: CrudField[],
|
|
15
44
|
initialValues?: Record<string, unknown>,
|
|
@@ -97,3 +126,36 @@ describe('CrudForm — datetime field types render correct picker component', ()
|
|
|
97
126
|
expect(html).toContain('02:30 PM')
|
|
98
127
|
})
|
|
99
128
|
})
|
|
129
|
+
|
|
130
|
+
describe('CrudForm — date and time fields agree on the app locale', () => {
|
|
131
|
+
const DATE_AND_TIME: CrudField[] = [
|
|
132
|
+
{ id: 'due_date', label: 'Due Date', type: 'datepicker' },
|
|
133
|
+
{ id: 'sync_time', label: 'Sync Time', type: 'time' },
|
|
134
|
+
]
|
|
135
|
+
const VALUES = { due_date: '2026-01-15', sync_time: '14:30' }
|
|
136
|
+
|
|
137
|
+
// A date field and a time field sitting in the same form must not disagree about which
|
|
138
|
+
// language they speak: before #5942 the date picker defaulted to English while the time
|
|
139
|
+
// picker defaulted to a 12h clock, and fixing only one of them is worse than fixing neither.
|
|
140
|
+
it('localizes both fields for a non-English app', () => {
|
|
141
|
+
const html = renderFormWithLocale(DATE_AND_TIME, 'pl', VALUES)
|
|
142
|
+
expect(html).toContain('15 sty 2026')
|
|
143
|
+
expect(html).toContain('14:30')
|
|
144
|
+
expect(html).not.toContain('PM')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('leaves both fields on the English defaults for an English app', () => {
|
|
148
|
+
const html = renderFormWithLocale(DATE_AND_TIME, 'en', VALUES)
|
|
149
|
+
expect(html).toContain('Jan 15, 2026')
|
|
150
|
+
expect(html).toContain('02:30 PM')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('lets a field pin its own locale ahead of the app locale', () => {
|
|
154
|
+
const html = renderFormWithLocale(
|
|
155
|
+
[{ id: 'due_date', label: 'Due Date', type: 'datepicker', locale: pl }],
|
|
156
|
+
'en',
|
|
157
|
+
{ due_date: '2026-01-15' },
|
|
158
|
+
)
|
|
159
|
+
expect(html).toContain('15 sty 2026')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { Project, ScriptKind, SyntaxKind, type ObjectLiteralExpression, type SourceFile } from 'ts-morph'
|
|
6
6
|
import { discoverResolvedIcons } from '../../../../scripts/lucideIconDiscovery.cjs'
|
|
7
|
-
import { buildLucideRegistrySource } from '../../../../scripts/lucideRegistrySource.cjs'
|
|
7
|
+
import { buildLucideRegistrySource, checkLucideRegistryDrift } from '../../../../scripts/lucideRegistrySource.cjs'
|
|
8
8
|
import jestConfig from '../../../../jest.config.cjs'
|
|
9
9
|
|
|
10
10
|
const packageDir = join(__dirname, '..', '..', '..', '..')
|
|
@@ -272,6 +272,32 @@ describe('committed lucideRegistry.generated.tsx', () => {
|
|
|
272
272
|
})
|
|
273
273
|
})
|
|
274
274
|
|
|
275
|
+
// `yarn ui:icons:check` runs this decision unfiltered in CI, where only the in-sync
|
|
276
|
+
// branch is ever exercised — every PR runs it against a registry that matches. The
|
|
277
|
+
// branch that does the work is the drifted one, so it is asserted here instead.
|
|
278
|
+
describe('checkLucideRegistryDrift', () => {
|
|
279
|
+
const source = buildLucideRegistrySource(populatedFixture)
|
|
280
|
+
|
|
281
|
+
it('reports no drift when the committed file matches the emitter', () => {
|
|
282
|
+
expect(checkLucideRegistryDrift(source, source)).toEqual({ drifted: false })
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('reports drift when an icon is missing from the committed file', () => {
|
|
286
|
+
const stale = buildLucideRegistrySource(populatedFixture.slice(0, -1))
|
|
287
|
+
expect(checkLucideRegistryDrift(stale, source).drifted).toBe(true)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('reports drift when the committed file has never been written', () => {
|
|
291
|
+
expect(checkLucideRegistryDrift('', source).drifted).toBe(true)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('names the drifted file and the command that regenerates it', () => {
|
|
295
|
+
const drift = checkLucideRegistryDrift('', source, '/repo/lucideRegistry.generated.tsx')
|
|
296
|
+
expect(drift.message).toContain('/repo/lucideRegistry.generated.tsx')
|
|
297
|
+
expect(drift.message).toContain('yarn workspace @open-mercato/ui build')
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
275
301
|
describe('jest transform scope', () => {
|
|
276
302
|
const transformPatterns = Object.keys(jestConfig.transform).map((key) => new RegExp(key))
|
|
277
303
|
|
|
@@ -7,7 +7,9 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
7
7
|
import {
|
|
8
8
|
TimePicker as TimePickerPrimitive,
|
|
9
9
|
formatTimePickerDisplay,
|
|
10
|
+
type TimeFormat,
|
|
10
11
|
} from '../../primitives/time-picker'
|
|
12
|
+
import { useTimeDisplayFormat } from '../../primitives/date-locale'
|
|
11
13
|
|
|
12
14
|
export type TimePickerProps = {
|
|
13
15
|
value?: string | null
|
|
@@ -19,6 +21,13 @@ export type TimePickerProps = {
|
|
|
19
21
|
minuteStep?: number
|
|
20
22
|
showNowButton?: boolean
|
|
21
23
|
showClearButton?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Clock convention for the trigger label and the slot list. Defaults to the one the active
|
|
26
|
+
* locale writes time in — `12h` for English and Korean, `24h` for Polish, German and Spanish —
|
|
27
|
+
* so a tenant does not read an AM/PM clock its language never uses. Pass an explicit value to
|
|
28
|
+
* pin one regardless of locale.
|
|
29
|
+
*/
|
|
30
|
+
format?: TimeFormat
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
function currentHHMM(): string {
|
|
@@ -50,8 +59,11 @@ export function TimePicker({
|
|
|
50
59
|
// by "Clear" in most flows. Pass `showClearButton={true}` to opt back in when
|
|
51
60
|
// you need an explicit "set value to null" action distinct from "dismiss".
|
|
52
61
|
showClearButton = false,
|
|
62
|
+
format,
|
|
53
63
|
}: TimePickerProps) {
|
|
54
64
|
const t = useT()
|
|
65
|
+
const localeFormat = useTimeDisplayFormat()
|
|
66
|
+
const timeFormat = format ?? localeFormat
|
|
55
67
|
const [open, setOpen] = React.useState(false)
|
|
56
68
|
|
|
57
69
|
const placeholderText = placeholder ?? t('ui.timePicker.placeholder', 'Pick a time')
|
|
@@ -59,11 +71,11 @@ export function TimePicker({
|
|
|
59
71
|
const clearText = t('ui.timePicker.clearButton', 'Clear')
|
|
60
72
|
const isInteractive = !disabled && !readOnly
|
|
61
73
|
|
|
62
|
-
// Render the trigger label in the same
|
|
63
|
-
//
|
|
74
|
+
// Render the trigger label in the same format as the slot list inside the popover
|
|
75
|
+
// so users see one consistent representation.
|
|
64
76
|
const displayValue = value
|
|
65
77
|
? (() => {
|
|
66
|
-
const { main, suffix } = formatTimePickerDisplay(value,
|
|
78
|
+
const { main, suffix } = formatTimePickerDisplay(value, timeFormat)
|
|
67
79
|
return suffix ? `${main} ${suffix}` : main
|
|
68
80
|
})()
|
|
69
81
|
: null
|
|
@@ -101,7 +113,7 @@ export function TimePicker({
|
|
|
101
113
|
}> = []
|
|
102
114
|
if (showNowButton) {
|
|
103
115
|
const nowValue = currentHHMM()
|
|
104
|
-
const { main: nowMain, suffix: nowSuffix } = formatTimePickerDisplay(nowValue,
|
|
116
|
+
const { main: nowMain, suffix: nowSuffix } = formatTimePickerDisplay(nowValue, timeFormat)
|
|
105
117
|
pinnedTopActions.push({
|
|
106
118
|
label: nowText,
|
|
107
119
|
icon: <ClockIcon aria-hidden="true" />,
|
|
@@ -133,6 +145,7 @@ export function TimePicker({
|
|
|
133
145
|
onChange(next)
|
|
134
146
|
}}
|
|
135
147
|
intervalMinutes={Math.max(1, minuteStep)}
|
|
148
|
+
format={timeFormat}
|
|
136
149
|
// Trigger button already displays current value — don't duplicate it
|
|
137
150
|
// inside the popover header.
|
|
138
151
|
showHeader={false}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
jest.mock('next/navigation', () => ({
|
|
2
2
|
useRouter: () => ({ push: jest.fn() }),
|
|
3
3
|
}))
|
|
4
|
+
let mockLocale: string | undefined
|
|
4
5
|
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
5
6
|
useT: () => (_key: string, fallback: string) => fallback,
|
|
7
|
+
useOptionalLocale: () => mockLocale,
|
|
6
8
|
}))
|
|
7
9
|
|
|
8
10
|
import * as React from 'react'
|
|
9
11
|
import { renderToString } from 'react-dom/server'
|
|
10
12
|
import { TimePicker } from '../TimePicker'
|
|
11
13
|
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mockLocale = 'en'
|
|
16
|
+
})
|
|
17
|
+
|
|
12
18
|
// Note: Tests for Now/Clear button visibility and click behaviour require opening
|
|
13
19
|
// the Radix Popover (portal content is not rendered by renderToString when closed).
|
|
14
20
|
// Those tests are covered by integration tests (TC-DTP-002) post-migration.
|
|
@@ -28,6 +34,31 @@ describe('TimePicker SSR render', () => {
|
|
|
28
34
|
expect(html).toContain('02:30 PM')
|
|
29
35
|
})
|
|
30
36
|
|
|
37
|
+
it('shows a 24h value for a locale that writes time on a 24-hour clock', () => {
|
|
38
|
+
mockLocale = 'pl'
|
|
39
|
+
const html = render(<TimePicker value="14:30" onChange={jest.fn()} />)
|
|
40
|
+
expect(html).toContain('14:30')
|
|
41
|
+
expect(html).not.toContain('PM')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps the 12h clock for a locale that uses AM/PM', () => {
|
|
45
|
+
mockLocale = 'ko'
|
|
46
|
+
const html = render(<TimePicker value="14:30" onChange={jest.fn()} />)
|
|
47
|
+
expect(html).toContain('02:30 PM')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('lets an explicit format prop override the locale default', () => {
|
|
51
|
+
mockLocale = 'pl'
|
|
52
|
+
const html = render(<TimePicker value="14:30" onChange={jest.fn()} format="12h" />)
|
|
53
|
+
expect(html).toContain('02:30 PM')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('falls back to the 12h clock when rendered outside an i18n provider', () => {
|
|
57
|
+
mockLocale = undefined
|
|
58
|
+
const html = render(<TimePicker value="14:30" onChange={jest.fn()} />)
|
|
59
|
+
expect(html).toContain('02:30 PM')
|
|
60
|
+
})
|
|
61
|
+
|
|
31
62
|
it('renders trigger button with aria-haspopup="dialog"', () => {
|
|
32
63
|
const html = render(<TimePicker onChange={jest.fn()} />)
|
|
33
64
|
expect(html).toContain('aria-haspopup="dialog"')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { de } from 'date-fns/locale/de'
|
|
2
|
+
import { enUS } from 'date-fns/locale/en-US'
|
|
3
|
+
import { es } from 'date-fns/locale/es'
|
|
4
|
+
import { ko } from 'date-fns/locale/ko'
|
|
5
|
+
import { pl } from 'date-fns/locale/pl'
|
|
6
|
+
import { deriveTimeDisplayFormat, resolveDateFnsLocale } from '../date-locale'
|
|
7
|
+
|
|
8
|
+
describe('resolveDateFnsLocale', () => {
|
|
9
|
+
it('maps every locale the app ships to its date-fns locale', () => {
|
|
10
|
+
expect(resolveDateFnsLocale('en')).toBe(enUS)
|
|
11
|
+
expect(resolveDateFnsLocale('pl')).toBe(pl)
|
|
12
|
+
expect(resolveDateFnsLocale('de')).toBe(de)
|
|
13
|
+
expect(resolveDateFnsLocale('es')).toBe(es)
|
|
14
|
+
expect(resolveDateFnsLocale('ko')).toBe(ko)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('resolves a region-qualified code by its language', () => {
|
|
18
|
+
expect(resolveDateFnsLocale('pl-PL')).toBe(pl)
|
|
19
|
+
expect(resolveDateFnsLocale('en-GB')).toBe(enUS)
|
|
20
|
+
expect(resolveDateFnsLocale('DE')).toBe(de)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns undefined for an unmapped or absent locale so consumers keep their own default', () => {
|
|
24
|
+
expect(resolveDateFnsLocale('fr')).toBeUndefined()
|
|
25
|
+
expect(resolveDateFnsLocale('')).toBeUndefined()
|
|
26
|
+
expect(resolveDateFnsLocale(null)).toBeUndefined()
|
|
27
|
+
expect(resolveDateFnsLocale(undefined)).toBeUndefined()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('resolves a locale that starts the week on Monday', () => {
|
|
31
|
+
expect(resolveDateFnsLocale('pl')?.options?.weekStartsOn).toBe(1)
|
|
32
|
+
expect(resolveDateFnsLocale('en')?.options?.weekStartsOn).toBe(0)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('deriveTimeDisplayFormat', () => {
|
|
37
|
+
it('reports 24h for locales that write time on a 24-hour clock', () => {
|
|
38
|
+
expect(deriveTimeDisplayFormat('pl')).toBe('24h')
|
|
39
|
+
expect(deriveTimeDisplayFormat('de')).toBe('24h')
|
|
40
|
+
expect(deriveTimeDisplayFormat('es')).toBe('24h')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('reports 12h for locales that use AM/PM — including Korean', () => {
|
|
44
|
+
expect(deriveTimeDisplayFormat('en')).toBe('12h')
|
|
45
|
+
expect(deriveTimeDisplayFormat('ko')).toBe('12h')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('falls back to 12h when there is no locale or Intl cannot read it', () => {
|
|
49
|
+
expect(deriveTimeDisplayFormat(undefined)).toBe('12h')
|
|
50
|
+
expect(deriveTimeDisplayFormat(null)).toBe('12h')
|
|
51
|
+
expect(deriveTimeDisplayFormat('not a locale tag')).toBe('12h')
|
|
52
|
+
})
|
|
53
|
+
})
|