@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,109 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render } from '@testing-library/react'
5
+ import {
6
+ ScrollArea,
7
+ ScrollAreaRoot,
8
+ ScrollAreaViewport,
9
+ ScrollAreaScrollbar,
10
+ ScrollAreaThumb,
11
+ ScrollAreaCorner,
12
+ } from '../scroll-area'
13
+
14
+ // NOTE: Radix ScrollArea defers scrollbar / corner rendering until the viewport
15
+ // reports overflow via layout measurements. jsdom does not implement layout,
16
+ // so scrollbars are not mounted in these tests. The assertions below cover
17
+ // the primitive's own surface area (root + viewport + className/ref
18
+ // forwarding + exported sub-components). Scrollbar visual behaviour belongs
19
+ // to Radix's own test suite.
20
+
21
+ describe('ScrollArea', () => {
22
+ it('renders children inside the viewport', () => {
23
+ const { getByText, container } = render(
24
+ <ScrollArea className="h-32">
25
+ <div>Scrollable content</div>
26
+ </ScrollArea>,
27
+ )
28
+ expect(getByText('Scrollable content')).toBeInTheDocument()
29
+ const viewport = container.querySelector('[data-slot="scroll-area-viewport"]')
30
+ expect(viewport).not.toBeNull()
31
+ expect(viewport?.textContent).toContain('Scrollable content')
32
+ })
33
+
34
+ it('forwards className to the root element', () => {
35
+ const { container } = render(
36
+ <ScrollArea className="custom-class h-32">
37
+ <div>content</div>
38
+ </ScrollArea>,
39
+ )
40
+ const root = container.querySelector('[data-slot="scroll-area-root"]')
41
+ expect(root).not.toBeNull()
42
+ expect(root?.className).toContain('custom-class')
43
+ expect(root?.className).toContain('h-32')
44
+ expect(root?.className).toContain('relative')
45
+ expect(root?.className).toContain('overflow-hidden')
46
+ })
47
+
48
+ it('forwards viewportClassName to the viewport', () => {
49
+ const { container } = render(
50
+ <ScrollArea className="h-32" viewportClassName="custom-viewport">
51
+ <div>content</div>
52
+ </ScrollArea>,
53
+ )
54
+ const viewport = container.querySelector('[data-slot="scroll-area-viewport"]')
55
+ expect(viewport?.className).toContain('custom-viewport')
56
+ expect(viewport?.className).toContain('h-full')
57
+ expect(viewport?.className).toContain('w-full')
58
+ })
59
+
60
+ it('forwards ref to the root element', () => {
61
+ const ref = React.createRef<HTMLDivElement>()
62
+ render(
63
+ <ScrollArea ref={ref} className="h-32">
64
+ <div>content</div>
65
+ </ScrollArea>,
66
+ )
67
+ expect(ref.current).not.toBeNull()
68
+ expect(ref.current?.getAttribute('data-slot')).toBe('scroll-area-root')
69
+ })
70
+
71
+ it('accepts dir, type, and other Radix Root passthrough props without error', () => {
72
+ expect(() => {
73
+ render(
74
+ <ScrollArea className="h-32" dir="rtl" type="always">
75
+ <div>content</div>
76
+ </ScrollArea>,
77
+ )
78
+ }).not.toThrow()
79
+ })
80
+
81
+ it('exposes compound sub-components for custom composition', () => {
82
+ // The convenience `<ScrollArea>` wrapper covers the 90% case; reaching for
83
+ // the lower-level primitives must stay possible for callers that need
84
+ // conditional scrollbars, a custom viewport, or a custom corner element.
85
+ const { container, getByText } = render(
86
+ <ScrollAreaRoot className="h-32">
87
+ <ScrollAreaViewport>
88
+ <div>custom composition</div>
89
+ </ScrollAreaViewport>
90
+ <ScrollAreaScrollbar orientation="vertical">
91
+ <ScrollAreaThumb />
92
+ </ScrollAreaScrollbar>
93
+ <ScrollAreaCorner />
94
+ </ScrollAreaRoot>,
95
+ )
96
+ expect(getByText('custom composition')).toBeInTheDocument()
97
+ expect(container.querySelector('[data-slot="scroll-area-root"]')).not.toBeNull()
98
+ expect(container.querySelector('[data-slot="scroll-area-viewport"]')).not.toBeNull()
99
+ })
100
+
101
+ it('exports every sub-component as a referenceable React component', () => {
102
+ // Guards against accidental removal of the compound API exports.
103
+ expect(typeof ScrollAreaRoot).toBe('object')
104
+ expect(typeof ScrollAreaViewport).toBe('object')
105
+ expect(typeof ScrollAreaScrollbar).toBe('object')
106
+ expect(typeof ScrollAreaThumb).toBe('object')
107
+ expect(typeof ScrollAreaCorner).toBe('object')
108
+ })
109
+ })
@@ -0,0 +1,129 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render, fireEvent } from '@testing-library/react'
5
+ import { SegmentedControl, SegmentedControlItem } from '../segmented-control'
6
+
7
+ function Controlled({
8
+ initial = 'all',
9
+ size,
10
+ disabled,
11
+ onChange,
12
+ }: {
13
+ initial?: string
14
+ size?: 'sm' | 'default'
15
+ disabled?: boolean
16
+ onChange?: (next: string) => void
17
+ }) {
18
+ const [value, setValue] = React.useState(initial)
19
+ return (
20
+ <SegmentedControl
21
+ value={value}
22
+ onValueChange={(next) => {
23
+ setValue(next)
24
+ onChange?.(next)
25
+ }}
26
+ size={size}
27
+ disabled={disabled}
28
+ aria-label="View"
29
+ >
30
+ <SegmentedControlItem value="all">All</SegmentedControlItem>
31
+ <SegmentedControlItem value="active">Active</SegmentedControlItem>
32
+ <SegmentedControlItem value="archived">Archived</SegmentedControlItem>
33
+ </SegmentedControl>
34
+ )
35
+ }
36
+
37
+ describe('SegmentedControl', () => {
38
+ it('renders a radiogroup with one radio item per child', () => {
39
+ const { container, getByRole, getAllByRole } = render(<Controlled />)
40
+ const root = getByRole('radiogroup', { name: 'View' })
41
+ expect(root).toBeInTheDocument()
42
+ expect(root.getAttribute('data-slot')).toBe('segmented-control')
43
+ const items = getAllByRole('radio')
44
+ expect(items.length).toBe(3)
45
+ expect(container.querySelectorAll('[data-slot="segmented-control-item"]').length).toBe(3)
46
+ })
47
+
48
+ it('marks the initial value as checked', () => {
49
+ const { getByRole } = render(<Controlled initial="active" />)
50
+ const all = getByRole('radio', { name: 'All' })
51
+ const active = getByRole('radio', { name: 'Active' })
52
+ const archived = getByRole('radio', { name: 'Archived' })
53
+ expect(all.getAttribute('aria-checked')).toBe('false')
54
+ expect(active.getAttribute('aria-checked')).toBe('true')
55
+ expect(active.getAttribute('data-state')).toBe('checked')
56
+ expect(archived.getAttribute('aria-checked')).toBe('false')
57
+ })
58
+
59
+ it('fires onValueChange when an unselected item is clicked', () => {
60
+ const onChange = jest.fn()
61
+ const { getByRole } = render(<Controlled onChange={onChange} />)
62
+ fireEvent.click(getByRole('radio', { name: 'Active' }))
63
+ expect(onChange).toHaveBeenCalledWith('active')
64
+ })
65
+
66
+ it('updates aria-checked + data-state after selection', () => {
67
+ const { getByRole } = render(<Controlled />)
68
+ fireEvent.click(getByRole('radio', { name: 'Archived' }))
69
+ expect(getByRole('radio', { name: 'Archived' }).getAttribute('aria-checked')).toBe('true')
70
+ expect(getByRole('radio', { name: 'All' }).getAttribute('aria-checked')).toBe('false')
71
+ })
72
+
73
+ it('applies size="default" classes by default (h-8 track, h-7 items, text-sm)', () => {
74
+ const { container } = render(<Controlled />)
75
+ const root = container.querySelector('[data-slot="segmented-control"]') as HTMLElement
76
+ expect(root.className).toContain('h-8')
77
+ expect(root.className).toContain('rounded-full')
78
+ const item = container.querySelector('[data-slot="segmented-control-item"]') as HTMLElement
79
+ expect(item.className).toContain('h-7')
80
+ expect(item.className).toContain('text-sm')
81
+ })
82
+
83
+ it('applies size="sm" classes (h-7 track, h-6 items, text-xs)', () => {
84
+ const { container } = render(<Controlled size="sm" />)
85
+ const root = container.querySelector('[data-slot="segmented-control"]') as HTMLElement
86
+ expect(root.className).toContain('h-7')
87
+ expect(root.className).not.toContain('h-8')
88
+ const item = container.querySelector('[data-slot="segmented-control-item"]') as HTMLElement
89
+ expect(item.className).toContain('h-6')
90
+ expect(item.className).toContain('text-xs')
91
+ })
92
+
93
+ it('disables all items when disabled prop is set on the root', () => {
94
+ const onChange = jest.fn()
95
+ const { container, getByRole } = render(<Controlled disabled onChange={onChange} />)
96
+ const root = container.querySelector('[data-slot="segmented-control"]') as HTMLElement
97
+ expect(root.className).toContain('opacity-60')
98
+ const items = container.querySelectorAll('[data-slot="segmented-control-item"]')
99
+ items.forEach((item) => {
100
+ expect((item as HTMLButtonElement).disabled).toBe(true)
101
+ })
102
+ fireEvent.click(getByRole('radio', { name: 'Active' }))
103
+ expect(onChange).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('forwards ref to the root element', () => {
107
+ const ref = React.createRef<HTMLDivElement>()
108
+ render(
109
+ <SegmentedControl ref={ref} value="a" onValueChange={() => {}} aria-label="x">
110
+ <SegmentedControlItem value="a">A</SegmentedControlItem>
111
+ <SegmentedControlItem value="b">B</SegmentedControlItem>
112
+ </SegmentedControl>,
113
+ )
114
+ expect(ref.current).not.toBeNull()
115
+ expect(ref.current?.getAttribute('data-slot')).toBe('segmented-control')
116
+ })
117
+
118
+ it('forwards className without dropping variant classes', () => {
119
+ const { container } = render(
120
+ <SegmentedControl value="a" onValueChange={() => {}} className="custom-class">
121
+ <SegmentedControlItem value="a">A</SegmentedControlItem>
122
+ </SegmentedControl>,
123
+ )
124
+ const root = container.querySelector('[data-slot="segmented-control"]') as HTMLElement
125
+ expect(root.className).toContain('custom-class')
126
+ expect(root.className).toContain('rounded-full')
127
+ expect(root.className).toContain('bg-muted')
128
+ })
129
+ })
@@ -0,0 +1,143 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render } from '@testing-library/react'
5
+
6
+ import { Separator } from '../separator'
7
+
8
+ describe('Separator', () => {
9
+ describe('horizontal (default)', () => {
10
+ it('renders role="separator" with the bare-rule look (backward compat)', () => {
11
+ const { container } = render(<Separator />)
12
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
13
+ expect(sep).not.toBeNull()
14
+ expect(sep.getAttribute('role')).toBe('separator')
15
+ expect(sep.getAttribute('aria-orientation')).toBe('horizontal')
16
+ expect(sep.getAttribute('data-variant')).toBe('solid')
17
+ expect(sep.className).toContain('h-px')
18
+ expect(sep.className).toContain('w-full')
19
+ expect(sep.className).toContain('bg-border')
20
+ })
21
+
22
+ it('forwards className', () => {
23
+ const { container } = render(<Separator className="my-4 custom-class" />)
24
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
25
+ expect(sep.className).toContain('my-4')
26
+ expect(sep.className).toContain('custom-class')
27
+ // Default base classes still applied.
28
+ expect(sep.className).toContain('bg-border')
29
+ })
30
+ })
31
+
32
+ describe('vertical', () => {
33
+ it('renders w-px h-full vertical rule', () => {
34
+ const { container } = render(<Separator orientation="vertical" />)
35
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
36
+ expect(sep.getAttribute('aria-orientation')).toBe('vertical')
37
+ expect(sep.className).toContain('h-full')
38
+ expect(sep.className).toContain('w-px')
39
+ expect(sep.className).toContain('bg-border')
40
+ })
41
+
42
+ it('vertical + dashed switches to border-l-dashed', () => {
43
+ const { container } = render(
44
+ <Separator orientation="vertical" variant="dashed" />,
45
+ )
46
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
47
+ expect(sep.className).toContain('border-l')
48
+ expect(sep.className).toContain('border-dashed')
49
+ expect(sep.className).not.toContain('bg-border')
50
+ })
51
+ })
52
+
53
+ describe('variant="dashed"', () => {
54
+ it('renders horizontal dashed rule with border-t border-dashed', () => {
55
+ const { container } = render(<Separator variant="dashed" />)
56
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
57
+ expect(sep.getAttribute('data-variant')).toBe('dashed')
58
+ expect(sep.className).toContain('border-t')
59
+ expect(sep.className).toContain('border-dashed')
60
+ expect(sep.className).toContain('h-0')
61
+ expect(sep.className).not.toContain('bg-border')
62
+ })
63
+ })
64
+
65
+ describe('label (inline label between two rule halves)', () => {
66
+ it('renders the label slot with two rule segments by default', () => {
67
+ const { container } = render(<Separator label="OR" />)
68
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
69
+ expect(sep.getAttribute('data-label-align')).toBe('center')
70
+ const label = container.querySelector('[data-slot="separator-label"]') as HTMLElement
71
+ expect(label).not.toBeNull()
72
+ expect(label.textContent).toBe('OR')
73
+ const rules = container.querySelectorAll('[data-slot="separator-rule"]')
74
+ expect(rules.length).toBe(2)
75
+ })
76
+
77
+ it('start-aligned label has a short left segment + flex-1 right segment', () => {
78
+ const { container } = render(<Separator label="Section" labelAlign="start" />)
79
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
80
+ expect(sep.getAttribute('data-label-align')).toBe('start')
81
+ const rules = Array.from(
82
+ container.querySelectorAll('[data-slot="separator-rule"]'),
83
+ ) as HTMLElement[]
84
+ expect(rules.length).toBe(2)
85
+ // First segment is the short stub (w-6), second is flex-1.
86
+ expect(rules[0].className).toContain('w-6')
87
+ expect(rules[1].className).toContain('flex-1')
88
+ })
89
+
90
+ it('end-aligned label has flex-1 left segment + short right segment', () => {
91
+ const { container } = render(<Separator label="Section" labelAlign="end" />)
92
+ const rules = Array.from(
93
+ container.querySelectorAll('[data-slot="separator-rule"]'),
94
+ ) as HTMLElement[]
95
+ expect(rules[0].className).toContain('flex-1')
96
+ expect(rules[1].className).toContain('w-6')
97
+ })
98
+
99
+ it('label + dashed variant flows through to both rule segments', () => {
100
+ const { container } = render(<Separator label="OR" variant="dashed" />)
101
+ const rules = Array.from(
102
+ container.querySelectorAll('[data-slot="separator-rule"]'),
103
+ ) as HTMLElement[]
104
+ expect(rules.length).toBe(2)
105
+ for (const rule of rules) {
106
+ expect(rule.className).toContain('border-dashed')
107
+ expect(rule.className).toContain('border-t')
108
+ }
109
+ })
110
+ })
111
+
112
+ describe('section variant', () => {
113
+ it('renders a full-width bg-muted strip with uppercase label', () => {
114
+ const { container } = render(
115
+ <Separator section label="AMOUNT & ACCOUNT" />,
116
+ )
117
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
118
+ expect(sep.getAttribute('data-variant')).toBe('section')
119
+ expect(sep.className).toContain('bg-muted')
120
+ expect(sep.className).toContain('uppercase')
121
+ expect(sep.textContent).toBe('AMOUNT & ACCOUNT')
122
+ })
123
+
124
+ it('section variant accepts children when label is omitted', () => {
125
+ const { container } = render(
126
+ <Separator section>
127
+ <span data-testid="custom">Custom JSX</span>
128
+ </Separator>,
129
+ )
130
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
131
+ expect(sep.querySelector('[data-testid="custom"]')).not.toBeNull()
132
+ })
133
+
134
+ it('section variant forwards className', () => {
135
+ const { container } = render(
136
+ <Separator section label="X" className="mt-4 custom" />,
137
+ )
138
+ const sep = container.querySelector('[data-slot="separator"]') as HTMLElement
139
+ expect(sep.className).toContain('mt-4')
140
+ expect(sep.className).toContain('custom')
141
+ })
142
+ })
143
+ })
@@ -0,0 +1,86 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render } from '@testing-library/react'
5
+ import { Slider } from '../slider'
6
+
7
+ describe('Slider', () => {
8
+ it('renders a single thumb when value has one entry', () => {
9
+ const { container } = render(<Slider value={[42]} onValueChange={() => {}} />)
10
+ expect(container.querySelectorAll('[data-slot="slider-thumb"]').length).toBe(1)
11
+ expect(container.querySelector('[data-slot="slider-root"]')).not.toBeNull()
12
+ expect(container.querySelector('[data-slot="slider-track"]')).not.toBeNull()
13
+ expect(container.querySelector('[data-slot="slider-range"]')).not.toBeNull()
14
+ })
15
+
16
+ it('renders two thumbs when value has two entries (range mode)', () => {
17
+ const { container } = render(<Slider value={[10, 80]} onValueChange={() => {}} />)
18
+ expect(container.querySelectorAll('[data-slot="slider-thumb"]').length).toBe(2)
19
+ })
20
+
21
+ it('falls back to a single thumb when only defaultValue is provided', () => {
22
+ const { container } = render(<Slider defaultValue={[30]} />)
23
+ expect(container.querySelectorAll('[data-slot="slider-thumb"]').length).toBe(1)
24
+ })
25
+
26
+ it('respects defaultValue with two entries (uncontrolled range)', () => {
27
+ const { container } = render(<Slider defaultValue={[20, 70]} />)
28
+ expect(container.querySelectorAll('[data-slot="slider-thumb"]').length).toBe(2)
29
+ })
30
+
31
+ it('exposes role="slider" with min/max/value via Radix', () => {
32
+ const { container } = render(
33
+ <Slider value={[25]} onValueChange={() => {}} min={0} max={100} step={1} />,
34
+ )
35
+ const thumb = container.querySelector('[role="slider"]')
36
+ expect(thumb).not.toBeNull()
37
+ expect(thumb?.getAttribute('aria-valuemin')).toBe('0')
38
+ expect(thumb?.getAttribute('aria-valuemax')).toBe('100')
39
+ expect(thumb?.getAttribute('aria-valuenow')).toBe('25')
40
+ })
41
+
42
+ it('applies horizontal-orientation classes by default', () => {
43
+ const { container } = render(<Slider value={[10]} onValueChange={() => {}} />)
44
+ const root = container.querySelector('[data-slot="slider-root"]') as HTMLElement
45
+ expect(root.className).toContain('h-5')
46
+ expect(root.className).not.toContain('flex-col')
47
+ const track = container.querySelector('[data-slot="slider-track"]') as HTMLElement
48
+ expect(track.className).toContain('h-1.5')
49
+ expect(track.className).toContain('w-full')
50
+ })
51
+
52
+ it('applies vertical-orientation classes when orientation="vertical"', () => {
53
+ const { container } = render(
54
+ <Slider value={[10]} onValueChange={() => {}} orientation="vertical" />,
55
+ )
56
+ const root = container.querySelector('[data-slot="slider-root"]') as HTMLElement
57
+ expect(root.className).toContain('flex-col')
58
+ expect(root.className).toContain('w-5')
59
+ const track = container.querySelector('[data-slot="slider-track"]') as HTMLElement
60
+ expect(track.className).toContain('w-1.5')
61
+ expect(track.className).toContain('h-full')
62
+ })
63
+
64
+ it('propagates disabled state to the root data attribute', () => {
65
+ const { container } = render(<Slider value={[10]} onValueChange={() => {}} disabled />)
66
+ const root = container.querySelector('[data-slot="slider-root"]') as HTMLElement
67
+ expect(root.getAttribute('data-disabled')).not.toBeNull()
68
+ })
69
+
70
+ it('forwards className without dropping orientation classes', () => {
71
+ const { container } = render(
72
+ <Slider value={[10]} onValueChange={() => {}} className="custom-class" />,
73
+ )
74
+ const root = container.querySelector('[data-slot="slider-root"]') as HTMLElement
75
+ expect(root.className).toContain('custom-class')
76
+ expect(root.className).toContain('h-5')
77
+ expect(root.className).toContain('touch-none')
78
+ })
79
+
80
+ it('forwards ref to the root element', () => {
81
+ const ref = React.createRef<HTMLSpanElement>()
82
+ render(<Slider ref={ref} value={[10]} onValueChange={() => {}} />)
83
+ expect(ref.current).not.toBeNull()
84
+ expect(ref.current?.getAttribute('data-slot')).toBe('slider-root')
85
+ })
86
+ })
@@ -0,0 +1,205 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render as rtlRender, fireEvent } from '@testing-library/react'
5
+ import { StepIndicator, type StepIndicatorStep } from '../step-indicator'
6
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
7
+
8
+ // StepIndicator uses useT() for the "Go to step: {label}" aria-label.
9
+ // Wrap every render in an empty-dict I18nProvider so the primitive
10
+ // falls back to its English fallback 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
+ const baseSteps: StepIndicatorStep[] = [
20
+ { id: 'account', label: 'Account', status: 'complete' },
21
+ { id: 'profile', label: 'Profile', status: 'current' },
22
+ { id: 'review', label: 'Review', status: 'pending' },
23
+ ]
24
+
25
+ describe('StepIndicator', () => {
26
+ it('renders an ordered list with one item per step', () => {
27
+ const { container, getByText } = render(<StepIndicator steps={baseSteps} />)
28
+ const root = container.querySelector('[data-slot="step-indicator"]') as HTMLElement
29
+ expect(root.tagName).toBe('OL')
30
+ expect(root.getAttribute('aria-orientation')).toBe('horizontal')
31
+ const items = container.querySelectorAll('[data-slot="step-indicator-item"]')
32
+ expect(items.length).toBe(3)
33
+ expect(getByText('Account')).toBeInTheDocument()
34
+ expect(getByText('Profile')).toBeInTheDocument()
35
+ expect(getByText('Review')).toBeInTheDocument()
36
+ })
37
+
38
+ it('marks each step item with its status via data-status', () => {
39
+ const { container } = render(<StepIndicator steps={baseSteps} />)
40
+ const items = container.querySelectorAll('[data-slot="step-indicator-item"]')
41
+ expect(items[0].getAttribute('data-status')).toBe('complete')
42
+ expect(items[1].getAttribute('data-status')).toBe('current')
43
+ expect(items[2].getAttribute('data-status')).toBe('pending')
44
+ })
45
+
46
+ it('horizontal: places a ChevronRight connector between each pair of items', () => {
47
+ const { container } = render(<StepIndicator steps={baseSteps} />)
48
+ const connectors = container.querySelectorAll('[data-slot="step-indicator-connector"]')
49
+ // 3 items → 2 connectors (no trailing chevron after the last item)
50
+ expect(connectors.length).toBe(2)
51
+ })
52
+
53
+ it('horizontal: does NOT render a chevron after the last item', () => {
54
+ const { container } = render(
55
+ <StepIndicator
56
+ steps={[
57
+ { id: 'a', label: 'A', status: 'complete' },
58
+ { id: 'b', label: 'B', status: 'current' },
59
+ ]}
60
+ />,
61
+ )
62
+ expect(container.querySelectorAll('[data-slot="step-indicator-connector"]').length).toBe(1)
63
+ })
64
+
65
+ it('renders the complete dot with a Check icon (svg child)', () => {
66
+ const { container } = render(<StepIndicator steps={baseSteps} />)
67
+ const completeDot = container.querySelector(
68
+ '[data-slot="step-indicator-dot"][data-status="complete"]',
69
+ ) as HTMLElement
70
+ expect(completeDot.querySelector('svg')).not.toBeNull()
71
+ })
72
+
73
+ it('renders the current dot as a solid coloured circle (no inner glyph)', () => {
74
+ const { container } = render(<StepIndicator steps={baseSteps} />)
75
+ const currentDot = container.querySelector(
76
+ '[data-slot="step-indicator-dot"][data-status="current"]',
77
+ ) as HTMLElement
78
+ // No Check / X icon for current — just the coloured dot
79
+ expect(currentDot.querySelector('svg')).toBeNull()
80
+ expect(currentDot.className).toContain('bg-accent-indigo')
81
+ })
82
+
83
+ it('renders the pending dot as an outlined circle (no glyph, transparent bg)', () => {
84
+ const { container } = render(<StepIndicator steps={baseSteps} />)
85
+ const pendingDot = container.querySelector(
86
+ '[data-slot="step-indicator-dot"][data-status="pending"]',
87
+ ) as HTMLElement
88
+ expect(pendingDot.querySelector('svg')).toBeNull()
89
+ expect(pendingDot.className).toContain('border')
90
+ expect(pendingDot.className).toContain('bg-background')
91
+ })
92
+
93
+ it('renders the error dot with an X icon and status-error bg', () => {
94
+ const { container } = render(
95
+ <StepIndicator
96
+ steps={[
97
+ { id: 'a', label: 'A', status: 'complete' },
98
+ { id: 'b', label: 'B', status: 'error' },
99
+ { id: 'c', label: 'C', status: 'pending' },
100
+ ]}
101
+ />,
102
+ )
103
+ const errorDot = container.querySelector(
104
+ '[data-slot="step-indicator-dot"][data-status="error"]',
105
+ ) as HTMLElement
106
+ expect(errorDot.querySelector('svg')).not.toBeNull()
107
+ expect(errorDot.className).toContain('bg-status-error-icon')
108
+ })
109
+
110
+ it('marks the current step with aria-current="step"', () => {
111
+ const { container } = render(<StepIndicator steps={baseSteps} />)
112
+ const currentDot = container.querySelector(
113
+ '[data-slot="step-indicator-dot"][data-status="current"]',
114
+ ) as HTMLElement
115
+ expect(currentDot.getAttribute('aria-current')).toBe('step')
116
+ const pendingDot = container.querySelector(
117
+ '[data-slot="step-indicator-dot"][data-status="pending"]',
118
+ ) as HTMLElement
119
+ expect(pendingDot.getAttribute('aria-current')).toBeNull()
120
+ })
121
+
122
+ describe('vertical orientation', () => {
123
+ it('switches aria-orientation + flex-col on the root', () => {
124
+ const { container } = render(<StepIndicator steps={baseSteps} orientation="vertical" />)
125
+ const root = container.querySelector('[data-slot="step-indicator"]') as HTMLElement
126
+ expect(root.getAttribute('aria-orientation')).toBe('vertical')
127
+ expect(root.className).toContain('flex-col')
128
+ })
129
+
130
+ it('does NOT render chevron connectors between items (vertical = pill per item)', () => {
131
+ const { container } = render(<StepIndicator steps={baseSteps} orientation="vertical" />)
132
+ expect(container.querySelectorAll('[data-slot="step-indicator-connector"]').length).toBe(0)
133
+ })
134
+
135
+ it('renders the optional description below the label', () => {
136
+ const { getByText } = render(
137
+ <StepIndicator
138
+ steps={[{ id: 'a', label: 'Step A', description: 'Sub-text', status: 'current' }]}
139
+ orientation="vertical"
140
+ />,
141
+ )
142
+ expect(getByText('Step A')).toBeInTheDocument()
143
+ expect(getByText('Sub-text')).toBeInTheDocument()
144
+ })
145
+ })
146
+
147
+ describe('interactive mode', () => {
148
+ it('renders buttons for complete + current steps when onStepClick is provided', () => {
149
+ const onStepClick = jest.fn()
150
+ const { container } = render(
151
+ <StepIndicator steps={baseSteps} onStepClick={onStepClick} />,
152
+ )
153
+ const buttons = container.querySelectorAll('button')
154
+ // complete (1) + current (1) by default — pending is NOT clickable
155
+ expect(buttons.length).toBe(2)
156
+ })
157
+
158
+ it('fires onStepClick with the step id', () => {
159
+ const onStepClick = jest.fn()
160
+ const { container } = render(
161
+ <StepIndicator steps={baseSteps} onStepClick={onStepClick} />,
162
+ )
163
+ const buttons = container.querySelectorAll('button')
164
+ fireEvent.click(buttons[0])
165
+ expect(onStepClick).toHaveBeenCalledWith('account')
166
+ })
167
+
168
+ it('honors clickableStatuses to widen the click target', () => {
169
+ const onStepClick = jest.fn()
170
+ const { container } = render(
171
+ <StepIndicator
172
+ steps={baseSteps}
173
+ onStepClick={onStepClick}
174
+ clickableStatuses={['complete', 'current', 'pending']}
175
+ />,
176
+ )
177
+ expect(container.querySelectorAll('button').length).toBe(3)
178
+ })
179
+
180
+ it('does NOT render buttons when onStepClick is omitted', () => {
181
+ const { container } = render(<StepIndicator steps={baseSteps} />)
182
+ expect(container.querySelectorAll('button').length).toBe(0)
183
+ })
184
+ })
185
+
186
+ describe('size variants', () => {
187
+ it('default size applies size-5 to the dot', () => {
188
+ const { container } = render(<StepIndicator steps={baseSteps} />)
189
+ const dot = container.querySelector('[data-slot="step-indicator-dot"]') as HTMLElement
190
+ expect(dot.className).toContain('size-5')
191
+ })
192
+
193
+ it('sm size applies size-4', () => {
194
+ const { container } = render(<StepIndicator steps={baseSteps} size="sm" />)
195
+ const dot = container.querySelector('[data-slot="step-indicator-dot"]') as HTMLElement
196
+ expect(dot.className).toContain('size-4')
197
+ })
198
+ })
199
+
200
+ it('forwards ref to the root <ol> element', () => {
201
+ const ref = React.createRef<HTMLOListElement>()
202
+ render(<StepIndicator ref={ref} steps={baseSteps} />)
203
+ expect(ref.current?.getAttribute('data-slot')).toBe('step-indicator')
204
+ })
205
+ })