@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3260.1.dc719cac84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +46 -129
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +10 -3
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/EmptyState.js +1 -47
- package/dist/backend/EmptyState.js.map +2 -2
- package/dist/backend/FilterOverlay.js +31 -26
- package/dist/backend/FilterOverlay.js.map +2 -2
- package/dist/backend/FlashMessages.js +23 -25
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +4 -2
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/LogList.js +49 -0
- package/dist/backend/LogList.js.map +7 -0
- package/dist/backend/crud/CollapsibleGroup.js +13 -3
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +80 -32
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +60 -6
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/DatePicker.js +6 -142
- package/dist/backend/inputs/DatePicker.js.map +2 -2
- package/dist/backend/inputs/DateTimePicker.js +6 -182
- package/dist/backend/inputs/DateTimePicker.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +16 -19
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/TimeInput.js +1 -1
- package/dist/backend/inputs/TimeInput.js.map +2 -2
- package/dist/backend/inputs/TimePicker.js +83 -72
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +2 -2
- package/dist/backend/utils/customFieldForms.js.map +1 -1
- package/dist/portal/components/PortalEmptyState.js +13 -9
- package/dist/portal/components/PortalEmptyState.js.map +2 -2
- package/dist/primitives/accordion.js +137 -0
- package/dist/primitives/accordion.js.map +7 -0
- package/dist/primitives/alert.js +180 -12
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/calendar.js +122 -32
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/compact-select.js +47 -0
- package/dist/primitives/compact-select.js.map +7 -0
- package/dist/primitives/counter-input.js +174 -0
- package/dist/primitives/counter-input.js.map +7 -0
- package/dist/primitives/date-picker-helpers.js +79 -0
- package/dist/primitives/date-picker-helpers.js.map +7 -0
- package/dist/primitives/date-picker.js +216 -0
- package/dist/primitives/date-picker.js.map +7 -0
- package/dist/primitives/date-range-picker.js +239 -0
- package/dist/primitives/date-range-picker.js.map +7 -0
- package/dist/primitives/digit-input.js +157 -0
- package/dist/primitives/digit-input.js.map +7 -0
- package/dist/primitives/empty-state.js +93 -0
- package/dist/primitives/empty-state.js.map +7 -0
- package/dist/primitives/inline-input.js +29 -0
- package/dist/primitives/inline-input.js.map +7 -0
- package/dist/primitives/inline-select.js +41 -0
- package/dist/primitives/inline-select.js.map +7 -0
- package/dist/primitives/notification-stack.js +116 -0
- package/dist/primitives/notification-stack.js.map +7 -0
- package/dist/primitives/notification.js +62 -0
- package/dist/primitives/notification.js.map +7 -0
- package/dist/primitives/rich-editor.js +1230 -0
- package/dist/primitives/rich-editor.js.map +7 -0
- package/dist/primitives/select.js +1 -0
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/skeleton.js +55 -0
- package/dist/primitives/skeleton.js.map +7 -0
- package/dist/primitives/tag-input.js +186 -0
- package/dist/primitives/tag-input.js.map +7 -0
- package/dist/primitives/tag.js +66 -13
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/time-picker.js +793 -0
- package/dist/primitives/time-picker.js.map +7 -0
- package/package.json +4 -3
- package/src/backend/CrudForm.tsx +61 -113
- package/src/backend/DataTable.tsx +17 -5
- package/src/backend/EmptyState.tsx +13 -63
- package/src/backend/FilterOverlay.tsx +38 -22
- package/src/backend/FlashMessages.tsx +23 -24
- package/src/backend/JsonBuilder.tsx +4 -2
- package/src/backend/LogList.tsx +109 -0
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
- package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
- package/src/backend/__tests__/LogList.test.tsx +163 -0
- package/src/backend/crud/CollapsibleGroup.tsx +17 -3
- package/src/backend/detail/InlineEditors.tsx +78 -25
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
- package/src/backend/inputs/ComboboxInput.tsx +81 -9
- package/src/backend/inputs/DatePicker.tsx +28 -155
- package/src/backend/inputs/DateTimePicker.tsx +25 -193
- package/src/backend/inputs/TagsInput.tsx +10 -13
- package/src/backend/inputs/TimeInput.tsx +2 -1
- package/src/backend/inputs/TimePicker.tsx +99 -70
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
- package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
- package/src/backend/utils/customFieldForms.ts +2 -2
- package/src/portal/components/PortalEmptyState.tsx +27 -22
- package/src/primitives/__tests__/accordion.test.tsx +197 -0
- package/src/primitives/__tests__/alert.test.tsx +204 -0
- package/src/primitives/__tests__/compact-select.test.tsx +98 -0
- package/src/primitives/__tests__/counter-input.test.tsx +165 -0
- package/src/primitives/__tests__/date-picker.test.tsx +221 -0
- package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
- package/src/primitives/__tests__/digit-input.test.tsx +168 -0
- package/src/primitives/__tests__/empty-state.test.tsx +154 -0
- package/src/primitives/__tests__/inline-input.test.tsx +100 -0
- package/src/primitives/__tests__/inline-select.test.tsx +103 -0
- package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
- package/src/primitives/__tests__/notification.test.tsx +119 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
- package/src/primitives/__tests__/skeleton.test.tsx +76 -0
- package/src/primitives/__tests__/tag-input.test.tsx +198 -0
- package/src/primitives/__tests__/tag.test.tsx +123 -0
- package/src/primitives/__tests__/time-picker.test.tsx +392 -0
- package/src/primitives/accordion.tsx +225 -0
- package/src/primitives/alert.tsx +234 -24
- package/src/primitives/calendar.tsx +146 -38
- package/src/primitives/compact-select.tsx +59 -0
- package/src/primitives/counter-input.tsx +211 -0
- package/src/primitives/date-picker-helpers.ts +96 -0
- package/src/primitives/date-picker.tsx +290 -0
- package/src/primitives/date-range-picker.tsx +281 -0
- package/src/primitives/digit-input.tsx +208 -0
- package/src/primitives/empty-state.tsx +140 -0
- package/src/primitives/inline-input.tsx +55 -0
- package/src/primitives/inline-select.tsx +74 -0
- package/src/primitives/notification-stack.tsx +185 -0
- package/src/primitives/notification.tsx +136 -0
- package/src/primitives/rich-editor.tsx +1578 -0
- package/src/primitives/select.tsx +1 -0
- package/src/primitives/skeleton.tsx +70 -0
- package/src/primitives/tag-input.tsx +223 -0
- package/src/primitives/tag.tsx +69 -10
- package/src/primitives/time-picker.tsx +1059 -0
|
@@ -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
|
+
}
|