@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3276.1.1fcd145078
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +46 -129
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +10 -3
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/EmptyState.js +1 -47
- package/dist/backend/EmptyState.js.map +2 -2
- package/dist/backend/FilterOverlay.js +31 -26
- package/dist/backend/FilterOverlay.js.map +2 -2
- package/dist/backend/FlashMessages.js +23 -25
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +4 -2
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/LogList.js +49 -0
- package/dist/backend/LogList.js.map +7 -0
- package/dist/backend/crud/CollapsibleGroup.js +13 -3
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +80 -32
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +60 -6
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/DatePicker.js +6 -142
- package/dist/backend/inputs/DatePicker.js.map +2 -2
- package/dist/backend/inputs/DateTimePicker.js +6 -182
- package/dist/backend/inputs/DateTimePicker.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +16 -19
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/TimeInput.js +1 -1
- package/dist/backend/inputs/TimeInput.js.map +2 -2
- package/dist/backend/inputs/TimePicker.js +83 -72
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +2 -2
- package/dist/backend/utils/customFieldForms.js.map +1 -1
- package/dist/portal/components/PortalEmptyState.js +13 -9
- package/dist/portal/components/PortalEmptyState.js.map +2 -2
- package/dist/primitives/accordion.js +137 -0
- package/dist/primitives/accordion.js.map +7 -0
- package/dist/primitives/alert.js +180 -12
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/calendar.js +122 -32
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/compact-select.js +47 -0
- package/dist/primitives/compact-select.js.map +7 -0
- package/dist/primitives/counter-input.js +174 -0
- package/dist/primitives/counter-input.js.map +7 -0
- package/dist/primitives/date-picker-helpers.js +79 -0
- package/dist/primitives/date-picker-helpers.js.map +7 -0
- package/dist/primitives/date-picker.js +216 -0
- package/dist/primitives/date-picker.js.map +7 -0
- package/dist/primitives/date-range-picker.js +239 -0
- package/dist/primitives/date-range-picker.js.map +7 -0
- package/dist/primitives/digit-input.js +157 -0
- package/dist/primitives/digit-input.js.map +7 -0
- package/dist/primitives/empty-state.js +93 -0
- package/dist/primitives/empty-state.js.map +7 -0
- package/dist/primitives/inline-input.js +29 -0
- package/dist/primitives/inline-input.js.map +7 -0
- package/dist/primitives/inline-select.js +41 -0
- package/dist/primitives/inline-select.js.map +7 -0
- package/dist/primitives/notification-stack.js +116 -0
- package/dist/primitives/notification-stack.js.map +7 -0
- package/dist/primitives/notification.js +62 -0
- package/dist/primitives/notification.js.map +7 -0
- package/dist/primitives/rich-editor.js +1230 -0
- package/dist/primitives/rich-editor.js.map +7 -0
- package/dist/primitives/select.js +1 -0
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/skeleton.js +55 -0
- package/dist/primitives/skeleton.js.map +7 -0
- package/dist/primitives/tag-input.js +186 -0
- package/dist/primitives/tag-input.js.map +7 -0
- package/dist/primitives/tag.js +66 -13
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/time-picker.js +793 -0
- package/dist/primitives/time-picker.js.map +7 -0
- package/package.json +4 -3
- package/src/backend/CrudForm.tsx +61 -113
- package/src/backend/DataTable.tsx +17 -5
- package/src/backend/EmptyState.tsx +13 -63
- package/src/backend/FilterOverlay.tsx +38 -22
- package/src/backend/FlashMessages.tsx +23 -24
- package/src/backend/JsonBuilder.tsx +4 -2
- package/src/backend/LogList.tsx +109 -0
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
- package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
- package/src/backend/__tests__/LogList.test.tsx +163 -0
- package/src/backend/crud/CollapsibleGroup.tsx +17 -3
- package/src/backend/detail/InlineEditors.tsx +78 -25
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
- package/src/backend/inputs/ComboboxInput.tsx +81 -9
- package/src/backend/inputs/DatePicker.tsx +28 -155
- package/src/backend/inputs/DateTimePicker.tsx +25 -193
- package/src/backend/inputs/TagsInput.tsx +10 -13
- package/src/backend/inputs/TimeInput.tsx +2 -1
- package/src/backend/inputs/TimePicker.tsx +99 -70
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
- package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
- package/src/backend/utils/customFieldForms.ts +2 -2
- package/src/portal/components/PortalEmptyState.tsx +27 -22
- package/src/primitives/__tests__/accordion.test.tsx +197 -0
- package/src/primitives/__tests__/alert.test.tsx +204 -0
- package/src/primitives/__tests__/compact-select.test.tsx +98 -0
- package/src/primitives/__tests__/counter-input.test.tsx +165 -0
- package/src/primitives/__tests__/date-picker.test.tsx +221 -0
- package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
- package/src/primitives/__tests__/digit-input.test.tsx +168 -0
- package/src/primitives/__tests__/empty-state.test.tsx +154 -0
- package/src/primitives/__tests__/inline-input.test.tsx +100 -0
- package/src/primitives/__tests__/inline-select.test.tsx +103 -0
- package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
- package/src/primitives/__tests__/notification.test.tsx +119 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
- package/src/primitives/__tests__/skeleton.test.tsx +76 -0
- package/src/primitives/__tests__/tag-input.test.tsx +198 -0
- package/src/primitives/__tests__/tag.test.tsx +123 -0
- package/src/primitives/__tests__/time-picker.test.tsx +392 -0
- package/src/primitives/accordion.tsx +225 -0
- package/src/primitives/alert.tsx +234 -24
- package/src/primitives/calendar.tsx +146 -38
- package/src/primitives/compact-select.tsx +59 -0
- package/src/primitives/counter-input.tsx +211 -0
- package/src/primitives/date-picker-helpers.ts +96 -0
- package/src/primitives/date-picker.tsx +290 -0
- package/src/primitives/date-range-picker.tsx +281 -0
- package/src/primitives/digit-input.tsx +208 -0
- package/src/primitives/empty-state.tsx +140 -0
- package/src/primitives/inline-input.tsx +55 -0
- package/src/primitives/inline-select.tsx +74 -0
- package/src/primitives/notification-stack.tsx +185 -0
- package/src/primitives/notification.tsx +136 -0
- package/src/primitives/rich-editor.tsx +1578 -0
- package/src/primitives/select.tsx +1 -0
- package/src/primitives/skeleton.tsx +70 -0
- package/src/primitives/tag-input.tsx +223 -0
- package/src/primitives/tag.tsx +69 -10
- package/src/primitives/time-picker.tsx +1059 -0
|
@@ -0,0 +1,281 @@
|
|
|
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 type { DateRange as RDPRange } from 'react-day-picker'
|
|
13
|
+
import type { DateRange } from '../backend/date-range/dateRanges'
|
|
14
|
+
import {
|
|
15
|
+
defaultDateRangePresets,
|
|
16
|
+
type DateRangePresetItem,
|
|
17
|
+
} from './date-picker-helpers'
|
|
18
|
+
|
|
19
|
+
export type { DateRangePresetItem } from './date-picker-helpers'
|
|
20
|
+
|
|
21
|
+
export type DateRangePickerProps = {
|
|
22
|
+
value?: DateRange | null
|
|
23
|
+
onChange: (value: DateRange | null) => void
|
|
24
|
+
presets?: DateRangePresetItem[]
|
|
25
|
+
showPresets?: boolean
|
|
26
|
+
placeholder?: string
|
|
27
|
+
size?: 'sm' | 'default'
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
readOnly?: boolean
|
|
30
|
+
withFooter?: boolean
|
|
31
|
+
align?: 'start' | 'center' | 'end'
|
|
32
|
+
minDate?: Date
|
|
33
|
+
maxDate?: Date
|
|
34
|
+
numberOfMonths?: 1 | 2
|
|
35
|
+
locale?: Locale
|
|
36
|
+
formatRange?: (value: DateRange, locale?: Locale) => string
|
|
37
|
+
className?: string
|
|
38
|
+
popoverClassName?: string
|
|
39
|
+
id?: string
|
|
40
|
+
name?: string
|
|
41
|
+
required?: boolean
|
|
42
|
+
'aria-label'?: string
|
|
43
|
+
'aria-describedby'?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DAY_FIRST_LOCALE_CODES = new Set([
|
|
47
|
+
'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'ru', 'cs', 'sk', 'hu', 'ro',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
function deriveDateFormat(locale?: Locale): string {
|
|
51
|
+
const code = locale?.code?.split('-')[0]?.toLowerCase() ?? ''
|
|
52
|
+
return code && DAY_FIRST_LOCALE_CODES.has(code) ? 'd MMM yyyy' : 'MMM d, yyyy'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function defaultFormatRange(range: DateRange, locale?: Locale): string {
|
|
56
|
+
const fmt = deriveDateFormat(locale)
|
|
57
|
+
const opts = locale ? { locale } : undefined
|
|
58
|
+
return `${format(range.start, fmt, opts)} – ${format(range.end, fmt, opts)}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toRDPRange(range: DateRange | null | undefined): RDPRange | undefined {
|
|
62
|
+
if (!range) return undefined
|
|
63
|
+
return { from: range.start, to: range.end }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fromRDPRange(rdp: RDPRange | undefined): DateRange | null {
|
|
67
|
+
if (!rdp || !rdp.from) return null
|
|
68
|
+
return { start: rdp.from, end: rdp.to ?? rdp.from }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rangesEqual(a: DateRange | null, b: DateRange | null): boolean {
|
|
72
|
+
if (!a && !b) return true
|
|
73
|
+
if (!a || !b) return false
|
|
74
|
+
return a.start.getTime() === b.start.getTime() && a.end.getTime() === b.end.getTime()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function DateRangePicker({
|
|
78
|
+
value,
|
|
79
|
+
onChange,
|
|
80
|
+
presets,
|
|
81
|
+
showPresets = true,
|
|
82
|
+
placeholder,
|
|
83
|
+
size = 'default',
|
|
84
|
+
disabled = false,
|
|
85
|
+
readOnly = false,
|
|
86
|
+
withFooter = true,
|
|
87
|
+
align = 'start',
|
|
88
|
+
minDate,
|
|
89
|
+
maxDate,
|
|
90
|
+
numberOfMonths = 2,
|
|
91
|
+
locale,
|
|
92
|
+
formatRange,
|
|
93
|
+
className,
|
|
94
|
+
popoverClassName,
|
|
95
|
+
id,
|
|
96
|
+
name,
|
|
97
|
+
required,
|
|
98
|
+
'aria-label': ariaLabel,
|
|
99
|
+
'aria-describedby': ariaDescribedBy,
|
|
100
|
+
}: DateRangePickerProps) {
|
|
101
|
+
const t = useT()
|
|
102
|
+
const [open, setOpen] = React.useState(false)
|
|
103
|
+
const [draft, setDraft] = React.useState<DateRange | null>(value ?? null)
|
|
104
|
+
const [activePresetId, setActivePresetId] = React.useState<string | null>(null)
|
|
105
|
+
|
|
106
|
+
const isInteractive = !disabled && !readOnly
|
|
107
|
+
const useDraft = withFooter
|
|
108
|
+
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
if (open) {
|
|
111
|
+
setDraft(value ?? null)
|
|
112
|
+
setActivePresetId(null)
|
|
113
|
+
}
|
|
114
|
+
}, [open, value])
|
|
115
|
+
|
|
116
|
+
const resolvedPresets = React.useMemo<DateRangePresetItem[]>(
|
|
117
|
+
() => presets ?? defaultDateRangePresets(),
|
|
118
|
+
[presets],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const placeholderText = placeholder ?? t('ui.dateRangePicker.placeholder', 'Pick a date range')
|
|
122
|
+
const formatter = formatRange ?? defaultFormatRange
|
|
123
|
+
const formattedValue = React.useMemo(() => {
|
|
124
|
+
if (!value) return null
|
|
125
|
+
return formatter(value, locale)
|
|
126
|
+
}, [value, formatter, locale])
|
|
127
|
+
|
|
128
|
+
const selectedDate = useDraft ? draft : value
|
|
129
|
+
const rdpSelected = toRDPRange(selectedDate)
|
|
130
|
+
|
|
131
|
+
const handleRangeSelect = React.useCallback(
|
|
132
|
+
(rdpRange: RDPRange | undefined) => {
|
|
133
|
+
if (!isInteractive) return
|
|
134
|
+
const next = fromRDPRange(rdpRange)
|
|
135
|
+
setActivePresetId(null)
|
|
136
|
+
if (useDraft) {
|
|
137
|
+
setDraft(next)
|
|
138
|
+
} else {
|
|
139
|
+
onChange(next)
|
|
140
|
+
if (next?.start && next?.end) setOpen(false)
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
[isInteractive, useDraft, onChange],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const handlePresetClick = React.useCallback(
|
|
147
|
+
(preset: DateRangePresetItem) => {
|
|
148
|
+
if (!isInteractive) return
|
|
149
|
+
const range = preset.range()
|
|
150
|
+
setActivePresetId(preset.id)
|
|
151
|
+
if (useDraft) {
|
|
152
|
+
setDraft(range)
|
|
153
|
+
} else {
|
|
154
|
+
onChange(range)
|
|
155
|
+
setOpen(false)
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
[isInteractive, useDraft, onChange],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const handleApply = React.useCallback(() => {
|
|
162
|
+
onChange(draft)
|
|
163
|
+
setOpen(false)
|
|
164
|
+
}, [draft, onChange])
|
|
165
|
+
|
|
166
|
+
const handleCancel = React.useCallback(() => {
|
|
167
|
+
setDraft(value ?? null)
|
|
168
|
+
setActivePresetId(null)
|
|
169
|
+
setOpen(false)
|
|
170
|
+
}, [value])
|
|
171
|
+
|
|
172
|
+
const disabledMatcher = React.useMemo(() => {
|
|
173
|
+
if (!minDate && !maxDate) return undefined
|
|
174
|
+
const matchers: import('react-day-picker').Matcher[] = []
|
|
175
|
+
if (minDate) matchers.push({ before: minDate })
|
|
176
|
+
if (maxDate) matchers.push({ after: maxDate })
|
|
177
|
+
return matchers
|
|
178
|
+
}, [minDate, maxDate])
|
|
179
|
+
|
|
180
|
+
const heightClass = size === 'sm' ? 'h-8 px-2.5 text-xs' : 'h-9 px-3 text-sm'
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>
|
|
184
|
+
<PopoverTrigger asChild>
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
id={id}
|
|
188
|
+
name={name}
|
|
189
|
+
aria-label={ariaLabel}
|
|
190
|
+
aria-describedby={ariaDescribedBy}
|
|
191
|
+
aria-haspopup="dialog"
|
|
192
|
+
aria-required={required ? true : undefined}
|
|
193
|
+
data-crud-focus-target=""
|
|
194
|
+
data-slot="date-range-picker-trigger"
|
|
195
|
+
disabled={disabled}
|
|
196
|
+
className={cn(
|
|
197
|
+
'w-full inline-flex items-center gap-2 rounded-md border border-input bg-background shadow-xs transition-colors text-left',
|
|
198
|
+
'focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground',
|
|
199
|
+
'hover:bg-muted/40',
|
|
200
|
+
'disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none disabled:hover:bg-bg-disabled disabled:cursor-not-allowed',
|
|
201
|
+
'aria-invalid:border-destructive',
|
|
202
|
+
heightClass,
|
|
203
|
+
readOnly && 'cursor-default opacity-70',
|
|
204
|
+
!formattedValue && 'text-muted-foreground',
|
|
205
|
+
className,
|
|
206
|
+
)}
|
|
207
|
+
onClick={isInteractive ? undefined : (event) => event.preventDefault()}
|
|
208
|
+
>
|
|
209
|
+
<CalendarIcon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
210
|
+
<span className="flex-1 truncate">
|
|
211
|
+
{formattedValue ?? placeholderText}
|
|
212
|
+
</span>
|
|
213
|
+
</button>
|
|
214
|
+
</PopoverTrigger>
|
|
215
|
+
<PopoverContent
|
|
216
|
+
align={align}
|
|
217
|
+
className={cn(
|
|
218
|
+
'w-auto p-0 flex flex-col overflow-hidden',
|
|
219
|
+
'max-h-[var(--radix-popover-content-available-height)]',
|
|
220
|
+
popoverClassName,
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
<div className="flex min-h-0 flex-1 overflow-auto">
|
|
224
|
+
{showPresets ? (
|
|
225
|
+
<div
|
|
226
|
+
data-slot="date-range-presets"
|
|
227
|
+
className="flex w-[200px] shrink-0 flex-col gap-0.5 overflow-y-auto border-r p-2"
|
|
228
|
+
>
|
|
229
|
+
{resolvedPresets.map((preset) => {
|
|
230
|
+
const isActive = activePresetId === preset.id
|
|
231
|
+
return (
|
|
232
|
+
<button
|
|
233
|
+
key={preset.id}
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={() => handlePresetClick(preset)}
|
|
236
|
+
className={cn(
|
|
237
|
+
'inline-flex h-8 items-center rounded-md px-3 text-left text-sm transition-colors shrink-0',
|
|
238
|
+
'hover:bg-accent hover:text-accent-foreground',
|
|
239
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
240
|
+
isActive && 'bg-muted font-medium text-foreground',
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{t(preset.labelKey, preset.id.replace(/_/g, ' '))}
|
|
244
|
+
</button>
|
|
245
|
+
)
|
|
246
|
+
})}
|
|
247
|
+
</div>
|
|
248
|
+
) : null}
|
|
249
|
+
<div className="p-3">
|
|
250
|
+
<Calendar
|
|
251
|
+
mode="range"
|
|
252
|
+
selected={rdpSelected}
|
|
253
|
+
onSelect={handleRangeSelect}
|
|
254
|
+
locale={locale}
|
|
255
|
+
disabled={disabledMatcher}
|
|
256
|
+
numberOfMonths={numberOfMonths}
|
|
257
|
+
initialFocus
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
{withFooter ? (
|
|
262
|
+
<div className="flex shrink-0 items-center justify-between gap-3 border-t bg-popover px-3 py-2">
|
|
263
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
264
|
+
{selectedDate
|
|
265
|
+
? `${t('ui.dateRangePicker.rangeLabel', 'Range')}: ${formatter(selectedDate, locale)}`
|
|
266
|
+
: t('ui.dateRangePicker.rangeEmpty', 'No range selected')}
|
|
267
|
+
</span>
|
|
268
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
269
|
+
<Button type="button" variant="outline" size="sm" onClick={handleCancel}>
|
|
270
|
+
{t('ui.dateRangePicker.cancelButton', 'Cancel')}
|
|
271
|
+
</Button>
|
|
272
|
+
<Button type="button" size="sm" onClick={handleApply}>
|
|
273
|
+
{t('ui.dateRangePicker.applyButton', 'Apply')}
|
|
274
|
+
</Button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
) : null}
|
|
278
|
+
</PopoverContent>
|
|
279
|
+
</Popover>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
|
+
|
|
6
|
+
export type DigitInputProps = {
|
|
7
|
+
value?: string
|
|
8
|
+
onChange?: (value: string) => void
|
|
9
|
+
/** Number of cells. Default `6`. */
|
|
10
|
+
length?: number
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
autoFocus?: boolean
|
|
13
|
+
/** Restrict input characters. `'numeric'` filters out non-digits. */
|
|
14
|
+
inputMode?: 'numeric' | 'text'
|
|
15
|
+
/** Render each cell as a password input so characters appear as bullets. */
|
|
16
|
+
mask?: boolean
|
|
17
|
+
/** Fires when all `length` cells are filled. */
|
|
18
|
+
onComplete?: (value: string) => void
|
|
19
|
+
/** Override classes for the cell row wrapper. */
|
|
20
|
+
className?: string
|
|
21
|
+
/** Override classes applied to each cell `<input>`. */
|
|
22
|
+
cellClassName?: string
|
|
23
|
+
/** aria-label applied to the role=group wrapper. Defaults to `Verification code`. */
|
|
24
|
+
'aria-label'?: string
|
|
25
|
+
/** Indicates that the group's current value fails validation (mirrors Input's aria-invalid styling). */
|
|
26
|
+
'aria-invalid'?: boolean
|
|
27
|
+
/** Forwarded to the first cell so consumers can label the entire group with `<label htmlFor>`. */
|
|
28
|
+
id?: string
|
|
29
|
+
/** Forwarded to the first cell so form submissions carry the assembled value. */
|
|
30
|
+
name?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `length`-cell verification code input. Pasting a string distributes
|
|
35
|
+
* characters across cells and fires `onComplete` when all cells are filled.
|
|
36
|
+
* Backspace on an empty cell focuses the previous cell. ArrowLeft /
|
|
37
|
+
* ArrowRight move focus without committing values.
|
|
38
|
+
*
|
|
39
|
+
* The `value` prop is the assembled string (e.g. `'123456'`) — cells are an
|
|
40
|
+
* internal layout concern, not part of the contract. Same as `Input`,
|
|
41
|
+
* `mask` is purely visual (`type='password'` swap) so consumers always
|
|
42
|
+
* receive the raw characters in `onChange` / `onComplete`.
|
|
43
|
+
*/
|
|
44
|
+
export const DigitInput = React.forwardRef<HTMLInputElement, DigitInputProps>(
|
|
45
|
+
(
|
|
46
|
+
{
|
|
47
|
+
value: valueProp,
|
|
48
|
+
onChange,
|
|
49
|
+
length = 6,
|
|
50
|
+
disabled = false,
|
|
51
|
+
autoFocus = false,
|
|
52
|
+
inputMode = 'numeric',
|
|
53
|
+
mask = false,
|
|
54
|
+
onComplete,
|
|
55
|
+
className,
|
|
56
|
+
cellClassName,
|
|
57
|
+
'aria-label': ariaLabel,
|
|
58
|
+
'aria-invalid': ariaInvalid,
|
|
59
|
+
id,
|
|
60
|
+
name,
|
|
61
|
+
},
|
|
62
|
+
ref,
|
|
63
|
+
) => {
|
|
64
|
+
const [internalValue, setInternalValue] = React.useState('')
|
|
65
|
+
const isControlled = valueProp !== undefined
|
|
66
|
+
const value = isControlled ? valueProp ?? '' : internalValue
|
|
67
|
+
const cellRefs = React.useRef<Array<HTMLInputElement | null>>([])
|
|
68
|
+
|
|
69
|
+
React.useImperativeHandle(
|
|
70
|
+
ref,
|
|
71
|
+
() => (cellRefs.current[0] ?? null) as HTMLInputElement,
|
|
72
|
+
[],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const setCellRef = React.useCallback(
|
|
76
|
+
(index: number) => (el: HTMLInputElement | null) => {
|
|
77
|
+
cellRefs.current[index] = el
|
|
78
|
+
},
|
|
79
|
+
[],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const commitValue = React.useCallback(
|
|
83
|
+
(next: string) => {
|
|
84
|
+
const clamped = next.slice(0, length)
|
|
85
|
+
if (!isControlled) setInternalValue(clamped)
|
|
86
|
+
onChange?.(clamped)
|
|
87
|
+
if (clamped.length === length && onComplete) onComplete(clamped)
|
|
88
|
+
},
|
|
89
|
+
[isControlled, length, onChange, onComplete],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const filterChar = React.useCallback(
|
|
93
|
+
(char: string): string | null => {
|
|
94
|
+
if (inputMode === 'numeric' && !/^\d$/.test(char)) return null
|
|
95
|
+
return char
|
|
96
|
+
},
|
|
97
|
+
[inputMode],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const focusCell = React.useCallback(
|
|
101
|
+
(index: number) => {
|
|
102
|
+
const target = cellRefs.current[Math.max(0, Math.min(length - 1, index))]
|
|
103
|
+
if (target) target.focus()
|
|
104
|
+
},
|
|
105
|
+
[length],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const handleCellChange = React.useCallback(
|
|
109
|
+
(index: number, raw: string) => {
|
|
110
|
+
if (raw.length === 0) {
|
|
111
|
+
const next = value.slice(0, index) + value.slice(index + 1)
|
|
112
|
+
commitValue(next)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const lastChar = raw.slice(-1)
|
|
116
|
+
const filtered = filterChar(lastChar)
|
|
117
|
+
if (filtered === null) return
|
|
118
|
+
const next = value.slice(0, index) + filtered + value.slice(index + 1)
|
|
119
|
+
commitValue(next)
|
|
120
|
+
if (index < length - 1) focusCell(index + 1)
|
|
121
|
+
},
|
|
122
|
+
[commitValue, filterChar, focusCell, length, value],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const handleKeyDown = React.useCallback(
|
|
126
|
+
(index: number) => (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
127
|
+
if (event.key === 'Backspace') {
|
|
128
|
+
if (event.currentTarget.value === '' && index > 0) {
|
|
129
|
+
event.preventDefault()
|
|
130
|
+
const next = value.slice(0, index - 1) + value.slice(index)
|
|
131
|
+
commitValue(next)
|
|
132
|
+
focusCell(index - 1)
|
|
133
|
+
}
|
|
134
|
+
} else if (event.key === 'ArrowLeft' && index > 0) {
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
focusCell(index - 1)
|
|
137
|
+
} else if (event.key === 'ArrowRight' && index < length - 1) {
|
|
138
|
+
event.preventDefault()
|
|
139
|
+
focusCell(index + 1)
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
[commitValue, focusCell, length, value],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const handlePaste = React.useCallback(
|
|
146
|
+
(event: React.ClipboardEvent<HTMLInputElement>) => {
|
|
147
|
+
event.preventDefault()
|
|
148
|
+
const pasted = event.clipboardData.getData('text/plain')
|
|
149
|
+
const filtered = pasted
|
|
150
|
+
.split('')
|
|
151
|
+
.map(filterChar)
|
|
152
|
+
.filter((c): c is string => c !== null)
|
|
153
|
+
.join('')
|
|
154
|
+
.slice(0, length)
|
|
155
|
+
if (filtered.length === 0) return
|
|
156
|
+
commitValue(filtered)
|
|
157
|
+
const focusIndex = Math.min(filtered.length, length - 1)
|
|
158
|
+
focusCell(focusIndex)
|
|
159
|
+
},
|
|
160
|
+
[commitValue, filterChar, focusCell, length],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
className={cn('flex items-center gap-2', className)}
|
|
166
|
+
role="group"
|
|
167
|
+
aria-label={ariaLabel ?? 'Verification code'}
|
|
168
|
+
aria-invalid={ariaInvalid || undefined}
|
|
169
|
+
data-slot="digit-input"
|
|
170
|
+
>
|
|
171
|
+
{Array.from({ length }, (_, index) => {
|
|
172
|
+
const cellValue = value[index] ?? ''
|
|
173
|
+
return (
|
|
174
|
+
<input
|
|
175
|
+
key={index}
|
|
176
|
+
ref={setCellRef(index)}
|
|
177
|
+
id={index === 0 ? id : undefined}
|
|
178
|
+
name={index === 0 ? name : undefined}
|
|
179
|
+
type={mask ? 'password' : 'text'}
|
|
180
|
+
inputMode={inputMode === 'numeric' ? 'numeric' : 'text'}
|
|
181
|
+
autoComplete="one-time-code"
|
|
182
|
+
maxLength={1}
|
|
183
|
+
autoFocus={autoFocus && index === 0}
|
|
184
|
+
value={cellValue}
|
|
185
|
+
onChange={(event) => handleCellChange(index, event.target.value)}
|
|
186
|
+
onKeyDown={handleKeyDown(index)}
|
|
187
|
+
onPaste={handlePaste}
|
|
188
|
+
disabled={disabled}
|
|
189
|
+
aria-label={`${ariaLabel ?? 'Verification code'} digit ${index + 1}`}
|
|
190
|
+
aria-invalid={ariaInvalid || undefined}
|
|
191
|
+
data-slot="digit-input-cell"
|
|
192
|
+
data-index={index}
|
|
193
|
+
className={cn(
|
|
194
|
+
'h-16 w-12 sm:w-14 rounded-[10px] border border-input bg-background text-center text-2xl font-medium shadow-xs transition-colors',
|
|
195
|
+
'focus:outline-none focus-visible:border-foreground focus-visible:shadow-focus',
|
|
196
|
+
'aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive',
|
|
197
|
+
'disabled:cursor-not-allowed disabled:bg-bg-disabled disabled:border-border-disabled disabled:shadow-none',
|
|
198
|
+
'hover:bg-muted/40',
|
|
199
|
+
cellClassName,
|
|
200
|
+
)}
|
|
201
|
+
/>
|
|
202
|
+
)
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
DigitInput.displayName = 'DigitInput'
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
5
|
+
import { Plus } from 'lucide-react'
|
|
6
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import { Button } from './button'
|
|
8
|
+
|
|
9
|
+
const emptyStateVariants = cva(
|
|
10
|
+
'flex flex-col items-center justify-center text-center',
|
|
11
|
+
{
|
|
12
|
+
variants: {
|
|
13
|
+
size: {
|
|
14
|
+
sm: 'gap-2 px-4 py-6',
|
|
15
|
+
default: 'gap-3 px-6 py-10',
|
|
16
|
+
lg: 'gap-4 px-8 py-16',
|
|
17
|
+
},
|
|
18
|
+
variant: {
|
|
19
|
+
default: 'rounded-lg border border-dashed border-muted-foreground/40 bg-muted/30',
|
|
20
|
+
subtle: 'rounded-lg',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
size: 'default',
|
|
25
|
+
variant: 'default',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const titleVariants = cva('font-medium text-foreground', {
|
|
31
|
+
variants: {
|
|
32
|
+
size: {
|
|
33
|
+
sm: 'text-sm',
|
|
34
|
+
default: 'text-sm',
|
|
35
|
+
lg: 'text-base',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
defaultVariants: { size: 'default' },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
type LegacyAction = {
|
|
42
|
+
label: string
|
|
43
|
+
onClick?: () => void
|
|
44
|
+
icon?: React.ReactNode
|
|
45
|
+
disabled?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type EmptyStateProps = VariantProps<typeof emptyStateVariants> & {
|
|
49
|
+
title: string
|
|
50
|
+
description?: string
|
|
51
|
+
/** Custom action node — preferred for new code (e.g. `<Button>` or button group). */
|
|
52
|
+
actions?: React.ReactNode
|
|
53
|
+
/**
|
|
54
|
+
* Optional Figma-style illustration (preferred for new code) — typically a
|
|
55
|
+
* scaled SVG sourced from the DS Open Mercato illustrations library. When
|
|
56
|
+
* provided, takes precedence over `icon`. Rendered without any icon-box
|
|
57
|
+
* wrapping so the illustration's own circular background shows through.
|
|
58
|
+
*/
|
|
59
|
+
illustration?: React.ReactNode
|
|
60
|
+
/** Optional leading icon. With `variant="subtle"` it is wrapped in a round muted box; with default variant it sits inline tinted as muted-foreground. Ignored when `illustration` is provided. */
|
|
61
|
+
icon?: React.ReactNode
|
|
62
|
+
className?: string
|
|
63
|
+
children?: React.ReactNode
|
|
64
|
+
/** @deprecated Use `actions` with a `<Button>` instead. Legacy object kept for backwards compat with the old backend EmptyState. */
|
|
65
|
+
action?: LegacyAction
|
|
66
|
+
/** @deprecated Use `actions` with a `<Button>`. */
|
|
67
|
+
actionLabel?: string
|
|
68
|
+
/** @deprecated Use `actions` with a `<Button>`. */
|
|
69
|
+
onAction?: () => void
|
|
70
|
+
/** @deprecated Kept for legacy backend EmptyState consumers. Forwarded to the auto-generated action button. */
|
|
71
|
+
actionLabelClassName?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function EmptyState({
|
|
75
|
+
size = 'default',
|
|
76
|
+
variant = 'default',
|
|
77
|
+
title,
|
|
78
|
+
description,
|
|
79
|
+
actions,
|
|
80
|
+
illustration,
|
|
81
|
+
icon,
|
|
82
|
+
className,
|
|
83
|
+
children,
|
|
84
|
+
action,
|
|
85
|
+
actionLabel,
|
|
86
|
+
onAction,
|
|
87
|
+
actionLabelClassName,
|
|
88
|
+
}: EmptyStateProps) {
|
|
89
|
+
const legacyAction = action ?? (actionLabel ? { label: actionLabel, onClick: onAction } : undefined)
|
|
90
|
+
const renderLegacyButton = !actions && legacyAction
|
|
91
|
+
const iconBoxSize = size === 'sm' ? 'size-10' : size === 'lg' ? 'size-16' : 'size-12'
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className={cn(emptyStateVariants({ size, variant }), className)} data-slot="empty-state">
|
|
95
|
+
{illustration ? (
|
|
96
|
+
<div className="flex items-center justify-center" aria-hidden="true">
|
|
97
|
+
{illustration}
|
|
98
|
+
</div>
|
|
99
|
+
) : icon ? (
|
|
100
|
+
variant === 'subtle' ? (
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
'flex items-center justify-center rounded-full bg-muted text-muted-foreground',
|
|
104
|
+
iconBoxSize,
|
|
105
|
+
)}
|
|
106
|
+
aria-hidden="true"
|
|
107
|
+
>
|
|
108
|
+
{icon}
|
|
109
|
+
</div>
|
|
110
|
+
) : (
|
|
111
|
+
<div className="text-muted-foreground" aria-hidden="true">
|
|
112
|
+
{icon}
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
) : null}
|
|
116
|
+
<div className="space-y-1">
|
|
117
|
+
<p className={cn(titleVariants({ size }))}>{title}</p>
|
|
118
|
+
{description ? (
|
|
119
|
+
<p className="mx-auto max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
{children}
|
|
123
|
+
{actions ?? (renderLegacyButton ? (
|
|
124
|
+
<Button
|
|
125
|
+
type="button"
|
|
126
|
+
variant="outline"
|
|
127
|
+
size="sm"
|
|
128
|
+
onClick={legacyAction!.onClick}
|
|
129
|
+
className={cn('inline-flex items-center gap-2 text-foreground', actionLabelClassName)}
|
|
130
|
+
disabled={legacyAction!.disabled}
|
|
131
|
+
>
|
|
132
|
+
{legacyAction!.icon ?? <Plus className="h-4 w-4" aria-hidden="true" />}
|
|
133
|
+
<span>{legacyAction!.label}</span>
|
|
134
|
+
</Button>
|
|
135
|
+
) : null)}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { emptyStateVariants }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
|
+
import { Input, type InputProps } from './input'
|
|
6
|
+
|
|
7
|
+
export type InlineInputProps = Omit<InputProps, 'size'> & {
|
|
8
|
+
/**
|
|
9
|
+
* Trigger size. `sm` (h-8) is the default — inline editors are typically
|
|
10
|
+
* embedded inside dense rows. Pass `'default'` (h-9) when the editor sits
|
|
11
|
+
* alongside other h-9 controls.
|
|
12
|
+
*/
|
|
13
|
+
size?: 'sm' | 'default'
|
|
14
|
+
/**
|
|
15
|
+
* Render a subtle input border on hover so the affordance is discoverable.
|
|
16
|
+
* Default `true`. Set to `false` for editors that should remain visually
|
|
17
|
+
* invisible until focused (e.g. plain text cells inside read-only-looking
|
|
18
|
+
* card layouts).
|
|
19
|
+
*/
|
|
20
|
+
showBorderOnHover?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Borderless variant of `Input`. At rest the field looks like plain text
|
|
25
|
+
* (no border, no background, no shadow). Hover reveals a subtle border via
|
|
26
|
+
* `showBorderOnHover` (default `true`); focus always reveals the standard
|
|
27
|
+
* `border-foreground` + focus shadow inherited from the underlying `Input`
|
|
28
|
+
* wrapper for accessibility.
|
|
29
|
+
*
|
|
30
|
+
* Forwards every `Input` prop (placeholder, value, onChange, type, etc.).
|
|
31
|
+
* Consumers wire `onBlur` for the "save on blur" pattern typical of inline
|
|
32
|
+
* editors.
|
|
33
|
+
*/
|
|
34
|
+
export const InlineInput = React.forwardRef<HTMLInputElement, InlineInputProps>(
|
|
35
|
+
(
|
|
36
|
+
{ className, inputClassName, size = 'sm', showBorderOnHover = true, ...rest },
|
|
37
|
+
ref,
|
|
38
|
+
) => {
|
|
39
|
+
return (
|
|
40
|
+
<Input
|
|
41
|
+
ref={ref}
|
|
42
|
+
size={size}
|
|
43
|
+
className={cn(
|
|
44
|
+
'border-transparent bg-transparent shadow-none',
|
|
45
|
+
showBorderOnHover ? 'hover:border-input hover:bg-muted/40' : 'hover:bg-transparent',
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
inputClassName={inputClassName}
|
|
49
|
+
data-slot="inline-input"
|
|
50
|
+
{...rest}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
InlineInput.displayName = 'InlineInput'
|