@open-mercato/ui 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3260.1.dc719cac84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -0
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +226 -58
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiDock.js +3 -2
  8. package/dist/ai/AiDock.js.map +2 -2
  9. package/dist/ai/ModelPicker.js +186 -0
  10. package/dist/ai/ModelPicker.js.map +7 -0
  11. package/dist/ai/index.js +3 -1
  12. package/dist/ai/index.js.map +2 -2
  13. package/dist/ai/useAiChat.js +12 -16
  14. package/dist/ai/useAiChat.js.map +2 -2
  15. package/dist/backend/CrudForm.js +46 -129
  16. package/dist/backend/CrudForm.js.map +3 -3
  17. package/dist/backend/DataTable.js +10 -3
  18. package/dist/backend/DataTable.js.map +2 -2
  19. package/dist/backend/EmptyState.js +1 -47
  20. package/dist/backend/EmptyState.js.map +2 -2
  21. package/dist/backend/FilterOverlay.js +31 -26
  22. package/dist/backend/FilterOverlay.js.map +2 -2
  23. package/dist/backend/FlashMessages.js +23 -25
  24. package/dist/backend/FlashMessages.js.map +2 -2
  25. package/dist/backend/JsonBuilder.js +4 -2
  26. package/dist/backend/JsonBuilder.js.map +2 -2
  27. package/dist/backend/LogList.js +49 -0
  28. package/dist/backend/LogList.js.map +7 -0
  29. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  30. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  31. package/dist/backend/detail/InlineEditors.js +80 -32
  32. package/dist/backend/detail/InlineEditors.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +60 -6
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/DatePicker.js +6 -142
  36. package/dist/backend/inputs/DatePicker.js.map +2 -2
  37. package/dist/backend/inputs/DateTimePicker.js +6 -182
  38. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  39. package/dist/backend/inputs/TagsInput.js +16 -19
  40. package/dist/backend/inputs/TagsInput.js.map +2 -2
  41. package/dist/backend/inputs/TimeInput.js +1 -1
  42. package/dist/backend/inputs/TimeInput.js.map +2 -2
  43. package/dist/backend/inputs/TimePicker.js +83 -72
  44. package/dist/backend/inputs/TimePicker.js.map +2 -2
  45. package/dist/backend/utils/customFieldForms.js +2 -2
  46. package/dist/backend/utils/customFieldForms.js.map +1 -1
  47. package/dist/portal/components/PortalEmptyState.js +13 -9
  48. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  49. package/dist/primitives/accordion.js +137 -0
  50. package/dist/primitives/accordion.js.map +7 -0
  51. package/dist/primitives/alert.js +180 -12
  52. package/dist/primitives/alert.js.map +2 -2
  53. package/dist/primitives/calendar.js +122 -32
  54. package/dist/primitives/calendar.js.map +2 -2
  55. package/dist/primitives/compact-select.js +47 -0
  56. package/dist/primitives/compact-select.js.map +7 -0
  57. package/dist/primitives/counter-input.js +174 -0
  58. package/dist/primitives/counter-input.js.map +7 -0
  59. package/dist/primitives/date-picker-helpers.js +79 -0
  60. package/dist/primitives/date-picker-helpers.js.map +7 -0
  61. package/dist/primitives/date-picker.js +216 -0
  62. package/dist/primitives/date-picker.js.map +7 -0
  63. package/dist/primitives/date-range-picker.js +239 -0
  64. package/dist/primitives/date-range-picker.js.map +7 -0
  65. package/dist/primitives/digit-input.js +157 -0
  66. package/dist/primitives/digit-input.js.map +7 -0
  67. package/dist/primitives/empty-state.js +93 -0
  68. package/dist/primitives/empty-state.js.map +7 -0
  69. package/dist/primitives/inline-input.js +29 -0
  70. package/dist/primitives/inline-input.js.map +7 -0
  71. package/dist/primitives/inline-select.js +41 -0
  72. package/dist/primitives/inline-select.js.map +7 -0
  73. package/dist/primitives/notification-stack.js +116 -0
  74. package/dist/primitives/notification-stack.js.map +7 -0
  75. package/dist/primitives/notification.js +62 -0
  76. package/dist/primitives/notification.js.map +7 -0
  77. package/dist/primitives/rich-editor.js +1230 -0
  78. package/dist/primitives/rich-editor.js.map +7 -0
  79. package/dist/primitives/select.js +1 -0
  80. package/dist/primitives/select.js.map +2 -2
  81. package/dist/primitives/skeleton.js +55 -0
  82. package/dist/primitives/skeleton.js.map +7 -0
  83. package/dist/primitives/tag-input.js +186 -0
  84. package/dist/primitives/tag-input.js.map +7 -0
  85. package/dist/primitives/tag.js +66 -13
  86. package/dist/primitives/tag.js.map +2 -2
  87. package/dist/primitives/time-picker.js +793 -0
  88. package/dist/primitives/time-picker.js.map +7 -0
  89. package/package.json +4 -3
  90. package/src/ai/AiAssistantLauncher.tsx +8 -0
  91. package/src/ai/AiChat.tsx +229 -11
  92. package/src/ai/AiDock.tsx +3 -2
  93. package/src/ai/ModelPicker.tsx +248 -0
  94. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +180 -0
  99. package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
  100. package/src/ai/__tests__/AiDock.test.tsx +12 -1
  101. package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
  102. package/src/ai/index.ts +1 -0
  103. package/src/ai/useAiChat.ts +29 -16
  104. package/src/backend/CrudForm.tsx +61 -113
  105. package/src/backend/DataTable.tsx +17 -5
  106. package/src/backend/EmptyState.tsx +13 -63
  107. package/src/backend/FilterOverlay.tsx +38 -22
  108. package/src/backend/FlashMessages.tsx +23 -24
  109. package/src/backend/JsonBuilder.tsx +4 -2
  110. package/src/backend/LogList.tsx +109 -0
  111. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  112. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  113. package/src/backend/__tests__/LogList.test.tsx +163 -0
  114. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  115. package/src/backend/detail/InlineEditors.tsx +78 -25
  116. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  117. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  118. package/src/backend/inputs/DatePicker.tsx +28 -155
  119. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  120. package/src/backend/inputs/TagsInput.tsx +10 -13
  121. package/src/backend/inputs/TimeInput.tsx +2 -1
  122. package/src/backend/inputs/TimePicker.tsx +99 -70
  123. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  124. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  125. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  126. package/src/backend/utils/customFieldForms.ts +2 -2
  127. package/src/portal/components/PortalEmptyState.tsx +27 -22
  128. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  129. package/src/primitives/__tests__/alert.test.tsx +204 -0
  130. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  131. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  132. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  133. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  134. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  135. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  136. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  137. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  138. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  139. package/src/primitives/__tests__/notification.test.tsx +119 -0
  140. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  141. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  142. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  143. package/src/primitives/__tests__/tag.test.tsx +123 -0
  144. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  145. package/src/primitives/accordion.tsx +225 -0
  146. package/src/primitives/alert.tsx +234 -24
  147. package/src/primitives/calendar.tsx +146 -38
  148. package/src/primitives/compact-select.tsx +59 -0
  149. package/src/primitives/counter-input.tsx +211 -0
  150. package/src/primitives/date-picker-helpers.ts +96 -0
  151. package/src/primitives/date-picker.tsx +290 -0
  152. package/src/primitives/date-range-picker.tsx +281 -0
  153. package/src/primitives/digit-input.tsx +208 -0
  154. package/src/primitives/empty-state.tsx +140 -0
  155. package/src/primitives/inline-input.tsx +55 -0
  156. package/src/primitives/inline-select.tsx +74 -0
  157. package/src/primitives/notification-stack.tsx +185 -0
  158. package/src/primitives/notification.tsx +136 -0
  159. package/src/primitives/rich-editor.tsx +1578 -0
  160. package/src/primitives/select.tsx +1 -0
  161. package/src/primitives/skeleton.tsx +70 -0
  162. package/src/primitives/tag-input.tsx +223 -0
  163. package/src/primitives/tag.tsx +69 -10
  164. package/src/primitives/time-picker.tsx +1059 -0
@@ -0,0 +1,392 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen, within } from '@testing-library/react'
7
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
8
+ import {
9
+ TimePicker,
10
+ TimePickerSlot,
11
+ TimePickerDurationChip,
12
+ TimePickerStatusChip,
13
+ formatDuration,
14
+ type TimePickerValue,
15
+ } from '../time-picker'
16
+
17
+ function renderWithI18n(ui: React.ReactElement) {
18
+ const utils = render(<I18nProvider locale="en" dict={{}}>{ui}</I18nProvider>)
19
+ return {
20
+ ...utils,
21
+ rerender: (next: React.ReactElement) =>
22
+ utils.rerender(<I18nProvider locale="en" dict={{}}>{next}</I18nProvider>),
23
+ }
24
+ }
25
+
26
+ describe('formatDuration helper', () => {
27
+ it('formats minutes under an hour', () => {
28
+ expect(formatDuration(15)).toBe('15 min')
29
+ expect(formatDuration(45)).toBe('45 min')
30
+ })
31
+
32
+ it('formats whole hours', () => {
33
+ expect(formatDuration(60)).toBe('1 hour')
34
+ expect(formatDuration(120)).toBe('2 hours')
35
+ expect(formatDuration(720)).toBe('12 hours')
36
+ })
37
+
38
+ it('formats whole days', () => {
39
+ expect(formatDuration(1440)).toBe('1 day')
40
+ expect(formatDuration(2880)).toBe('2 days')
41
+ })
42
+
43
+ it('formats mixed hours and minutes', () => {
44
+ expect(formatDuration(90)).toBe('1h 30m')
45
+ })
46
+
47
+ it('supports the long form when short=false', () => {
48
+ expect(formatDuration(15, { short: false })).toBe('15 minutes')
49
+ expect(formatDuration(60, { short: false })).toBe('1 hour')
50
+ expect(formatDuration(120, { short: false })).toBe('2 hours')
51
+ })
52
+
53
+ it('handles zero and invalid values', () => {
54
+ expect(formatDuration(0)).toBe('0 min')
55
+ expect(formatDuration(-30)).toBe('0 min')
56
+ expect(formatDuration(Number.NaN)).toBe('0 min')
57
+ })
58
+ })
59
+
60
+ describe('TimePickerSlot atom', () => {
61
+ it('renders 12h format by default with AM/PM suffix', () => {
62
+ render(<TimePickerSlot value="13:30" onSelect={() => {}} />)
63
+ expect(screen.getByText('01:30')).toBeInTheDocument()
64
+ expect(screen.getByText('PM')).toBeInTheDocument()
65
+ })
66
+
67
+ it('renders 24h format when format="24h"', () => {
68
+ const { container } = render(<TimePickerSlot value="13:30" format="24h" onSelect={() => {}} />)
69
+ expect(container.textContent).toContain('13:30')
70
+ expect(container.textContent).not.toContain('PM')
71
+ })
72
+
73
+ it('renders trailing check icon when selected', () => {
74
+ const { container } = render(<TimePickerSlot value="10:00" selected onSelect={() => {}} />)
75
+ const slot = container.querySelector('[data-slot="time-picker-slot"]')
76
+ expect(slot).toHaveAttribute('data-state', 'active')
77
+ expect(slot!.querySelectorAll('svg').length).toBeGreaterThan(0)
78
+ })
79
+
80
+ it('fires onSelect with the canonical value on click', () => {
81
+ const onSelect = jest.fn()
82
+ render(<TimePickerSlot value="10:00" onSelect={onSelect} />)
83
+ fireEvent.click(screen.getByRole('button'))
84
+ expect(onSelect).toHaveBeenCalledWith('10:00')
85
+ })
86
+
87
+ it('does not fire onSelect when disabled', () => {
88
+ const onSelect = jest.fn()
89
+ render(<TimePickerSlot value="10:00" disabled onSelect={onSelect} />)
90
+ fireEvent.click(screen.getByRole('button'))
91
+ expect(onSelect).not.toHaveBeenCalled()
92
+ })
93
+
94
+ it('renders rightText when provided', () => {
95
+ render(<TimePickerSlot value="10:00" rightText="11:00" onSelect={() => {}} />)
96
+ expect(screen.getAllByText('10:00').length).toBeGreaterThan(0)
97
+ expect(screen.getByText('11:00')).toBeInTheDocument()
98
+ })
99
+ })
100
+
101
+ describe('TimePickerDurationChip atom', () => {
102
+ it('renders default state with formatted label', () => {
103
+ render(<TimePickerDurationChip value={30} onSelect={() => {}} />)
104
+ expect(screen.getByText('30 min')).toBeInTheDocument()
105
+ })
106
+
107
+ it('renders custom label when provided', () => {
108
+ render(<TimePickerDurationChip value={30} label="Half hour" onSelect={() => {}} />)
109
+ expect(screen.getByText('Half hour')).toBeInTheDocument()
110
+ })
111
+
112
+ it('renders leading check icon when selected', () => {
113
+ const { container } = render(<TimePickerDurationChip value={30} selected onSelect={() => {}} />)
114
+ const chip = container.querySelector('[data-slot="time-picker-duration-chip"]')
115
+ expect(chip).toHaveAttribute('data-state', 'active')
116
+ expect(chip!.querySelectorAll('svg').length).toBeGreaterThan(0)
117
+ })
118
+
119
+ it('fires onSelect with numeric value on click', () => {
120
+ const onSelect = jest.fn()
121
+ render(<TimePickerDurationChip value={45} onSelect={onSelect} />)
122
+ fireEvent.click(screen.getByRole('button'))
123
+ expect(onSelect).toHaveBeenCalledWith(45)
124
+ })
125
+
126
+ it('does not fire onSelect when disabled', () => {
127
+ const onSelect = jest.fn()
128
+ render(<TimePickerDurationChip value={45} disabled onSelect={onSelect} />)
129
+ fireEvent.click(screen.getByRole('button'))
130
+ expect(onSelect).not.toHaveBeenCalled()
131
+ })
132
+ })
133
+
134
+ describe('TimePickerStatusChip atom', () => {
135
+ it('renders the default label for each variant', () => {
136
+ const { rerender } = render(<TimePickerStatusChip variant="available" />)
137
+ expect(screen.getByText('Available')).toBeInTheDocument()
138
+ rerender(<TimePickerStatusChip variant="busy" />)
139
+ expect(screen.getByText('Busy')).toBeInTheDocument()
140
+ rerender(<TimePickerStatusChip variant="in-meeting" />)
141
+ expect(screen.getByText('In meeting')).toBeInTheDocument()
142
+ rerender(<TimePickerStatusChip variant="offline" />)
143
+ expect(screen.getByText('Offline')).toBeInTheDocument()
144
+ })
145
+
146
+ it('marks the chip selected and exposes data-variant', () => {
147
+ const { container } = render(
148
+ <TimePickerStatusChip variant="busy" selected onSelect={() => {}} />,
149
+ )
150
+ const chip = container.querySelector('[data-slot="time-picker-status-chip"]')
151
+ expect(chip).toHaveAttribute('data-state', 'selected')
152
+ expect(chip).toHaveAttribute('data-variant', 'busy')
153
+ })
154
+
155
+ it('fires onSelect with the variant on click', () => {
156
+ const onSelect = jest.fn()
157
+ render(<TimePickerStatusChip variant="in-meeting" onSelect={onSelect} />)
158
+ fireEvent.click(screen.getByRole('button'))
159
+ expect(onSelect).toHaveBeenCalledWith('in-meeting')
160
+ })
161
+
162
+ it('does not fire onSelect when disabled', () => {
163
+ const onSelect = jest.fn()
164
+ render(<TimePickerStatusChip variant="offline" disabled onSelect={onSelect} />)
165
+ fireEvent.click(screen.getByRole('button'))
166
+ expect(onSelect).not.toHaveBeenCalled()
167
+ })
168
+
169
+ it('respects a custom icon override', () => {
170
+ const { container } = render(
171
+ <TimePickerStatusChip variant="available" icon={<span data-testid="custom-icon" />} />,
172
+ )
173
+ expect(container.querySelector('[data-testid="custom-icon"]')).not.toBeNull()
174
+ })
175
+ })
176
+
177
+ describe('TimePicker composition (inline mode)', () => {
178
+ function Harness({
179
+ initial,
180
+ ...rest
181
+ }: { initial?: TimePickerValue } & Omit<React.ComponentProps<typeof TimePicker>, 'value' | 'onChange'>) {
182
+ const [value, setValue] = React.useState<TimePickerValue>(initial ?? null)
183
+ return <TimePicker value={value} onChange={setValue} {...rest} />
184
+ }
185
+
186
+ it('auto-generates slots from startTime/endTime/intervalMinutes', () => {
187
+ const { container } = renderWithI18n(<Harness startTime="09:00" endTime="10:00" intervalMinutes={30} />)
188
+ const slots = container.querySelectorAll('[data-slot="time-picker-slot"]')
189
+ expect(slots).toHaveLength(3) // 09:00, 09:30, 10:00
190
+ })
191
+
192
+ it('explicit slots prop overrides generation', () => {
193
+ const { container } = renderWithI18n(<Harness slots={['08:00', '12:00']} />)
194
+ const slots = container.querySelectorAll('[data-slot="time-picker-slot"]')
195
+ expect(slots).toHaveLength(2)
196
+ })
197
+
198
+ it('does not render duration row when durations prop is omitted', () => {
199
+ const { container } = renderWithI18n(<Harness slots={['09:00']} />)
200
+ expect(container.querySelectorAll('[data-slot="time-picker-duration-chip"]').length).toBe(0)
201
+ })
202
+
203
+ it('renders duration row and highlights active duration', () => {
204
+ const onDurationChange = jest.fn()
205
+ const { container } = renderWithI18n(
206
+ <Harness
207
+ slots={['09:00']}
208
+ durations={[{ value: 15 }, { value: 30 }, { value: 60 }]}
209
+ activeDuration={30}
210
+ onDurationChange={onDurationChange}
211
+ />,
212
+ )
213
+ const chips = container.querySelectorAll('[data-slot="time-picker-duration-chip"]')
214
+ expect(chips.length).toBe(3)
215
+ const active = container.querySelector('[data-slot="time-picker-duration-chip"][data-state="active"]')
216
+ expect(active).not.toBeNull()
217
+ expect(active!.textContent).toContain('30 min')
218
+ fireEvent.click(chips[0])
219
+ expect(onDurationChange).toHaveBeenCalledWith(15)
220
+ })
221
+
222
+ it('renders status row with custom statusLabel and fires onStatusChange', () => {
223
+ const onStatusChange = jest.fn()
224
+ const { container } = renderWithI18n(
225
+ <Harness
226
+ slots={['09:00']}
227
+ statuses={[{ variant: 'available' }, { variant: 'busy' }]}
228
+ statusLabel="Pick your status"
229
+ onStatusChange={onStatusChange}
230
+ />,
231
+ )
232
+ const row = container.querySelector('[data-slot="time-picker-status-row"]')
233
+ expect(row).not.toBeNull()
234
+ expect(within(row as HTMLElement).getByText('Pick your status')).toBeInTheDocument()
235
+ const busyChip = within(row as HTMLElement).getByText('Busy').closest('button')!
236
+ fireEvent.click(busyChip)
237
+ expect(onStatusChange).toHaveBeenCalledWith('busy')
238
+ })
239
+
240
+ it('shows header with placeholder text when value is null', () => {
241
+ const { container } = renderWithI18n(
242
+ <Harness slots={['09:00']} headerPlaceholder="Pick a time..." />,
243
+ )
244
+ const header = container.querySelector('[data-slot="time-picker-header"]')
245
+ expect(header).not.toBeNull()
246
+ expect(header!.textContent).toContain('Pick a time...')
247
+ })
248
+
249
+ it('shows formatted current time in header when value is set', () => {
250
+ const { container } = renderWithI18n(<Harness initial="10:30" slots={['10:00', '10:30']} />)
251
+ const header = container.querySelector('[data-slot="time-picker-header"]')
252
+ expect(header!.textContent).toContain('10:30')
253
+ expect(header!.textContent).toContain('AM')
254
+ })
255
+
256
+ it('hides header when showHeader=false', () => {
257
+ const { container } = renderWithI18n(<Harness slots={['09:00']} showHeader={false} />)
258
+ expect(container.querySelector('[data-slot="time-picker-header"]')).toBeNull()
259
+ })
260
+
261
+ it('hides footer when showFooter=false', () => {
262
+ const { container } = renderWithI18n(<Harness slots={['09:00']} showFooter={false} />)
263
+ expect(container.querySelector('[data-slot="time-picker-footer"]')).toBeNull()
264
+ })
265
+
266
+ it('Apply button fires onApply with current value', () => {
267
+ const onApply = jest.fn()
268
+ renderWithI18n(<Harness initial="09:30" slots={['09:30']} onApply={onApply} />)
269
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
270
+ expect(onApply).toHaveBeenCalledWith('09:30')
271
+ })
272
+
273
+ it('Cancel button fires onCancel in inline mode (no value revert)', () => {
274
+ const onCancel = jest.fn()
275
+ renderWithI18n(<Harness initial="09:30" slots={['09:30']} onCancel={onCancel} />)
276
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
277
+ expect(onCancel).toHaveBeenCalled()
278
+ })
279
+
280
+ it('clicking a slot updates the value', () => {
281
+ const onChange = jest.fn()
282
+ renderWithI18n(
283
+ <TimePicker
284
+ value="09:00"
285
+ onChange={onChange}
286
+ slots={['09:00', '09:30', '10:00']}
287
+ />,
288
+ )
289
+ fireEvent.click(screen.getAllByRole('button')[2]) // 0=close? no — no onClose given. 0=09:00 slot
290
+ // Be explicit: pick by label text rather than index.
291
+ const tenAm = screen.getAllByRole('button').find((btn) => btn.textContent?.includes('10:00') && btn.textContent?.includes('AM'))
292
+ expect(tenAm).toBeDefined()
293
+ fireEvent.click(tenAm!)
294
+ expect(onChange).toHaveBeenCalledWith('10:00')
295
+ })
296
+
297
+ it('disables all sub-components when disabled=true', () => {
298
+ const { container } = renderWithI18n(
299
+ <Harness
300
+ slots={['09:00', '09:30']}
301
+ durations={[{ value: 30 }]}
302
+ statuses={[{ variant: 'available' }]}
303
+ disabled
304
+ />,
305
+ )
306
+ container.querySelectorAll<HTMLButtonElement>('button[disabled]')
307
+ const slotButtons = container.querySelectorAll<HTMLButtonElement>('[data-slot="time-picker-slot"]')
308
+ slotButtons.forEach((btn) => expect(btn).toBeDisabled())
309
+ const durationChip = container.querySelector<HTMLButtonElement>('[data-slot="time-picker-duration-chip"]')
310
+ expect(durationChip).toBeDisabled()
311
+ const statusChip = container.querySelector<HTMLButtonElement>('[data-slot="time-picker-status-chip"]')
312
+ expect(statusChip).toBeDisabled()
313
+ })
314
+
315
+ it('Arrow Down moves focus to next slot, Arrow Up moves back', () => {
316
+ const { container } = renderWithI18n(<Harness slots={['09:00', '09:30', '10:00']} />)
317
+ const slots = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-slot="time-picker-slot"]'))
318
+ slots[0].focus()
319
+ fireEvent.keyDown(slots[0], { key: 'ArrowDown' })
320
+ expect(document.activeElement).toBe(slots[1])
321
+ fireEvent.keyDown(slots[1], { key: 'ArrowDown' })
322
+ expect(document.activeElement).toBe(slots[2])
323
+ fireEvent.keyDown(slots[2], { key: 'ArrowUp' })
324
+ expect(document.activeElement).toBe(slots[1])
325
+ })
326
+
327
+ it('renders legacyFooterActions as link-style buttons', () => {
328
+ const onNow = jest.fn()
329
+ const onClear = jest.fn()
330
+ renderWithI18n(
331
+ <Harness
332
+ slots={['09:00']}
333
+ legacyFooterActions={[
334
+ { label: 'Now', onClick: onNow, variant: 'link' },
335
+ { label: 'Clear', onClick: onClear, variant: 'muted' },
336
+ ]}
337
+ />,
338
+ )
339
+ fireEvent.click(screen.getByRole('button', { name: 'Now' }))
340
+ expect(onNow).toHaveBeenCalled()
341
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
342
+ expect(onClear).toHaveBeenCalled()
343
+ })
344
+
345
+ it('shows close button only when onClose is provided', () => {
346
+ const onClose = jest.fn()
347
+ const { container, rerender } = renderWithI18n(<Harness slots={['09:00']} />)
348
+ expect(container.querySelector('[data-slot="time-picker-close"]')).toBeNull()
349
+ rerender(<Harness slots={['09:00']} onClose={onClose} />)
350
+ fireEvent.click(container.querySelector('[data-slot="time-picker-close"]')!)
351
+ expect(onClose).toHaveBeenCalled()
352
+ })
353
+
354
+ it('falls back to default placeholder when none provided', () => {
355
+ const { container } = renderWithI18n(<TimePicker slots={['09:00']} />)
356
+ expect(container.querySelector('[data-slot="time-picker-header"]')!.textContent).toContain('Pick a time')
357
+ })
358
+ })
359
+
360
+ describe('TimePicker controlled vs uncontrolled', () => {
361
+ it('uncontrolled defaultValue is used as initial slot selection', () => {
362
+ const { container } = renderWithI18n(
363
+ <TimePicker defaultValue="10:00" slots={['09:30', '10:00']} />,
364
+ )
365
+ const slots = container.querySelectorAll('[data-slot="time-picker-slot"]')
366
+ expect(slots[1]).toHaveAttribute('data-state', 'active')
367
+ })
368
+
369
+ it('controlled value drives selection regardless of internal state', () => {
370
+ const { container, rerender } = renderWithI18n(
371
+ <TimePicker value="09:30" onChange={() => {}} slots={['09:30', '10:00']} />,
372
+ )
373
+ let slots = container.querySelectorAll('[data-slot="time-picker-slot"]')
374
+ expect(slots[0]).toHaveAttribute('data-state', 'active')
375
+ rerender(<TimePicker value="10:00" onChange={() => {}} slots={['09:30', '10:00']} />)
376
+ slots = container.querySelectorAll('[data-slot="time-picker-slot"]')
377
+ expect(slots[1]).toHaveAttribute('data-state', 'active')
378
+ })
379
+
380
+ it('uncontrolled defaultActiveDuration drives duration highlight', () => {
381
+ const { container } = renderWithI18n(
382
+ <TimePicker
383
+ slots={['09:00']}
384
+ durations={[{ value: 15 }, { value: 30 }]}
385
+ defaultActiveDuration={30}
386
+ />,
387
+ )
388
+ const chips = container.querySelectorAll('[data-slot="time-picker-duration-chip"]')
389
+ expect(chips[0]).toHaveAttribute('data-state', 'default')
390
+ expect(chips[1]).toHaveAttribute('data-state', 'active')
391
+ })
392
+ })
@@ -0,0 +1,225 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import * as AccordionPrimitive from '@radix-ui/react-accordion'
5
+ import { ChevronDown, Minus, Plus } from 'lucide-react'
6
+ import { cva, type VariantProps } from 'class-variance-authority'
7
+
8
+ import { cn } from '@open-mercato/shared/lib/utils'
9
+
10
+ export type AccordionTriggerIcon = 'plus-minus' | 'chevron' | 'none'
11
+ export type AccordionIconPosition = 'end' | 'start'
12
+
13
+ const accordionItemVariants = cva(
14
+ // `--accordion-indent` defaults to the container padding (14px). The
15
+ // `has-[…trigger-left-icon]` Tailwind v4 variant promotes it to 44px
16
+ // (icon 20 + gap 10 + container padding 14) whenever an `AccordionTrigger`
17
+ // declares a `leftIcon`, so `AccordionContent` aligns with the title text
18
+ // — matches Figma 210:4064 (Active state) without React state or hydration
19
+ // flash.
20
+ 'overflow-hidden rounded-[10px] transition-colors [--accordion-indent:14px] has-[[data-slot=accordion-trigger-left-icon]]:[--accordion-indent:44px]',
21
+ {
22
+ variants: {
23
+ variant: {
24
+ // Figma 210:4021 (Default), 210:4048 (Hover), 210:4064 (Active):
25
+ // closed = white card + soft border + x-small shadow,
26
+ // hover (closed) = weak-50 bg, no border, no shadow,
27
+ // open = weak-50 bg, no border, no shadow.
28
+ card:
29
+ 'border border-border bg-card shadow-xs ' +
30
+ 'data-[state=open]:border-transparent data-[state=open]:bg-muted data-[state=open]:shadow-none ' +
31
+ 'data-[state=closed]:hover:border-transparent data-[state=closed]:hover:bg-muted data-[state=closed]:hover:shadow-none',
32
+ // For embedded use cases (e.g. FAQ block on a coloured surface or
33
+ // borderless nav-style list where the surrounding container owns
34
+ // the visual chrome).
35
+ borderless: 'border-transparent bg-transparent shadow-none',
36
+ },
37
+ },
38
+ defaultVariants: { variant: 'card' },
39
+ },
40
+ )
41
+
42
+ const Accordion = AccordionPrimitive.Root
43
+
44
+ type AccordionItemProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> &
45
+ VariantProps<typeof accordionItemVariants>
46
+
47
+ const AccordionItem = React.forwardRef<
48
+ React.ElementRef<typeof AccordionPrimitive.Item>,
49
+ AccordionItemProps
50
+ >(({ className, variant, ...props }, ref) => (
51
+ <AccordionPrimitive.Item
52
+ ref={ref}
53
+ data-slot="accordion-item"
54
+ className={cn(accordionItemVariants({ variant }), className)}
55
+ {...props}
56
+ />
57
+ ))
58
+ AccordionItem.displayName = 'AccordionItem'
59
+
60
+ const AccordionHeader = React.forwardRef<
61
+ React.ElementRef<typeof AccordionPrimitive.Header>,
62
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Header>
63
+ >(({ className, ...props }, ref) => (
64
+ <AccordionPrimitive.Header
65
+ ref={ref}
66
+ data-slot="accordion-header"
67
+ className={cn('flex', className)}
68
+ {...props}
69
+ />
70
+ ))
71
+ AccordionHeader.displayName = 'AccordionHeader'
72
+
73
+ type AccordionTriggerProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
74
+ /**
75
+ * Leading icon (Figma 210:4017 `question-line` slot). Renders as a
76
+ * 20×20 decorative glyph before the label. Pass any Lucide icon or
77
+ * custom element; omit to skip the slot.
78
+ *
79
+ * When present and `iconPosition="end"`, the parent `AccordionItem`
80
+ * auto-promotes `AccordionContent`'s horizontal padding to 44px so the
81
+ * description aligns with the title text.
82
+ */
83
+ leftIcon?: React.ReactNode
84
+ /**
85
+ * Trailing indicator style. Figma reference uses `'plus-minus'`
86
+ * (add-line / subtract-line) — the default. `'chevron'` swaps in a
87
+ * rotating ChevronDown for shadcn-style consumers. `'none'` disables
88
+ * the indicator entirely (useful when `indicator` carries the visual).
89
+ */
90
+ triggerIcon?: AccordionTriggerIcon
91
+ /**
92
+ * Where the open/close indicator sits. `'end'` (default) puts it on
93
+ * the trailing edge per Figma `Flip Icon = Off`; `'start'` mirrors the
94
+ * Figma `Flip Icon = On` variant. With `'start'`, `leftIcon` is
95
+ * intentionally suppressed because the indicator already occupies the
96
+ * leading slot — pass any decorative glyph as the indicator itself.
97
+ */
98
+ iconPosition?: AccordionIconPosition
99
+ /**
100
+ * Custom indicator override. When provided, replaces the
101
+ * `triggerIcon` plus/chevron node entirely — useful for status
102
+ * badges, step numbers, or progress dots.
103
+ */
104
+ indicator?: React.ReactNode
105
+ /**
106
+ * Extra className applied to the internal `AccordionPrimitive.Header`
107
+ * (`<h3>`) wrapper. Use this when the trigger row needs to share its
108
+ * line with sibling action buttons (e.g. a kebab menu next to the
109
+ * Accordion list of settings rows) — the header can then take
110
+ * `flex-1` while the sibling stays its natural size.
111
+ */
112
+ headerClassName?: string
113
+ }
114
+
115
+ const AccordionTrigger = React.forwardRef<
116
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
117
+ AccordionTriggerProps
118
+ >(
119
+ (
120
+ {
121
+ className,
122
+ leftIcon,
123
+ triggerIcon = 'plus-minus',
124
+ iconPosition = 'end',
125
+ indicator,
126
+ headerClassName,
127
+ children,
128
+ ...props
129
+ },
130
+ ref,
131
+ ) => {
132
+ const indicatorNode =
133
+ indicator !== undefined
134
+ ? indicator
135
+ : triggerIcon === 'chevron'
136
+ ? <ChevronDown className="size-5 transition-transform duration-200 group-data-[state=open]/accordion-trigger:rotate-180" aria-hidden="true" />
137
+ : triggerIcon === 'plus-minus'
138
+ ? (
139
+ <>
140
+ <Plus className="size-5 group-data-[state=open]/accordion-trigger:hidden" aria-hidden="true" />
141
+ <Minus className="hidden size-5 group-data-[state=open]/accordion-trigger:block" aria-hidden="true" />
142
+ </>
143
+ )
144
+ : null
145
+
146
+ const renderLeftIcon = Boolean(leftIcon) && iconPosition === 'end'
147
+
148
+ return (
149
+ <AccordionPrimitive.Header className={cn('flex', headerClassName)} data-slot="accordion-header">
150
+ <AccordionPrimitive.Trigger
151
+ ref={ref}
152
+ data-slot="accordion-trigger"
153
+ className={cn(
154
+ 'group/accordion-trigger flex w-full items-start gap-2.5 p-3.5 text-left text-sm font-medium leading-5 tracking-tight text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50',
155
+ className,
156
+ )}
157
+ {...props}
158
+ >
159
+ {iconPosition === 'start' && indicatorNode ? (
160
+ <span
161
+ className="inline-flex size-5 shrink-0 items-center justify-center text-foreground"
162
+ data-slot="accordion-trigger-indicator"
163
+ aria-hidden="true"
164
+ >
165
+ {indicatorNode}
166
+ </span>
167
+ ) : null}
168
+ {renderLeftIcon ? (
169
+ <span
170
+ className="inline-flex size-5 shrink-0 items-center justify-center text-muted-foreground [&>svg]:size-5"
171
+ data-slot="accordion-trigger-left-icon"
172
+ aria-hidden="true"
173
+ >
174
+ {leftIcon}
175
+ </span>
176
+ ) : null}
177
+ <span className="min-w-0 flex-1 self-center" data-slot="accordion-trigger-label">
178
+ {children}
179
+ </span>
180
+ {iconPosition === 'end' && indicatorNode ? (
181
+ <span
182
+ className="inline-flex size-5 shrink-0 items-center justify-center text-foreground"
183
+ data-slot="accordion-trigger-indicator"
184
+ aria-hidden="true"
185
+ >
186
+ {indicatorNode}
187
+ </span>
188
+ ) : null}
189
+ </AccordionPrimitive.Trigger>
190
+ </AccordionPrimitive.Header>
191
+ )
192
+ },
193
+ )
194
+ AccordionTrigger.displayName = 'AccordionTrigger'
195
+
196
+ type AccordionContentProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
197
+
198
+ const AccordionContent = React.forwardRef<
199
+ React.ElementRef<typeof AccordionPrimitive.Content>,
200
+ AccordionContentProps
201
+ >(({ className, children, ...props }, ref) => (
202
+ <AccordionPrimitive.Content
203
+ ref={ref}
204
+ data-slot="accordion-content"
205
+ className={cn(
206
+ 'overflow-hidden text-sm leading-5 text-muted-foreground data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
207
+ className,
208
+ )}
209
+ {...props}
210
+ >
211
+ <div className="pb-3.5 pt-0 pl-[var(--accordion-indent)] pr-[var(--accordion-indent)]">
212
+ {children}
213
+ </div>
214
+ </AccordionPrimitive.Content>
215
+ ))
216
+ AccordionContent.displayName = 'AccordionContent'
217
+
218
+ export {
219
+ Accordion,
220
+ AccordionItem,
221
+ AccordionHeader,
222
+ AccordionTrigger,
223
+ AccordionContent,
224
+ accordionItemVariants,
225
+ }