@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3276.1.1fcd145078
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,221 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
4
|
+
import { DatePicker } from '../date-picker'
|
|
5
|
+
import { DatePicker as LegacyDatePicker } from '../../backend/inputs/DatePicker'
|
|
6
|
+
import { DateTimePicker as LegacyDateTimePicker } from '../../backend/inputs/DateTimePicker'
|
|
7
|
+
|
|
8
|
+
function renderWithI18n(ui: React.ReactElement) {
|
|
9
|
+
return render(<I18nProvider locale="en" dict={{}}>{ui}</I18nProvider>)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getTrigger(): HTMLElement {
|
|
13
|
+
const trigger = document.querySelector('[data-slot="date-picker-trigger"]')
|
|
14
|
+
if (!trigger) throw new Error('DatePicker trigger not found')
|
|
15
|
+
return trigger as HTMLElement
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function openPopover() {
|
|
19
|
+
await act(async () => {
|
|
20
|
+
fireEvent.click(getTrigger())
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('DatePicker primitive', () => {
|
|
25
|
+
it('renders the placeholder when value is null', () => {
|
|
26
|
+
renderWithI18n(<DatePicker value={null} onChange={() => {}} placeholder="Pick a date" />)
|
|
27
|
+
expect(screen.getByText('Pick a date')).toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders the formatted value when value is provided', () => {
|
|
31
|
+
renderWithI18n(<DatePicker value={new Date(2026, 4, 9)} onChange={() => {}} />)
|
|
32
|
+
expect(screen.getByText('May 9, 2026')).toBeInTheDocument()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('uses a custom display format when supplied', () => {
|
|
36
|
+
renderWithI18n(
|
|
37
|
+
<DatePicker value={new Date(2026, 4, 9)} onChange={() => {}} displayFormat="yyyy-MM-dd" />,
|
|
38
|
+
)
|
|
39
|
+
expect(screen.getByText('2026-05-09')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('opens the popover and renders the calendar grid on trigger click', async () => {
|
|
43
|
+
renderWithI18n(<DatePicker value={null} onChange={() => {}} placeholder="Pick" />)
|
|
44
|
+
await openPopover()
|
|
45
|
+
expect(screen.getByRole('grid')).toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders Apply and Cancel buttons in the default footer', async () => {
|
|
49
|
+
renderWithI18n(<DatePicker value={null} onChange={() => {}} />)
|
|
50
|
+
await openPopover()
|
|
51
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument()
|
|
52
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders Today and Clear links when footer="today-clear"', async () => {
|
|
56
|
+
renderWithI18n(<DatePicker value={null} onChange={() => {}} footer="today-clear" />)
|
|
57
|
+
await openPopover()
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Today' })).toBeInTheDocument()
|
|
59
|
+
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('hides the Today button when showTodayButton=false', async () => {
|
|
63
|
+
renderWithI18n(
|
|
64
|
+
<DatePicker
|
|
65
|
+
value={null}
|
|
66
|
+
onChange={() => {}}
|
|
67
|
+
footer="today-clear"
|
|
68
|
+
showTodayButton={false}
|
|
69
|
+
/>,
|
|
70
|
+
)
|
|
71
|
+
await openPopover()
|
|
72
|
+
expect(screen.queryByRole('button', { name: 'Today' })).not.toBeInTheDocument()
|
|
73
|
+
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('hides the Clear button when showClearButton=false', async () => {
|
|
77
|
+
renderWithI18n(
|
|
78
|
+
<DatePicker
|
|
79
|
+
value={null}
|
|
80
|
+
onChange={() => {}}
|
|
81
|
+
footer="today-clear"
|
|
82
|
+
showClearButton={false}
|
|
83
|
+
/>,
|
|
84
|
+
)
|
|
85
|
+
await openPopover()
|
|
86
|
+
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument()
|
|
87
|
+
expect(screen.getByRole('button', { name: 'Today' })).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renders no footer when footer="none"', async () => {
|
|
91
|
+
renderWithI18n(<DatePicker value={null} onChange={() => {}} footer="none" />)
|
|
92
|
+
await openPopover()
|
|
93
|
+
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument()
|
|
94
|
+
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
|
|
95
|
+
expect(screen.queryByRole('button', { name: 'Today' })).not.toBeInTheDocument()
|
|
96
|
+
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('blocks open when disabled', () => {
|
|
100
|
+
renderWithI18n(<DatePicker value={null} onChange={() => {}} disabled />)
|
|
101
|
+
const trigger = getTrigger()
|
|
102
|
+
expect(trigger).toBeDisabled()
|
|
103
|
+
expect(screen.queryByRole('grid')).not.toBeInTheDocument()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('commits today and closes when Today is clicked', async () => {
|
|
107
|
+
const onChange = jest.fn()
|
|
108
|
+
renderWithI18n(<DatePicker value={null} onChange={onChange} footer="today-clear" />)
|
|
109
|
+
await openPopover()
|
|
110
|
+
await act(async () => {
|
|
111
|
+
fireEvent.click(screen.getByRole('button', { name: 'Today' }))
|
|
112
|
+
})
|
|
113
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
114
|
+
const committed = onChange.mock.calls[0][0] as Date
|
|
115
|
+
expect(committed).toBeInstanceOf(Date)
|
|
116
|
+
expect(committed.getHours()).toBe(0)
|
|
117
|
+
expect(committed.getMinutes()).toBe(0)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('commits null when Clear is clicked', async () => {
|
|
121
|
+
const onChange = jest.fn()
|
|
122
|
+
renderWithI18n(
|
|
123
|
+
<DatePicker value={new Date(2026, 4, 9)} onChange={onChange} footer="today-clear" />,
|
|
124
|
+
)
|
|
125
|
+
await openPopover()
|
|
126
|
+
await act(async () => {
|
|
127
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
|
|
128
|
+
})
|
|
129
|
+
expect(onChange).toHaveBeenCalledWith(null)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('does not commit when Cancel is clicked in apply-cancel mode', async () => {
|
|
133
|
+
const onChange = jest.fn()
|
|
134
|
+
renderWithI18n(<DatePicker value={null} onChange={onChange} />)
|
|
135
|
+
await openPopover()
|
|
136
|
+
await act(async () => {
|
|
137
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
138
|
+
})
|
|
139
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('renders the time input when withTime=true', async () => {
|
|
143
|
+
renderWithI18n(<DatePicker value={new Date(2026, 4, 9, 10, 30)} onChange={() => {}} withTime />)
|
|
144
|
+
expect(screen.getByText('May 9, 2026 10:30')).toBeInTheDocument()
|
|
145
|
+
await openPopover()
|
|
146
|
+
expect(screen.getByLabelText('Hour')).toBeInTheDocument()
|
|
147
|
+
expect(screen.getByLabelText('Minute')).toBeInTheDocument()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('forwards aria-label to the trigger', () => {
|
|
151
|
+
renderWithI18n(
|
|
152
|
+
<DatePicker value={null} onChange={() => {}} aria-label="Birthday" />,
|
|
153
|
+
)
|
|
154
|
+
expect(screen.getByRole('button', { name: 'Birthday' })).toBeInTheDocument()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('selected day cell paints primary fill and forces button transparent (regression)', async () => {
|
|
158
|
+
renderWithI18n(<DatePicker value={new Date(2026, 4, 9)} onChange={() => {}} footer="none" />)
|
|
159
|
+
await openPopover()
|
|
160
|
+
// react-day-picker v9 applies `selected` className to the day `<td>` cell, not
|
|
161
|
+
// the inner `<button>`. The button defaults (hover/focus-visible bg) would
|
|
162
|
+
// otherwise paint over the cell's primary fill, so we force the button to
|
|
163
|
+
// render transparent on selected cells.
|
|
164
|
+
const selectedCells = Array.from(document.querySelectorAll('td.\\!bg-primary')) as HTMLElement[]
|
|
165
|
+
expect(selectedCells.length).toBeGreaterThan(0)
|
|
166
|
+
const cellClassName = selectedCells[0].className
|
|
167
|
+
expect(cellClassName).toMatch(/!bg-primary/)
|
|
168
|
+
// The td-level rule that neutralises the button's bg/hover/focus paint.
|
|
169
|
+
expect(cellClassName).toMatch(/\[&_button\]:!bg-transparent/)
|
|
170
|
+
expect(cellClassName).toMatch(/\[&_button:hover\]:!bg-transparent/)
|
|
171
|
+
expect(cellClassName).toMatch(/\[&_button:focus-visible\]:!bg-transparent/)
|
|
172
|
+
// No `ring-2` overlay on the default day_button class (replaced by bg-accent
|
|
173
|
+
// on focus-visible so selected days don't get a ring-on-top). Target a day cell
|
|
174
|
+
// button specifically (not month-nav buttons which still use ring styling).
|
|
175
|
+
const dayCellButton = selectedCells[0].querySelector('button')
|
|
176
|
+
if (dayCellButton) {
|
|
177
|
+
expect(dayCellButton.className).not.toMatch(/focus-visible:ring-2/)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('DatePicker backwards-compat shims', () => {
|
|
183
|
+
it('backend/inputs/DatePicker defaults to the Figma-aligned Apply/Cancel footer', async () => {
|
|
184
|
+
renderWithI18n(<LegacyDatePicker value={null} onChange={() => {}} />)
|
|
185
|
+
await act(async () => {
|
|
186
|
+
fireEvent.click(getTrigger())
|
|
187
|
+
})
|
|
188
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument()
|
|
189
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('backend/inputs/DatePicker still honours explicit footer="today-clear"', async () => {
|
|
193
|
+
renderWithI18n(<LegacyDatePicker value={null} onChange={() => {}} footer="today-clear" />)
|
|
194
|
+
await act(async () => {
|
|
195
|
+
fireEvent.click(getTrigger())
|
|
196
|
+
})
|
|
197
|
+
expect(screen.getByRole('button', { name: 'Today' })).toBeInTheDocument()
|
|
198
|
+
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('backend/inputs/DateTimePicker renders the time input', async () => {
|
|
202
|
+
renderWithI18n(
|
|
203
|
+
<LegacyDateTimePicker value={new Date(2026, 4, 9, 10, 30)} onChange={() => {}} />,
|
|
204
|
+
)
|
|
205
|
+
expect(screen.getByText('May 9, 2026 10:30')).toBeInTheDocument()
|
|
206
|
+
await act(async () => {
|
|
207
|
+
fireEvent.click(getTrigger())
|
|
208
|
+
})
|
|
209
|
+
expect(screen.getByLabelText('Hour')).toBeInTheDocument()
|
|
210
|
+
expect(screen.getByLabelText('Minute')).toBeInTheDocument()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('backend/inputs/DateTimePicker defaults to the Figma-aligned Apply/Cancel footer', async () => {
|
|
214
|
+
renderWithI18n(<LegacyDateTimePicker value={null} onChange={() => {}} />)
|
|
215
|
+
await act(async () => {
|
|
216
|
+
fireEvent.click(getTrigger())
|
|
217
|
+
})
|
|
218
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument()
|
|
219
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
4
|
+
import { DateRangePicker } from '../date-range-picker'
|
|
5
|
+
import { defaultDateRangePresets } from '../date-picker-helpers'
|
|
6
|
+
|
|
7
|
+
function renderWithI18n(ui: React.ReactElement) {
|
|
8
|
+
return render(<I18nProvider locale="en" dict={{}}>{ui}</I18nProvider>)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getTrigger(): HTMLElement {
|
|
12
|
+
const trigger = document.querySelector('[data-slot="date-range-picker-trigger"]')
|
|
13
|
+
if (!trigger) throw new Error('DateRangePicker trigger not found')
|
|
14
|
+
return trigger as HTMLElement
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function openPopover() {
|
|
18
|
+
await act(async () => {
|
|
19
|
+
fireEvent.click(getTrigger())
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('DateRangePicker primitive', () => {
|
|
24
|
+
it('renders the placeholder when value is null', () => {
|
|
25
|
+
renderWithI18n(<DateRangePicker value={null} onChange={() => {}} placeholder="Pick a range" />)
|
|
26
|
+
expect(screen.getByText('Pick a range')).toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders the formatted range when value is provided', () => {
|
|
30
|
+
renderWithI18n(
|
|
31
|
+
<DateRangePicker
|
|
32
|
+
value={{ start: new Date(2026, 4, 1), end: new Date(2026, 4, 9) }}
|
|
33
|
+
onChange={() => {}}
|
|
34
|
+
/>,
|
|
35
|
+
)
|
|
36
|
+
expect(screen.getByText('May 1, 2026 – May 9, 2026')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('opens the popover and renders two month grids by default', async () => {
|
|
40
|
+
renderWithI18n(<DateRangePicker value={null} onChange={() => {}} />)
|
|
41
|
+
await openPopover()
|
|
42
|
+
expect(screen.getAllByRole('grid')).toHaveLength(2)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders one month grid when numberOfMonths={1}', async () => {
|
|
46
|
+
renderWithI18n(<DateRangePicker value={null} onChange={() => {}} numberOfMonths={1} />)
|
|
47
|
+
await openPopover()
|
|
48
|
+
expect(screen.getAllByRole('grid')).toHaveLength(1)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders the preset sidebar with default 8 presets (Figma 446:7412)', async () => {
|
|
52
|
+
renderWithI18n(<DateRangePicker value={null} onChange={() => {}} />)
|
|
53
|
+
await openPopover()
|
|
54
|
+
const sidebar = document.querySelector('[data-slot="date-range-presets"]')
|
|
55
|
+
expect(sidebar).toBeInTheDocument()
|
|
56
|
+
const buttons = sidebar!.querySelectorAll('button')
|
|
57
|
+
expect(buttons.length).toBe(8)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('hides the preset sidebar when showPresets={false}', async () => {
|
|
61
|
+
renderWithI18n(<DateRangePicker value={null} onChange={() => {}} showPresets={false} />)
|
|
62
|
+
await openPopover()
|
|
63
|
+
expect(document.querySelector('[data-slot="date-range-presets"]')).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders Apply and Cancel buttons in the footer by default', async () => {
|
|
67
|
+
renderWithI18n(<DateRangePicker value={null} onChange={() => {}} />)
|
|
68
|
+
await openPopover()
|
|
69
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument()
|
|
70
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not render footer when withFooter={false}', async () => {
|
|
74
|
+
renderWithI18n(<DateRangePicker value={null} onChange={() => {}} withFooter={false} />)
|
|
75
|
+
await openPopover()
|
|
76
|
+
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument()
|
|
77
|
+
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('Cancel reverts the draft and does not call onChange', async () => {
|
|
81
|
+
const onChange = jest.fn()
|
|
82
|
+
renderWithI18n(<DateRangePicker value={null} onChange={onChange} />)
|
|
83
|
+
await openPopover()
|
|
84
|
+
await act(async () => {
|
|
85
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
86
|
+
})
|
|
87
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('clicking a preset stages a draft range matching the preset', async () => {
|
|
91
|
+
const onChange = jest.fn()
|
|
92
|
+
renderWithI18n(<DateRangePicker value={null} onChange={onChange} />)
|
|
93
|
+
await openPopover()
|
|
94
|
+
const sidebar = document.querySelector('[data-slot="date-range-presets"]')
|
|
95
|
+
expect(sidebar).toBeInTheDocument()
|
|
96
|
+
const todayPreset = Array.from(sidebar!.querySelectorAll('button'))
|
|
97
|
+
.find((btn) => btn.textContent?.trim() === 'today')
|
|
98
|
+
|| Array.from(sidebar!.querySelectorAll('button'))[0]
|
|
99
|
+
expect(todayPreset).toBeDefined()
|
|
100
|
+
await act(async () => {
|
|
101
|
+
fireEvent.click(todayPreset!)
|
|
102
|
+
})
|
|
103
|
+
// withFooter=true means draft is staged, not committed yet
|
|
104
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
105
|
+
// Apply commits the staged preset range
|
|
106
|
+
await act(async () => {
|
|
107
|
+
fireEvent.click(screen.getByRole('button', { name: 'Apply' }))
|
|
108
|
+
})
|
|
109
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
110
|
+
const committed = onChange.mock.calls[0][0]
|
|
111
|
+
expect(committed).toMatchObject({ start: expect.any(Date), end: expect.any(Date) })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('blocks open when disabled', () => {
|
|
115
|
+
renderWithI18n(<DateRangePicker value={null} onChange={() => {}} disabled />)
|
|
116
|
+
const trigger = getTrigger()
|
|
117
|
+
expect(trigger).toBeDisabled()
|
|
118
|
+
expect(screen.queryByRole('grid')).not.toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('forwards aria-label to the trigger', () => {
|
|
122
|
+
renderWithI18n(
|
|
123
|
+
<DateRangePicker value={null} onChange={() => {}} aria-label="Date filter" />,
|
|
124
|
+
)
|
|
125
|
+
expect(screen.getByRole('button', { name: 'Date filter' })).toBeInTheDocument()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('defaultDateRangePresets()', () => {
|
|
130
|
+
it('returns 8 presets (Figma 446:7412)', () => {
|
|
131
|
+
const presets = defaultDateRangePresets()
|
|
132
|
+
expect(presets).toHaveLength(8)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('exposes preset id, labelKey, and a callable range function', () => {
|
|
136
|
+
const presets = defaultDateRangePresets()
|
|
137
|
+
for (const preset of presets) {
|
|
138
|
+
expect(typeof preset.id).toBe('string')
|
|
139
|
+
expect(typeof preset.labelKey).toBe('string')
|
|
140
|
+
expect(preset.labelKey).toMatch(/^ui\.dateRangePicker\.presets\./)
|
|
141
|
+
expect(typeof preset.range).toBe('function')
|
|
142
|
+
const range = preset.range()
|
|
143
|
+
expect(range.start).toBeInstanceOf(Date)
|
|
144
|
+
expect(range.end).toBeInstanceOf(Date)
|
|
145
|
+
expect(range.end.getTime()).toBeGreaterThanOrEqual(range.start.getTime())
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('includes the canonical Figma preset ids', () => {
|
|
150
|
+
const presets = defaultDateRangePresets()
|
|
151
|
+
const ids = presets.map((p) => p.id)
|
|
152
|
+
expect(ids).toEqual([
|
|
153
|
+
'today',
|
|
154
|
+
'last_7_days',
|
|
155
|
+
'last_30_days',
|
|
156
|
+
'last_3_months',
|
|
157
|
+
'last_12_months',
|
|
158
|
+
'month_to_date',
|
|
159
|
+
'year_to_date',
|
|
160
|
+
'all_time',
|
|
161
|
+
])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('today preset returns a range that contains the current date', () => {
|
|
165
|
+
const presets = defaultDateRangePresets()
|
|
166
|
+
const today = presets.find((p) => p.id === 'today')
|
|
167
|
+
expect(today).toBeDefined()
|
|
168
|
+
const range = today!.range()
|
|
169
|
+
const now = Date.now()
|
|
170
|
+
expect(range.start.getTime()).toBeLessThanOrEqual(now)
|
|
171
|
+
expect(range.end.getTime()).toBeGreaterThanOrEqual(now)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
7
|
+
import { DigitInput } from '../digit-input'
|
|
8
|
+
|
|
9
|
+
describe('DigitInput primitive', () => {
|
|
10
|
+
it('renders 6 cells by default', () => {
|
|
11
|
+
const { container } = render(<DigitInput />)
|
|
12
|
+
const cells = container.querySelectorAll('[data-slot="digit-input-cell"]')
|
|
13
|
+
expect(cells).toHaveLength(6)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('honors the length prop', () => {
|
|
17
|
+
const { container } = render(<DigitInput length={4} />)
|
|
18
|
+
const cells = container.querySelectorAll('[data-slot="digit-input-cell"]')
|
|
19
|
+
expect(cells).toHaveLength(4)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders the value distributed across cells', () => {
|
|
23
|
+
const { container } = render(<DigitInput value="12" length={4} onChange={() => {}} />)
|
|
24
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
25
|
+
expect(cells[0].value).toBe('1')
|
|
26
|
+
expect(cells[1].value).toBe('2')
|
|
27
|
+
expect(cells[2].value).toBe('')
|
|
28
|
+
expect(cells[3].value).toBe('')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('focuses the next cell after typing a digit', () => {
|
|
32
|
+
const { container } = render(<DigitInput length={4} onChange={() => {}} />)
|
|
33
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
34
|
+
cells[0].focus()
|
|
35
|
+
fireEvent.change(cells[0], { target: { value: '7' } })
|
|
36
|
+
expect(document.activeElement).toBe(cells[1])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('commits typed digit via onChange', () => {
|
|
40
|
+
const onChange = jest.fn()
|
|
41
|
+
const { container } = render(<DigitInput length={4} onChange={onChange} />)
|
|
42
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
43
|
+
fireEvent.change(cells[0], { target: { value: '5' } })
|
|
44
|
+
expect(onChange).toHaveBeenCalledWith('5')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('rejects non-digit characters when inputMode is numeric (default)', () => {
|
|
48
|
+
const onChange = jest.fn()
|
|
49
|
+
const { container } = render(<DigitInput length={4} onChange={onChange} />)
|
|
50
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
51
|
+
fireEvent.change(cells[0], { target: { value: 'a' } })
|
|
52
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('accepts non-digit characters when inputMode="text"', () => {
|
|
56
|
+
const onChange = jest.fn()
|
|
57
|
+
const { container } = render(<DigitInput length={4} inputMode="text" onChange={onChange} />)
|
|
58
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
59
|
+
fireEvent.change(cells[0], { target: { value: 'X' } })
|
|
60
|
+
expect(onChange).toHaveBeenCalledWith('X')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('Backspace on an empty cell focuses the previous cell and clears its value', () => {
|
|
64
|
+
const onChange = jest.fn()
|
|
65
|
+
const { container } = render(
|
|
66
|
+
<DigitInput value="12" length={4} onChange={onChange} />,
|
|
67
|
+
)
|
|
68
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
69
|
+
cells[2].focus()
|
|
70
|
+
fireEvent.keyDown(cells[2], { key: 'Backspace' })
|
|
71
|
+
expect(document.activeElement).toBe(cells[1])
|
|
72
|
+
expect(onChange).toHaveBeenCalledWith('1')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('ArrowLeft moves focus to the previous cell', () => {
|
|
76
|
+
const { container } = render(<DigitInput length={4} onChange={() => {}} />)
|
|
77
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
78
|
+
cells[2].focus()
|
|
79
|
+
fireEvent.keyDown(cells[2], { key: 'ArrowLeft' })
|
|
80
|
+
expect(document.activeElement).toBe(cells[1])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('ArrowRight moves focus to the next cell', () => {
|
|
84
|
+
const { container } = render(<DigitInput length={4} onChange={() => {}} />)
|
|
85
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
86
|
+
cells[1].focus()
|
|
87
|
+
fireEvent.keyDown(cells[1], { key: 'ArrowRight' })
|
|
88
|
+
expect(document.activeElement).toBe(cells[2])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('distributes a pasted string across cells and fires onComplete when filled', () => {
|
|
92
|
+
const onChange = jest.fn()
|
|
93
|
+
const onComplete = jest.fn()
|
|
94
|
+
const { container } = render(
|
|
95
|
+
<DigitInput length={4} onChange={onChange} onComplete={onComplete} />,
|
|
96
|
+
)
|
|
97
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
98
|
+
fireEvent.paste(cells[0], {
|
|
99
|
+
clipboardData: { getData: () => '5678' },
|
|
100
|
+
})
|
|
101
|
+
expect(onChange).toHaveBeenCalledWith('5678')
|
|
102
|
+
expect(onComplete).toHaveBeenCalledWith('5678')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('filters non-digit characters out of pasted strings in numeric mode', () => {
|
|
106
|
+
const onChange = jest.fn()
|
|
107
|
+
const { container } = render(<DigitInput length={4} onChange={onChange} />)
|
|
108
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
109
|
+
fireEvent.paste(cells[0], {
|
|
110
|
+
clipboardData: { getData: () => '1a2b3c4d' },
|
|
111
|
+
})
|
|
112
|
+
expect(onChange).toHaveBeenCalledWith('1234')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('renders each cell as type="password" when mask is true', () => {
|
|
116
|
+
const { container } = render(
|
|
117
|
+
<DigitInput value="12" length={2} mask onChange={() => {}} />,
|
|
118
|
+
)
|
|
119
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
120
|
+
cells.forEach((cell) => expect(cell.type).toBe('password'))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('disables every cell when disabled prop is set', () => {
|
|
124
|
+
const { container } = render(<DigitInput length={4} disabled onChange={() => {}} />)
|
|
125
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
126
|
+
cells.forEach((cell) => expect(cell).toBeDisabled())
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('uncontrolled mode keeps internal state and emits onChange', () => {
|
|
130
|
+
const onChange = jest.fn()
|
|
131
|
+
const { container } = render(<DigitInput length={4} onChange={onChange} />)
|
|
132
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
133
|
+
fireEvent.change(cells[0], { target: { value: '1' } })
|
|
134
|
+
fireEvent.change(cells[1], { target: { value: '2' } })
|
|
135
|
+
expect(onChange).toHaveBeenLastCalledWith('12')
|
|
136
|
+
expect(cells[0].value).toBe('1')
|
|
137
|
+
expect(cells[1].value).toBe('2')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('aria-invalid propagates to the group wrapper and to each cell', () => {
|
|
141
|
+
const { container } = render(
|
|
142
|
+
<DigitInput length={2} value="12" onChange={() => {}} aria-invalid />,
|
|
143
|
+
)
|
|
144
|
+
const wrapper = container.querySelector('[data-slot="digit-input"]')
|
|
145
|
+
expect(wrapper).toHaveAttribute('aria-invalid', 'true')
|
|
146
|
+
const cells = container.querySelectorAll('[data-slot="digit-input-cell"]')
|
|
147
|
+
cells.forEach((cell) => expect(cell).toHaveAttribute('aria-invalid', 'true'))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('uses a provided aria-label for the group and cell labels', () => {
|
|
151
|
+
render(<DigitInput length={3} aria-label="Two factor code" onChange={() => {}} />)
|
|
152
|
+
expect(screen.getByRole('group', { name: 'Two factor code' })).toBeInTheDocument()
|
|
153
|
+
expect(screen.getByLabelText('Two factor code digit 1')).toBeInTheDocument()
|
|
154
|
+
expect(screen.getByLabelText('Two factor code digit 2')).toBeInTheDocument()
|
|
155
|
+
expect(screen.getByLabelText('Two factor code digit 3')).toBeInTheDocument()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('forwards id and name only to the first cell', () => {
|
|
159
|
+
const { container } = render(
|
|
160
|
+
<DigitInput length={3} id="otp" name="code" onChange={() => {}} />,
|
|
161
|
+
)
|
|
162
|
+
const cells = container.querySelectorAll<HTMLInputElement>('[data-slot="digit-input-cell"]')
|
|
163
|
+
expect(cells[0].id).toBe('otp')
|
|
164
|
+
expect(cells[0].name).toBe('code')
|
|
165
|
+
expect(cells[1].id).toBe('')
|
|
166
|
+
expect(cells[1].name).toBe('')
|
|
167
|
+
})
|
|
168
|
+
})
|