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