@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,165 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { CounterInput } from '../counter-input'
8
+
9
+ describe('CounterInput primitive', () => {
10
+ it('renders the value passed via the value prop', () => {
11
+ render(<CounterInput value={5} onChange={() => {}} />)
12
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
13
+ expect(input.value).toBe('5')
14
+ })
15
+
16
+ it('renders an empty string when value is null', () => {
17
+ render(<CounterInput value={null} onChange={() => {}} />)
18
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
19
+ expect(input.value).toBe('')
20
+ })
21
+
22
+ it('increments by step when + is clicked', () => {
23
+ const onChange = jest.fn()
24
+ render(<CounterInput value={3} onChange={onChange} step={2} />)
25
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
26
+ expect(onChange).toHaveBeenCalledWith(5)
27
+ })
28
+
29
+ it('decrements by step when - is clicked', () => {
30
+ const onChange = jest.fn()
31
+ render(<CounterInput value={10} onChange={onChange} step={3} />)
32
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease' }))
33
+ expect(onChange).toHaveBeenCalledWith(7)
34
+ })
35
+
36
+ it('defaults step to 1', () => {
37
+ const onChange = jest.fn()
38
+ render(<CounterInput value={0} onChange={onChange} />)
39
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
40
+ expect(onChange).toHaveBeenCalledWith(1)
41
+ })
42
+
43
+ it('clamps to max when incrementing past max', () => {
44
+ const onChange = jest.fn()
45
+ render(<CounterInput value={9} onChange={onChange} max={10} step={5} />)
46
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
47
+ expect(onChange).toHaveBeenCalledWith(10)
48
+ })
49
+
50
+ it('clamps to min when decrementing below min', () => {
51
+ const onChange = jest.fn()
52
+ render(<CounterInput value={2} onChange={onChange} min={0} step={5} />)
53
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease' }))
54
+ expect(onChange).toHaveBeenCalledWith(0)
55
+ })
56
+
57
+ it('disables the increment button when value is at max', () => {
58
+ render(<CounterInput value={10} onChange={() => {}} max={10} />)
59
+ expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled()
60
+ expect(screen.getByRole('button', { name: 'Decrease' })).not.toBeDisabled()
61
+ })
62
+
63
+ it('disables the decrement button when value is at min', () => {
64
+ render(<CounterInput value={1} onChange={() => {}} min={1} />)
65
+ expect(screen.getByRole('button', { name: 'Decrease' })).toBeDisabled()
66
+ expect(screen.getByRole('button', { name: 'Increase' })).not.toBeDisabled()
67
+ })
68
+
69
+ it('disables both buttons when disabled prop is set', () => {
70
+ render(<CounterInput value={5} onChange={() => {}} disabled />)
71
+ expect(screen.getByRole('button', { name: 'Decrease' })).toBeDisabled()
72
+ expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled()
73
+ expect(screen.getByRole('spinbutton')).toBeDisabled()
74
+ })
75
+
76
+ it('commits valid number typed directly', () => {
77
+ const onChange = jest.fn()
78
+ render(<CounterInput value={0} onChange={onChange} />)
79
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '42' } })
80
+ expect(onChange).toHaveBeenCalledWith(42)
81
+ })
82
+
83
+ it('emits null when input is cleared', () => {
84
+ const onChange = jest.fn()
85
+ render(<CounterInput value={5} onChange={onChange} />)
86
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } })
87
+ expect(onChange).toHaveBeenCalledWith(null)
88
+ })
89
+
90
+ it('clamps typed value to max', () => {
91
+ const onChange = jest.fn()
92
+ render(<CounterInput value={0} onChange={onChange} max={100} />)
93
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '999' } })
94
+ expect(onChange).toHaveBeenCalledWith(100)
95
+ })
96
+
97
+ it('clamps typed value to min', () => {
98
+ const onChange = jest.fn()
99
+ render(<CounterInput value={0} onChange={onChange} min={1} />)
100
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '-5' } })
101
+ expect(onChange).toHaveBeenCalledWith(1)
102
+ })
103
+
104
+ it('formats output to precision decimals', () => {
105
+ const onChange = jest.fn()
106
+ render(<CounterInput value={1} onChange={onChange} precision={2} step={0.5} />)
107
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
108
+ expect(input.value).toBe('1.00')
109
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
110
+ expect(onChange).toHaveBeenCalledWith(1.5)
111
+ })
112
+
113
+ it('ArrowUp increments by step', () => {
114
+ const onChange = jest.fn()
115
+ render(<CounterInput value={2} onChange={onChange} step={3} />)
116
+ fireEvent.keyDown(screen.getByRole('spinbutton'), { key: 'ArrowUp' })
117
+ expect(onChange).toHaveBeenCalledWith(5)
118
+ })
119
+
120
+ it('ArrowDown decrements by step', () => {
121
+ const onChange = jest.fn()
122
+ render(<CounterInput value={5} onChange={onChange} step={2} />)
123
+ fireEvent.keyDown(screen.getByRole('spinbutton'), { key: 'ArrowDown' })
124
+ expect(onChange).toHaveBeenCalledWith(3)
125
+ })
126
+
127
+ it('uncontrolled mode keeps internal state and emits onChange', () => {
128
+ const onChange = jest.fn()
129
+ render(<CounterInput onChange={onChange} />)
130
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
131
+ expect(onChange).toHaveBeenCalledWith(1)
132
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
133
+ expect(input.value).toBe('1')
134
+ })
135
+
136
+ it('honors custom decrement / increment aria labels', () => {
137
+ render(
138
+ <CounterInput
139
+ value={0}
140
+ onChange={() => {}}
141
+ decrementAriaLabel="Zmniejsz"
142
+ incrementAriaLabel="Zwiększ"
143
+ />,
144
+ )
145
+ expect(screen.getByRole('button', { name: 'Zmniejsz' })).toBeInTheDocument()
146
+ expect(screen.getByRole('button', { name: 'Zwiększ' })).toBeInTheDocument()
147
+ })
148
+
149
+ it('renders aria-invalid styling when aria-invalid is set', () => {
150
+ const { container } = render(
151
+ <CounterInput value={5} onChange={() => {}} aria-invalid />,
152
+ )
153
+ const input = container.querySelector('input')
154
+ expect(input).toHaveAttribute('aria-invalid', 'true')
155
+ })
156
+
157
+ it('applies size variants to the wrapper', () => {
158
+ const { container, rerender } = render(<CounterInput value={1} onChange={() => {}} size="sm" />)
159
+ expect(container.querySelector('[data-slot="counter-input"]')).toHaveClass('h-8')
160
+ rerender(<CounterInput value={1} onChange={() => {}} size="default" />)
161
+ expect(container.querySelector('[data-slot="counter-input"]')).toHaveClass('h-9')
162
+ rerender(<CounterInput value={1} onChange={() => {}} size="lg" />)
163
+ expect(container.querySelector('[data-slot="counter-input"]')).toHaveClass('h-10')
164
+ })
165
+ })
@@ -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
+ })