@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3260.1.dc719cac84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +46 -129
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +10 -3
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/EmptyState.js +1 -47
- package/dist/backend/EmptyState.js.map +2 -2
- package/dist/backend/FilterOverlay.js +31 -26
- package/dist/backend/FilterOverlay.js.map +2 -2
- package/dist/backend/FlashMessages.js +23 -25
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +4 -2
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/LogList.js +49 -0
- package/dist/backend/LogList.js.map +7 -0
- package/dist/backend/crud/CollapsibleGroup.js +13 -3
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +80 -32
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +60 -6
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/DatePicker.js +6 -142
- package/dist/backend/inputs/DatePicker.js.map +2 -2
- package/dist/backend/inputs/DateTimePicker.js +6 -182
- package/dist/backend/inputs/DateTimePicker.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +16 -19
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/TimeInput.js +1 -1
- package/dist/backend/inputs/TimeInput.js.map +2 -2
- package/dist/backend/inputs/TimePicker.js +83 -72
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +2 -2
- package/dist/backend/utils/customFieldForms.js.map +1 -1
- package/dist/portal/components/PortalEmptyState.js +13 -9
- package/dist/portal/components/PortalEmptyState.js.map +2 -2
- package/dist/primitives/accordion.js +137 -0
- package/dist/primitives/accordion.js.map +7 -0
- package/dist/primitives/alert.js +180 -12
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/calendar.js +122 -32
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/compact-select.js +47 -0
- package/dist/primitives/compact-select.js.map +7 -0
- package/dist/primitives/counter-input.js +174 -0
- package/dist/primitives/counter-input.js.map +7 -0
- package/dist/primitives/date-picker-helpers.js +79 -0
- package/dist/primitives/date-picker-helpers.js.map +7 -0
- package/dist/primitives/date-picker.js +216 -0
- package/dist/primitives/date-picker.js.map +7 -0
- package/dist/primitives/date-range-picker.js +239 -0
- package/dist/primitives/date-range-picker.js.map +7 -0
- package/dist/primitives/digit-input.js +157 -0
- package/dist/primitives/digit-input.js.map +7 -0
- package/dist/primitives/empty-state.js +93 -0
- package/dist/primitives/empty-state.js.map +7 -0
- package/dist/primitives/inline-input.js +29 -0
- package/dist/primitives/inline-input.js.map +7 -0
- package/dist/primitives/inline-select.js +41 -0
- package/dist/primitives/inline-select.js.map +7 -0
- package/dist/primitives/notification-stack.js +116 -0
- package/dist/primitives/notification-stack.js.map +7 -0
- package/dist/primitives/notification.js +62 -0
- package/dist/primitives/notification.js.map +7 -0
- package/dist/primitives/rich-editor.js +1230 -0
- package/dist/primitives/rich-editor.js.map +7 -0
- package/dist/primitives/select.js +1 -0
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/skeleton.js +55 -0
- package/dist/primitives/skeleton.js.map +7 -0
- package/dist/primitives/tag-input.js +186 -0
- package/dist/primitives/tag-input.js.map +7 -0
- package/dist/primitives/tag.js +66 -13
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/time-picker.js +793 -0
- package/dist/primitives/time-picker.js.map +7 -0
- package/package.json +4 -3
- package/src/backend/CrudForm.tsx +61 -113
- package/src/backend/DataTable.tsx +17 -5
- package/src/backend/EmptyState.tsx +13 -63
- package/src/backend/FilterOverlay.tsx +38 -22
- package/src/backend/FlashMessages.tsx +23 -24
- package/src/backend/JsonBuilder.tsx +4 -2
- package/src/backend/LogList.tsx +109 -0
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
- package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
- package/src/backend/__tests__/LogList.test.tsx +163 -0
- package/src/backend/crud/CollapsibleGroup.tsx +17 -3
- package/src/backend/detail/InlineEditors.tsx +78 -25
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
- package/src/backend/inputs/ComboboxInput.tsx +81 -9
- package/src/backend/inputs/DatePicker.tsx +28 -155
- package/src/backend/inputs/DateTimePicker.tsx +25 -193
- package/src/backend/inputs/TagsInput.tsx +10 -13
- package/src/backend/inputs/TimeInput.tsx +2 -1
- package/src/backend/inputs/TimePicker.tsx +99 -70
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
- package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
- package/src/backend/utils/customFieldForms.ts +2 -2
- package/src/portal/components/PortalEmptyState.tsx +27 -22
- package/src/primitives/__tests__/accordion.test.tsx +197 -0
- package/src/primitives/__tests__/alert.test.tsx +204 -0
- package/src/primitives/__tests__/compact-select.test.tsx +98 -0
- package/src/primitives/__tests__/counter-input.test.tsx +165 -0
- package/src/primitives/__tests__/date-picker.test.tsx +221 -0
- package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
- package/src/primitives/__tests__/digit-input.test.tsx +168 -0
- package/src/primitives/__tests__/empty-state.test.tsx +154 -0
- package/src/primitives/__tests__/inline-input.test.tsx +100 -0
- package/src/primitives/__tests__/inline-select.test.tsx +103 -0
- package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
- package/src/primitives/__tests__/notification.test.tsx +119 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
- package/src/primitives/__tests__/skeleton.test.tsx +76 -0
- package/src/primitives/__tests__/tag-input.test.tsx +198 -0
- package/src/primitives/__tests__/tag.test.tsx +123 -0
- package/src/primitives/__tests__/time-picker.test.tsx +392 -0
- package/src/primitives/accordion.tsx +225 -0
- package/src/primitives/alert.tsx +234 -24
- package/src/primitives/calendar.tsx +146 -38
- package/src/primitives/compact-select.tsx +59 -0
- package/src/primitives/counter-input.tsx +211 -0
- package/src/primitives/date-picker-helpers.ts +96 -0
- package/src/primitives/date-picker.tsx +290 -0
- package/src/primitives/date-range-picker.tsx +281 -0
- package/src/primitives/digit-input.tsx +208 -0
- package/src/primitives/empty-state.tsx +140 -0
- package/src/primitives/inline-input.tsx +55 -0
- package/src/primitives/inline-select.tsx +74 -0
- package/src/primitives/notification-stack.tsx +185 -0
- package/src/primitives/notification.tsx +136 -0
- package/src/primitives/rich-editor.tsx +1578 -0
- package/src/primitives/select.tsx +1 -0
- package/src/primitives/skeleton.tsx +70 -0
- package/src/primitives/tag-input.tsx +223 -0
- package/src/primitives/tag.tsx +69 -10
- package/src/primitives/time-picker.tsx +1059 -0
|
@@ -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 }
|
|
@@ -1,32 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Import `EmptyState` from `@open-mercato/ui/primitives/empty-state`
|
|
3
|
+
* directly with `variant="subtle"` and `size="lg"` (matches the previous
|
|
4
|
+
* portal layout). The standalone `PortalEmptyState` is replaced by the
|
|
5
|
+
* unified primitive in DS Foundation v3.
|
|
6
|
+
*
|
|
7
|
+
* This shim preserves the legacy import path
|
|
8
|
+
* `@open-mercato/ui/portal/components/PortalEmptyState` for existing
|
|
9
|
+
* portal consumers — it forwards to the primitive with the portal-style
|
|
10
|
+
* variant + size pre-set.
|
|
11
|
+
*/
|
|
12
|
+
|
|
1
13
|
"use client"
|
|
2
|
-
import type { ReactNode } from 'react'
|
|
3
14
|
|
|
4
|
-
|
|
5
|
-
|
|
15
|
+
import * as React from 'react'
|
|
16
|
+
import { EmptyState } from '../../primitives/empty-state'
|
|
17
|
+
|
|
18
|
+
export type PortalEmptyStateProps = {
|
|
19
|
+
icon?: React.ReactNode
|
|
6
20
|
title: string
|
|
7
21
|
description?: string
|
|
8
|
-
action?: ReactNode
|
|
22
|
+
action?: React.ReactNode
|
|
9
23
|
}
|
|
10
24
|
|
|
11
|
-
/**
|
|
12
|
-
* Empty state placeholder for portal sections with no content.
|
|
13
|
-
* Dashed border, centered layout, optional icon and CTA.
|
|
14
|
-
*/
|
|
25
|
+
/** @deprecated Use `EmptyState` from `@open-mercato/ui/primitives/empty-state` with `variant="subtle"` and `size="lg"`. */
|
|
15
26
|
export function PortalEmptyState({ icon, title, description, action }: PortalEmptyStateProps) {
|
|
16
27
|
return (
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
{description ? (
|
|
26
|
-
<p className="mt-0.5 max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
27
|
-
) : null}
|
|
28
|
-
</div>
|
|
29
|
-
{action ?? null}
|
|
30
|
-
</div>
|
|
28
|
+
<EmptyState
|
|
29
|
+
variant="subtle"
|
|
30
|
+
size="lg"
|
|
31
|
+
icon={icon}
|
|
32
|
+
title={title}
|
|
33
|
+
description={description}
|
|
34
|
+
actions={action}
|
|
35
|
+
/>
|
|
31
36
|
)
|
|
32
37
|
}
|