@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
@@ -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 { 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 '../../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
- const disabledMatcher = React.useMemo(() => {
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
- return (
131
- <Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>
132
- <PopoverTrigger asChild>
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 { IconButton } from '../../primitives/icon-button'
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
- <span key={tag} className="inline-flex items-center gap-2 rounded-sm bg-muted px-2 py-0.5 text-xs">
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
- <IconButton
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 = 'text-center tabular-nums'
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 { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
8
- import { TimeInput } from './TimeInput'
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 = 1,
46
+ minuteStep = 30,
37
47
  showNowButton = true,
38
- showClearButton = true,
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 handleTimeChange = React.useCallback(
48
- (time: string) => {
49
- onChange(time)
50
- },
51
- [onChange]
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
- const handleNow = React.useCallback(() => {
55
- onChange(currentHHMM())
56
- setOpen(false)
57
- }, [onChange])
92
+ if (!isInteractive) {
93
+ return triggerButton
94
+ }
58
95
 
59
- const handleClear = React.useCallback(() => {
60
- onChange(null)
61
- setOpen(false)
62
- }, [onChange])
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 isInteractive = !disabled && !readOnly
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
- <Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>
68
- <PopoverTrigger asChild>
69
- <button
70
- type="button"
71
- data-crud-focus-target=""
72
- disabled={disabled}
73
- aria-haspopup="dialog"
74
- className={cn(
75
- 'w-full h-9 flex items-center gap-2 rounded border px-3 text-sm text-left',
76
- 'bg-background transition-colors',
77
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
78
- 'disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed',
79
- readOnly && 'cursor-default opacity-70',
80
- !value && 'text-muted-foreground',
81
- className
82
- )}
83
- onClick={isInteractive ? undefined : (e) => e.preventDefault()}
84
- >
85
- <ClockIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
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 HH:MM value in trigger when value is provided', () => {
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('14:30')
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>Hithere</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' = 'uiw'
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' = 'uiw'
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 }