@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.
- package/.turbo/turbo-build.log +1 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
- package/dist/ai/AiAssistantLauncher.js +8 -0
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +226 -58
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiDock.js +3 -2
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ModelPicker.js +186 -0
- package/dist/ai/ModelPicker.js.map +7 -0
- package/dist/ai/index.js +3 -1
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiChat.js +12 -16
- package/dist/ai/useAiChat.js.map +2 -2
- 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/ai/AiAssistantLauncher.tsx +8 -0
- package/src/ai/AiChat.tsx +229 -11
- package/src/ai/AiDock.tsx +3 -2
- package/src/ai/ModelPicker.tsx +248 -0
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.test.tsx +180 -0
- package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
- package/src/ai/__tests__/AiDock.test.tsx +12 -1
- package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/useAiChat.ts +29 -16
- 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
|
@@ -1,199 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Import `DatePicker` from `@open-mercato/ui/primitives/date-picker`
|
|
3
|
+
* and pass `withTime` instead. The standalone `DateTimePicker` is replaced by
|
|
4
|
+
* a `withTime` prop on the unified primitive in DS Foundation v3.
|
|
5
|
+
*
|
|
6
|
+
* Default footer is the Figma-aligned `'apply-cancel'` mode (per user
|
|
7
|
+
* directive 2026-05-09 — "zmień globalnie"). Consumers that explicitly
|
|
8
|
+
* need the legacy `Today` / `Clear` footer can pass `footer="today-clear"`.
|
|
9
|
+
*
|
|
10
|
+
* Migration: replace
|
|
11
|
+
* import { DateTimePicker } from '@open-mercato/ui/backend/inputs/DateTimePicker'
|
|
12
|
+
* <DateTimePicker value={...} onChange={...} />
|
|
13
|
+
* with
|
|
14
|
+
* import { DatePicker } from '@open-mercato/ui/primitives/date-picker'
|
|
15
|
+
* <DatePicker value={...} onChange={...} withTime />
|
|
16
|
+
*/
|
|
17
|
+
|
|
1
18
|
"use client"
|
|
2
19
|
|
|
3
20
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
9
|
-
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
|
|
10
|
-
import { Calendar } from '../../primitives/calendar'
|
|
11
|
-
import { TimeInput } from './TimeInput'
|
|
12
|
-
|
|
13
|
-
export type DateTimePickerProps = {
|
|
14
|
-
value?: Date | null
|
|
15
|
-
onChange: (date: Date | null) => void
|
|
16
|
-
placeholder?: string
|
|
17
|
-
disabled?: boolean
|
|
18
|
-
readOnly?: boolean
|
|
19
|
-
className?: string
|
|
20
|
-
locale?: Locale
|
|
21
|
-
displayFormat?: string
|
|
22
|
-
minuteStep?: number
|
|
23
|
-
showTodayButton?: boolean
|
|
24
|
-
showClearButton?: boolean
|
|
25
|
-
minDate?: Date
|
|
26
|
-
maxDate?: Date
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const DAY_FIRST_LOCALE_CODES = new Set([
|
|
30
|
-
'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'ru', 'cs', 'sk', 'hu', 'ro',
|
|
31
|
-
])
|
|
32
|
-
|
|
33
|
-
function deriveDisplayFormat(locale?: Locale): string {
|
|
34
|
-
if (!locale) return 'MMM d, yyyy HH:mm'
|
|
35
|
-
const code = locale.code?.split('-')[0]?.toLowerCase() ?? ''
|
|
36
|
-
return DAY_FIRST_LOCALE_CODES.has(code) ? 'd MMM yyyy HH:mm' : 'MMM d, yyyy HH:mm'
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function extractTime(date: Date): string {
|
|
40
|
-
const hour = String(date.getHours()).padStart(2, '0')
|
|
41
|
-
const minute = String(date.getMinutes()).padStart(2, '0')
|
|
42
|
-
return `${hour}:${minute}`
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function applyTimeToDate(base: Date, time: string): Date {
|
|
46
|
-
const parts = time.split(':')
|
|
47
|
-
const hour = parseInt(parts[0] ?? '0', 10)
|
|
48
|
-
const minute = parseInt(parts[1] ?? '0', 10)
|
|
49
|
-
const next = new Date(base)
|
|
50
|
-
next.setHours(isNaN(hour) ? 0 : hour)
|
|
51
|
-
next.setMinutes(isNaN(minute) ? 0 : minute)
|
|
52
|
-
next.setSeconds(0)
|
|
53
|
-
next.setMilliseconds(0)
|
|
54
|
-
return next
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function DateTimePicker({
|
|
58
|
-
value,
|
|
59
|
-
onChange,
|
|
60
|
-
placeholder,
|
|
61
|
-
disabled = false,
|
|
62
|
-
readOnly = false,
|
|
63
|
-
className,
|
|
64
|
-
locale,
|
|
65
|
-
displayFormat,
|
|
66
|
-
minuteStep = 1,
|
|
67
|
-
showTodayButton = true,
|
|
68
|
-
showClearButton = true,
|
|
69
|
-
minDate,
|
|
70
|
-
maxDate,
|
|
71
|
-
}: DateTimePickerProps) {
|
|
72
|
-
const t = useT()
|
|
73
|
-
const [open, setOpen] = React.useState(false)
|
|
74
|
-
|
|
75
|
-
const resolvedFormat = displayFormat ?? deriveDisplayFormat(locale)
|
|
76
|
-
const placeholderText = placeholder ?? t('ui.dateTimePicker.placeholder', 'Pick date and time')
|
|
77
|
-
const timeLabelText = t('ui.dateTimePicker.timeLabel', 'Time')
|
|
78
|
-
const todayText = t('ui.dateTimePicker.todayButton', 'Today')
|
|
79
|
-
const clearText = t('ui.dateTimePicker.clearButton', 'Clear')
|
|
80
|
-
|
|
81
|
-
const formattedValue = React.useMemo(() => {
|
|
82
|
-
if (!value) return null
|
|
83
|
-
try {
|
|
84
|
-
return format(value, resolvedFormat, locale ? { locale } : undefined)
|
|
85
|
-
} catch {
|
|
86
|
-
return null
|
|
87
|
-
}
|
|
88
|
-
}, [value, resolvedFormat, locale])
|
|
89
|
-
|
|
90
|
-
const handleDaySelect = React.useCallback(
|
|
91
|
-
(day: Date | undefined) => {
|
|
92
|
-
if (!day) return
|
|
93
|
-
const currentTime = value ? extractTime(value) : '00:00'
|
|
94
|
-
onChange(applyTimeToDate(day, currentTime))
|
|
95
|
-
},
|
|
96
|
-
[onChange, value]
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
const handleTimeChange = React.useCallback(
|
|
100
|
-
(time: string) => {
|
|
101
|
-
const base = value ?? null
|
|
102
|
-
if (!base) return
|
|
103
|
-
onChange(applyTimeToDate(base, time))
|
|
104
|
-
},
|
|
105
|
-
[onChange, value]
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
const handleToday = React.useCallback(() => {
|
|
109
|
-
const now = new Date()
|
|
110
|
-
const currentTime = value ? extractTime(value) : extractTime(now)
|
|
111
|
-
onChange(applyTimeToDate(now, currentTime))
|
|
112
|
-
setOpen(false)
|
|
113
|
-
}, [onChange, value])
|
|
114
|
-
|
|
115
|
-
const handleClear = React.useCallback(() => {
|
|
116
|
-
onChange(null)
|
|
117
|
-
setOpen(false)
|
|
118
|
-
}, [onChange])
|
|
119
|
-
|
|
120
|
-
const isInteractive = !disabled && !readOnly
|
|
21
|
+
import {
|
|
22
|
+
DatePicker as DatePickerPrimitive,
|
|
23
|
+
type DatePickerProps as DatePickerPrimitiveProps,
|
|
24
|
+
} from '../../primitives/date-picker'
|
|
121
25
|
|
|
122
|
-
|
|
123
|
-
if (!minDate && !maxDate) return undefined
|
|
124
|
-
const matchers: import('react-day-picker').Matcher[] = []
|
|
125
|
-
if (minDate) matchers.push({ before: minDate })
|
|
126
|
-
if (maxDate) matchers.push({ after: maxDate })
|
|
127
|
-
return matchers
|
|
128
|
-
}, [minDate, maxDate])
|
|
26
|
+
export type DateTimePickerProps = Omit<DatePickerPrimitiveProps, 'withTime'>
|
|
129
27
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
<button
|
|
134
|
-
type="button"
|
|
135
|
-
data-crud-focus-target=""
|
|
136
|
-
disabled={disabled}
|
|
137
|
-
aria-haspopup="dialog"
|
|
138
|
-
className={cn(
|
|
139
|
-
'w-full h-9 flex items-center gap-2 rounded border px-3 text-sm text-left',
|
|
140
|
-
'bg-background transition-colors',
|
|
141
|
-
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
|
142
|
-
'disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed',
|
|
143
|
-
readOnly && 'cursor-default opacity-70',
|
|
144
|
-
!formattedValue && 'text-muted-foreground',
|
|
145
|
-
className
|
|
146
|
-
)}
|
|
147
|
-
onClick={isInteractive ? undefined : (e) => e.preventDefault()}
|
|
148
|
-
>
|
|
149
|
-
<CalendarIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
150
|
-
<span className="flex-1 truncate">
|
|
151
|
-
{formattedValue ?? placeholderText}
|
|
152
|
-
</span>
|
|
153
|
-
</button>
|
|
154
|
-
</PopoverTrigger>
|
|
155
|
-
<PopoverContent className="p-0 w-auto">
|
|
156
|
-
<Calendar
|
|
157
|
-
mode="single"
|
|
158
|
-
selected={value ?? undefined}
|
|
159
|
-
onSelect={handleDaySelect}
|
|
160
|
-
locale={locale}
|
|
161
|
-
disabled={disabledMatcher}
|
|
162
|
-
initialFocus
|
|
163
|
-
/>
|
|
164
|
-
<div className="border-t px-3 py-2 space-y-2">
|
|
165
|
-
<div className="flex items-center gap-2">
|
|
166
|
-
<span className="text-sm text-muted-foreground shrink-0">{timeLabelText}:</span>
|
|
167
|
-
<TimeInput
|
|
168
|
-
value={value ? extractTime(value) : undefined}
|
|
169
|
-
onChange={handleTimeChange}
|
|
170
|
-
minuteStep={minuteStep}
|
|
171
|
-
/>
|
|
172
|
-
</div>
|
|
173
|
-
{(showTodayButton || showClearButton) && (
|
|
174
|
-
<div className="flex items-center justify-between gap-2">
|
|
175
|
-
{showTodayButton && (
|
|
176
|
-
<button
|
|
177
|
-
type="button"
|
|
178
|
-
onClick={handleToday}
|
|
179
|
-
className="text-sm text-primary hover:underline focus-visible:outline-none"
|
|
180
|
-
>
|
|
181
|
-
{todayText}
|
|
182
|
-
</button>
|
|
183
|
-
)}
|
|
184
|
-
{showClearButton && (
|
|
185
|
-
<button
|
|
186
|
-
type="button"
|
|
187
|
-
onClick={handleClear}
|
|
188
|
-
className="text-sm text-muted-foreground hover:text-foreground hover:underline focus-visible:outline-none ml-auto"
|
|
189
|
-
>
|
|
190
|
-
{clearText}
|
|
191
|
-
</button>
|
|
192
|
-
)}
|
|
193
|
-
</div>
|
|
194
|
-
)}
|
|
195
|
-
</div>
|
|
196
|
-
</PopoverContent>
|
|
197
|
-
</Popover>
|
|
198
|
-
)
|
|
28
|
+
/** @deprecated Use `DatePicker withTime` from `@open-mercato/ui/primitives/date-picker`. */
|
|
29
|
+
export function DateTimePicker(props: DateTimePickerProps) {
|
|
30
|
+
return <DatePickerPrimitive {...props} withTime />
|
|
199
31
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
5
|
import { Button } from '../../primitives/button'
|
|
6
|
-
import {
|
|
6
|
+
import { Tag } from '../../primitives/tag'
|
|
7
7
|
|
|
8
8
|
export type TagsInputOption = {
|
|
9
9
|
value: string
|
|
@@ -208,24 +208,21 @@ export function TagsInput({
|
|
|
208
208
|
const label = option?.label ?? tag
|
|
209
209
|
const description = option?.description
|
|
210
210
|
return (
|
|
211
|
-
<
|
|
211
|
+
<Tag
|
|
212
|
+
key={tag}
|
|
213
|
+
shape="square"
|
|
214
|
+
variant="default"
|
|
215
|
+
disabled={disabled}
|
|
216
|
+
onRemove={() => removeTag(tag)}
|
|
217
|
+
removeAriaLabel={t('ui.inputs.tagsInput.removeTag', 'Remove {label}', { label })}
|
|
218
|
+
>
|
|
212
219
|
<span className="flex flex-col items-start leading-tight">
|
|
213
220
|
<span className="whitespace-nowrap">{label}</span>
|
|
214
221
|
{description ? (
|
|
215
222
|
<span className="text-overline text-muted-foreground">{description}</span>
|
|
216
223
|
) : null}
|
|
217
224
|
</span>
|
|
218
|
-
|
|
219
|
-
type="button"
|
|
220
|
-
variant="ghost"
|
|
221
|
-
size="xs"
|
|
222
|
-
className="opacity-60 hover:opacity-100"
|
|
223
|
-
onClick={() => removeTag(tag)}
|
|
224
|
-
disabled={disabled}
|
|
225
|
-
>
|
|
226
|
-
×
|
|
227
|
-
</IconButton>
|
|
228
|
-
</span>
|
|
225
|
+
</Tag>
|
|
229
226
|
)
|
|
230
227
|
})}
|
|
231
228
|
<input
|
|
@@ -109,7 +109,8 @@ export function TimeInput({
|
|
|
109
109
|
)
|
|
110
110
|
|
|
111
111
|
const wrapperClass = 'w-14'
|
|
112
|
-
const innerInputClass =
|
|
112
|
+
const innerInputClass =
|
|
113
|
+
'text-center tabular-nums leading-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0'
|
|
113
114
|
|
|
114
115
|
return (
|
|
115
116
|
<div className={cn('flex items-center gap-1', className)}>
|
|
@@ -4,8 +4,10 @@ import * as React from 'react'
|
|
|
4
4
|
import { ClockIcon } from 'lucide-react'
|
|
5
5
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
6
6
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
TimePicker as TimePickerPrimitive,
|
|
9
|
+
formatTimePickerDisplay,
|
|
10
|
+
} from '../../primitives/time-picker'
|
|
9
11
|
|
|
10
12
|
export type TimePickerProps = {
|
|
11
13
|
value?: string | null
|
|
@@ -26,6 +28,14 @@ function currentHHMM(): string {
|
|
|
26
28
|
return `${hour}:${minute}`
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Legacy popover-anchored time picker. Preserved for backward compatibility with
|
|
33
|
+
* existing CrudForm `type: 'time'` consumers and DateTimePicker. Internally a shim
|
|
34
|
+
* over the new `<TimePicker>` primitive from `@open-mercato/ui/primitives/time-picker`.
|
|
35
|
+
*
|
|
36
|
+
* `minuteStep` maps to `intervalMinutes` (default 30 for sane slot list size).
|
|
37
|
+
* `showNowButton` / `showClearButton` render as `legacyFooterActions`.
|
|
38
|
+
*/
|
|
29
39
|
export function TimePicker({
|
|
30
40
|
value,
|
|
31
41
|
onChange,
|
|
@@ -33,9 +43,13 @@ export function TimePicker({
|
|
|
33
43
|
disabled = false,
|
|
34
44
|
readOnly = false,
|
|
35
45
|
className,
|
|
36
|
-
minuteStep =
|
|
46
|
+
minuteStep = 30,
|
|
37
47
|
showNowButton = true,
|
|
38
|
-
|
|
48
|
+
// Default changed 2026-05-11 from `true` → `false`: the new primitive's Cancel
|
|
49
|
+
// button already exits the popover without committing, which is what users mean
|
|
50
|
+
// by "Clear" in most flows. Pass `showClearButton={true}` to opt back in when
|
|
51
|
+
// you need an explicit "set value to null" action distinct from "dismiss".
|
|
52
|
+
showClearButton = false,
|
|
39
53
|
}: TimePickerProps) {
|
|
40
54
|
const t = useT()
|
|
41
55
|
const [open, setOpen] = React.useState(false)
|
|
@@ -43,78 +57,93 @@ export function TimePicker({
|
|
|
43
57
|
const placeholderText = placeholder ?? t('ui.timePicker.placeholder', 'Pick a time')
|
|
44
58
|
const nowText = t('ui.timePicker.nowButton', 'Now')
|
|
45
59
|
const clearText = t('ui.timePicker.clearButton', 'Clear')
|
|
60
|
+
const isInteractive = !disabled && !readOnly
|
|
61
|
+
|
|
62
|
+
// Render the trigger label in the same 12h "HH:MM AM/PM" format as the slot list
|
|
63
|
+
// inside the popover so users see one consistent representation.
|
|
64
|
+
const displayValue = value
|
|
65
|
+
? (() => {
|
|
66
|
+
const { main, suffix } = formatTimePickerDisplay(value, '12h')
|
|
67
|
+
return suffix ? `${main} ${suffix}` : main
|
|
68
|
+
})()
|
|
69
|
+
: null
|
|
46
70
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
const triggerButton = (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
data-crud-focus-target=""
|
|
75
|
+
disabled={disabled}
|
|
76
|
+
aria-haspopup="dialog"
|
|
77
|
+
className={cn(
|
|
78
|
+
'w-full h-10 flex items-center gap-2 rounded-md border bg-background px-3 text-sm text-left transition-colors',
|
|
79
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
|
80
|
+
'disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed',
|
|
81
|
+
readOnly && 'cursor-default opacity-70',
|
|
82
|
+
!value && 'text-muted-foreground',
|
|
83
|
+
className,
|
|
84
|
+
)}
|
|
85
|
+
onClick={!isInteractive ? (event) => event.preventDefault() : undefined}
|
|
86
|
+
>
|
|
87
|
+
<ClockIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
88
|
+
<span className="flex-1 truncate">{displayValue ?? placeholderText}</span>
|
|
89
|
+
</button>
|
|
52
90
|
)
|
|
53
91
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}, [onChange])
|
|
92
|
+
if (!isInteractive) {
|
|
93
|
+
return triggerButton
|
|
94
|
+
}
|
|
58
95
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
96
|
+
const pinnedTopActions: Array<{
|
|
97
|
+
label: string
|
|
98
|
+
onClick: () => void
|
|
99
|
+
icon?: React.ReactNode
|
|
100
|
+
rightText?: string
|
|
101
|
+
}> = []
|
|
102
|
+
if (showNowButton) {
|
|
103
|
+
const nowValue = currentHHMM()
|
|
104
|
+
const { main: nowMain, suffix: nowSuffix } = formatTimePickerDisplay(nowValue, '12h')
|
|
105
|
+
pinnedTopActions.push({
|
|
106
|
+
label: nowText,
|
|
107
|
+
icon: <ClockIcon aria-hidden="true" />,
|
|
108
|
+
rightText: nowSuffix ? `${nowMain} ${nowSuffix}` : nowMain,
|
|
109
|
+
onClick: () => {
|
|
110
|
+
onChange(nowValue)
|
|
111
|
+
setOpen(false)
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
63
115
|
|
|
64
|
-
const
|
|
116
|
+
const legacyFooterActions: Array<{ label: string; onClick: () => void; variant: 'link' | 'muted' }> = []
|
|
117
|
+
if (showClearButton) {
|
|
118
|
+
legacyFooterActions.push({
|
|
119
|
+
label: clearText,
|
|
120
|
+
onClick: () => {
|
|
121
|
+
onChange(null)
|
|
122
|
+
setOpen(false)
|
|
123
|
+
},
|
|
124
|
+
variant: 'muted',
|
|
125
|
+
})
|
|
126
|
+
}
|
|
65
127
|
|
|
66
128
|
return (
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<span className="flex-1 truncate">{value ?? placeholderText}</span>
|
|
87
|
-
</button>
|
|
88
|
-
</PopoverTrigger>
|
|
89
|
-
<PopoverContent className="p-3 w-auto min-w-[180px]">
|
|
90
|
-
<TimeInput
|
|
91
|
-
value={value}
|
|
92
|
-
onChange={handleTimeChange}
|
|
93
|
-
minuteStep={minuteStep}
|
|
94
|
-
/>
|
|
95
|
-
{(showNowButton || showClearButton) && (
|
|
96
|
-
<div className="flex items-center justify-between gap-2 mt-3 pt-2 border-t">
|
|
97
|
-
{showNowButton && (
|
|
98
|
-
<button
|
|
99
|
-
type="button"
|
|
100
|
-
onClick={handleNow}
|
|
101
|
-
className="text-sm text-primary hover:underline focus-visible:outline-none"
|
|
102
|
-
>
|
|
103
|
-
{nowText}
|
|
104
|
-
</button>
|
|
105
|
-
)}
|
|
106
|
-
{showClearButton && (
|
|
107
|
-
<button
|
|
108
|
-
type="button"
|
|
109
|
-
onClick={handleClear}
|
|
110
|
-
className="text-sm text-muted-foreground hover:text-foreground hover:underline focus-visible:outline-none ml-auto"
|
|
111
|
-
>
|
|
112
|
-
{clearText}
|
|
113
|
-
</button>
|
|
114
|
-
)}
|
|
115
|
-
</div>
|
|
116
|
-
)}
|
|
117
|
-
</PopoverContent>
|
|
118
|
-
</Popover>
|
|
129
|
+
<TimePickerPrimitive
|
|
130
|
+
value={value ?? null}
|
|
131
|
+
onChange={(next) => onChange(next)}
|
|
132
|
+
onApply={(next) => {
|
|
133
|
+
onChange(next)
|
|
134
|
+
}}
|
|
135
|
+
intervalMinutes={Math.max(1, minuteStep)}
|
|
136
|
+
// Trigger button already displays current value — don't duplicate it
|
|
137
|
+
// inside the popover header.
|
|
138
|
+
showHeader={false}
|
|
139
|
+
showFooter
|
|
140
|
+
headerPlaceholder={placeholderText}
|
|
141
|
+
pinnedTopActions={pinnedTopActions.length > 0 ? pinnedTopActions : undefined}
|
|
142
|
+
legacyFooterActions={legacyFooterActions.length > 0 ? legacyFooterActions : undefined}
|
|
143
|
+
trigger={triggerButton}
|
|
144
|
+
open={open}
|
|
145
|
+
onOpenChange={setOpen}
|
|
146
|
+
disabled={disabled}
|
|
147
|
+
/>
|
|
119
148
|
)
|
|
120
149
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { act, render, fireEvent, waitFor } from '@testing-library/react'
|
|
3
|
+
import { ComboboxInput } from '../ComboboxInput'
|
|
4
|
+
|
|
5
|
+
function getInput(container: HTMLElement): HTMLInputElement {
|
|
6
|
+
const el = container.querySelector('input')
|
|
7
|
+
if (!el) throw new Error('input not found')
|
|
8
|
+
return el as HTMLInputElement
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('ComboboxInput — eager label resolution', () => {
|
|
12
|
+
it('renders the raw value when nothing can resolve it', () => {
|
|
13
|
+
const { container } = render(<ComboboxInput value="uuid-123" onChange={() => {}} />)
|
|
14
|
+
expect(getInput(container).value).toBe('uuid-123')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('hydrates the label from seedOptions without any interaction', () => {
|
|
18
|
+
const { container } = render(
|
|
19
|
+
<ComboboxInput
|
|
20
|
+
value="uuid-123"
|
|
21
|
+
onChange={() => {}}
|
|
22
|
+
seedOptions={[{ value: 'uuid-123', label: 'Acme Corp' }]}
|
|
23
|
+
/>,
|
|
24
|
+
)
|
|
25
|
+
expect(getInput(container).value).toBe('Acme Corp')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('resolves the label via resolveLabel (async) on mount', async () => {
|
|
29
|
+
const resolveLabel = jest.fn(async (value: string) => `Resolved ${value}`)
|
|
30
|
+
const { container } = render(
|
|
31
|
+
<ComboboxInput value="uuid-123" onChange={() => {}} resolveLabel={resolveLabel} />,
|
|
32
|
+
)
|
|
33
|
+
// before the promise resolves, falls back to the raw value
|
|
34
|
+
expect(getInput(container).value).toBe('uuid-123')
|
|
35
|
+
await waitFor(() => expect(getInput(container).value).toBe('Resolved uuid-123'))
|
|
36
|
+
expect(resolveLabel).toHaveBeenCalledWith('uuid-123')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('resolves the label via a synchronous resolveLabel', () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<ComboboxInput
|
|
42
|
+
value="uuid-123"
|
|
43
|
+
onChange={() => {}}
|
|
44
|
+
resolveLabel={(value) => (value === 'uuid-123' ? 'Sync Label' : value)}
|
|
45
|
+
/>,
|
|
46
|
+
)
|
|
47
|
+
expect(getInput(container).value).toBe('Sync Label')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('does not call resolveLabel when the value is already covered by suggestions', () => {
|
|
51
|
+
const resolveLabel = jest.fn(() => 'should-not-be-used')
|
|
52
|
+
render(
|
|
53
|
+
<ComboboxInput
|
|
54
|
+
value="uuid-123"
|
|
55
|
+
onChange={() => {}}
|
|
56
|
+
suggestions={[{ value: 'uuid-123', label: 'Already Known' }]}
|
|
57
|
+
resolveLabel={resolveLabel}
|
|
58
|
+
/>,
|
|
59
|
+
)
|
|
60
|
+
expect(resolveLabel).not.toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('falls back to loadSuggestions() (no query) when resolveLabel is absent', async () => {
|
|
64
|
+
const loadSuggestions = jest.fn(async () => [{ value: 'uuid-123', label: 'From Loader' }])
|
|
65
|
+
const { container } = render(
|
|
66
|
+
<ComboboxInput value="uuid-123" onChange={() => {}} loadSuggestions={loadSuggestions} />,
|
|
67
|
+
)
|
|
68
|
+
await waitFor(() => expect(getInput(container).value).toBe('From Loader'))
|
|
69
|
+
expect(loadSuggestions).toHaveBeenCalledWith()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps the resolved label after a blur revert when custom values are disallowed', async () => {
|
|
73
|
+
const onChange = jest.fn()
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<ComboboxInput
|
|
76
|
+
value="uuid-123"
|
|
77
|
+
onChange={onChange}
|
|
78
|
+
allowCustomValues={false}
|
|
79
|
+
resolveLabel={async () => 'Acme Corp'}
|
|
80
|
+
/>,
|
|
81
|
+
)
|
|
82
|
+
await waitFor(() => expect(getInput(container).value).toBe('Acme Corp'))
|
|
83
|
+
const input = getInput(container)
|
|
84
|
+
act(() => {
|
|
85
|
+
fireEvent.change(input, { target: { value: 'partial typing' } })
|
|
86
|
+
fireEvent.blur(input)
|
|
87
|
+
})
|
|
88
|
+
await act(async () => {
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
90
|
+
})
|
|
91
|
+
expect(getInput(container).value).toBe('Acme Corp')
|
|
92
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('does not bake a placeholder back into the input on blur while resolution is pending', async () => {
|
|
96
|
+
let resolve: (label: string) => void = () => {}
|
|
97
|
+
const resolveLabel = jest.fn(
|
|
98
|
+
() => new Promise<string>((res) => { resolve = res }),
|
|
99
|
+
)
|
|
100
|
+
const { container } = render(
|
|
101
|
+
<ComboboxInput
|
|
102
|
+
value="uuid-123"
|
|
103
|
+
onChange={() => {}}
|
|
104
|
+
allowCustomValues={false}
|
|
105
|
+
resolveLabel={resolveLabel}
|
|
106
|
+
/>,
|
|
107
|
+
)
|
|
108
|
+
const input = getInput(container)
|
|
109
|
+
// user clears the field before resolution lands, then blurs
|
|
110
|
+
act(() => {
|
|
111
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
112
|
+
fireEvent.blur(input)
|
|
113
|
+
})
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await new Promise((res) => setTimeout(res, 250))
|
|
116
|
+
})
|
|
117
|
+
// the raw uuid must not have been written into the visible input
|
|
118
|
+
expect(getInput(container).value).not.toBe('uuid-123')
|
|
119
|
+
// once resolution arrives, the label shows up
|
|
120
|
+
act(() => resolve('Acme Corp'))
|
|
121
|
+
await waitFor(() => expect(getInput(container).value).toBe('Acme Corp'))
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('updates the displayed label when the value changes to one resolvable via resolveLabel', async () => {
|
|
125
|
+
const resolveLabel = jest.fn(async (value: string) => `Label-${value}`)
|
|
126
|
+
const { container, rerender } = render(
|
|
127
|
+
<ComboboxInput value="a" onChange={() => {}} resolveLabel={resolveLabel} />,
|
|
128
|
+
)
|
|
129
|
+
await waitFor(() => expect(getInput(container).value).toBe('Label-a'))
|
|
130
|
+
rerender(<ComboboxInput value="b" onChange={() => {}} resolveLabel={resolveLabel} />)
|
|
131
|
+
await waitFor(() => expect(getInput(container).value).toBe('Label-b'))
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -23,9 +23,9 @@ describe('TimePicker SSR render', () => {
|
|
|
23
23
|
expect(html).toContain('Pick a time')
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
it('shows
|
|
26
|
+
it('shows 12h-formatted value in trigger when value is provided (matches slot list)', () => {
|
|
27
27
|
const html = render(<TimePicker value="14:30" onChange={jest.fn()} />)
|
|
28
|
-
expect(html).toContain('
|
|
28
|
+
expect(html).toContain('02:30 PM')
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
it('renders trigger button with aria-haspopup="dialog"', () => {
|
|
@@ -8,9 +8,12 @@ describe('richTextSanitizer', () => {
|
|
|
8
8
|
})
|
|
9
9
|
|
|
10
10
|
it('removes executable content and unsafe attributes', () => {
|
|
11
|
+
// `<img>` is allowlisted (RichEditor `insertImage` toolbar action), so
|
|
12
|
+
// the tag stays but `onerror` is stripped and `color:red` (CSS keyword,
|
|
13
|
+
// not in the hex/rgb regex) falls away too.
|
|
11
14
|
expect(
|
|
12
15
|
sanitizeHtmlRichText('<p onclick="alert(1)" style="color:red">Hi<script>alert(1)</script><img src=x onerror=alert(2)>there</p>'),
|
|
13
|
-
).toBe('<p>
|
|
16
|
+
).toBe('<p>Hi<img src="x" />there</p>')
|
|
14
17
|
})
|
|
15
18
|
|
|
16
19
|
it('removes unsafe href values from links', () => {
|
|
@@ -40,7 +43,7 @@ describe('richTextSanitizer', () => {
|
|
|
40
43
|
),
|
|
41
44
|
).toEqual({
|
|
42
45
|
command: 'insertHTML',
|
|
43
|
-
value: '<p>Safe text</p><a>Bad link</a>',
|
|
46
|
+
value: '<p>Safe text</p><img src="x" /><a>Bad link</a>',
|
|
44
47
|
})
|
|
45
48
|
})
|
|
46
49
|
|
|
@@ -85,7 +85,7 @@ export function buildFormFieldFromCustomFieldDef(
|
|
|
85
85
|
case 'datetime':
|
|
86
86
|
return { id, label, type: 'datetime', ...baseProps }
|
|
87
87
|
case 'multiline': {
|
|
88
|
-
let editor: 'simple' | 'uiw' | 'html' = '
|
|
88
|
+
let editor: 'simple' | 'uiw' | 'html' = 'html'
|
|
89
89
|
if (def.editor === 'simpleMarkdown') editor = 'simple'
|
|
90
90
|
else if (def.editor === 'htmlRichText') editor = 'html'
|
|
91
91
|
return { id, label, type: 'richtext', ...baseProps, editor }
|
|
@@ -136,7 +136,7 @@ export function buildFormFieldFromCustomFieldDef(
|
|
|
136
136
|
return base
|
|
137
137
|
}
|
|
138
138
|
if (def.kind === 'text' && typeof def.editor === 'string' && def.editor) {
|
|
139
|
-
let editor: 'simple' | 'uiw' | 'html' = '
|
|
139
|
+
let editor: 'simple' | 'uiw' | 'html' = 'html'
|
|
140
140
|
if (def.editor === 'simpleMarkdown') editor = 'simple'
|
|
141
141
|
else if (def.editor === 'htmlRichText') editor = 'html'
|
|
142
142
|
return { id, label, type: 'richtext', ...baseProps, editor }
|