@open-mercato/ui 0.6.4-develop.4178.1.aad9ddaa95 → 0.6.4-develop.4210.1.d412061cfe
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/AGENTS.md +14 -0
- package/dist/backend/DataTable.js +28 -62
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/primitives/activity-feed.js +196 -0
- package/dist/primitives/activity-feed.js.map +7 -0
- package/dist/primitives/avatar.js +140 -14
- package/dist/primitives/avatar.js.map +3 -3
- package/dist/primitives/badge.js +94 -5
- package/dist/primitives/badge.js.map +2 -2
- package/dist/primitives/button-group.js +52 -0
- package/dist/primitives/button-group.js.map +7 -0
- package/dist/primitives/color-picker.js +520 -0
- package/dist/primitives/color-picker.js.map +7 -0
- package/dist/primitives/command-menu.js +355 -0
- package/dist/primitives/command-menu.js.map +7 -0
- package/dist/primitives/dialog.js +210 -35
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/drawer.js +219 -0
- package/dist/primitives/drawer.js.map +7 -0
- package/dist/primitives/notification-feed.js +213 -0
- package/dist/primitives/notification-feed.js.map +7 -0
- package/dist/primitives/notification.js +23 -2
- package/dist/primitives/notification.js.map +2 -2
- package/dist/primitives/pagination.js +245 -0
- package/dist/primitives/pagination.js.map +7 -0
- package/dist/primitives/progress.js +178 -5
- package/dist/primitives/progress.js.map +2 -2
- package/dist/primitives/rating.js +174 -0
- package/dist/primitives/rating.js.map +7 -0
- package/dist/primitives/scroll-area.js +82 -0
- package/dist/primitives/scroll-area.js.map +7 -0
- package/dist/primitives/segmented-control.js +101 -0
- package/dist/primitives/segmented-control.js.map +7 -0
- package/dist/primitives/separator.js +103 -4
- package/dist/primitives/separator.js.map +2 -2
- package/dist/primitives/slider.js +115 -0
- package/dist/primitives/slider.js.map +7 -0
- package/dist/primitives/step-indicator.js +187 -0
- package/dist/primitives/step-indicator.js.map +7 -0
- package/dist/primitives/table.js +102 -10
- package/dist/primitives/table.js.map +2 -2
- package/dist/primitives/tabs.js +122 -14
- package/dist/primitives/tabs.js.map +2 -2
- package/dist/primitives/time-picker.js +9 -11
- package/dist/primitives/time-picker.js.map +2 -2
- package/package.json +8 -4
- package/src/backend/DataTable.tsx +43 -60
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
- package/src/primitives/__tests__/avatar.test.tsx +131 -0
- package/src/primitives/__tests__/badge.test.tsx +189 -0
- package/src/primitives/__tests__/button-group.test.tsx +117 -0
- package/src/primitives/__tests__/color-picker.test.tsx +589 -0
- package/src/primitives/__tests__/command-menu.test.tsx +231 -0
- package/src/primitives/__tests__/dialog.test.tsx +290 -0
- package/src/primitives/__tests__/drawer.test.tsx +264 -0
- package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
- package/src/primitives/__tests__/notification.test.tsx +102 -2
- package/src/primitives/__tests__/pagination.test.tsx +268 -0
- package/src/primitives/__tests__/progress.test.tsx +222 -0
- package/src/primitives/__tests__/rating.test.tsx +164 -0
- package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
- package/src/primitives/__tests__/separator.test.tsx +143 -0
- package/src/primitives/__tests__/slider.test.tsx +86 -0
- package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
- package/src/primitives/__tests__/table.test.tsx +199 -0
- package/src/primitives/__tests__/tabs.test.tsx +139 -0
- package/src/primitives/activity-feed.tsx +326 -0
- package/src/primitives/avatar.tsx +225 -22
- package/src/primitives/badge.tsx +141 -6
- package/src/primitives/button-group.tsx +100 -0
- package/src/primitives/color-picker.tsx +693 -0
- package/src/primitives/command-menu.tsx +503 -0
- package/src/primitives/dialog.tsx +315 -51
- package/src/primitives/drawer.tsx +338 -0
- package/src/primitives/notification-feed.tsx +358 -0
- package/src/primitives/notification.tsx +42 -2
- package/src/primitives/pagination.tsx +376 -0
- package/src/primitives/progress.tsx +267 -6
- package/src/primitives/rating.tsx +245 -0
- package/src/primitives/scroll-area.tsx +169 -0
- package/src/primitives/segmented-control.tsx +162 -0
- package/src/primitives/separator.tsx +163 -3
- package/src/primitives/slider.tsx +160 -0
- package/src/primitives/step-indicator.tsx +287 -0
- package/src/primitives/table.tsx +142 -11
- package/src/primitives/tabs.tsx +174 -12
- package/src/primitives/time-picker.tsx +9 -11
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { render as rtlRender, fireEvent } from '@testing-library/react'
|
|
5
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { Pagination, buildPaginationItems } from '../pagination'
|
|
7
|
+
|
|
8
|
+
// Pagination uses useT() for aria-labels; wrap every render in an
|
|
9
|
+
// empty-dict I18nProvider so the primitive falls back to its English
|
|
10
|
+
// hardcoded defaults ("First page", "Previous page", ...).
|
|
11
|
+
const render: typeof rtlRender = (ui: React.ReactElement, options?: Parameters<typeof rtlRender>[1]) =>
|
|
12
|
+
rtlRender(
|
|
13
|
+
<I18nProvider locale="en" dict={{}}>
|
|
14
|
+
{ui}
|
|
15
|
+
</I18nProvider>,
|
|
16
|
+
options,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
describe('buildPaginationItems', () => {
|
|
20
|
+
it('returns every page when totalPages <= total slots', () => {
|
|
21
|
+
expect(buildPaginationItems(1, 5, 1, 1)).toEqual([1, 2, 3, 4, 5])
|
|
22
|
+
expect(buildPaginationItems(3, 7, 1, 1)).toEqual([1, 2, 3, 4, 5, 6, 7])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('inserts left + right ellipsis around the current page in the middle', () => {
|
|
26
|
+
const items = buildPaginationItems(10, 20, 1, 1)
|
|
27
|
+
expect(items[0]).toBe(1)
|
|
28
|
+
expect(items[items.length - 1]).toBe(20)
|
|
29
|
+
expect(items).toContain('ellipsis-left')
|
|
30
|
+
expect(items).toContain('ellipsis-right')
|
|
31
|
+
expect(items).toContain(10)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('omits left ellipsis when current page is near the start', () => {
|
|
35
|
+
const items = buildPaginationItems(2, 20, 1, 1)
|
|
36
|
+
expect(items.includes('ellipsis-left')).toBe(false)
|
|
37
|
+
expect(items.includes('ellipsis-right')).toBe(true)
|
|
38
|
+
expect(items[0]).toBe(1)
|
|
39
|
+
expect(items[items.length - 1]).toBe(20)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('omits right ellipsis when current page is near the end', () => {
|
|
43
|
+
const items = buildPaginationItems(19, 20, 1, 1)
|
|
44
|
+
expect(items.includes('ellipsis-right')).toBe(false)
|
|
45
|
+
expect(items.includes('ellipsis-left')).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns empty array for totalPages = 0', () => {
|
|
49
|
+
expect(buildPaginationItems(1, 0, 1, 1)).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('Pagination', () => {
|
|
54
|
+
it('renders the navigation landmark with default aria-label', () => {
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<Pagination page={1} pageSize={10} total={100} onPageChange={() => {}} />,
|
|
57
|
+
)
|
|
58
|
+
const nav = container.querySelector('[data-slot="pagination"]') as HTMLElement
|
|
59
|
+
expect(nav.tagName).toBe('NAV')
|
|
60
|
+
expect(nav.getAttribute('aria-label')).toBe('Pagination')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('renders "Page X of Y" info by default', () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<Pagination page={2} pageSize={10} total={100} onPageChange={() => {}} />,
|
|
66
|
+
)
|
|
67
|
+
const info = container.querySelector('[data-slot="pagination-info"]') as HTMLElement
|
|
68
|
+
expect(info.textContent).toBe('Page 2 of 10')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('hides the page info when showInfo=false', () => {
|
|
72
|
+
const { container } = render(
|
|
73
|
+
<Pagination
|
|
74
|
+
page={1}
|
|
75
|
+
pageSize={10}
|
|
76
|
+
total={100}
|
|
77
|
+
onPageChange={() => {}}
|
|
78
|
+
showInfo={false}
|
|
79
|
+
/>,
|
|
80
|
+
)
|
|
81
|
+
expect(container.querySelector('[data-slot="pagination-info"]')).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders first, prev, next, last buttons by default', () => {
|
|
85
|
+
const { container } = render(
|
|
86
|
+
<Pagination page={5} pageSize={10} total={100} onPageChange={() => {}} />,
|
|
87
|
+
)
|
|
88
|
+
expect(container.querySelector('[data-slot="pagination-first"]')).not.toBeNull()
|
|
89
|
+
expect(container.querySelector('[data-slot="pagination-prev"]')).not.toBeNull()
|
|
90
|
+
expect(container.querySelector('[data-slot="pagination-next"]')).not.toBeNull()
|
|
91
|
+
expect(container.querySelector('[data-slot="pagination-last"]')).not.toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('hides first/last buttons when showFirstLast=false', () => {
|
|
95
|
+
const { container } = render(
|
|
96
|
+
<Pagination
|
|
97
|
+
page={1}
|
|
98
|
+
pageSize={10}
|
|
99
|
+
total={100}
|
|
100
|
+
onPageChange={() => {}}
|
|
101
|
+
showFirstLast={false}
|
|
102
|
+
/>,
|
|
103
|
+
)
|
|
104
|
+
expect(container.querySelector('[data-slot="pagination-first"]')).toBeNull()
|
|
105
|
+
expect(container.querySelector('[data-slot="pagination-last"]')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('hides prev/next buttons when showPrevNext=false', () => {
|
|
109
|
+
const { container } = render(
|
|
110
|
+
<Pagination
|
|
111
|
+
page={1}
|
|
112
|
+
pageSize={10}
|
|
113
|
+
total={100}
|
|
114
|
+
onPageChange={() => {}}
|
|
115
|
+
showPrevNext={false}
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
expect(container.querySelector('[data-slot="pagination-prev"]')).toBeNull()
|
|
119
|
+
expect(container.querySelector('[data-slot="pagination-next"]')).toBeNull()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('marks the current page button with aria-current="page" and data-state="on"', () => {
|
|
123
|
+
const { container } = render(
|
|
124
|
+
<Pagination page={3} pageSize={10} total={100} onPageChange={() => {}} />,
|
|
125
|
+
)
|
|
126
|
+
const currentPage = container.querySelector(
|
|
127
|
+
'[data-slot="pagination-page"][data-state="on"]',
|
|
128
|
+
) as HTMLButtonElement
|
|
129
|
+
expect(currentPage).not.toBeNull()
|
|
130
|
+
expect(currentPage.textContent).toBe('3')
|
|
131
|
+
expect(currentPage.getAttribute('aria-current')).toBe('page')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('fires onPageChange when a page button is clicked', () => {
|
|
135
|
+
const onPageChange = jest.fn()
|
|
136
|
+
const { container } = render(
|
|
137
|
+
<Pagination page={1} pageSize={10} total={100} onPageChange={onPageChange} />,
|
|
138
|
+
)
|
|
139
|
+
const pageButtons = container.querySelectorAll('[data-slot="pagination-page"]')
|
|
140
|
+
fireEvent.click(pageButtons[2])
|
|
141
|
+
expect(onPageChange).toHaveBeenCalledWith(3)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('disables the first / prev buttons on page 1', () => {
|
|
145
|
+
const { container } = render(
|
|
146
|
+
<Pagination page={1} pageSize={10} total={100} onPageChange={() => {}} />,
|
|
147
|
+
)
|
|
148
|
+
expect(
|
|
149
|
+
(container.querySelector('[data-slot="pagination-first"]') as HTMLButtonElement).disabled,
|
|
150
|
+
).toBe(true)
|
|
151
|
+
expect(
|
|
152
|
+
(container.querySelector('[data-slot="pagination-prev"]') as HTMLButtonElement).disabled,
|
|
153
|
+
).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('disables the next / last buttons on the last page', () => {
|
|
157
|
+
const { container } = render(
|
|
158
|
+
<Pagination page={10} pageSize={10} total={100} onPageChange={() => {}} />,
|
|
159
|
+
)
|
|
160
|
+
expect(
|
|
161
|
+
(container.querySelector('[data-slot="pagination-next"]') as HTMLButtonElement).disabled,
|
|
162
|
+
).toBe(true)
|
|
163
|
+
expect(
|
|
164
|
+
(container.querySelector('[data-slot="pagination-last"]') as HTMLButtonElement).disabled,
|
|
165
|
+
).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('Last button jumps to the final page', () => {
|
|
169
|
+
const onPageChange = jest.fn()
|
|
170
|
+
const { container } = render(
|
|
171
|
+
<Pagination page={3} pageSize={10} total={100} onPageChange={onPageChange} />,
|
|
172
|
+
)
|
|
173
|
+
const lastButton = container.querySelector(
|
|
174
|
+
'[data-slot="pagination-last"]',
|
|
175
|
+
) as HTMLButtonElement
|
|
176
|
+
fireEvent.click(lastButton)
|
|
177
|
+
expect(onPageChange).toHaveBeenCalledWith(10)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('Prev button jumps to the previous page', () => {
|
|
181
|
+
const onPageChange = jest.fn()
|
|
182
|
+
const { container } = render(
|
|
183
|
+
<Pagination page={5} pageSize={10} total={100} onPageChange={onPageChange} />,
|
|
184
|
+
)
|
|
185
|
+
const prevButton = container.querySelector(
|
|
186
|
+
'[data-slot="pagination-prev"]',
|
|
187
|
+
) as HTMLButtonElement
|
|
188
|
+
fireEvent.click(prevButton)
|
|
189
|
+
expect(onPageChange).toHaveBeenCalledWith(4)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('renders the page-size select when onPageSizeChange is provided', () => {
|
|
193
|
+
const { container } = render(
|
|
194
|
+
<Pagination
|
|
195
|
+
page={1}
|
|
196
|
+
pageSize={25}
|
|
197
|
+
total={100}
|
|
198
|
+
onPageChange={() => {}}
|
|
199
|
+
onPageSizeChange={() => {}}
|
|
200
|
+
/>,
|
|
201
|
+
)
|
|
202
|
+
expect(container.querySelector('[data-slot="pagination-page-size"]')).not.toBeNull()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('does NOT render the page-size select without onPageSizeChange', () => {
|
|
206
|
+
const { container } = render(
|
|
207
|
+
<Pagination page={1} pageSize={25} total={100} onPageChange={() => {}} />,
|
|
208
|
+
)
|
|
209
|
+
expect(container.querySelector('[data-slot="pagination-page-size"]')).toBeNull()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('renders ellipsis placeholders when totalPages is large', () => {
|
|
213
|
+
const { container } = render(
|
|
214
|
+
<Pagination page={10} pageSize={10} total={200} onPageChange={() => {}} />,
|
|
215
|
+
)
|
|
216
|
+
const ellipses = container.querySelectorAll('[data-slot="pagination-ellipsis"]')
|
|
217
|
+
expect(ellipses.length).toBeGreaterThan(0)
|
|
218
|
+
expect(ellipses[0].textContent).toBe('…')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('clamps out-of-range page input to [1..totalPages]', () => {
|
|
222
|
+
const { container } = render(
|
|
223
|
+
<Pagination page={999} pageSize={10} total={50} onPageChange={() => {}} />,
|
|
224
|
+
)
|
|
225
|
+
const info = container.querySelector('[data-slot="pagination-info"]') as HTMLElement
|
|
226
|
+
expect(info.textContent).toBe('Page 5 of 5')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('disables all controls when disabled prop is set', () => {
|
|
230
|
+
const onPageChange = jest.fn()
|
|
231
|
+
const { container } = render(
|
|
232
|
+
<Pagination
|
|
233
|
+
page={5}
|
|
234
|
+
pageSize={10}
|
|
235
|
+
total={100}
|
|
236
|
+
onPageChange={onPageChange}
|
|
237
|
+
disabled
|
|
238
|
+
/>,
|
|
239
|
+
)
|
|
240
|
+
const buttons = container.querySelectorAll('button')
|
|
241
|
+
buttons.forEach((btn) => expect(btn.disabled).toBe(true))
|
|
242
|
+
fireEvent.click(buttons[0])
|
|
243
|
+
expect(onPageChange).not.toHaveBeenCalled()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('honors a custom formatPageInfo callback', () => {
|
|
247
|
+
const { container } = render(
|
|
248
|
+
<Pagination
|
|
249
|
+
page={3}
|
|
250
|
+
pageSize={10}
|
|
251
|
+
total={100}
|
|
252
|
+
onPageChange={() => {}}
|
|
253
|
+
formatPageInfo={(p, t) => `Strona ${p} z ${t}`}
|
|
254
|
+
/>,
|
|
255
|
+
)
|
|
256
|
+
expect(
|
|
257
|
+
container.querySelector('[data-slot="pagination-info"]')?.textContent,
|
|
258
|
+
).toBe('Strona 3 z 10')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('forwards ref to the root nav element', () => {
|
|
262
|
+
const ref = React.createRef<HTMLDivElement>()
|
|
263
|
+
render(
|
|
264
|
+
<Pagination ref={ref} page={1} pageSize={10} total={100} onPageChange={() => {}} />,
|
|
265
|
+
)
|
|
266
|
+
expect(ref.current?.getAttribute('data-slot')).toBe('pagination')
|
|
267
|
+
})
|
|
268
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { render } from '@testing-library/react'
|
|
5
|
+
|
|
6
|
+
import { Progress, CircularProgress } from '../progress'
|
|
7
|
+
|
|
8
|
+
describe('Progress (linear)', () => {
|
|
9
|
+
it('renders bar role + aria attrs with computed percentage width', () => {
|
|
10
|
+
const { container } = render(<Progress value={42} />)
|
|
11
|
+
const bar = container.querySelector('[data-slot="progress"]') as HTMLElement
|
|
12
|
+
expect(bar).not.toBeNull()
|
|
13
|
+
expect(bar.getAttribute('role')).toBe('progressbar')
|
|
14
|
+
expect(bar.getAttribute('aria-valuenow')).toBe('42')
|
|
15
|
+
expect(bar.getAttribute('aria-valuemin')).toBe('0')
|
|
16
|
+
expect(bar.getAttribute('aria-valuemax')).toBe('100')
|
|
17
|
+
const fill = bar.querySelector('[data-slot="progress-fill"]') as HTMLElement
|
|
18
|
+
expect(fill).not.toBeNull()
|
|
19
|
+
expect(fill.style.width).toBe('42%')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('clamps value to [0, max]', () => {
|
|
23
|
+
const { container, rerender } = render(<Progress value={-50} />)
|
|
24
|
+
let fill = container.querySelector('[data-slot="progress-fill"]') as HTMLElement
|
|
25
|
+
expect(fill.style.width).toBe('0%')
|
|
26
|
+
rerender(<Progress value={250} max={100} />)
|
|
27
|
+
fill = container.querySelector('[data-slot="progress-fill"]') as HTMLElement
|
|
28
|
+
expect(fill.style.width).toBe('100%')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('honors custom max', () => {
|
|
32
|
+
const { container } = render(<Progress value={5} max={10} />)
|
|
33
|
+
const fill = container.querySelector('[data-slot="progress-fill"]') as HTMLElement
|
|
34
|
+
expect(fill.style.width).toBe('50%')
|
|
35
|
+
const bar = container.querySelector('[data-slot="progress"]') as HTMLElement
|
|
36
|
+
expect(bar.getAttribute('aria-valuemax')).toBe('10')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('applies size variant classes', () => {
|
|
40
|
+
const { container, rerender } = render(<Progress value={50} />)
|
|
41
|
+
let bar = container.querySelector('[data-slot="progress"]') as HTMLElement
|
|
42
|
+
expect(bar.className).toContain('h-2')
|
|
43
|
+
|
|
44
|
+
rerender(<Progress value={50} size="sm" />)
|
|
45
|
+
bar = container.querySelector('[data-slot="progress"]') as HTMLElement
|
|
46
|
+
expect(bar.className).toContain('h-1')
|
|
47
|
+
|
|
48
|
+
rerender(<Progress value={50} size="lg" />)
|
|
49
|
+
bar = container.querySelector('[data-slot="progress"]') as HTMLElement
|
|
50
|
+
expect(bar.className).toContain('h-3')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('applies tone variant classes to the fill', () => {
|
|
54
|
+
const cases: Array<{ tone: 'accent' | 'success' | 'warning' | 'destructive' | 'muted'; cls: string }> = [
|
|
55
|
+
{ tone: 'accent', cls: 'bg-accent-indigo' },
|
|
56
|
+
{ tone: 'success', cls: 'bg-status-success-icon' },
|
|
57
|
+
{ tone: 'warning', cls: 'bg-status-warning-icon' },
|
|
58
|
+
{ tone: 'destructive', cls: 'bg-status-error-icon' },
|
|
59
|
+
{ tone: 'muted', cls: 'bg-muted-foreground' },
|
|
60
|
+
]
|
|
61
|
+
for (const { tone, cls } of cases) {
|
|
62
|
+
const { container, unmount } = render(<Progress value={50} tone={tone} />)
|
|
63
|
+
const fill = container.querySelector('[data-slot="progress-fill"]') as HTMLElement
|
|
64
|
+
expect(fill.className).toContain(cls)
|
|
65
|
+
const bar = container.querySelector('[data-slot="progress"]') as HTMLElement
|
|
66
|
+
expect(bar.getAttribute('data-tone')).toBe(tone)
|
|
67
|
+
unmount()
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders the label + value row when `label` is provided and showValue=true', () => {
|
|
72
|
+
const { container } = render(
|
|
73
|
+
<Progress value={80} label="Data Storage" showValue description="Upgrade to unlock more." />,
|
|
74
|
+
)
|
|
75
|
+
expect(container.querySelector('[data-slot="progress-wrapper"]')).not.toBeNull()
|
|
76
|
+
const label = container.querySelector('[data-slot="progress-label"]') as HTMLElement
|
|
77
|
+
expect(label.textContent).toBe('Data Storage')
|
|
78
|
+
const value = container.querySelector('[data-slot="progress-value"]') as HTMLElement
|
|
79
|
+
expect(value.textContent).toBe('80%')
|
|
80
|
+
const description = container.querySelector('[data-slot="progress-description"]') as HTMLElement
|
|
81
|
+
expect(description.textContent).toBe('Upgrade to unlock more.')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('omits the label row entirely when no label/showValue/description is set', () => {
|
|
85
|
+
const { container } = render(<Progress value={50} />)
|
|
86
|
+
expect(container.querySelector('[data-slot="progress-wrapper"]')).toBeNull()
|
|
87
|
+
expect(container.querySelector('[data-slot="progress-label"]')).toBeNull()
|
|
88
|
+
expect(container.querySelector('[data-slot="progress-value"]')).toBeNull()
|
|
89
|
+
expect(container.querySelector('[data-slot="progress-description"]')).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('renders showValue alone (no label) — empty label slot stays in place for layout', () => {
|
|
93
|
+
const { container } = render(<Progress value={42} showValue />)
|
|
94
|
+
expect(container.querySelector('[data-slot="progress-wrapper"]')).not.toBeNull()
|
|
95
|
+
const value = container.querySelector('[data-slot="progress-value"]') as HTMLElement
|
|
96
|
+
expect(value.textContent).toBe('42%')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('forwards className to the bar without dropping track classes', () => {
|
|
100
|
+
const { container } = render(<Progress value={50} className="custom-class" />)
|
|
101
|
+
const bar = container.querySelector('[data-slot="progress"]') as HTMLElement
|
|
102
|
+
expect(bar.className).toContain('custom-class')
|
|
103
|
+
expect(bar.className).toContain('rounded-full')
|
|
104
|
+
expect(bar.className).toContain('bg-input')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('forwards fillClassName to override the fill', () => {
|
|
108
|
+
const { container } = render(
|
|
109
|
+
<Progress value={50} fillClassName="custom-fill" />,
|
|
110
|
+
)
|
|
111
|
+
const fill = container.querySelector('[data-slot="progress-fill"]') as HTMLElement
|
|
112
|
+
expect(fill.className).toContain('custom-fill')
|
|
113
|
+
// Default tone class still applied (cn merges, doesn't replace).
|
|
114
|
+
expect(fill.className).toContain('bg-accent-indigo')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('CircularProgress', () => {
|
|
119
|
+
it('renders an SVG with two concentric circles + aria attrs', () => {
|
|
120
|
+
const { container } = render(<CircularProgress value={50} />)
|
|
121
|
+
const root = container.querySelector('[data-slot="circular-progress"]') as HTMLElement
|
|
122
|
+
expect(root).not.toBeNull()
|
|
123
|
+
expect(root.getAttribute('role')).toBe('progressbar')
|
|
124
|
+
expect(root.getAttribute('aria-valuenow')).toBe('50')
|
|
125
|
+
expect(root.getAttribute('aria-valuemin')).toBe('0')
|
|
126
|
+
expect(root.getAttribute('aria-valuemax')).toBe('100')
|
|
127
|
+
expect(root.getAttribute('aria-label')).toBe('50%')
|
|
128
|
+
const track = root.querySelector('[data-slot="circular-progress-track"]') as SVGCircleElement
|
|
129
|
+
const fill = root.querySelector('[data-slot="circular-progress-fill"]') as SVGCircleElement
|
|
130
|
+
expect(track).not.toBeNull()
|
|
131
|
+
expect(fill).not.toBeNull()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('computes stroke-dashoffset from percentage', () => {
|
|
135
|
+
const { container } = render(<CircularProgress value={75} size="default" />)
|
|
136
|
+
const fill = container.querySelector('[data-slot="circular-progress-fill"]') as SVGCircleElement
|
|
137
|
+
// size="default": box=48, stroke=4, radius=22, circumference≈138.23, dashOffset≈34.56
|
|
138
|
+
const dashArray = parseFloat(fill.getAttribute('stroke-dasharray') ?? '0')
|
|
139
|
+
const dashOffset = parseFloat(fill.getAttribute('stroke-dashoffset') ?? '0')
|
|
140
|
+
expect(dashArray).toBeCloseTo(2 * Math.PI * 22, 1)
|
|
141
|
+
expect(dashOffset).toBeCloseTo(dashArray * 0.25, 1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('clamps value', () => {
|
|
145
|
+
const { container, rerender } = render(<CircularProgress value={-10} />)
|
|
146
|
+
let fill = container.querySelector('[data-slot="circular-progress-fill"]') as SVGCircleElement
|
|
147
|
+
const dashArray = parseFloat(fill.getAttribute('stroke-dasharray') ?? '0')
|
|
148
|
+
expect(parseFloat(fill.getAttribute('stroke-dashoffset') ?? '0')).toBeCloseTo(dashArray, 1)
|
|
149
|
+
|
|
150
|
+
rerender(<CircularProgress value={500} max={100} />)
|
|
151
|
+
fill = container.querySelector('[data-slot="circular-progress-fill"]') as SVGCircleElement
|
|
152
|
+
expect(parseFloat(fill.getAttribute('stroke-dashoffset') ?? '0')).toBeCloseTo(0, 1)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('renders the centre value badge when showValue=true', () => {
|
|
156
|
+
const { container } = render(<CircularProgress value={42} showValue />)
|
|
157
|
+
const valueLabel = container.querySelector(
|
|
158
|
+
'[data-slot="circular-progress-value"]',
|
|
159
|
+
) as HTMLElement
|
|
160
|
+
expect(valueLabel).not.toBeNull()
|
|
161
|
+
expect(valueLabel.textContent).toBe('42%')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('replaces the centre value with children when both are provided', () => {
|
|
165
|
+
const { container } = render(
|
|
166
|
+
<CircularProgress value={42} showValue>
|
|
167
|
+
<span data-testid="custom-center">3/7</span>
|
|
168
|
+
</CircularProgress>,
|
|
169
|
+
)
|
|
170
|
+
const valueLabel = container.querySelector(
|
|
171
|
+
'[data-slot="circular-progress-value"]',
|
|
172
|
+
) as HTMLElement
|
|
173
|
+
expect(valueLabel.querySelector('[data-testid="custom-center"]')).not.toBeNull()
|
|
174
|
+
expect(valueLabel.textContent).toBe('3/7')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('honors ariaLabel override', () => {
|
|
178
|
+
const { container } = render(
|
|
179
|
+
<CircularProgress value={50} ariaLabel="Sprint completion" />,
|
|
180
|
+
)
|
|
181
|
+
const root = container.querySelector('[data-slot="circular-progress"]') as HTMLElement
|
|
182
|
+
expect(root.getAttribute('aria-label')).toBe('Sprint completion')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('applies tone variant strokes', () => {
|
|
186
|
+
const cases: Array<{ tone: 'accent' | 'success' | 'warning' | 'destructive' | 'muted'; cls: string }> = [
|
|
187
|
+
{ tone: 'accent', cls: 'stroke-accent-indigo' },
|
|
188
|
+
{ tone: 'success', cls: 'stroke-status-success-icon' },
|
|
189
|
+
{ tone: 'warning', cls: 'stroke-status-warning-icon' },
|
|
190
|
+
{ tone: 'destructive', cls: 'stroke-status-error-icon' },
|
|
191
|
+
{ tone: 'muted', cls: 'stroke-muted-foreground' },
|
|
192
|
+
]
|
|
193
|
+
for (const { tone, cls } of cases) {
|
|
194
|
+
const { container, unmount } = render(<CircularProgress value={50} tone={tone} />)
|
|
195
|
+
const fill = container.querySelector(
|
|
196
|
+
'[data-slot="circular-progress-fill"]',
|
|
197
|
+
) as SVGCircleElement
|
|
198
|
+
expect(fill.getAttribute('class')).toContain(cls)
|
|
199
|
+
const root = container.querySelector('[data-slot="circular-progress"]') as HTMLElement
|
|
200
|
+
expect(root.getAttribute('data-tone')).toBe(tone)
|
|
201
|
+
unmount()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('honors size variants (box dimensions)', () => {
|
|
206
|
+
const cases: Array<{ size: 'xs' | 'sm' | 'default' | 'lg'; box: number }> = [
|
|
207
|
+
{ size: 'xs', box: 24 },
|
|
208
|
+
{ size: 'sm', box: 32 },
|
|
209
|
+
{ size: 'default', box: 48 },
|
|
210
|
+
{ size: 'lg', box: 64 },
|
|
211
|
+
]
|
|
212
|
+
for (const { size, box } of cases) {
|
|
213
|
+
const { container, unmount } = render(<CircularProgress value={50} size={size} />)
|
|
214
|
+
const root = container.querySelector('[data-slot="circular-progress"]') as HTMLElement
|
|
215
|
+
expect(root.getAttribute('data-size')).toBe(size)
|
|
216
|
+
const svg = root.querySelector('svg') as SVGElement
|
|
217
|
+
expect(svg.getAttribute('width')).toBe(String(box))
|
|
218
|
+
expect(svg.getAttribute('height')).toBe(String(box))
|
|
219
|
+
unmount()
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { render as rtlRender, fireEvent } from '@testing-library/react'
|
|
5
|
+
import { Rating } from '../rating'
|
|
6
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
|
|
8
|
+
// Rating uses useT() for read-only summary + item aria-labels.
|
|
9
|
+
// Wrap every render in an empty-dict I18nProvider so the primitive
|
|
10
|
+
// falls back to its English fallbacks without real translations.
|
|
11
|
+
const render: typeof rtlRender = (ui: React.ReactElement, options?: Parameters<typeof rtlRender>[1]) =>
|
|
12
|
+
rtlRender(
|
|
13
|
+
<I18nProvider locale="en" dict={{}}>
|
|
14
|
+
{ui}
|
|
15
|
+
</I18nProvider>,
|
|
16
|
+
options,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
describe('Rating', () => {
|
|
20
|
+
describe('read-only mode (no onChange)', () => {
|
|
21
|
+
it('renders role="img" with aria-label "{value} out of {max}" by default', () => {
|
|
22
|
+
const { getByRole } = render(<Rating value={3} max={5} />)
|
|
23
|
+
const node = getByRole('img')
|
|
24
|
+
expect(node.getAttribute('aria-label')).toBe('3 out of 5')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('honors a custom aria-label', () => {
|
|
28
|
+
const { getByRole } = render(<Rating value={4} max={5} aria-label="Average review score" />)
|
|
29
|
+
expect(getByRole('img').getAttribute('aria-label')).toBe('Average review score')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('renders max items, marking the first `value` as filled', () => {
|
|
33
|
+
const { container } = render(<Rating value={3} max={5} />)
|
|
34
|
+
const items = container.querySelectorAll('[data-slot="rating-item"]')
|
|
35
|
+
expect(items.length).toBe(5)
|
|
36
|
+
expect(items[0].getAttribute('data-fill')).toBe('full')
|
|
37
|
+
expect(items[1].getAttribute('data-fill')).toBe('full')
|
|
38
|
+
expect(items[2].getAttribute('data-fill')).toBe('full')
|
|
39
|
+
expect(items[3].getAttribute('data-fill')).toBe('empty')
|
|
40
|
+
expect(items[4].getAttribute('data-fill')).toBe('empty')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('does NOT render buttons when no onChange is provided', () => {
|
|
44
|
+
const { container } = render(<Rating value={3} max={5} />)
|
|
45
|
+
expect(container.querySelector('button')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders a half-fill state when allowHalf and value falls between integers', () => {
|
|
49
|
+
const { container } = render(<Rating value={3.5} max={5} allowHalf />)
|
|
50
|
+
const items = container.querySelectorAll('[data-slot="rating-item"]')
|
|
51
|
+
expect(items[2].getAttribute('data-fill')).toBe('full')
|
|
52
|
+
expect(items[3].getAttribute('data-fill')).toBe('half')
|
|
53
|
+
expect(items[4].getAttribute('data-fill')).toBe('empty')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('treats half values as full when allowHalf is false (default)', () => {
|
|
57
|
+
const { container } = render(<Rating value={3.5} max={5} />)
|
|
58
|
+
const items = container.querySelectorAll('[data-slot="rating-item"]')
|
|
59
|
+
// 3.5 with allowHalf=false → first 3 full, 4th is empty (cannot half)
|
|
60
|
+
expect(items[2].getAttribute('data-fill')).toBe('full')
|
|
61
|
+
expect(items[3].getAttribute('data-fill')).toBe('empty')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('interactive mode (onChange provided)', () => {
|
|
66
|
+
it('renders role="radiogroup" with N role="radio" buttons', () => {
|
|
67
|
+
const { getByRole, getAllByRole } = render(
|
|
68
|
+
<Rating value={2} max={5} onChange={() => {}} aria-label="Rate" />,
|
|
69
|
+
)
|
|
70
|
+
expect(getByRole('radiogroup', { name: 'Rate' })).toBeInTheDocument()
|
|
71
|
+
expect(getAllByRole('radio').length).toBe(5)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('fires onChange with (index + 1) on full-click without allowHalf', () => {
|
|
75
|
+
const onChange = jest.fn()
|
|
76
|
+
const { getAllByRole } = render(
|
|
77
|
+
<Rating value={0} max={5} onChange={onChange} aria-label="Rate" />,
|
|
78
|
+
)
|
|
79
|
+
fireEvent.click(getAllByRole('radio')[2])
|
|
80
|
+
expect(onChange).toHaveBeenCalledWith(3)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('does not fire onChange when disabled', () => {
|
|
84
|
+
const onChange = jest.fn()
|
|
85
|
+
const { getAllByRole } = render(
|
|
86
|
+
<Rating value={0} max={5} onChange={onChange} disabled aria-label="Rate" />,
|
|
87
|
+
)
|
|
88
|
+
fireEvent.click(getAllByRole('radio')[3])
|
|
89
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('marks aria-checked on items that are filled (full or half)', () => {
|
|
93
|
+
const { getAllByRole } = render(
|
|
94
|
+
<Rating value={2.5} max={5} onChange={() => {}} allowHalf aria-label="Rate" />,
|
|
95
|
+
)
|
|
96
|
+
const items = getAllByRole('radio')
|
|
97
|
+
expect(items[0].getAttribute('aria-checked')).toBe('true')
|
|
98
|
+
expect(items[1].getAttribute('aria-checked')).toBe('true')
|
|
99
|
+
expect(items[2].getAttribute('aria-checked')).toBe('true') // half counts as checked
|
|
100
|
+
expect(items[3].getAttribute('aria-checked')).toBe('false')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('moves the value with ArrowRight / ArrowLeft keys', () => {
|
|
104
|
+
const onChange = jest.fn()
|
|
105
|
+
const { getAllByRole } = render(
|
|
106
|
+
<Rating value={2} max={5} onChange={onChange} aria-label="Rate" />,
|
|
107
|
+
)
|
|
108
|
+
fireEvent.keyDown(getAllByRole('radio')[1], { key: 'ArrowRight' })
|
|
109
|
+
expect(onChange).toHaveBeenLastCalledWith(3)
|
|
110
|
+
fireEvent.keyDown(getAllByRole('radio')[1], { key: 'ArrowLeft' })
|
|
111
|
+
expect(onChange).toHaveBeenLastCalledWith(1)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('Home / End keys snap to the first / last position', () => {
|
|
115
|
+
const onChange = jest.fn()
|
|
116
|
+
const { getAllByRole } = render(
|
|
117
|
+
<Rating value={3} max={5} onChange={onChange} aria-label="Rate" />,
|
|
118
|
+
)
|
|
119
|
+
fireEvent.keyDown(getAllByRole('radio')[2], { key: 'Home' })
|
|
120
|
+
expect(onChange).toHaveBeenLastCalledWith(1)
|
|
121
|
+
fireEvent.keyDown(getAllByRole('radio')[2], { key: 'End' })
|
|
122
|
+
expect(onChange).toHaveBeenLastCalledWith(5)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('keyboard step with allowHalf is 0.5', () => {
|
|
126
|
+
const onChange = jest.fn()
|
|
127
|
+
const { getAllByRole } = render(
|
|
128
|
+
<Rating value={2} max={5} onChange={onChange} allowHalf aria-label="Rate" />,
|
|
129
|
+
)
|
|
130
|
+
fireEvent.keyDown(getAllByRole('radio')[1], { key: 'ArrowRight' })
|
|
131
|
+
expect(onChange).toHaveBeenLastCalledWith(2.5)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('clamps keyboard moves at 0 and max', () => {
|
|
135
|
+
const onChange = jest.fn()
|
|
136
|
+
const { getAllByRole } = render(
|
|
137
|
+
<Rating value={0} max={5} onChange={onChange} aria-label="Rate" />,
|
|
138
|
+
)
|
|
139
|
+
fireEvent.keyDown(getAllByRole('radio')[0], { key: 'ArrowLeft' })
|
|
140
|
+
expect(onChange).toHaveBeenLastCalledWith(0)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('shape & sizing', () => {
|
|
145
|
+
it('applies default size (size-5 via [&>*]:size-5) when size prop omitted', () => {
|
|
146
|
+
const { container } = render(<Rating value={1} max={3} />)
|
|
147
|
+
const root = container.querySelector('[data-slot="rating"]') as HTMLElement
|
|
148
|
+
expect(root.className).toContain('size-5')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('applies sm and lg size classes', () => {
|
|
152
|
+
const small = render(<Rating value={1} max={3} size="sm" />)
|
|
153
|
+
expect(small.container.querySelector('[data-slot="rating"]')?.className).toContain('size-4')
|
|
154
|
+
const large = render(<Rating value={1} max={3} size="lg" />)
|
|
155
|
+
expect(large.container.querySelector('[data-slot="rating"]')?.className).toContain('size-6')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('forwards ref to the root element', () => {
|
|
159
|
+
const ref = React.createRef<HTMLSpanElement>()
|
|
160
|
+
render(<Rating ref={ref} value={1} max={3} />)
|
|
161
|
+
expect(ref.current?.getAttribute('data-slot')).toBe('rating')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
})
|