@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.
Files changed (92) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -0
  3. package/dist/backend/DataTable.js +28 -62
  4. package/dist/backend/DataTable.js.map +2 -2
  5. package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
  6. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  7. package/dist/primitives/activity-feed.js +196 -0
  8. package/dist/primitives/activity-feed.js.map +7 -0
  9. package/dist/primitives/avatar.js +140 -14
  10. package/dist/primitives/avatar.js.map +3 -3
  11. package/dist/primitives/badge.js +94 -5
  12. package/dist/primitives/badge.js.map +2 -2
  13. package/dist/primitives/button-group.js +52 -0
  14. package/dist/primitives/button-group.js.map +7 -0
  15. package/dist/primitives/color-picker.js +520 -0
  16. package/dist/primitives/color-picker.js.map +7 -0
  17. package/dist/primitives/command-menu.js +355 -0
  18. package/dist/primitives/command-menu.js.map +7 -0
  19. package/dist/primitives/dialog.js +210 -35
  20. package/dist/primitives/dialog.js.map +2 -2
  21. package/dist/primitives/drawer.js +219 -0
  22. package/dist/primitives/drawer.js.map +7 -0
  23. package/dist/primitives/notification-feed.js +213 -0
  24. package/dist/primitives/notification-feed.js.map +7 -0
  25. package/dist/primitives/notification.js +23 -2
  26. package/dist/primitives/notification.js.map +2 -2
  27. package/dist/primitives/pagination.js +245 -0
  28. package/dist/primitives/pagination.js.map +7 -0
  29. package/dist/primitives/progress.js +178 -5
  30. package/dist/primitives/progress.js.map +2 -2
  31. package/dist/primitives/rating.js +174 -0
  32. package/dist/primitives/rating.js.map +7 -0
  33. package/dist/primitives/scroll-area.js +82 -0
  34. package/dist/primitives/scroll-area.js.map +7 -0
  35. package/dist/primitives/segmented-control.js +101 -0
  36. package/dist/primitives/segmented-control.js.map +7 -0
  37. package/dist/primitives/separator.js +103 -4
  38. package/dist/primitives/separator.js.map +2 -2
  39. package/dist/primitives/slider.js +115 -0
  40. package/dist/primitives/slider.js.map +7 -0
  41. package/dist/primitives/step-indicator.js +187 -0
  42. package/dist/primitives/step-indicator.js.map +7 -0
  43. package/dist/primitives/table.js +102 -10
  44. package/dist/primitives/table.js.map +2 -2
  45. package/dist/primitives/tabs.js +122 -14
  46. package/dist/primitives/tabs.js.map +2 -2
  47. package/dist/primitives/time-picker.js +9 -11
  48. package/dist/primitives/time-picker.js.map +2 -2
  49. package/package.json +8 -4
  50. package/src/backend/DataTable.tsx +43 -60
  51. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  52. package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
  53. package/src/primitives/__tests__/avatar.test.tsx +131 -0
  54. package/src/primitives/__tests__/badge.test.tsx +189 -0
  55. package/src/primitives/__tests__/button-group.test.tsx +117 -0
  56. package/src/primitives/__tests__/color-picker.test.tsx +589 -0
  57. package/src/primitives/__tests__/command-menu.test.tsx +231 -0
  58. package/src/primitives/__tests__/dialog.test.tsx +290 -0
  59. package/src/primitives/__tests__/drawer.test.tsx +264 -0
  60. package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
  61. package/src/primitives/__tests__/notification.test.tsx +102 -2
  62. package/src/primitives/__tests__/pagination.test.tsx +268 -0
  63. package/src/primitives/__tests__/progress.test.tsx +222 -0
  64. package/src/primitives/__tests__/rating.test.tsx +164 -0
  65. package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
  66. package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
  67. package/src/primitives/__tests__/separator.test.tsx +143 -0
  68. package/src/primitives/__tests__/slider.test.tsx +86 -0
  69. package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
  70. package/src/primitives/__tests__/table.test.tsx +199 -0
  71. package/src/primitives/__tests__/tabs.test.tsx +139 -0
  72. package/src/primitives/activity-feed.tsx +326 -0
  73. package/src/primitives/avatar.tsx +225 -22
  74. package/src/primitives/badge.tsx +141 -6
  75. package/src/primitives/button-group.tsx +100 -0
  76. package/src/primitives/color-picker.tsx +693 -0
  77. package/src/primitives/command-menu.tsx +503 -0
  78. package/src/primitives/dialog.tsx +315 -51
  79. package/src/primitives/drawer.tsx +338 -0
  80. package/src/primitives/notification-feed.tsx +358 -0
  81. package/src/primitives/notification.tsx +42 -2
  82. package/src/primitives/pagination.tsx +376 -0
  83. package/src/primitives/progress.tsx +267 -6
  84. package/src/primitives/rating.tsx +245 -0
  85. package/src/primitives/scroll-area.tsx +169 -0
  86. package/src/primitives/segmented-control.tsx +162 -0
  87. package/src/primitives/separator.tsx +163 -3
  88. package/src/primitives/slider.tsx +160 -0
  89. package/src/primitives/step-indicator.tsx +287 -0
  90. package/src/primitives/table.tsx +142 -11
  91. package/src/primitives/tabs.tsx +174 -12
  92. 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
+ })