@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
|
@@ -75,11 +75,21 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
|
|
|
75
75
|
<div
|
|
76
76
|
id={`collapsible-group-wrapper-${groupId}`}
|
|
77
77
|
className={cn(
|
|
78
|
-
|
|
78
|
+
// Aligned with Figma Accordion `210:4022`: rounded-10, three visual
|
|
79
|
+
// states (closed = white card + soft border + x-small shadow,
|
|
80
|
+
// hover-while-closed = bg-muted + no border + no shadow,
|
|
81
|
+
// open = bg-muted + no border + no shadow). Destructive border
|
|
82
|
+
// wins over the Figma states when the group has validation errors.
|
|
83
|
+
'rounded-[10px] border bg-card transition-colors',
|
|
79
84
|
!isHydrated && 'invisible',
|
|
80
|
-
errorCount > 0
|
|
85
|
+
errorCount > 0
|
|
86
|
+
? 'border-destructive'
|
|
87
|
+
: expanded
|
|
88
|
+
? 'border-transparent bg-muted shadow-none'
|
|
89
|
+
: 'border-border shadow-xs hover:border-transparent hover:bg-muted hover:shadow-none',
|
|
81
90
|
)}
|
|
82
91
|
data-collapsible-group-id={groupId}
|
|
92
|
+
data-state={expanded ? 'open' : 'closed'}
|
|
83
93
|
data-persistence-hydrated={isHydrated ? 'true' : 'false'}
|
|
84
94
|
aria-hidden={isHydrated ? undefined : true}
|
|
85
95
|
>
|
|
@@ -96,7 +106,11 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
|
|
|
96
106
|
variant="muted"
|
|
97
107
|
onClick={toggle}
|
|
98
108
|
className={cn(
|
|
99
|
-
|
|
109
|
+
// Defer hover styling to the surrounding wrapper so the whole
|
|
110
|
+
// row toggles to `bg-muted` together (Figma Accordion behaviour)
|
|
111
|
+
// instead of stacking a second hover layer over the trigger
|
|
112
|
+
// button.
|
|
113
|
+
'flex-1 rounded-md px-2 py-1 text-sm font-medium hover:bg-transparent dark:hover:bg-transparent',
|
|
100
114
|
chevronPosition === 'left' ? 'justify-start gap-2' : 'justify-between',
|
|
101
115
|
)}
|
|
102
116
|
aria-expanded={expanded}
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
SelectValue,
|
|
15
15
|
} from '@open-mercato/ui/primitives/select'
|
|
16
16
|
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
17
|
+
import { DatePicker } from '@open-mercato/ui/primitives/date-picker'
|
|
17
18
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
18
19
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
20
|
+
import { format } from 'date-fns'
|
|
19
21
|
import { LoadingMessage } from './LoadingMessage'
|
|
20
22
|
import { mapCrudServerErrorToFormErrors } from '../utils/serverErrors'
|
|
21
23
|
import { MarkdownPreview } from '../markdown'
|
|
@@ -317,33 +319,82 @@ export function InlineTextEditor({
|
|
|
317
319
|
}
|
|
318
320
|
}}
|
|
319
321
|
>
|
|
320
|
-
{
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
322
|
+
{(() => {
|
|
323
|
+
const lowerType = (inputType ?? resolvedType ?? '').toLowerCase()
|
|
324
|
+
const isDate = lowerType === 'date'
|
|
325
|
+
const isDateTime = lowerType === 'datetime-local' || lowerType === 'datetime'
|
|
326
|
+
if (resolvedType === 'tel') {
|
|
327
|
+
return (
|
|
328
|
+
<PhoneNumberField
|
|
329
|
+
value={draft.length ? draft : undefined}
|
|
330
|
+
onValueChange={(next) => {
|
|
331
|
+
if (error) setError(null)
|
|
332
|
+
setDraft(next ?? '')
|
|
333
|
+
}}
|
|
334
|
+
placeholder={placeholder}
|
|
335
|
+
autoFocus
|
|
336
|
+
disabled={saving}
|
|
337
|
+
minDigits={7}
|
|
338
|
+
/>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
if (isDate || isDateTime) {
|
|
342
|
+
let parsed: Date | null = null
|
|
343
|
+
if (draft && draft.length) {
|
|
344
|
+
const candidate = new Date(draft)
|
|
345
|
+
if (!Number.isNaN(candidate.getTime())) parsed = candidate
|
|
346
|
+
}
|
|
347
|
+
return (
|
|
348
|
+
<DatePicker
|
|
349
|
+
value={parsed}
|
|
350
|
+
onChange={(date) => {
|
|
351
|
+
if (error) setError(null)
|
|
352
|
+
const formatted = !date
|
|
353
|
+
? ''
|
|
354
|
+
: isDateTime
|
|
355
|
+
? format(date, "yyyy-MM-dd'T'HH:mm")
|
|
356
|
+
: format(date, 'yyyy-MM-dd')
|
|
357
|
+
setDraft(formatted)
|
|
358
|
+
setSaving(true)
|
|
359
|
+
;(async () => {
|
|
360
|
+
try {
|
|
361
|
+
await onSave(formatted.length ? formatted : null)
|
|
362
|
+
setEditingSafe(false)
|
|
363
|
+
} catch (err) {
|
|
364
|
+
setError(resolveInlineErrorMessage(err, fallbackError))
|
|
365
|
+
} finally {
|
|
366
|
+
setSaving(false)
|
|
367
|
+
}
|
|
368
|
+
})()
|
|
369
|
+
}}
|
|
370
|
+
withTime={isDateTime}
|
|
371
|
+
footer="apply-cancel"
|
|
372
|
+
placeholder={placeholder}
|
|
373
|
+
disabled={saving}
|
|
374
|
+
/>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
return (
|
|
378
|
+
<input
|
|
379
|
+
className="w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
380
|
+
value={draft}
|
|
381
|
+
onChange={(event) => {
|
|
382
|
+
if (error) setError(null)
|
|
383
|
+
setDraft(event.target.value)
|
|
384
|
+
}}
|
|
385
|
+
placeholder={placeholder}
|
|
386
|
+
type={inputType ?? resolvedType}
|
|
387
|
+
autoFocus
|
|
388
|
+
/>
|
|
389
|
+
)
|
|
390
|
+
})()}
|
|
345
391
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
346
392
|
{renderBelowInput ? renderBelowInput({ draft, resolvedType, error, saving }) : null}
|
|
393
|
+
{(() => {
|
|
394
|
+
const lowerType = (inputType ?? resolvedType ?? '').toLowerCase()
|
|
395
|
+
const isDateLike = lowerType === 'date' || lowerType === 'datetime-local' || lowerType === 'datetime'
|
|
396
|
+
if (isDateLike) return null
|
|
397
|
+
return (
|
|
347
398
|
<div className="flex items-center gap-2">
|
|
348
399
|
<Button type="submit" size="sm" disabled={saving}>
|
|
349
400
|
{saving ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
|
@@ -353,6 +404,8 @@ export function InlineTextEditor({
|
|
|
353
404
|
{t('ui.detail.inline.cancel', 'Cancel')}
|
|
354
405
|
</Button>
|
|
355
406
|
</div>
|
|
407
|
+
)
|
|
408
|
+
})()}
|
|
356
409
|
</form>
|
|
357
410
|
) : (
|
|
358
411
|
<div className={variant === 'plain' ? '' : 'mt-1'}>{displayContent}</div>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as React from 'react'
|
|
6
|
-
import { screen } from '@testing-library/react'
|
|
6
|
+
import { act, fireEvent, screen } from '@testing-library/react'
|
|
7
7
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
8
8
|
import { InlineTextEditor, resolveSafeInlineUrlHref } from '../InlineEditors'
|
|
9
9
|
|
|
@@ -42,6 +42,86 @@ describe('InlineTextEditor URL display', () => {
|
|
|
42
42
|
})
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
+
describe('InlineTextEditor date types — DS unification (no native date input)', () => {
|
|
46
|
+
function activateEditor() {
|
|
47
|
+
// Pencil/edit toggle is the trailing icon-only button on the InlineTextEditor.
|
|
48
|
+
// It has no accessible text label, so we grab the last data-slot="button".
|
|
49
|
+
const buttons = document.querySelectorAll('[data-slot="button"]')
|
|
50
|
+
expect(buttons.length).toBeGreaterThan(0)
|
|
51
|
+
const editToggle = buttons[buttons.length - 1] as HTMLButtonElement
|
|
52
|
+
act(() => {
|
|
53
|
+
fireEvent.click(editToggle)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('inputType="date" renders the DatePicker primitive trigger when editing', () => {
|
|
58
|
+
renderWithProviders(
|
|
59
|
+
<InlineTextEditor
|
|
60
|
+
label="Date"
|
|
61
|
+
value="2026-05-09"
|
|
62
|
+
emptyLabel="No date"
|
|
63
|
+
type="date"
|
|
64
|
+
inputType="date"
|
|
65
|
+
onSave={jest.fn()}
|
|
66
|
+
/>,
|
|
67
|
+
{ dict: {} },
|
|
68
|
+
)
|
|
69
|
+
activateEditor()
|
|
70
|
+
expect(document.querySelector('[data-slot="date-picker-trigger"]')).toBeInTheDocument()
|
|
71
|
+
expect(document.querySelector('input[type="date"]')).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('inputType="datetime-local" renders the DatePicker primitive (with withTime) when editing', () => {
|
|
75
|
+
renderWithProviders(
|
|
76
|
+
<InlineTextEditor
|
|
77
|
+
label="When"
|
|
78
|
+
value="2026-05-09T10:30"
|
|
79
|
+
emptyLabel="No value"
|
|
80
|
+
type="text"
|
|
81
|
+
inputType="datetime-local"
|
|
82
|
+
onSave={jest.fn()}
|
|
83
|
+
/>,
|
|
84
|
+
{ dict: {} },
|
|
85
|
+
)
|
|
86
|
+
activateEditor()
|
|
87
|
+
expect(document.querySelector('[data-slot="date-picker-trigger"]')).toBeInTheDocument()
|
|
88
|
+
expect(document.querySelector('input[type="datetime-local"]')).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('non-date inputType still renders a raw input (regression: text editing path unchanged)', () => {
|
|
92
|
+
renderWithProviders(
|
|
93
|
+
<InlineTextEditor
|
|
94
|
+
label="Title"
|
|
95
|
+
value="Hello"
|
|
96
|
+
emptyLabel="No title"
|
|
97
|
+
type="text"
|
|
98
|
+
onSave={jest.fn()}
|
|
99
|
+
/>,
|
|
100
|
+
{ dict: {} },
|
|
101
|
+
)
|
|
102
|
+
activateEditor()
|
|
103
|
+
expect(document.querySelector('input[type="text"]')).toBeInTheDocument()
|
|
104
|
+
expect(document.querySelector('[data-slot="date-picker-trigger"]')).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('date type hides the form Save/Cancel buttons (DatePicker provides Apply/Cancel in popover)', () => {
|
|
108
|
+
renderWithProviders(
|
|
109
|
+
<InlineTextEditor
|
|
110
|
+
label="Date"
|
|
111
|
+
value="2026-05-09"
|
|
112
|
+
emptyLabel="No date"
|
|
113
|
+
type="date"
|
|
114
|
+
inputType="date"
|
|
115
|
+
onSave={jest.fn()}
|
|
116
|
+
/>,
|
|
117
|
+
{ dict: {} },
|
|
118
|
+
)
|
|
119
|
+
activateEditor()
|
|
120
|
+
// Form Save (submit) button should not be rendered for date types
|
|
121
|
+
expect(screen.queryByRole('button', { name: /Save|Zapisz|⌘/ })).not.toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
45
125
|
describe('resolveSafeInlineUrlHref', () => {
|
|
46
126
|
it.each(['http://example.com', 'https://example.com', 'mailto:user@example.com', 'tel:+48123456789'])(
|
|
47
127
|
'allows %s',
|
|
@@ -14,8 +14,15 @@ export type ComboboxInputProps = {
|
|
|
14
14
|
onChange: (next: string) => void
|
|
15
15
|
placeholder?: string
|
|
16
16
|
suggestions?: Array<string | ComboboxOption>
|
|
17
|
+
// Options to hydrate the option map up front (typically the linked entity's
|
|
18
|
+
// display fields, already present in a record-detail payload). Merged with
|
|
19
|
+
// `suggestions` so a pre-selected value renders its label without interaction.
|
|
20
|
+
seedOptions?: ComboboxOption[]
|
|
17
21
|
loadSuggestions?: (query?: string) => Promise<Array<string | ComboboxOption>>
|
|
18
|
-
|
|
22
|
+
// Eagerly resolve a pre-selected `value` to a human label when it is not
|
|
23
|
+
// covered by `suggestions`/`seedOptions`/`loadSuggestions` results. Runs once
|
|
24
|
+
// per value, before any user interaction. May be sync or async.
|
|
25
|
+
resolveLabel?: (value: string) => string | Promise<string>
|
|
19
26
|
resolveDescription?: (value: string) => string | null | undefined
|
|
20
27
|
autoFocus?: boolean
|
|
21
28
|
disabled?: boolean
|
|
@@ -47,6 +54,7 @@ export function ComboboxInput({
|
|
|
47
54
|
onChange,
|
|
48
55
|
placeholder,
|
|
49
56
|
suggestions,
|
|
57
|
+
seedOptions,
|
|
50
58
|
loadSuggestions,
|
|
51
59
|
resolveLabel,
|
|
52
60
|
resolveDescription,
|
|
@@ -56,6 +64,7 @@ export function ComboboxInput({
|
|
|
56
64
|
}: ComboboxInputProps) {
|
|
57
65
|
const [input, setInput] = React.useState('')
|
|
58
66
|
const [asyncOptions, setAsyncOptions] = React.useState<ComboboxOption[]>([])
|
|
67
|
+
const [resolvedOptions, setResolvedOptions] = React.useState<ComboboxOption[]>([])
|
|
59
68
|
const [loading, setLoading] = React.useState(false)
|
|
60
69
|
const [touched, setTouched] = React.useState(false)
|
|
61
70
|
const [showSuggestions, setShowSuggestions] = React.useState(false)
|
|
@@ -63,29 +72,46 @@ export function ComboboxInput({
|
|
|
63
72
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
64
73
|
const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled))
|
|
65
74
|
|
|
66
|
-
const staticOptions = React.useMemo(
|
|
75
|
+
const staticOptions = React.useMemo(
|
|
76
|
+
() => normalizeOptions([...(seedOptions ?? []), ...(suggestions ?? [])]),
|
|
77
|
+
[seedOptions, suggestions]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// Options whose label is genuinely known (not a self-mapping `value === label`
|
|
81
|
+
// placeholder). Used to decide whether eager resolution still needs to run.
|
|
82
|
+
const knownLabelValues = React.useMemo(() => {
|
|
83
|
+
const set = new Set<string>()
|
|
84
|
+
for (const option of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
|
|
85
|
+
if (option.label && option.label !== option.value) set.add(option.value)
|
|
86
|
+
}
|
|
87
|
+
return set
|
|
88
|
+
}, [staticOptions, asyncOptions, resolvedOptions])
|
|
67
89
|
|
|
68
90
|
const optionMap = React.useMemo(() => {
|
|
69
91
|
const map = new Map<string, ComboboxOption>()
|
|
70
92
|
const register = (option: ComboboxOption) => {
|
|
71
|
-
|
|
93
|
+
const existing = map.get(option.value)
|
|
94
|
+
// Prefer an entry that carries a real label over a self-mapping placeholder.
|
|
95
|
+
if (!existing || (existing.label === existing.value && option.label !== option.value)) {
|
|
72
96
|
map.set(option.value, option)
|
|
73
97
|
}
|
|
74
98
|
}
|
|
75
99
|
staticOptions.forEach(register)
|
|
76
100
|
asyncOptions.forEach(register)
|
|
101
|
+
resolvedOptions.forEach(register)
|
|
77
102
|
if (value) {
|
|
78
103
|
const existing = map.get(value)
|
|
79
104
|
if (!existing) {
|
|
105
|
+
const sync = typeof resolveLabel === 'function' ? resolveLabel(value) : undefined
|
|
80
106
|
map.set(value, {
|
|
81
107
|
value,
|
|
82
|
-
label:
|
|
108
|
+
label: typeof sync === 'string' && sync ? sync : value,
|
|
83
109
|
description: resolveDescription?.(value) ?? null,
|
|
84
110
|
})
|
|
85
111
|
}
|
|
86
112
|
}
|
|
87
113
|
return map
|
|
88
|
-
}, [asyncOptions, resolveDescription, resolveLabel, staticOptions, value])
|
|
114
|
+
}, [asyncOptions, resolvedOptions, resolveDescription, resolveLabel, staticOptions, value])
|
|
89
115
|
|
|
90
116
|
const availableOptions = React.useMemo(() => {
|
|
91
117
|
return Array.from(optionMap.values())
|
|
@@ -122,7 +148,47 @@ export function ComboboxInput({
|
|
|
122
148
|
}
|
|
123
149
|
}, [disabled, input, loadSuggestions, touched])
|
|
124
150
|
|
|
125
|
-
//
|
|
151
|
+
// Eagerly resolve a pre-selected value to its label without requiring the user
|
|
152
|
+
// to focus the field. Runs once per value when it is not already covered.
|
|
153
|
+
const eagerResolveLabel = typeof resolveLabel === 'function' ? resolveLabel : undefined
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
if (!value || disabled) return
|
|
156
|
+
if (knownLabelValues.has(value)) return
|
|
157
|
+
let cancelled = false
|
|
158
|
+
const apply = (label?: string | null, description?: string | null) => {
|
|
159
|
+
const clean = typeof label === 'string' ? label.trim() : ''
|
|
160
|
+
if (cancelled || !clean || clean === value) return
|
|
161
|
+
setResolvedOptions((prev) => {
|
|
162
|
+
if (prev.some((option) => option.value === value && option.label === clean)) return prev
|
|
163
|
+
return [...prev.filter((option) => option.value !== value), { value, label: clean, description: description ?? null }]
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
if (eagerResolveLabel) {
|
|
167
|
+
Promise.resolve()
|
|
168
|
+
.then(() => eagerResolveLabel(value))
|
|
169
|
+
.then((label) => apply(label, resolveDescription?.(value)))
|
|
170
|
+
.catch(() => {})
|
|
171
|
+
return () => { cancelled = true }
|
|
172
|
+
}
|
|
173
|
+
// Fallback: pull the first page of async suggestions so a remount that lost
|
|
174
|
+
// its option cache can still recover the label without user interaction.
|
|
175
|
+
if (loadSuggestions) {
|
|
176
|
+
setLoading(true)
|
|
177
|
+
Promise.resolve()
|
|
178
|
+
.then(() => loadSuggestions())
|
|
179
|
+
.then((items) => {
|
|
180
|
+
if (cancelled) return
|
|
181
|
+
const normalized = normalizeOptions(items)
|
|
182
|
+
setAsyncOptions(normalized)
|
|
183
|
+
})
|
|
184
|
+
.catch(() => {})
|
|
185
|
+
.finally(() => { if (!cancelled) setLoading(false) })
|
|
186
|
+
}
|
|
187
|
+
return () => { cancelled = true }
|
|
188
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
189
|
+
}, [value, disabled, knownLabelValues, eagerResolveLabel, loadSuggestions])
|
|
190
|
+
|
|
191
|
+
// Sync input with value when value changes externally and input is not focused.
|
|
126
192
|
React.useEffect(() => {
|
|
127
193
|
if (document.activeElement !== inputRef.current) {
|
|
128
194
|
const option = optionMap.get(value)
|
|
@@ -165,10 +231,16 @@ export function ComboboxInput({
|
|
|
165
231
|
return
|
|
166
232
|
}
|
|
167
233
|
if (!allowCustomValues) {
|
|
168
|
-
// Revert to current value if
|
|
169
|
-
|
|
170
|
-
|
|
234
|
+
// Revert to the current value's label — but only if we actually know it.
|
|
235
|
+
// Baking the raw value back in while eager resolution is still pending
|
|
236
|
+
// would freeze a placeholder (e.g. a UUID) into the visible input.
|
|
171
237
|
setShowSuggestions(false)
|
|
238
|
+
const currentOption = optionMap.get(value)
|
|
239
|
+
if (currentOption && currentOption.label !== currentOption.value) {
|
|
240
|
+
setInput(currentOption.label)
|
|
241
|
+
} else if (!value) {
|
|
242
|
+
setInput('')
|
|
243
|
+
}
|
|
172
244
|
return
|
|
173
245
|
}
|
|
174
246
|
selectValue(raw)
|
|
@@ -1,162 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Import from `@open-mercato/ui/primitives/date-picker` instead.
|
|
3
|
+
*
|
|
4
|
+
* This shim preserves the legacy import path
|
|
5
|
+
* `@open-mercato/ui/backend/inputs/DatePicker` for existing consumers
|
|
6
|
+
* (CrudForm, example pages) while the DatePicker is promoted to a
|
|
7
|
+
* primitive in DS Foundation v3.
|
|
8
|
+
*
|
|
9
|
+
* Default footer is the Figma-aligned `'apply-cancel'` mode (per user
|
|
10
|
+
* directive 2026-05-09 — "zmień globalnie"). Consumers that explicitly
|
|
11
|
+
* need the legacy `Today` / `Clear` footer can pass `footer="today-clear"`.
|
|
12
|
+
*
|
|
13
|
+
* Migration: replace
|
|
14
|
+
* import { DatePicker } from '@open-mercato/ui/backend/inputs/DatePicker'
|
|
15
|
+
* with
|
|
16
|
+
* import { DatePicker } from '@open-mercato/ui/primitives/date-picker'
|
|
17
|
+
*/
|
|
18
|
+
|
|
1
19
|
"use client"
|
|
2
20
|
|
|
3
21
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
|
|
10
|
-
import { Calendar } from '../../primitives/calendar'
|
|
11
|
-
|
|
12
|
-
export type DatePickerProps = {
|
|
13
|
-
value?: Date | null
|
|
14
|
-
onChange: (date: Date | null) => void
|
|
15
|
-
placeholder?: string
|
|
16
|
-
disabled?: boolean
|
|
17
|
-
readOnly?: boolean
|
|
18
|
-
className?: string
|
|
19
|
-
locale?: Locale
|
|
20
|
-
displayFormat?: string
|
|
21
|
-
showTodayButton?: boolean
|
|
22
|
-
showClearButton?: boolean
|
|
23
|
-
closeOnSelect?: boolean
|
|
24
|
-
minDate?: Date
|
|
25
|
-
maxDate?: Date
|
|
26
|
-
}
|
|
22
|
+
import {
|
|
23
|
+
DatePicker as DatePickerPrimitive,
|
|
24
|
+
type DatePickerProps as DatePickerPrimitivePropsBase,
|
|
25
|
+
type DatePickerFooter,
|
|
26
|
+
} from '../../primitives/date-picker'
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'ru', 'cs', 'sk', 'hu', 'ro',
|
|
30
|
-
])
|
|
28
|
+
export type DatePickerProps = DatePickerPrimitivePropsBase
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return DAY_FIRST_LOCALE_CODES.has(code) ? 'd MMM yyyy' : 'MMM d, yyyy'
|
|
30
|
+
/** @deprecated Use `DatePicker` from `@open-mercato/ui/primitives/date-picker`. */
|
|
31
|
+
export function DatePicker(props: DatePickerProps) {
|
|
32
|
+
return <DatePickerPrimitive {...props} />
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
export
|
|
39
|
-
value,
|
|
40
|
-
onChange,
|
|
41
|
-
placeholder,
|
|
42
|
-
disabled = false,
|
|
43
|
-
readOnly = false,
|
|
44
|
-
className,
|
|
45
|
-
locale,
|
|
46
|
-
displayFormat,
|
|
47
|
-
showTodayButton = true,
|
|
48
|
-
showClearButton = true,
|
|
49
|
-
closeOnSelect = true,
|
|
50
|
-
minDate,
|
|
51
|
-
maxDate,
|
|
52
|
-
}: DatePickerProps) {
|
|
53
|
-
const t = useT()
|
|
54
|
-
const [open, setOpen] = React.useState(false)
|
|
55
|
-
|
|
56
|
-
const resolvedFormat = displayFormat ?? deriveDisplayFormat(locale)
|
|
57
|
-
const placeholderText = placeholder ?? t('ui.datePicker.placeholder', 'Pick a date')
|
|
58
|
-
const todayText = t('ui.datePicker.todayButton', 'Today')
|
|
59
|
-
const clearText = t('ui.datePicker.clearButton', 'Clear')
|
|
60
|
-
|
|
61
|
-
const formattedValue = React.useMemo(() => {
|
|
62
|
-
if (!value) return null
|
|
63
|
-
try {
|
|
64
|
-
return format(value, resolvedFormat, locale ? { locale } : undefined)
|
|
65
|
-
} catch {
|
|
66
|
-
return null
|
|
67
|
-
}
|
|
68
|
-
}, [value, resolvedFormat, locale])
|
|
69
|
-
|
|
70
|
-
const handleDaySelect = React.useCallback(
|
|
71
|
-
(day: Date | undefined) => {
|
|
72
|
-
if (!day) return
|
|
73
|
-
const next = new Date(day)
|
|
74
|
-
next.setHours(0, 0, 0, 0)
|
|
75
|
-
onChange(next)
|
|
76
|
-
if (closeOnSelect) setOpen(false)
|
|
77
|
-
},
|
|
78
|
-
[onChange, closeOnSelect]
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
const handleToday = React.useCallback(() => {
|
|
82
|
-
const today = new Date()
|
|
83
|
-
today.setHours(0, 0, 0, 0)
|
|
84
|
-
onChange(today)
|
|
85
|
-
setOpen(false)
|
|
86
|
-
}, [onChange])
|
|
87
|
-
|
|
88
|
-
const handleClear = React.useCallback(() => {
|
|
89
|
-
onChange(null)
|
|
90
|
-
setOpen(false)
|
|
91
|
-
}, [onChange])
|
|
92
|
-
|
|
93
|
-
const isInteractive = !disabled && !readOnly
|
|
94
|
-
|
|
95
|
-
const disabledMatcher = React.useMemo(() => {
|
|
96
|
-
if (!minDate && !maxDate) return undefined
|
|
97
|
-
const matchers: import('react-day-picker').Matcher[] = []
|
|
98
|
-
if (minDate) matchers.push({ before: minDate })
|
|
99
|
-
if (maxDate) matchers.push({ after: maxDate })
|
|
100
|
-
return matchers
|
|
101
|
-
}, [minDate, maxDate])
|
|
102
|
-
|
|
103
|
-
return (
|
|
104
|
-
<Popover open={open} onOpenChange={isInteractive ? setOpen : undefined}>
|
|
105
|
-
<PopoverTrigger asChild>
|
|
106
|
-
<button
|
|
107
|
-
type="button"
|
|
108
|
-
data-crud-focus-target=""
|
|
109
|
-
disabled={disabled}
|
|
110
|
-
aria-haspopup="dialog"
|
|
111
|
-
className={cn(
|
|
112
|
-
'w-full h-9 flex items-center gap-2 rounded border px-3 text-sm text-left',
|
|
113
|
-
'bg-background transition-colors',
|
|
114
|
-
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
|
115
|
-
'disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed',
|
|
116
|
-
readOnly && 'cursor-default opacity-70',
|
|
117
|
-
!formattedValue && 'text-muted-foreground',
|
|
118
|
-
className
|
|
119
|
-
)}
|
|
120
|
-
onClick={isInteractive ? undefined : (e) => e.preventDefault()}
|
|
121
|
-
>
|
|
122
|
-
<CalendarIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
123
|
-
<span className="flex-1 truncate">
|
|
124
|
-
{formattedValue ?? placeholderText}
|
|
125
|
-
</span>
|
|
126
|
-
</button>
|
|
127
|
-
</PopoverTrigger>
|
|
128
|
-
<PopoverContent className="p-0 w-auto">
|
|
129
|
-
<Calendar
|
|
130
|
-
mode="single"
|
|
131
|
-
selected={value ?? undefined}
|
|
132
|
-
onSelect={handleDaySelect}
|
|
133
|
-
locale={locale}
|
|
134
|
-
disabled={disabledMatcher}
|
|
135
|
-
initialFocus
|
|
136
|
-
/>
|
|
137
|
-
{(showTodayButton || showClearButton) && (
|
|
138
|
-
<div className="flex items-center justify-between gap-2 border-t px-3 py-2">
|
|
139
|
-
{showTodayButton && (
|
|
140
|
-
<button
|
|
141
|
-
type="button"
|
|
142
|
-
onClick={handleToday}
|
|
143
|
-
className="text-sm text-primary hover:underline focus-visible:outline-none"
|
|
144
|
-
>
|
|
145
|
-
{todayText}
|
|
146
|
-
</button>
|
|
147
|
-
)}
|
|
148
|
-
{showClearButton && (
|
|
149
|
-
<button
|
|
150
|
-
type="button"
|
|
151
|
-
onClick={handleClear}
|
|
152
|
-
className="text-sm text-muted-foreground hover:text-foreground hover:underline focus-visible:outline-none ml-auto"
|
|
153
|
-
>
|
|
154
|
-
{clearText}
|
|
155
|
-
</button>
|
|
156
|
-
)}
|
|
157
|
-
</div>
|
|
158
|
-
)}
|
|
159
|
-
</PopoverContent>
|
|
160
|
-
</Popover>
|
|
161
|
-
)
|
|
162
|
-
}
|
|
35
|
+
export type { DatePickerFooter }
|