@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.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +46 -129
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +10 -3
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/EmptyState.js +1 -47
  7. package/dist/backend/EmptyState.js.map +2 -2
  8. package/dist/backend/FilterOverlay.js +31 -26
  9. package/dist/backend/FilterOverlay.js.map +2 -2
  10. package/dist/backend/FlashMessages.js +23 -25
  11. package/dist/backend/FlashMessages.js.map +2 -2
  12. package/dist/backend/JsonBuilder.js +4 -2
  13. package/dist/backend/JsonBuilder.js.map +2 -2
  14. package/dist/backend/LogList.js +49 -0
  15. package/dist/backend/LogList.js.map +7 -0
  16. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  17. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  18. package/dist/backend/detail/InlineEditors.js +80 -32
  19. package/dist/backend/detail/InlineEditors.js.map +2 -2
  20. package/dist/backend/inputs/ComboboxInput.js +60 -6
  21. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  22. package/dist/backend/inputs/DatePicker.js +6 -142
  23. package/dist/backend/inputs/DatePicker.js.map +2 -2
  24. package/dist/backend/inputs/DateTimePicker.js +6 -182
  25. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  26. package/dist/backend/inputs/TagsInput.js +16 -19
  27. package/dist/backend/inputs/TagsInput.js.map +2 -2
  28. package/dist/backend/inputs/TimeInput.js +1 -1
  29. package/dist/backend/inputs/TimeInput.js.map +2 -2
  30. package/dist/backend/inputs/TimePicker.js +83 -72
  31. package/dist/backend/inputs/TimePicker.js.map +2 -2
  32. package/dist/backend/utils/customFieldForms.js +2 -2
  33. package/dist/backend/utils/customFieldForms.js.map +1 -1
  34. package/dist/portal/components/PortalEmptyState.js +13 -9
  35. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  36. package/dist/primitives/accordion.js +137 -0
  37. package/dist/primitives/accordion.js.map +7 -0
  38. package/dist/primitives/alert.js +180 -12
  39. package/dist/primitives/alert.js.map +2 -2
  40. package/dist/primitives/calendar.js +122 -32
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/dist/primitives/compact-select.js +47 -0
  43. package/dist/primitives/compact-select.js.map +7 -0
  44. package/dist/primitives/counter-input.js +174 -0
  45. package/dist/primitives/counter-input.js.map +7 -0
  46. package/dist/primitives/date-picker-helpers.js +79 -0
  47. package/dist/primitives/date-picker-helpers.js.map +7 -0
  48. package/dist/primitives/date-picker.js +216 -0
  49. package/dist/primitives/date-picker.js.map +7 -0
  50. package/dist/primitives/date-range-picker.js +239 -0
  51. package/dist/primitives/date-range-picker.js.map +7 -0
  52. package/dist/primitives/digit-input.js +157 -0
  53. package/dist/primitives/digit-input.js.map +7 -0
  54. package/dist/primitives/empty-state.js +93 -0
  55. package/dist/primitives/empty-state.js.map +7 -0
  56. package/dist/primitives/inline-input.js +29 -0
  57. package/dist/primitives/inline-input.js.map +7 -0
  58. package/dist/primitives/inline-select.js +41 -0
  59. package/dist/primitives/inline-select.js.map +7 -0
  60. package/dist/primitives/notification-stack.js +116 -0
  61. package/dist/primitives/notification-stack.js.map +7 -0
  62. package/dist/primitives/notification.js +62 -0
  63. package/dist/primitives/notification.js.map +7 -0
  64. package/dist/primitives/rich-editor.js +1230 -0
  65. package/dist/primitives/rich-editor.js.map +7 -0
  66. package/dist/primitives/select.js +1 -0
  67. package/dist/primitives/select.js.map +2 -2
  68. package/dist/primitives/skeleton.js +55 -0
  69. package/dist/primitives/skeleton.js.map +7 -0
  70. package/dist/primitives/tag-input.js +186 -0
  71. package/dist/primitives/tag-input.js.map +7 -0
  72. package/dist/primitives/tag.js +66 -13
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/time-picker.js +793 -0
  75. package/dist/primitives/time-picker.js.map +7 -0
  76. package/package.json +4 -3
  77. package/src/backend/CrudForm.tsx +61 -113
  78. package/src/backend/DataTable.tsx +17 -5
  79. package/src/backend/EmptyState.tsx +13 -63
  80. package/src/backend/FilterOverlay.tsx +38 -22
  81. package/src/backend/FlashMessages.tsx +23 -24
  82. package/src/backend/JsonBuilder.tsx +4 -2
  83. package/src/backend/LogList.tsx +109 -0
  84. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  85. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  86. package/src/backend/__tests__/LogList.test.tsx +163 -0
  87. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  88. package/src/backend/detail/InlineEditors.tsx +78 -25
  89. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  90. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  91. package/src/backend/inputs/DatePicker.tsx +28 -155
  92. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  93. package/src/backend/inputs/TagsInput.tsx +10 -13
  94. package/src/backend/inputs/TimeInput.tsx +2 -1
  95. package/src/backend/inputs/TimePicker.tsx +99 -70
  96. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  97. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  98. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  99. package/src/backend/utils/customFieldForms.ts +2 -2
  100. package/src/portal/components/PortalEmptyState.tsx +27 -22
  101. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  102. package/src/primitives/__tests__/alert.test.tsx +204 -0
  103. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  104. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  105. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  106. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  107. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  108. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  109. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  110. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  111. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  112. package/src/primitives/__tests__/notification.test.tsx +119 -0
  113. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  114. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  115. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  116. package/src/primitives/__tests__/tag.test.tsx +123 -0
  117. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  118. package/src/primitives/accordion.tsx +225 -0
  119. package/src/primitives/alert.tsx +234 -24
  120. package/src/primitives/calendar.tsx +146 -38
  121. package/src/primitives/compact-select.tsx +59 -0
  122. package/src/primitives/counter-input.tsx +211 -0
  123. package/src/primitives/date-picker-helpers.ts +96 -0
  124. package/src/primitives/date-picker.tsx +290 -0
  125. package/src/primitives/date-range-picker.tsx +281 -0
  126. package/src/primitives/digit-input.tsx +208 -0
  127. package/src/primitives/empty-state.tsx +140 -0
  128. package/src/primitives/inline-input.tsx +55 -0
  129. package/src/primitives/inline-select.tsx +74 -0
  130. package/src/primitives/notification-stack.tsx +185 -0
  131. package/src/primitives/notification.tsx +136 -0
  132. package/src/primitives/rich-editor.tsx +1578 -0
  133. package/src/primitives/select.tsx +1 -0
  134. package/src/primitives/skeleton.tsx +70 -0
  135. package/src/primitives/tag-input.tsx +223 -0
  136. package/src/primitives/tag.tsx +69 -10
  137. package/src/primitives/time-picker.tsx +1059 -0
@@ -1,10 +1,16 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
- import { X } from 'lucide-react'
4
- import { IconButton } from '../primitives/icon-button'
3
+ import { Alert, type AlertStatus } from '../primitives/alert'
5
4
 
6
5
  export type FlashKind = 'success' | 'error' | 'warning' | 'info'
7
6
 
7
+ const flashKindToAlertStatus: Record<FlashKind, AlertStatus> = {
8
+ success: 'success',
9
+ error: 'error',
10
+ warning: 'warning',
11
+ info: 'information',
12
+ }
13
+
8
14
  // Programmatic API to show a flash message without navigation.
9
15
  // Consumers can import { flash } and call flash('text', 'error').
10
16
  export function flash(message: string, type: FlashKind = 'info') {
@@ -141,32 +147,25 @@ function FlashMessagesInner() {
141
147
  return () => window.removeEventListener('flash', handler as EventListener)
142
148
  }, [showFlash])
143
149
 
144
- if (!msg) return null
150
+ const handleDismiss = React.useCallback(() => {
151
+ clearDismissTimer()
152
+ setMsg(null)
153
+ }, [clearDismissTimer])
145
154
 
146
- const colorMap: Record<FlashKind, string> = {
147
- success: 'bg-status-success-icon',
148
- error: 'bg-status-error-icon',
149
- warning: 'bg-status-warning-icon',
150
- info: 'bg-status-info-icon',
151
- }
152
- const color = colorMap[kind]
155
+ if (!msg) return null
153
156
 
154
157
  return (
155
158
  <div className="pointer-events-none fixed left-3 right-3 top-3 z-toast sm:left-auto sm:right-4 sm:w-[380px]">
156
- <div className={`pointer-events-auto rounded px-3 py-2 text-white shadow-md ${color}`}>
157
- <div className="flex items-center justify-between gap-2">
158
- <div className="text-sm">{msg}</div>
159
- <IconButton
160
- type="button"
161
- variant="ghost"
162
- size="sm"
163
- className="text-white/90 hover:text-white hover:bg-white/10"
164
- onClick={() => setMsg(null)}
165
- aria-label="Dismiss"
166
- >
167
- <X size={16} />
168
- </IconButton>
169
- </div>
159
+ <div className="pointer-events-auto">
160
+ <Alert
161
+ status={flashKindToAlertStatus[kind]}
162
+ size="sm"
163
+ dismissible
164
+ onDismiss={handleDismiss}
165
+ className="shadow-md"
166
+ >
167
+ {msg}
168
+ </Alert>
170
169
  </div>
171
170
  </div>
172
171
  )
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { Button } from '../primitives/button'
5
5
  import { IconButton } from '../primitives/icon-button'
6
6
  import { Input } from '../primitives/input'
7
+ import { InlineInput } from '../primitives/inline-input'
7
8
  import {
8
9
  Select,
9
10
  SelectContent,
@@ -327,8 +328,9 @@ function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot }: JsonNod
327
328
  <div key={idx} className="flex">
328
329
  <div className="pt-2">
329
330
  {/* Key Renamer */}
330
- <input
331
- className="w-full sm:w-[100px] text-xs font-mono border-b border-transparent hover:border-gray-300 focus-visible:border-ring bg-transparent focus-visible:outline-none text-right pr-1"
331
+ <InlineInput
332
+ className="w-full sm:w-[100px]"
333
+ inputClassName="text-right text-xs font-mono pr-1"
332
334
  value={key}
333
335
  onChange={(e) => handleKeyRename(key, e.target.value)}
334
336
  disabled={readOnly}
@@ -0,0 +1,109 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '@open-mercato/shared/lib/utils'
5
+ import {
6
+ Accordion,
7
+ AccordionContent,
8
+ AccordionItem,
9
+ AccordionTrigger,
10
+ } from '@open-mercato/ui/primitives/accordion'
11
+ import { Badge, type BadgeProps } from '@open-mercato/ui/primitives/badge'
12
+
13
+ export type LogListLevel = 'info' | 'warn' | 'warning' | 'error' | 'debug' | string
14
+
15
+ export type LogListEntry = {
16
+ id: string
17
+ /** Pre-formatted timestamp (consumer formats with `formatDateTime()` / locale). */
18
+ time: React.ReactNode
19
+ /** Raw level value — drives the badge color via the built-in map. */
20
+ level: LogListLevel
21
+ /** Optional translated label override; falls back to `level` verbatim. */
22
+ levelLabel?: React.ReactNode
23
+ /** Trigger message (one-liner — truncates in the trigger row, full text available inside `body`). */
24
+ message: React.ReactNode
25
+ /** Body content rendered inside `AccordionContent` when expanded — typically a metadata grid + JSON payload. */
26
+ body: React.ReactNode
27
+ }
28
+
29
+ type LogLevelBadgeVariant = NonNullable<BadgeProps['variant']>
30
+
31
+ const LOG_LEVEL_BADGE_VARIANT: Record<string, LogLevelBadgeVariant> = {
32
+ info: 'info',
33
+ warn: 'warning',
34
+ warning: 'warning',
35
+ error: 'error',
36
+ debug: 'neutral',
37
+ }
38
+
39
+ export type LogLevelBadgeProps = {
40
+ level: LogListLevel
41
+ label?: React.ReactNode
42
+ className?: string
43
+ }
44
+
45
+ export function LogLevelBadge({ level, label, className }: LogLevelBadgeProps) {
46
+ const key = typeof level === 'string' ? level.toLowerCase() : ''
47
+ const variant = LOG_LEVEL_BADGE_VARIANT[key] ?? 'secondary'
48
+ return (
49
+ <Badge variant={variant} className={className} data-log-level={key || undefined}>
50
+ {label ?? level}
51
+ </Badge>
52
+ )
53
+ }
54
+
55
+ export type LogListProps = {
56
+ entries: LogListEntry[]
57
+ /** Optional message rendered when the list is empty. Pass through `useT()` for i18n. */
58
+ emptyMessage?: React.ReactNode
59
+ className?: string
60
+ }
61
+
62
+ /**
63
+ * Unified `Accordion`-driven log list for admin "logs" tabs (integrations,
64
+ * data sync runs, payment gateway transactions, …). Replaces the per-module
65
+ * `<table>` + `expandedLogId` row-expand pattern with a Figma-aligned card
66
+ * list that uses the DS `Accordion` primitive under the hood. Each row
67
+ * shows time + level badge + message in the trigger; the consumer controls
68
+ * the expanded body content (metadata grid, JSON payload, etc.).
69
+ *
70
+ * `type='single' collapsible` matches the previous "one expanded row at a
71
+ * time" behaviour. Switch to `Accordion` directly if you need multi-open.
72
+ */
73
+ export function LogList({ entries, emptyMessage, className }: LogListProps) {
74
+ if (entries.length === 0) {
75
+ return emptyMessage ? (
76
+ <p className={cn('py-4 text-sm text-muted-foreground', className)} data-slot="log-list-empty">
77
+ {emptyMessage}
78
+ </p>
79
+ ) : null
80
+ }
81
+
82
+ return (
83
+ <Accordion
84
+ type="single"
85
+ collapsible
86
+ className={cn('space-y-2', className)}
87
+ data-slot="log-list"
88
+ >
89
+ {entries.map((entry) => (
90
+ <AccordionItem key={entry.id} value={entry.id} data-log-entry-id={entry.id}>
91
+ <AccordionTrigger triggerIcon="chevron">
92
+ <span className="grid w-full items-center gap-3 sm:grid-cols-[10rem_5rem_minmax(0,1fr)]">
93
+ <span className="truncate whitespace-nowrap text-xs text-muted-foreground sm:text-sm">
94
+ {entry.time}
95
+ </span>
96
+ <span className="justify-self-start">
97
+ <LogLevelBadge level={entry.level} label={entry.levelLabel} />
98
+ </span>
99
+ <span className="min-w-0 truncate text-left text-sm font-normal">
100
+ {entry.message}
101
+ </span>
102
+ </span>
103
+ </AccordionTrigger>
104
+ <AccordionContent>{entry.body}</AccordionContent>
105
+ </AccordionItem>
106
+ ))}
107
+ </Accordion>
108
+ )
109
+ }
@@ -60,17 +60,20 @@ describe('CrudForm — datetime field types render correct picker component', ()
60
60
  expect(html).toContain('Pick a time')
61
61
  })
62
62
 
63
- it('type: datetime-local renders raw <input> (backward compatibility)', () => {
63
+ it('type: datetime-local renders DateTimePicker trigger (DS unification — no native input)', () => {
64
64
  const fields: CrudField[] = [{ id: 'at', label: 'At', type: 'datetime-local' }]
65
65
  const html = renderForm(fields)
66
- expect(html).toContain('type="datetime-local"')
67
- expect(html).not.toContain('Pick date and time')
66
+ expect(html).toContain('aria-haspopup="dialog"')
67
+ expect(html).toContain('data-slot="date-picker-trigger"')
68
+ expect(html).not.toContain('type="datetime-local"')
68
69
  })
69
70
 
70
- it('type: date renders raw <input type="date"> (backward compatibility)', () => {
71
+ it('type: date renders DatePicker trigger (DS unification — no native input)', () => {
71
72
  const fields: CrudField[] = [{ id: 'dt', label: 'Date', type: 'date' }]
72
73
  const html = renderForm(fields)
73
- expect(html).toContain('type="date"')
74
+ expect(html).toContain('aria-haspopup="dialog"')
75
+ expect(html).toContain('data-slot="date-picker-trigger"')
76
+ expect(html).not.toContain('type="date"')
74
77
  })
75
78
 
76
79
  it('datepicker shows formatted date in trigger when initialValues provided', () => {
@@ -87,9 +90,11 @@ describe('CrudForm — datetime field types render correct picker component', ()
87
90
  expect(html).toContain('2026')
88
91
  })
89
92
 
90
- it('time shows HH:MM in trigger when initialValues provided', () => {
93
+ it('time shows 12h-formatted value in trigger when initialValues provided', () => {
91
94
  const fields: CrudField[] = [{ id: 'sync_time', label: 'Sync Time', type: 'time' }]
92
95
  const html = renderForm(fields, { sync_time: '14:30' })
93
- expect(html).toContain('14:30')
96
+ // Legacy TimePicker shim renders the trigger in 12h "HH:MM AM/PM" format to
97
+ // match the slot list inside the popover.
98
+ expect(html).toContain('02:30 PM')
94
99
  })
95
100
  })
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, fireEvent, render, screen } from '@testing-library/react'
7
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
8
+ import { FilterOverlay, type FilterDef, type FilterValues } from '../FilterOverlay'
9
+
10
+ function renderOverlay({
11
+ filters,
12
+ initialValues = {},
13
+ onApply = jest.fn(),
14
+ onClear = jest.fn(),
15
+ }: {
16
+ filters: FilterDef[]
17
+ initialValues?: FilterValues
18
+ onApply?: (v: FilterValues) => void
19
+ onClear?: () => void
20
+ }) {
21
+ return render(
22
+ <I18nProvider locale="en" dict={{}}>
23
+ <FilterOverlay
24
+ open={true}
25
+ onOpenChange={() => {}}
26
+ filters={filters}
27
+ initialValues={initialValues}
28
+ onApply={onApply}
29
+ onClear={onClear}
30
+ />
31
+ </I18nProvider>,
32
+ )
33
+ }
34
+
35
+ describe('FilterOverlay dateRange — DS unification (DateRangePicker swap)', () => {
36
+ const dateRangeFilter: FilterDef = {
37
+ id: 'createdRange',
38
+ label: 'Created',
39
+ type: 'dateRange',
40
+ }
41
+
42
+ it('renders the DateRangePicker primitive trigger (not raw <input type="date">)', () => {
43
+ renderOverlay({ filters: [dateRangeFilter] })
44
+ expect(document.querySelector('[data-slot="date-range-picker-trigger"]')).toBeInTheDocument()
45
+ expect(document.querySelector('input[type="date"]')).toBeNull()
46
+ })
47
+
48
+ it('preserves the existing { from, to } string output shape on Apply (BC for consumers)', async () => {
49
+ const onApply = jest.fn()
50
+ renderOverlay({
51
+ filters: [dateRangeFilter],
52
+ initialValues: { createdRange: { from: '2026-05-01', to: '2026-05-09' } },
53
+ onApply,
54
+ })
55
+ // Apply existing values — output shape stays as { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' }.
56
+ // Multiple Apply buttons may exist (FilterOverlay top + DateRangePicker popover);
57
+ // pick the FilterOverlay top one (always rendered, popover may or may not be open).
58
+ const applyBtns = screen.getAllByRole('button', { name: /^apply$/i })
59
+ const overlayApply = applyBtns[0]
60
+ await act(async () => {
61
+ fireEvent.click(overlayApply)
62
+ })
63
+ expect(onApply).toHaveBeenCalledTimes(1)
64
+ const applied = onApply.mock.calls[0][0]
65
+ expect(applied.createdRange).toBeDefined()
66
+ expect(typeof applied.createdRange.from).toBe('string')
67
+ expect(typeof applied.createdRange.to).toBe('string')
68
+ expect(applied.createdRange.from).toBe('2026-05-01')
69
+ expect(applied.createdRange.to).toBe('2026-05-09')
70
+ })
71
+
72
+ it('renders the formatted range in the trigger when initial { from, to } is provided', () => {
73
+ renderOverlay({
74
+ filters: [dateRangeFilter],
75
+ initialValues: { createdRange: { from: '2026-05-01', to: '2026-05-09' } },
76
+ })
77
+ const trigger = document.querySelector('[data-slot="date-range-picker-trigger"]')
78
+ expect(trigger).toBeInTheDocument()
79
+ expect(trigger!.textContent).toContain('May')
80
+ expect(trigger!.textContent).toContain('2026')
81
+ })
82
+
83
+ it('handles empty / undefined values gracefully (no crash)', () => {
84
+ expect(() => {
85
+ renderOverlay({ filters: [dateRangeFilter], initialValues: {} })
86
+ }).not.toThrow()
87
+ expect(document.querySelector('[data-slot="date-range-picker-trigger"]')).toBeInTheDocument()
88
+ })
89
+ })
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { LogLevelBadge, LogList, type LogListEntry } from '../LogList'
8
+
9
+ function makeEntry(over: Partial<LogListEntry> = {}): LogListEntry {
10
+ return {
11
+ id: 'log-1',
12
+ time: '2026-05-12 10:00',
13
+ level: 'info',
14
+ message: 'Sample log message',
15
+ body: <div data-testid="body-1">Body of log-1</div>,
16
+ ...over,
17
+ }
18
+ }
19
+
20
+ describe('LogLevelBadge', () => {
21
+ it('applies the info palette by default (semantic status tokens)', () => {
22
+ const { container } = render(<LogLevelBadge level="info" />)
23
+ const badge = container.firstElementChild as HTMLElement
24
+ expect(badge).toHaveAttribute('data-log-level', 'info')
25
+ expect(badge.className).toContain('bg-status-info-bg')
26
+ expect(badge.className).toContain('text-status-info-text')
27
+ })
28
+
29
+ it('maps warn / warning / error / debug to the matching semantic palette', () => {
30
+ const { rerender, container } = render(<LogLevelBadge level="warn" />)
31
+ expect(container.firstElementChild!).toHaveAttribute('data-log-level', 'warn')
32
+ expect(container.firstElementChild!.className).toContain('bg-status-warning-bg')
33
+
34
+ rerender(<LogLevelBadge level="warning" />)
35
+ expect(container.firstElementChild!).toHaveAttribute('data-log-level', 'warning')
36
+ expect(container.firstElementChild!.className).toContain('bg-status-warning-bg')
37
+
38
+ rerender(<LogLevelBadge level="error" />)
39
+ expect(container.firstElementChild!).toHaveAttribute('data-log-level', 'error')
40
+ expect(container.firstElementChild!.className).toContain('bg-status-error-bg')
41
+
42
+ rerender(<LogLevelBadge level="debug" />)
43
+ expect(container.firstElementChild!).toHaveAttribute('data-log-level', 'debug')
44
+ expect(container.firstElementChild!.className).toContain('bg-status-neutral-bg')
45
+ })
46
+
47
+ it('renders an unrecognized level without status palette classes (falls back to Badge secondary)', () => {
48
+ const { container } = render(<LogLevelBadge level="custom-level" />)
49
+ const badge = container.firstElementChild as HTMLElement
50
+ expect(badge).toHaveAttribute('data-log-level', 'custom-level')
51
+ expect(badge.className).not.toContain('bg-status-info-bg')
52
+ expect(badge.className).not.toContain('bg-status-warning-bg')
53
+ expect(badge.className).not.toContain('bg-status-error-bg')
54
+ expect(badge.className).not.toContain('bg-status-neutral-bg')
55
+ expect(badge.className).toContain('bg-secondary')
56
+ })
57
+
58
+ it('honors a custom label and falls back to level when label is omitted', () => {
59
+ const { rerender } = render(<LogLevelBadge level="info" label="Information" />)
60
+ expect(screen.getByText('Information')).toBeInTheDocument()
61
+
62
+ rerender(<LogLevelBadge level="error" />)
63
+ expect(screen.getByText('error')).toBeInTheDocument()
64
+ })
65
+ })
66
+
67
+ describe('LogList', () => {
68
+ it('renders the empty message when entries are empty', () => {
69
+ render(<LogList entries={[]} emptyMessage="No log entries" />)
70
+ expect(screen.getByText('No log entries')).toBeInTheDocument()
71
+ expect(screen.getByText('No log entries').getAttribute('data-slot')).toBe('log-list-empty')
72
+ })
73
+
74
+ it('renders nothing when entries are empty and no empty message is provided', () => {
75
+ const { container } = render(<LogList entries={[]} />)
76
+ expect(container.firstChild).toBeNull()
77
+ })
78
+
79
+ it('renders one accordion item per entry with time / level / message in the trigger', () => {
80
+ render(
81
+ <LogList
82
+ entries={[
83
+ makeEntry({ id: 'a', time: '10:00', level: 'info', message: 'Connected' }),
84
+ makeEntry({ id: 'b', time: '10:01', level: 'error', message: 'Timeout' }),
85
+ ]}
86
+ />,
87
+ )
88
+ expect(screen.getByText('Connected')).toBeInTheDocument()
89
+ expect(screen.getByText('Timeout')).toBeInTheDocument()
90
+ expect(screen.getByText('10:00')).toBeInTheDocument()
91
+ expect(screen.getByText('10:01')).toBeInTheDocument()
92
+ expect(screen.getByText('info')).toBeInTheDocument()
93
+ expect(screen.getByText('error')).toBeInTheDocument()
94
+ })
95
+
96
+ it('forwards the entry id as data-log-entry-id on the AccordionItem', () => {
97
+ const { container } = render(
98
+ <LogList entries={[makeEntry({ id: 'log-42' })]} />,
99
+ )
100
+ expect(container.querySelector('[data-log-entry-id="log-42"]')).not.toBeNull()
101
+ })
102
+
103
+ it('hides each entry body until the trigger is clicked, then reveals it (single + collapsible)', () => {
104
+ const { container } = render(
105
+ <LogList
106
+ entries={[
107
+ makeEntry({ id: 'a', message: 'A' }),
108
+ makeEntry({ id: 'b', message: 'B' }),
109
+ ]}
110
+ />,
111
+ )
112
+ const items = container.querySelectorAll('[data-slot="accordion-item"]')
113
+ expect(items[0]).toHaveAttribute('data-state', 'closed')
114
+ expect(items[1]).toHaveAttribute('data-state', 'closed')
115
+
116
+ fireEvent.click(screen.getByRole('button', { name: /A/ }))
117
+ expect(items[0]).toHaveAttribute('data-state', 'open')
118
+ expect(items[1]).toHaveAttribute('data-state', 'closed')
119
+
120
+ fireEvent.click(screen.getByRole('button', { name: /B/ }))
121
+ expect(items[0]).toHaveAttribute('data-state', 'closed')
122
+ expect(items[1]).toHaveAttribute('data-state', 'open')
123
+ })
124
+
125
+ it('uses the levelLabel override when provided (e.g. translated level text)', () => {
126
+ render(
127
+ <LogList
128
+ entries={[
129
+ makeEntry({ level: 'warn', levelLabel: 'Ostrzeżenie' }),
130
+ ]}
131
+ />,
132
+ )
133
+ expect(screen.getByText('Ostrzeżenie')).toBeInTheDocument()
134
+ expect(screen.queryByText('warn')).toBeNull()
135
+ })
136
+
137
+ it('supports ReactNode message + body slots (not just strings); body mounts on expand', () => {
138
+ render(
139
+ <LogList
140
+ entries={[
141
+ {
142
+ id: 'rich',
143
+ time: <span data-testid="time">10:00:01</span>,
144
+ level: 'info',
145
+ message: <span data-testid="message">Detailed event</span>,
146
+ body: <pre data-testid="body">JSON payload</pre>,
147
+ },
148
+ ]}
149
+ />,
150
+ )
151
+ expect(screen.getByTestId('time')).toBeInTheDocument()
152
+ expect(screen.getByTestId('message')).toBeInTheDocument()
153
+ // Radix lazy-mounts AccordionContent children — body appears only after the trigger is opened.
154
+ expect(screen.queryByTestId('body')).toBeNull()
155
+ fireEvent.click(screen.getByRole('button', { name: /Detailed event/ }))
156
+ expect(screen.getByTestId('body')).toBeInTheDocument()
157
+ })
158
+
159
+ it('marks the root with data-slot="log-list"', () => {
160
+ const { container } = render(<LogList entries={[makeEntry()]} />)
161
+ expect(container.querySelector('[data-slot="log-list"]')).not.toBeNull()
162
+ })
163
+ })
@@ -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
- 'rounded-lg border bg-card',
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 && 'border-destructive',
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
- 'flex-1 px-2 py-1 text-sm font-medium hover:bg-accent/50 rounded-md',
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
- {resolvedType === 'tel' ? (
321
- <PhoneNumberField
322
- value={draft.length ? draft : undefined}
323
- onValueChange={(next) => {
324
- if (error) setError(null)
325
- setDraft(next ?? '')
326
- }}
327
- placeholder={placeholder}
328
- autoFocus
329
- disabled={saving}
330
- minDigits={7}
331
- />
332
- ) : (
333
- <input
334
- className="w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
335
- value={draft}
336
- onChange={(event) => {
337
- if (error) setError(null)
338
- setDraft(event.target.value)
339
- }}
340
- placeholder={placeholder}
341
- type={inputType ?? resolvedType}
342
- autoFocus
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>