@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,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
|
+
})
|