@open-mercato/ui 0.6.1-develop.3246.1.dbef9d7392 → 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 (164) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -0
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +226 -58
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiDock.js +3 -2
  8. package/dist/ai/AiDock.js.map +2 -2
  9. package/dist/ai/ModelPicker.js +186 -0
  10. package/dist/ai/ModelPicker.js.map +7 -0
  11. package/dist/ai/index.js +3 -1
  12. package/dist/ai/index.js.map +2 -2
  13. package/dist/ai/useAiChat.js +12 -16
  14. package/dist/ai/useAiChat.js.map +2 -2
  15. package/dist/backend/CrudForm.js +46 -129
  16. package/dist/backend/CrudForm.js.map +3 -3
  17. package/dist/backend/DataTable.js +10 -3
  18. package/dist/backend/DataTable.js.map +2 -2
  19. package/dist/backend/EmptyState.js +1 -47
  20. package/dist/backend/EmptyState.js.map +2 -2
  21. package/dist/backend/FilterOverlay.js +31 -26
  22. package/dist/backend/FilterOverlay.js.map +2 -2
  23. package/dist/backend/FlashMessages.js +23 -25
  24. package/dist/backend/FlashMessages.js.map +2 -2
  25. package/dist/backend/JsonBuilder.js +4 -2
  26. package/dist/backend/JsonBuilder.js.map +2 -2
  27. package/dist/backend/LogList.js +49 -0
  28. package/dist/backend/LogList.js.map +7 -0
  29. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  30. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  31. package/dist/backend/detail/InlineEditors.js +80 -32
  32. package/dist/backend/detail/InlineEditors.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +60 -6
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/DatePicker.js +6 -142
  36. package/dist/backend/inputs/DatePicker.js.map +2 -2
  37. package/dist/backend/inputs/DateTimePicker.js +6 -182
  38. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  39. package/dist/backend/inputs/TagsInput.js +16 -19
  40. package/dist/backend/inputs/TagsInput.js.map +2 -2
  41. package/dist/backend/inputs/TimeInput.js +1 -1
  42. package/dist/backend/inputs/TimeInput.js.map +2 -2
  43. package/dist/backend/inputs/TimePicker.js +83 -72
  44. package/dist/backend/inputs/TimePicker.js.map +2 -2
  45. package/dist/backend/utils/customFieldForms.js +2 -2
  46. package/dist/backend/utils/customFieldForms.js.map +1 -1
  47. package/dist/portal/components/PortalEmptyState.js +13 -9
  48. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  49. package/dist/primitives/accordion.js +137 -0
  50. package/dist/primitives/accordion.js.map +7 -0
  51. package/dist/primitives/alert.js +180 -12
  52. package/dist/primitives/alert.js.map +2 -2
  53. package/dist/primitives/calendar.js +122 -32
  54. package/dist/primitives/calendar.js.map +2 -2
  55. package/dist/primitives/compact-select.js +47 -0
  56. package/dist/primitives/compact-select.js.map +7 -0
  57. package/dist/primitives/counter-input.js +174 -0
  58. package/dist/primitives/counter-input.js.map +7 -0
  59. package/dist/primitives/date-picker-helpers.js +79 -0
  60. package/dist/primitives/date-picker-helpers.js.map +7 -0
  61. package/dist/primitives/date-picker.js +216 -0
  62. package/dist/primitives/date-picker.js.map +7 -0
  63. package/dist/primitives/date-range-picker.js +239 -0
  64. package/dist/primitives/date-range-picker.js.map +7 -0
  65. package/dist/primitives/digit-input.js +157 -0
  66. package/dist/primitives/digit-input.js.map +7 -0
  67. package/dist/primitives/empty-state.js +93 -0
  68. package/dist/primitives/empty-state.js.map +7 -0
  69. package/dist/primitives/inline-input.js +29 -0
  70. package/dist/primitives/inline-input.js.map +7 -0
  71. package/dist/primitives/inline-select.js +41 -0
  72. package/dist/primitives/inline-select.js.map +7 -0
  73. package/dist/primitives/notification-stack.js +116 -0
  74. package/dist/primitives/notification-stack.js.map +7 -0
  75. package/dist/primitives/notification.js +62 -0
  76. package/dist/primitives/notification.js.map +7 -0
  77. package/dist/primitives/rich-editor.js +1230 -0
  78. package/dist/primitives/rich-editor.js.map +7 -0
  79. package/dist/primitives/select.js +1 -0
  80. package/dist/primitives/select.js.map +2 -2
  81. package/dist/primitives/skeleton.js +55 -0
  82. package/dist/primitives/skeleton.js.map +7 -0
  83. package/dist/primitives/tag-input.js +186 -0
  84. package/dist/primitives/tag-input.js.map +7 -0
  85. package/dist/primitives/tag.js +66 -13
  86. package/dist/primitives/tag.js.map +2 -2
  87. package/dist/primitives/time-picker.js +793 -0
  88. package/dist/primitives/time-picker.js.map +7 -0
  89. package/package.json +4 -3
  90. package/src/ai/AiAssistantLauncher.tsx +8 -0
  91. package/src/ai/AiChat.tsx +229 -11
  92. package/src/ai/AiDock.tsx +3 -2
  93. package/src/ai/ModelPicker.tsx +248 -0
  94. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +180 -0
  99. package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
  100. package/src/ai/__tests__/AiDock.test.tsx +12 -1
  101. package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
  102. package/src/ai/index.ts +1 -0
  103. package/src/ai/useAiChat.ts +29 -16
  104. package/src/backend/CrudForm.tsx +61 -113
  105. package/src/backend/DataTable.tsx +17 -5
  106. package/src/backend/EmptyState.tsx +13 -63
  107. package/src/backend/FilterOverlay.tsx +38 -22
  108. package/src/backend/FlashMessages.tsx +23 -24
  109. package/src/backend/JsonBuilder.tsx +4 -2
  110. package/src/backend/LogList.tsx +109 -0
  111. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  112. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  113. package/src/backend/__tests__/LogList.test.tsx +163 -0
  114. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  115. package/src/backend/detail/InlineEditors.tsx +78 -25
  116. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  117. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  118. package/src/backend/inputs/DatePicker.tsx +28 -155
  119. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  120. package/src/backend/inputs/TagsInput.tsx +10 -13
  121. package/src/backend/inputs/TimeInput.tsx +2 -1
  122. package/src/backend/inputs/TimePicker.tsx +99 -70
  123. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  124. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  125. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  126. package/src/backend/utils/customFieldForms.ts +2 -2
  127. package/src/portal/components/PortalEmptyState.tsx +27 -22
  128. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  129. package/src/primitives/__tests__/alert.test.tsx +204 -0
  130. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  131. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  132. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  133. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  134. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  135. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  136. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  137. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  138. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  139. package/src/primitives/__tests__/notification.test.tsx +119 -0
  140. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  141. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  142. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  143. package/src/primitives/__tests__/tag.test.tsx +123 -0
  144. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  145. package/src/primitives/accordion.tsx +225 -0
  146. package/src/primitives/alert.tsx +234 -24
  147. package/src/primitives/calendar.tsx +146 -38
  148. package/src/primitives/compact-select.tsx +59 -0
  149. package/src/primitives/counter-input.tsx +211 -0
  150. package/src/primitives/date-picker-helpers.ts +96 -0
  151. package/src/primitives/date-picker.tsx +290 -0
  152. package/src/primitives/date-range-picker.tsx +281 -0
  153. package/src/primitives/digit-input.tsx +208 -0
  154. package/src/primitives/empty-state.tsx +140 -0
  155. package/src/primitives/inline-input.tsx +55 -0
  156. package/src/primitives/inline-select.tsx +74 -0
  157. package/src/primitives/notification-stack.tsx +185 -0
  158. package/src/primitives/notification.tsx +136 -0
  159. package/src/primitives/rich-editor.tsx +1578 -0
  160. package/src/primitives/select.tsx +1 -0
  161. package/src/primitives/skeleton.tsx +70 -0
  162. package/src/primitives/tag-input.tsx +223 -0
  163. package/src/primitives/tag.tsx +69 -10
  164. 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'