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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +46 -129
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +10 -3
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/EmptyState.js +1 -47
  7. package/dist/backend/EmptyState.js.map +2 -2
  8. package/dist/backend/FilterOverlay.js +31 -26
  9. package/dist/backend/FilterOverlay.js.map +2 -2
  10. package/dist/backend/FlashMessages.js +23 -25
  11. package/dist/backend/FlashMessages.js.map +2 -2
  12. package/dist/backend/JsonBuilder.js +4 -2
  13. package/dist/backend/JsonBuilder.js.map +2 -2
  14. package/dist/backend/LogList.js +49 -0
  15. package/dist/backend/LogList.js.map +7 -0
  16. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  17. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  18. package/dist/backend/detail/InlineEditors.js +80 -32
  19. package/dist/backend/detail/InlineEditors.js.map +2 -2
  20. package/dist/backend/inputs/ComboboxInput.js +60 -6
  21. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  22. package/dist/backend/inputs/DatePicker.js +6 -142
  23. package/dist/backend/inputs/DatePicker.js.map +2 -2
  24. package/dist/backend/inputs/DateTimePicker.js +6 -182
  25. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  26. package/dist/backend/inputs/TagsInput.js +16 -19
  27. package/dist/backend/inputs/TagsInput.js.map +2 -2
  28. package/dist/backend/inputs/TimeInput.js +1 -1
  29. package/dist/backend/inputs/TimeInput.js.map +2 -2
  30. package/dist/backend/inputs/TimePicker.js +83 -72
  31. package/dist/backend/inputs/TimePicker.js.map +2 -2
  32. package/dist/backend/utils/customFieldForms.js +2 -2
  33. package/dist/backend/utils/customFieldForms.js.map +1 -1
  34. package/dist/portal/components/PortalEmptyState.js +13 -9
  35. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  36. package/dist/primitives/accordion.js +137 -0
  37. package/dist/primitives/accordion.js.map +7 -0
  38. package/dist/primitives/alert.js +180 -12
  39. package/dist/primitives/alert.js.map +2 -2
  40. package/dist/primitives/calendar.js +122 -32
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/dist/primitives/compact-select.js +47 -0
  43. package/dist/primitives/compact-select.js.map +7 -0
  44. package/dist/primitives/counter-input.js +174 -0
  45. package/dist/primitives/counter-input.js.map +7 -0
  46. package/dist/primitives/date-picker-helpers.js +79 -0
  47. package/dist/primitives/date-picker-helpers.js.map +7 -0
  48. package/dist/primitives/date-picker.js +216 -0
  49. package/dist/primitives/date-picker.js.map +7 -0
  50. package/dist/primitives/date-range-picker.js +239 -0
  51. package/dist/primitives/date-range-picker.js.map +7 -0
  52. package/dist/primitives/digit-input.js +157 -0
  53. package/dist/primitives/digit-input.js.map +7 -0
  54. package/dist/primitives/empty-state.js +93 -0
  55. package/dist/primitives/empty-state.js.map +7 -0
  56. package/dist/primitives/inline-input.js +29 -0
  57. package/dist/primitives/inline-input.js.map +7 -0
  58. package/dist/primitives/inline-select.js +41 -0
  59. package/dist/primitives/inline-select.js.map +7 -0
  60. package/dist/primitives/notification-stack.js +116 -0
  61. package/dist/primitives/notification-stack.js.map +7 -0
  62. package/dist/primitives/notification.js +62 -0
  63. package/dist/primitives/notification.js.map +7 -0
  64. package/dist/primitives/rich-editor.js +1230 -0
  65. package/dist/primitives/rich-editor.js.map +7 -0
  66. package/dist/primitives/select.js +1 -0
  67. package/dist/primitives/select.js.map +2 -2
  68. package/dist/primitives/skeleton.js +55 -0
  69. package/dist/primitives/skeleton.js.map +7 -0
  70. package/dist/primitives/tag-input.js +186 -0
  71. package/dist/primitives/tag-input.js.map +7 -0
  72. package/dist/primitives/tag.js +66 -13
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/time-picker.js +793 -0
  75. package/dist/primitives/time-picker.js.map +7 -0
  76. package/package.json +4 -3
  77. package/src/backend/CrudForm.tsx +61 -113
  78. package/src/backend/DataTable.tsx +17 -5
  79. package/src/backend/EmptyState.tsx +13 -63
  80. package/src/backend/FilterOverlay.tsx +38 -22
  81. package/src/backend/FlashMessages.tsx +23 -24
  82. package/src/backend/JsonBuilder.tsx +4 -2
  83. package/src/backend/LogList.tsx +109 -0
  84. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  85. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  86. package/src/backend/__tests__/LogList.test.tsx +163 -0
  87. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  88. package/src/backend/detail/InlineEditors.tsx +78 -25
  89. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  90. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  91. package/src/backend/inputs/DatePicker.tsx +28 -155
  92. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  93. package/src/backend/inputs/TagsInput.tsx +10 -13
  94. package/src/backend/inputs/TimeInput.tsx +2 -1
  95. package/src/backend/inputs/TimePicker.tsx +99 -70
  96. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  97. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  98. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  99. package/src/backend/utils/customFieldForms.ts +2 -2
  100. package/src/portal/components/PortalEmptyState.tsx +27 -22
  101. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  102. package/src/primitives/__tests__/alert.test.tsx +204 -0
  103. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  104. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  105. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  106. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  107. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  108. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  109. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  110. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  111. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  112. package/src/primitives/__tests__/notification.test.tsx +119 -0
  113. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  114. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  115. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  116. package/src/primitives/__tests__/tag.test.tsx +123 -0
  117. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  118. package/src/primitives/accordion.tsx +225 -0
  119. package/src/primitives/alert.tsx +234 -24
  120. package/src/primitives/calendar.tsx +146 -38
  121. package/src/primitives/compact-select.tsx +59 -0
  122. package/src/primitives/counter-input.tsx +211 -0
  123. package/src/primitives/date-picker-helpers.ts +96 -0
  124. package/src/primitives/date-picker.tsx +290 -0
  125. package/src/primitives/date-range-picker.tsx +281 -0
  126. package/src/primitives/digit-input.tsx +208 -0
  127. package/src/primitives/empty-state.tsx +140 -0
  128. package/src/primitives/inline-input.tsx +55 -0
  129. package/src/primitives/inline-select.tsx +74 -0
  130. package/src/primitives/notification-stack.tsx +185 -0
  131. package/src/primitives/notification.tsx +136 -0
  132. package/src/primitives/rich-editor.tsx +1578 -0
  133. package/src/primitives/select.tsx +1 -0
  134. package/src/primitives/skeleton.tsx +70 -0
  135. package/src/primitives/tag-input.tsx +223 -0
  136. package/src/primitives/tag.tsx +69 -10
  137. package/src/primitives/time-picker.tsx +1059 -0
@@ -0,0 +1,211 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Minus, Plus } from 'lucide-react'
5
+ import { cva, type VariantProps } from 'class-variance-authority'
6
+ import { cn } from '@open-mercato/shared/lib/utils'
7
+
8
+ const counterWrapperVariants = cva(
9
+ 'inline-flex w-full items-center bg-background border border-input shadow-xs transition-colors ' +
10
+ 'focus-within:outline-none focus-within:shadow-focus focus-within:border-foreground ' +
11
+ 'hover:bg-muted/40 ' +
12
+ 'has-[input:disabled]:bg-bg-disabled has-[input:disabled]:border-border-disabled has-[input:disabled]:shadow-none has-[input:disabled]:hover:bg-bg-disabled ' +
13
+ 'has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:focus-within:border-destructive',
14
+ {
15
+ variants: {
16
+ size: {
17
+ sm: 'h-8 gap-1 p-1 rounded-md',
18
+ default: 'h-9 gap-1.5 p-1.5 rounded-md',
19
+ lg: 'h-10 gap-2 p-2 rounded-[10px]',
20
+ },
21
+ },
22
+ defaultVariants: { size: 'default' },
23
+ },
24
+ )
25
+
26
+ const counterButtonVariants = cva(
27
+ 'inline-flex shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50',
28
+ {
29
+ variants: {
30
+ size: {
31
+ sm: 'size-6 [&_svg]:size-3.5',
32
+ default: 'size-6 [&_svg]:size-4',
33
+ lg: 'size-6 [&_svg]:size-4',
34
+ },
35
+ },
36
+ defaultVariants: { size: 'default' },
37
+ },
38
+ )
39
+
40
+ const counterInputElementVariants = cva(
41
+ 'flex-1 min-w-0 bg-transparent border-0 outline-none text-center placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:bg-transparent ' +
42
+ '[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
43
+ {
44
+ variants: {
45
+ size: {
46
+ sm: 'text-xs',
47
+ default: 'text-sm',
48
+ lg: 'text-sm',
49
+ },
50
+ },
51
+ defaultVariants: { size: 'default' },
52
+ },
53
+ )
54
+
55
+ export type CounterInputProps = Omit<
56
+ React.ComponentPropsWithoutRef<'input'>,
57
+ 'value' | 'onChange' | 'size' | 'type' | 'children'
58
+ > &
59
+ VariantProps<typeof counterWrapperVariants> & {
60
+ value?: number | null
61
+ /**
62
+ * Fired whenever the value changes through `-`/`+`, keyboard arrows, or direct typing.
63
+ * `null` is emitted when the user clears the input. Component clamps to `min`/`max`
64
+ * and rounds to `precision` decimal places before emitting.
65
+ */
66
+ onChange?: (value: number | null) => void
67
+ min?: number
68
+ max?: number
69
+ /** Increment amount for `+` / `-` and ArrowUp / ArrowDown. Default `1`. */
70
+ step?: number
71
+ /** Decimal places used when formatting the displayed value. Default `0`. */
72
+ precision?: number
73
+ /** aria-label for the decrement (`-`) button. Default English `Decrease`. */
74
+ decrementAriaLabel?: string
75
+ /** aria-label for the increment (`+`) button. Default English `Increase`. */
76
+ incrementAriaLabel?: string
77
+ /** Override classes applied to the inner `<input>` element. */
78
+ inputClassName?: string
79
+ }
80
+
81
+ export const CounterInput = React.forwardRef<HTMLInputElement, CounterInputProps>(
82
+ (
83
+ {
84
+ className,
85
+ inputClassName,
86
+ size,
87
+ value: valueProp,
88
+ onChange,
89
+ min,
90
+ max,
91
+ step = 1,
92
+ precision = 0,
93
+ disabled = false,
94
+ placeholder,
95
+ decrementAriaLabel = 'Decrease',
96
+ incrementAriaLabel = 'Increase',
97
+ ...rest
98
+ },
99
+ ref,
100
+ ) => {
101
+ const [internalValue, setInternalValue] = React.useState<number | null>(
102
+ valueProp ?? null,
103
+ )
104
+ const isControlled = valueProp !== undefined
105
+ const value = isControlled ? valueProp ?? null : internalValue
106
+
107
+ const factor = React.useMemo(() => 10 ** precision, [precision])
108
+
109
+ const clamp = React.useCallback(
110
+ (n: number) => {
111
+ let next = n
112
+ if (typeof min === 'number' && next < min) next = min
113
+ if (typeof max === 'number' && next > max) next = max
114
+ return Math.round(next * factor) / factor
115
+ },
116
+ [factor, max, min],
117
+ )
118
+
119
+ const commitValue = React.useCallback(
120
+ (next: number | null) => {
121
+ if (!isControlled) setInternalValue(next)
122
+ onChange?.(next)
123
+ },
124
+ [isControlled, onChange],
125
+ )
126
+
127
+ const formatNumber = React.useCallback(
128
+ (n: number) => (precision > 0 ? n.toFixed(precision) : String(n)),
129
+ [precision],
130
+ )
131
+
132
+ const adjust = React.useCallback(
133
+ (direction: 1 | -1) => {
134
+ const current = typeof value === 'number' ? value : 0
135
+ commitValue(clamp(current + direction * step))
136
+ },
137
+ [clamp, commitValue, step, value],
138
+ )
139
+
140
+ const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
141
+ const text = event.target.value
142
+ if (text === '' || text === '-') {
143
+ commitValue(null)
144
+ return
145
+ }
146
+ const parsed = Number(text)
147
+ if (!Number.isFinite(parsed)) return
148
+ commitValue(clamp(parsed))
149
+ }
150
+
151
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
152
+ if (event.key === 'ArrowUp') {
153
+ event.preventDefault()
154
+ adjust(1)
155
+ } else if (event.key === 'ArrowDown') {
156
+ event.preventDefault()
157
+ adjust(-1)
158
+ }
159
+ }
160
+
161
+ const incrementDisabled =
162
+ disabled || (typeof max === 'number' && value !== null && value >= max)
163
+ const decrementDisabled =
164
+ disabled || (typeof min === 'number' && value !== null && value <= min)
165
+
166
+ return (
167
+ <div
168
+ className={cn(counterWrapperVariants({ size }), className)}
169
+ data-slot="counter-input"
170
+ >
171
+ <button
172
+ type="button"
173
+ onClick={() => adjust(-1)}
174
+ disabled={decrementDisabled}
175
+ aria-label={decrementAriaLabel}
176
+ className={cn(counterButtonVariants({ size }))}
177
+ data-slot="counter-input-decrement"
178
+ >
179
+ <Minus aria-hidden="true" />
180
+ </button>
181
+ <input
182
+ ref={ref}
183
+ type="number"
184
+ inputMode="decimal"
185
+ value={value === null ? '' : formatNumber(value)}
186
+ onChange={handleInputChange}
187
+ onKeyDown={handleKeyDown}
188
+ disabled={disabled}
189
+ placeholder={placeholder}
190
+ min={min}
191
+ max={max}
192
+ step={step}
193
+ className={cn(counterInputElementVariants({ size }), inputClassName)}
194
+ {...rest}
195
+ />
196
+ <button
197
+ type="button"
198
+ onClick={() => adjust(1)}
199
+ disabled={incrementDisabled}
200
+ aria-label={incrementAriaLabel}
201
+ className={cn(counterButtonVariants({ size }))}
202
+ data-slot="counter-input-increment"
203
+ >
204
+ <Plus aria-hidden="true" />
205
+ </button>
206
+ </div>
207
+ )
208
+ },
209
+ )
210
+
211
+ CounterInput.displayName = 'CounterInput'
@@ -0,0 +1,96 @@
1
+ import {
2
+ startOfDay,
3
+ endOfDay,
4
+ startOfMonth,
5
+ startOfYear,
6
+ subDays,
7
+ subMonths,
8
+ subYears,
9
+ } from 'date-fns'
10
+ import type { DateRange } from '../backend/date-range/dateRanges'
11
+
12
+ export type DateRangePresetItem = {
13
+ id: string
14
+ labelKey: string
15
+ range: (referenceDate?: Date) => DateRange
16
+ }
17
+
18
+ /**
19
+ * Returns the canonical 8 date-range presets aligned with the Figma
20
+ * Range Picker spec (`446:7412` Period Range track).
21
+ *
22
+ * The list is intentionally shorter than `DATE_RANGE_OPTIONS` from
23
+ * `@open-mercato/ui/backend/date-range`: that constant powers
24
+ * dashboard/analytics modules with 13 presets (today, yesterday,
25
+ * this/last week/month/quarter/year, last 7/30/90 days), but the Figma
26
+ * Range Picker UI exposes 8 options optimised for filter UX (today plus
27
+ * sliding windows and month/year-to-date).
28
+ *
29
+ * Used by `<DateRangePicker>` as the default `presets` value.
30
+ */
31
+ export function defaultDateRangePresets(): DateRangePresetItem[] {
32
+ return [
33
+ {
34
+ id: 'today',
35
+ labelKey: 'ui.dateRangePicker.presets.today',
36
+ range: (ref = new Date()) => ({ start: startOfDay(ref), end: endOfDay(ref) }),
37
+ },
38
+ {
39
+ id: 'last_7_days',
40
+ labelKey: 'ui.dateRangePicker.presets.last7Days',
41
+ range: (ref = new Date()) => ({
42
+ start: startOfDay(subDays(ref, 6)),
43
+ end: endOfDay(ref),
44
+ }),
45
+ },
46
+ {
47
+ id: 'last_30_days',
48
+ labelKey: 'ui.dateRangePicker.presets.last30Days',
49
+ range: (ref = new Date()) => ({
50
+ start: startOfDay(subDays(ref, 29)),
51
+ end: endOfDay(ref),
52
+ }),
53
+ },
54
+ {
55
+ id: 'last_3_months',
56
+ labelKey: 'ui.dateRangePicker.presets.last3Months',
57
+ range: (ref = new Date()) => ({
58
+ start: startOfDay(subMonths(ref, 3)),
59
+ end: endOfDay(ref),
60
+ }),
61
+ },
62
+ {
63
+ id: 'last_12_months',
64
+ labelKey: 'ui.dateRangePicker.presets.last12Months',
65
+ range: (ref = new Date()) => ({
66
+ start: startOfDay(subYears(ref, 1)),
67
+ end: endOfDay(ref),
68
+ }),
69
+ },
70
+ {
71
+ id: 'month_to_date',
72
+ labelKey: 'ui.dateRangePicker.presets.monthToDate',
73
+ range: (ref = new Date()) => ({
74
+ start: startOfMonth(ref),
75
+ end: endOfDay(ref),
76
+ }),
77
+ },
78
+ {
79
+ id: 'year_to_date',
80
+ labelKey: 'ui.dateRangePicker.presets.yearToDate',
81
+ range: (ref = new Date()) => ({
82
+ start: startOfYear(ref),
83
+ end: endOfDay(ref),
84
+ }),
85
+ },
86
+ {
87
+ id: 'all_time',
88
+ labelKey: 'ui.dateRangePicker.presets.allTime',
89
+ range: (ref = new Date()) => ({
90
+ // Symbolic "all time": from epoch to end of reference day.
91
+ start: new Date(0),
92
+ end: endOfDay(ref),
93
+ }),
94
+ },
95
+ ]
96
+ }
@@ -0,0 +1,290 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { format } from 'date-fns'
5
+ import type { Locale } from 'date-fns'
6
+ import { CalendarIcon } from 'lucide-react'
7
+ import { cn } from '@open-mercato/shared/lib/utils'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { Popover, PopoverContent, PopoverTrigger } from './popover'
10
+ import { Calendar } from './calendar'
11
+ import { Button } from './button'
12
+ import { LinkButton } from './link-button'
13
+ import { TimeInput } from '../backend/inputs/TimeInput'
14
+
15
+ export type DatePickerFooter = 'apply-cancel' | 'today-clear' | 'none'
16
+
17
+ export type DatePickerProps = {
18
+ value?: Date | null
19
+ onChange: (value: Date | null) => void
20
+ placeholder?: string
21
+ size?: 'sm' | 'default'
22
+ disabled?: boolean
23
+ readOnly?: boolean
24
+ /**
25
+ * Footer mode in the popover.
26
+ * - `'apply-cancel'` (default, Figma-aligned): selecting a day stages a draft;
27
+ * Apply commits and closes; Cancel reverts and closes.
28
+ * - `'today-clear'`: legacy behavior — Today commits today, Clear sets null.
29
+ * - `'none'`: no footer; selecting a day commits immediately and closes.
30
+ */
31
+ footer?: DatePickerFooter
32
+ /**
33
+ * Whether selecting a day commits the value and closes the popover.
34
+ * - In `footer='apply-cancel'`: ignored (commit happens via Apply).
35
+ * - In `footer='today-clear'`: default `true` for backward compatibility.
36
+ * - In `footer='none'`: always `true` (popover always closes after selection).
37
+ */
38
+ closeOnSelect?: boolean
39
+ /**
40
+ * Whether to show the "Today" link in `footer='today-clear'` mode. Default `true`.
41
+ * Only meaningful when `footer='today-clear'`.
42
+ */
43
+ showTodayButton?: boolean
44
+ /**
45
+ * Whether to show the "Clear" link in `footer='today-clear'` mode. Default `true`.
46
+ * Only meaningful when `footer='today-clear'`.
47
+ */
48
+ showClearButton?: boolean
49
+ withTime?: boolean
50
+ minuteStep?: number
51
+ align?: 'start' | 'center' | 'end'
52
+ minDate?: Date
53
+ maxDate?: Date
54
+ locale?: Locale
55
+ displayFormat?: string
56
+ className?: string
57
+ popoverClassName?: string
58
+ id?: string
59
+ name?: string
60
+ required?: boolean
61
+ 'aria-label'?: string
62
+ 'aria-describedby'?: string
63
+ }
64
+
65
+ const DAY_FIRST_LOCALE_CODES = new Set([
66
+ 'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'ru', 'cs', 'sk', 'hu', 'ro',
67
+ ])
68
+
69
+ function deriveDisplayFormat(locale: Locale | undefined, withTime: boolean): string {
70
+ const code = locale?.code?.split('-')[0]?.toLowerCase() ?? ''
71
+ const dateFmt = code && DAY_FIRST_LOCALE_CODES.has(code) ? 'd MMM yyyy' : 'MMM d, yyyy'
72
+ return withTime ? `${dateFmt} HH:mm` : dateFmt
73
+ }
74
+
75
+ function extractTime(date: Date): string {
76
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
77
+ }
78
+
79
+ function applyTimeToDate(base: Date, time: string): Date {
80
+ const [hourStr = '0', minuteStr = '0'] = time.split(':')
81
+ const hour = parseInt(hourStr, 10)
82
+ const minute = parseInt(minuteStr, 10)
83
+ const next = new Date(base)
84
+ next.setHours(isNaN(hour) ? 0 : hour, isNaN(minute) ? 0 : minute, 0, 0)
85
+ return next
86
+ }
87
+
88
+ function startOfDay(date: Date): Date {
89
+ const next = new Date(date)
90
+ next.setHours(0, 0, 0, 0)
91
+ return next
92
+ }
93
+
94
+ export function DatePicker({
95
+ value,
96
+ onChange,
97
+ placeholder,
98
+ size = 'default',
99
+ disabled = false,
100
+ readOnly = false,
101
+ footer = 'apply-cancel',
102
+ closeOnSelect,
103
+ showTodayButton = true,
104
+ showClearButton = true,
105
+ withTime = false,
106
+ minuteStep = 1,
107
+ align = 'start',
108
+ minDate,
109
+ maxDate,
110
+ locale,
111
+ displayFormat,
112
+ className,
113
+ popoverClassName,
114
+ id,
115
+ name,
116
+ required,
117
+ 'aria-label': ariaLabel,
118
+ 'aria-describedby': ariaDescribedBy,
119
+ }: DatePickerProps) {
120
+ const resolvedCloseOnSelect = closeOnSelect ?? footer === 'today-clear'
121
+ const t = useT()
122
+ const [open, setOpen] = React.useState(false)
123
+ const [draft, setDraft] = React.useState<Date | null>(value ?? null)
124
+
125
+ const isInteractive = !disabled && !readOnly
126
+ const useDraft = footer === 'apply-cancel'
127
+
128
+ React.useEffect(() => {
129
+ if (open) setDraft(value ?? null)
130
+ }, [open, value])
131
+
132
+ const resolvedFormat = displayFormat ?? deriveDisplayFormat(locale, withTime)
133
+ const placeholderText = placeholder ?? (
134
+ withTime
135
+ ? t('ui.dateTimePicker.placeholder', 'Pick date and time')
136
+ : t('ui.datePicker.placeholder', 'Pick a date')
137
+ )
138
+
139
+ const formattedValue = React.useMemo(() => {
140
+ if (!value) return null
141
+ try {
142
+ return format(value, resolvedFormat, locale ? { locale } : undefined)
143
+ } catch {
144
+ return null
145
+ }
146
+ }, [value, resolvedFormat, locale])
147
+
148
+ const handleDaySelect = React.useCallback(
149
+ (day: Date | undefined) => {
150
+ if (!day || !isInteractive) return
151
+ const reference = withTime ? (useDraft ? draft : value) : null
152
+ const base = withTime
153
+ ? applyTimeToDate(day, reference ? extractTime(reference) : '00:00')
154
+ : startOfDay(day)
155
+ if (useDraft) {
156
+ setDraft(base)
157
+ } else {
158
+ onChange(base)
159
+ if (footer === 'none' || resolvedCloseOnSelect) setOpen(false)
160
+ }
161
+ },
162
+ [isInteractive, withTime, useDraft, draft, value, footer, resolvedCloseOnSelect, onChange],
163
+ )
164
+
165
+ const handleTimeChange = React.useCallback(
166
+ (time: string) => {
167
+ const target = useDraft ? draft : value
168
+ if (!target) return
169
+ const next = applyTimeToDate(target, time)
170
+ if (useDraft) setDraft(next)
171
+ else onChange(next)
172
+ },
173
+ [useDraft, draft, value, onChange],
174
+ )
175
+
176
+ const handleApply = React.useCallback(() => {
177
+ onChange(draft)
178
+ setOpen(false)
179
+ }, [draft, onChange])
180
+
181
+ const handleCancel = React.useCallback(() => {
182
+ setDraft(value ?? null)
183
+ setOpen(false)
184
+ }, [value])
185
+
186
+ const handleToday = React.useCallback(() => {
187
+ const today = withTime ? new Date() : startOfDay(new Date())
188
+ onChange(today)
189
+ setOpen(false)
190
+ }, [withTime, onChange])
191
+
192
+ const handleClear = React.useCallback(() => {
193
+ onChange(null)
194
+ setOpen(false)
195
+ }, [onChange])
196
+
197
+ const disabledMatcher = React.useMemo(() => {
198
+ if (!minDate && !maxDate) return undefined
199
+ const matchers: import('react-day-picker').Matcher[] = []
200
+ if (minDate) matchers.push({ before: minDate })
201
+ if (maxDate) matchers.push({ after: maxDate })
202
+ return matchers
203
+ }, [minDate, maxDate])
204
+
205
+ const heightClass = size === 'sm' ? 'h-8 px-2.5 text-xs' : 'h-9 px-3 text-sm'
206
+ const selectedDate = useDraft ? draft : value
207
+
208
+ return (
209
+ <Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>
210
+ <PopoverTrigger asChild>
211
+ <button
212
+ type="button"
213
+ id={id}
214
+ name={name}
215
+ aria-label={ariaLabel}
216
+ aria-describedby={ariaDescribedBy}
217
+ aria-haspopup="dialog"
218
+ aria-required={required ? true : undefined}
219
+ data-crud-focus-target=""
220
+ data-slot="date-picker-trigger"
221
+ disabled={disabled}
222
+ className={cn(
223
+ 'w-full inline-flex items-center gap-2 rounded-md border border-input bg-background shadow-xs transition-colors text-left',
224
+ 'focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground',
225
+ 'hover:bg-muted/40',
226
+ 'disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled disabled:cursor-not-allowed',
227
+ 'aria-invalid:border-destructive',
228
+ heightClass,
229
+ readOnly && 'cursor-default opacity-70',
230
+ !formattedValue && 'text-muted-foreground',
231
+ className,
232
+ )}
233
+ onClick={isInteractive ? undefined : (event) => event.preventDefault()}
234
+ >
235
+ <CalendarIcon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
236
+ <span className="flex-1 truncate">
237
+ {formattedValue ?? placeholderText}
238
+ </span>
239
+ </button>
240
+ </PopoverTrigger>
241
+ <PopoverContent align={align} className={cn('w-auto p-0', popoverClassName)}>
242
+ <Calendar
243
+ mode="single"
244
+ selected={selectedDate ?? undefined}
245
+ onSelect={handleDaySelect}
246
+ locale={locale}
247
+ disabled={disabledMatcher}
248
+ initialFocus
249
+ />
250
+ {withTime && (
251
+ <div className="flex items-center gap-2 border-t px-3 py-2">
252
+ <span className="text-sm text-muted-foreground shrink-0">
253
+ {t('ui.datePicker.timeLabel', 'Time')}:
254
+ </span>
255
+ <TimeInput
256
+ value={selectedDate ? extractTime(selectedDate) : undefined}
257
+ onChange={handleTimeChange}
258
+ minuteStep={minuteStep}
259
+ disabled={!isInteractive || !selectedDate}
260
+ />
261
+ </div>
262
+ )}
263
+ {footer === 'apply-cancel' && (
264
+ <div className="flex items-center justify-end gap-2 border-t px-3 py-2">
265
+ <Button type="button" variant="outline" size="sm" onClick={handleCancel}>
266
+ {t('ui.datePicker.cancelButton', 'Cancel')}
267
+ </Button>
268
+ <Button type="button" size="sm" onClick={handleApply}>
269
+ {t('ui.datePicker.applyButton', 'Apply')}
270
+ </Button>
271
+ </div>
272
+ )}
273
+ {footer === 'today-clear' && (showTodayButton || showClearButton) && (
274
+ <div className="flex items-center justify-between gap-2 border-t px-3 py-2">
275
+ {showTodayButton ? (
276
+ <LinkButton type="button" variant="primary" onClick={handleToday}>
277
+ {t('ui.datePicker.todayButton', 'Today')}
278
+ </LinkButton>
279
+ ) : null}
280
+ {showClearButton ? (
281
+ <LinkButton type="button" variant="gray" onClick={handleClear} className="ml-auto">
282
+ {t('ui.datePicker.clearButton', 'Clear')}
283
+ </LinkButton>
284
+ ) : null}
285
+ </div>
286
+ )}
287
+ </PopoverContent>
288
+ </Popover>
289
+ )
290
+ }