@linktr.ee/messaging-react 4.1.7-rc-1787194909 → 4.1.7-rc-1787202056
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/package.json +2 -2
- package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +182 -0
- package/src/components/MessageAttachment/_shared/Bubble.test.tsx +77 -0
- package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +36 -0
- package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +116 -0
- package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +35 -0
- package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +149 -0
- package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +52 -0
- package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +69 -0
- package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +45 -0
- package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +55 -0
- package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +246 -0
- package/src/components/MessageAttachment/_shared/fileMeta.test.ts +44 -0
- package/src/components/MessageAttachment/_shared/triggerDownload.test.ts +113 -0
- package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +68 -0
- package/src/components/MessageAttachment/_shared/viewerChromeClasses.test.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linktr.ee/messaging-react",
|
|
3
|
-
"version": "4.1.7-rc-
|
|
3
|
+
"version": "4.1.7-rc-1787202056",
|
|
4
4
|
"description": "React messaging components built on messaging-core for web applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"chromatic:local": "yarn storybook:build && chromatic"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@linktr.ee/messaging-core": "2.5.0-rc-
|
|
53
|
+
"@linktr.ee/messaging-core": "2.5.0-rc-1787202056",
|
|
54
54
|
"@linktr.ee/messaging-taxonomy": "^0.10.0",
|
|
55
55
|
"@phosphor-icons/react": "^2.1.10"
|
|
56
56
|
},
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../test/utils'
|
|
4
|
+
|
|
5
|
+
import { triggerDownload } from './_shared/triggerDownload'
|
|
6
|
+
import { bubbleVariantForState, bubbleGroupPositionFromStream } from './types'
|
|
7
|
+
|
|
8
|
+
import MessageAttachment from '.'
|
|
9
|
+
|
|
10
|
+
vi.mock('./_shared/triggerDownload', () => ({
|
|
11
|
+
triggerDownload: vi.fn(() => Promise.resolve()),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const mockedDownload = vi.mocked(triggerDownload)
|
|
15
|
+
|
|
16
|
+
describe('MessageAttachment behavior', () => {
|
|
17
|
+
it('renders file rows with precedence, fallback names, and cancellation', () => {
|
|
18
|
+
const onClick = vi.fn(() => false)
|
|
19
|
+
const { rerender } = renderWithProviders(
|
|
20
|
+
<MessageAttachment.File.Sent
|
|
21
|
+
src="https://cdn.example.com/fallback.txt"
|
|
22
|
+
filename="fallback.txt"
|
|
23
|
+
items={[
|
|
24
|
+
{
|
|
25
|
+
src: 'https://cdn.example.com/first.pdf',
|
|
26
|
+
filename: 'first.pdf',
|
|
27
|
+
},
|
|
28
|
+
{ src: 'https://cdn.example.com/second.pdf' },
|
|
29
|
+
]}
|
|
30
|
+
onClick={onClick}
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
expect(screen.getByText('first.pdf')).toBeInTheDocument()
|
|
34
|
+
expect(screen.getByText('second.pdf')).toBeInTheDocument()
|
|
35
|
+
fireEvent.click(screen.getByRole('button', { name: 'Download first.pdf' }))
|
|
36
|
+
expect(onClick).toHaveBeenCalledWith(0)
|
|
37
|
+
expect(mockedDownload).not.toHaveBeenCalled()
|
|
38
|
+
|
|
39
|
+
rerender(
|
|
40
|
+
<MessageAttachment.File.Sent src="https://cdn.example.com/fallback.txt" />
|
|
41
|
+
)
|
|
42
|
+
fireEvent.click(
|
|
43
|
+
screen.getByRole('button', { name: 'Download fallback.txt' })
|
|
44
|
+
)
|
|
45
|
+
expect(mockedDownload).toHaveBeenCalledWith(
|
|
46
|
+
'https://cdn.example.com/fallback.txt',
|
|
47
|
+
'fallback.txt'
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders no file when inputs are absent and supports composer dismissal', () => {
|
|
52
|
+
const { container } = renderWithProviders(
|
|
53
|
+
<MessageAttachment.File.Received />
|
|
54
|
+
)
|
|
55
|
+
expect(container.firstChild).toBeNull()
|
|
56
|
+
const onDismiss = vi.fn()
|
|
57
|
+
renderWithProviders(
|
|
58
|
+
<MessageAttachment.File.Composer
|
|
59
|
+
src="https://cdn.example.com/file"
|
|
60
|
+
onDismiss={onDismiss}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
|
|
64
|
+
expect(onDismiss).toHaveBeenCalledOnce()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders image stacks, forwards loading, and supports click cancellation', () => {
|
|
68
|
+
const onClick = vi.fn(() => false)
|
|
69
|
+
renderWithProviders(
|
|
70
|
+
<MessageAttachment.Image.Sent
|
|
71
|
+
src="https://cdn.example.com/fallback.jpg"
|
|
72
|
+
items={[
|
|
73
|
+
{ src: 'https://cdn.example.com/one.jpg' },
|
|
74
|
+
{ src: 'https://cdn.example.com/two.jpg', loading: 'eager' },
|
|
75
|
+
]}
|
|
76
|
+
loading="lazy"
|
|
77
|
+
onClick={onClick}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
expect(screen.getAllByRole('img')[0]).toHaveAttribute('loading', 'lazy')
|
|
81
|
+
expect(screen.getAllByRole('img')).toHaveLength(2)
|
|
82
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open image 2 of 2' }))
|
|
83
|
+
expect(onClick).toHaveBeenCalledWith(1)
|
|
84
|
+
expect(screen.queryByTestId('image-viewer')).not.toHaveAttribute('open')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('opens images at the clicked index and suffixes a shared filename', () => {
|
|
88
|
+
renderWithProviders(
|
|
89
|
+
<MessageAttachment.Image.Sent
|
|
90
|
+
filename="photos.jpg"
|
|
91
|
+
items={[
|
|
92
|
+
{ src: 'https://cdn.example.com/one.jpg' },
|
|
93
|
+
{ src: 'https://cdn.example.com/two.jpg' },
|
|
94
|
+
]}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open image 2 of 2' }))
|
|
98
|
+
expect(screen.getByTestId('image-viewer')).toHaveAttribute(
|
|
99
|
+
'aria-label',
|
|
100
|
+
'photos.jpg (2)'
|
|
101
|
+
)
|
|
102
|
+
expect(screen.getByRole('img', { name: 'photos.jpg (2)' })).toHaveAttribute(
|
|
103
|
+
'src',
|
|
104
|
+
'https://cdn.example.com/two.jpg'
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('renders video poster and fallback, and wires viewer attributes', () => {
|
|
109
|
+
const onDismiss = vi.fn()
|
|
110
|
+
const { rerender } = renderWithProviders(
|
|
111
|
+
<MessageAttachment.Video.Composer
|
|
112
|
+
src="https://cdn.example.com/video.mp4"
|
|
113
|
+
poster="/poster.jpg"
|
|
114
|
+
mimeType="video/mp4"
|
|
115
|
+
preload="auto"
|
|
116
|
+
onDismiss={onDismiss}
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
119
|
+
expect(
|
|
120
|
+
screen.getByRole('img', { name: 'Video 1 thumbnail' })
|
|
121
|
+
).toHaveAttribute('src', '/poster.jpg')
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
|
|
123
|
+
expect(onDismiss).toHaveBeenCalledOnce()
|
|
124
|
+
rerender(
|
|
125
|
+
<MessageAttachment.Video.Sent
|
|
126
|
+
items={[{ src: 'https://cdn.example.com/video.mp4' }]}
|
|
127
|
+
/>
|
|
128
|
+
)
|
|
129
|
+
expect(screen.getByLabelText('Play video 1 of 1')).toBeInTheDocument()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('supports PDF titles, download actions, dismiss, and viewer index', () => {
|
|
133
|
+
const onDismiss = vi.fn()
|
|
134
|
+
renderWithProviders(
|
|
135
|
+
<MessageAttachment.Pdf.Composer
|
|
136
|
+
src="https://cdn.example.com/report.pdf"
|
|
137
|
+
title="Quarterly report"
|
|
138
|
+
onDismiss={onDismiss}
|
|
139
|
+
/>
|
|
140
|
+
)
|
|
141
|
+
expect(screen.getByText('Quarterly report')).toBeInTheDocument()
|
|
142
|
+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
|
|
143
|
+
expect(onDismiss).toHaveBeenCalledOnce()
|
|
144
|
+
|
|
145
|
+
renderWithProviders(
|
|
146
|
+
<MessageAttachment.Pdf.Sent
|
|
147
|
+
items={[
|
|
148
|
+
{ src: 'https://cdn.example.com/one.pdf' },
|
|
149
|
+
{ src: 'https://cdn.example.com/two.pdf', filename: 'two.pdf' },
|
|
150
|
+
]}
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open two.pdf' }))
|
|
154
|
+
expect(screen.getAllByTestId('pdf-viewer').at(-1)).toHaveAttribute(
|
|
155
|
+
'aria-label',
|
|
156
|
+
'two.pdf'
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('maps state and stream grouping contracts', () => {
|
|
161
|
+
expect(bubbleVariantForState('composer')).toBe('dark')
|
|
162
|
+
expect(bubbleVariantForState('sent')).toBe('dark')
|
|
163
|
+
expect(bubbleVariantForState('received')).toBe('light')
|
|
164
|
+
expect(bubbleGroupPositionFromStream({})).toBe('single')
|
|
165
|
+
expect(
|
|
166
|
+
bubbleGroupPositionFromStream({
|
|
167
|
+
groupedByUser: true,
|
|
168
|
+
firstOfGroup: true,
|
|
169
|
+
endOfGroup: true,
|
|
170
|
+
})
|
|
171
|
+
).toBe('single')
|
|
172
|
+
expect(
|
|
173
|
+
bubbleGroupPositionFromStream({ groupedByUser: true, firstOfGroup: true })
|
|
174
|
+
).toBe('first')
|
|
175
|
+
expect(
|
|
176
|
+
bubbleGroupPositionFromStream({ groupedByUser: true, endOfGroup: true })
|
|
177
|
+
).toBe('end')
|
|
178
|
+
expect(bubbleGroupPositionFromStream({ groupedByUser: true })).toBe(
|
|
179
|
+
'middle'
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import Bubble from './Bubble'
|
|
6
|
+
|
|
7
|
+
describe('Bubble', () => {
|
|
8
|
+
it.each([
|
|
9
|
+
[undefined, false],
|
|
10
|
+
[null, false],
|
|
11
|
+
['', false],
|
|
12
|
+
['caption', true],
|
|
13
|
+
[0, true],
|
|
14
|
+
])('renders caption %j according to its value', (text, present) => {
|
|
15
|
+
renderWithProviders(
|
|
16
|
+
<Bubble variant="dark" text={text} data-testid="bubble">
|
|
17
|
+
<span>child</span>
|
|
18
|
+
</Bubble>
|
|
19
|
+
)
|
|
20
|
+
const bubble = screen.getByTestId('bubble')
|
|
21
|
+
if (present) expect(bubble.querySelector('p')).not.toBeNull()
|
|
22
|
+
else expect(bubble.querySelector('p')).toBeNull()
|
|
23
|
+
expect(bubble).toHaveClass(present ? 'pb-0' : 'py-2')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('maps variants, group positions, borders, and forwarded content', () => {
|
|
27
|
+
const { rerender } = renderWithProviders(
|
|
28
|
+
<Bubble
|
|
29
|
+
variant="dark"
|
|
30
|
+
groupPosition="middle"
|
|
31
|
+
data-testid="bubble"
|
|
32
|
+
className="custom"
|
|
33
|
+
>
|
|
34
|
+
child
|
|
35
|
+
</Bubble>
|
|
36
|
+
)
|
|
37
|
+
let bubble = screen.getByTestId('bubble')
|
|
38
|
+
expect(bubble).toHaveClass('bg-[#1e2330]', 'text-white', 'border')
|
|
39
|
+
expect(bubble).toHaveClass('rounded-tr-[4px]', 'rounded-br-[4px]')
|
|
40
|
+
expect(bubble).toHaveAttribute('data-group-position', 'middle')
|
|
41
|
+
expect(bubble).toHaveClass('custom')
|
|
42
|
+
expect(bubble).toHaveTextContent('child')
|
|
43
|
+
|
|
44
|
+
rerender(
|
|
45
|
+
<Bubble
|
|
46
|
+
variant="light"
|
|
47
|
+
bordered={false}
|
|
48
|
+
groupPosition="first"
|
|
49
|
+
data-testid="bubble"
|
|
50
|
+
>
|
|
51
|
+
child
|
|
52
|
+
</Bubble>
|
|
53
|
+
)
|
|
54
|
+
bubble = screen.getByTestId('bubble')
|
|
55
|
+
expect(bubble).toHaveClass('bg-[#f2f1ef]', 'text-[#000000]')
|
|
56
|
+
expect(bubble).not.toHaveClass('border', 'border-black/[0.08]')
|
|
57
|
+
expect(bubble).toHaveClass('rounded-bl-[4px]')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('lets an explicit radius replace the group corner table', () => {
|
|
61
|
+
renderWithProviders(
|
|
62
|
+
<Bubble
|
|
63
|
+
variant="light"
|
|
64
|
+
groupPosition="middle"
|
|
65
|
+
radiusClassName="rounded-[20px]"
|
|
66
|
+
widthClassName="w-fit"
|
|
67
|
+
paddingClassName="p-0.5"
|
|
68
|
+
data-testid="bubble"
|
|
69
|
+
>
|
|
70
|
+
child
|
|
71
|
+
</Bubble>
|
|
72
|
+
)
|
|
73
|
+
const bubble = screen.getByTestId('bubble')
|
|
74
|
+
expect(bubble).toHaveClass('rounded-[20px]', 'w-fit', 'p-0.5')
|
|
75
|
+
expect(bubble).not.toHaveClass('rounded-tl-[4px]')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import CarouselNav from './CarouselNav'
|
|
6
|
+
import { VIEWER_NAV_CLASS } from './viewerChromeClasses'
|
|
7
|
+
|
|
8
|
+
describe('CarouselNav', () => {
|
|
9
|
+
it('uses default labels and independent handlers', () => {
|
|
10
|
+
const onPrev = vi.fn()
|
|
11
|
+
const onNext = vi.fn()
|
|
12
|
+
renderWithProviders(<CarouselNav onPrev={onPrev} onNext={onNext} />)
|
|
13
|
+
|
|
14
|
+
const prev = screen.getByRole('button', { name: 'Previous' })
|
|
15
|
+
const next = screen.getByRole('button', { name: 'Next' })
|
|
16
|
+
expect(prev).toHaveClass(VIEWER_NAV_CLASS, 'left-4')
|
|
17
|
+
expect(next).toHaveClass(VIEWER_NAV_CLASS, 'right-4')
|
|
18
|
+
fireEvent.click(prev)
|
|
19
|
+
fireEvent.click(next)
|
|
20
|
+
expect(onPrev).toHaveBeenCalledOnce()
|
|
21
|
+
expect(onNext).toHaveBeenCalledOnce()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('supports custom accessible labels', () => {
|
|
25
|
+
renderWithProviders(
|
|
26
|
+
<CarouselNav
|
|
27
|
+
onPrev={vi.fn()}
|
|
28
|
+
onNext={vi.fn()}
|
|
29
|
+
prevLabel="Previous image"
|
|
30
|
+
nextLabel="Next image"
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Previous image' })).toBeVisible()
|
|
34
|
+
expect(screen.getByRole('button', { name: 'Next image' })).toBeVisible()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import CompactDocumentRow from './CompactDocumentRow'
|
|
6
|
+
|
|
7
|
+
describe('CompactDocumentRow', () => {
|
|
8
|
+
it('resolves title from title, filename, then File', () => {
|
|
9
|
+
const { rerender } = renderWithProviders(
|
|
10
|
+
<CompactDocumentRow variant="dark" title="Custom" filename="name.pdf" />
|
|
11
|
+
)
|
|
12
|
+
expect(screen.getByText('Custom')).toBeInTheDocument()
|
|
13
|
+
rerender(<CompactDocumentRow variant="dark" filename="name.pdf" />)
|
|
14
|
+
expect(screen.getByText('name.pdf')).toBeInTheDocument()
|
|
15
|
+
rerender(<CompactDocumentRow variant="dark" />)
|
|
16
|
+
expect(screen.getByText('File')).toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('renders metadata only when it has a value and defaults MIME type', () => {
|
|
20
|
+
const { container, rerender } = renderWithProviders(
|
|
21
|
+
<CompactDocumentRow
|
|
22
|
+
variant="dark"
|
|
23
|
+
filename="report.pdf"
|
|
24
|
+
fileSize={1024}
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
expect(screen.getByText('PDF · 1.0 KB')).toBeInTheDocument()
|
|
28
|
+
rerender(
|
|
29
|
+
<CompactDocumentRow
|
|
30
|
+
variant="dark"
|
|
31
|
+
filename="report"
|
|
32
|
+
mimeType="application/octet-stream"
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
expect(screen.queryByText(/·/)).toBeNull()
|
|
36
|
+
const genericIconMarkup = container.querySelector('svg')?.innerHTML
|
|
37
|
+
rerender(
|
|
38
|
+
<CompactDocumentRow
|
|
39
|
+
variant="dark"
|
|
40
|
+
filename="report.pdf"
|
|
41
|
+
mimeType="application/pdf"
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
expect(container.querySelector('svg')?.innerHTML).not.toBe(
|
|
45
|
+
genericIconMarkup
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('maps variant classes across metadata, icon, and activation', () => {
|
|
50
|
+
const onActivate = vi.fn()
|
|
51
|
+
const { container, rerender } = renderWithProviders(
|
|
52
|
+
<CompactDocumentRow
|
|
53
|
+
variant="dark"
|
|
54
|
+
filename="report.pdf"
|
|
55
|
+
onActivate={onActivate}
|
|
56
|
+
activateLabel="Open report"
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
expect(screen.getByText('PDF')).toHaveClass('text-white/55')
|
|
60
|
+
const darkIconTile = container.querySelector('div[aria-hidden]')
|
|
61
|
+
expect(darkIconTile).toHaveClass('bg-white/10')
|
|
62
|
+
expect(darkIconTile?.querySelector('svg')).toHaveClass(
|
|
63
|
+
'size-6',
|
|
64
|
+
'text-white/85'
|
|
65
|
+
)
|
|
66
|
+
expect(screen.getByRole('button', { name: 'Open report' })).toHaveClass(
|
|
67
|
+
'hover:bg-white/[0.04]'
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
rerender(
|
|
71
|
+
<CompactDocumentRow
|
|
72
|
+
variant="light"
|
|
73
|
+
filename="report.pdf"
|
|
74
|
+
onActivate={onActivate}
|
|
75
|
+
activateLabel="Open report"
|
|
76
|
+
/>
|
|
77
|
+
)
|
|
78
|
+
expect(screen.getByText('PDF')).toHaveClass('text-black/65')
|
|
79
|
+
const lightIconTile = container.querySelector('div[aria-hidden]')
|
|
80
|
+
expect(lightIconTile).toHaveClass('bg-black/5')
|
|
81
|
+
expect(lightIconTile?.querySelector('svg')).toHaveClass(
|
|
82
|
+
'size-6',
|
|
83
|
+
'text-black/85'
|
|
84
|
+
)
|
|
85
|
+
expect(screen.getByRole('button', { name: 'Open report' })).toHaveClass(
|
|
86
|
+
'hover:bg-black/[0.04]'
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('activates its body without nesting the trailing action', () => {
|
|
91
|
+
const onActivate = vi.fn()
|
|
92
|
+
renderWithProviders(
|
|
93
|
+
<CompactDocumentRow
|
|
94
|
+
variant="dark"
|
|
95
|
+
filename="report.pdf"
|
|
96
|
+
onActivate={onActivate}
|
|
97
|
+
activateLabel="Open report"
|
|
98
|
+
trailingAction={<button type="button">Download</button>}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
const body = screen.getByRole('button', { name: 'Open report' })
|
|
102
|
+
expect(body).toBeInTheDocument()
|
|
103
|
+
expect(body).not.toContainElement(
|
|
104
|
+
screen.getByRole('button', { name: 'Download' })
|
|
105
|
+
)
|
|
106
|
+
fireEvent.click(body)
|
|
107
|
+
expect(onActivate).toHaveBeenCalledOnce()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('renders a plain body when activation is omitted', () => {
|
|
111
|
+
renderWithProviders(
|
|
112
|
+
<CompactDocumentRow variant="dark" filename="report.pdf" />
|
|
113
|
+
)
|
|
114
|
+
expect(screen.queryByRole('button', { name: /report/ })).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import DismissButton from './DismissButton'
|
|
6
|
+
|
|
7
|
+
describe('DismissButton', () => {
|
|
8
|
+
it('uses the default label, invokes the handler, and stops propagation', () => {
|
|
9
|
+
const onClick = vi.fn()
|
|
10
|
+
const outerClick = vi.fn()
|
|
11
|
+
renderWithProviders(
|
|
12
|
+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
|
13
|
+
<div onClick={outerClick}>
|
|
14
|
+
<DismissButton onClick={onClick} />
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
|
|
19
|
+
expect(onClick).toHaveBeenCalledOnce()
|
|
20
|
+
expect(outerClick).not.toHaveBeenCalled()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('supports a custom label and inline styling', () => {
|
|
24
|
+
renderWithProviders(
|
|
25
|
+
<DismissButton
|
|
26
|
+
onClick={vi.fn()}
|
|
27
|
+
ariaLabel="Remove file"
|
|
28
|
+
variant="inline"
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
const button = screen.getByRole('button', { name: 'Remove file' })
|
|
32
|
+
expect(button).toHaveClass('bg-white/15')
|
|
33
|
+
expect(button).not.toHaveClass('bg-[#121110]/85')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
fireEvent,
|
|
5
|
+
renderWithProviders,
|
|
6
|
+
screen,
|
|
7
|
+
waitFor,
|
|
8
|
+
} from '../../../test/utils'
|
|
9
|
+
|
|
10
|
+
import DownloadAction from './DownloadAction'
|
|
11
|
+
import { triggerDownload } from './triggerDownload'
|
|
12
|
+
import { VIEWER_ACTION_CLASS } from './viewerChromeClasses'
|
|
13
|
+
|
|
14
|
+
vi.mock('./triggerDownload', () => ({
|
|
15
|
+
triggerDownload: vi.fn(() => Promise.resolve()),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const mockedTriggerDownload = vi.mocked(triggerDownload)
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mockedTriggerDownload.mockReset()
|
|
22
|
+
mockedTriggerDownload.mockResolvedValue(undefined)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('DownloadAction', () => {
|
|
26
|
+
it('uses a visible label for the default pill variant', () => {
|
|
27
|
+
renderWithProviders(<DownloadAction url="/file.pdf" />)
|
|
28
|
+
const button = screen.getByRole('button', { name: 'Download' })
|
|
29
|
+
expect(button).toHaveTextContent('Download')
|
|
30
|
+
expect(button).not.toHaveAttribute('aria-label')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it.each(['inline', 'viewer'] as const)(
|
|
34
|
+
'uses an accessible-only label for the %s variant',
|
|
35
|
+
(variant) => {
|
|
36
|
+
renderWithProviders(
|
|
37
|
+
<DownloadAction url="/file.pdf" variant={variant} label="Save file" />
|
|
38
|
+
)
|
|
39
|
+
const button = screen.getByRole('button', { name: 'Save file' })
|
|
40
|
+
expect(button).not.toHaveTextContent('Save file')
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
it('allows iconOnly to override the pill default', () => {
|
|
45
|
+
const { rerender } = renderWithProviders(
|
|
46
|
+
<DownloadAction url="/file.pdf" label="Save" iconOnly />
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getByRole('button', { name: 'Save' })).not.toHaveTextContent(
|
|
49
|
+
'Save'
|
|
50
|
+
)
|
|
51
|
+
rerender(
|
|
52
|
+
<DownloadAction
|
|
53
|
+
url="/file.pdf"
|
|
54
|
+
variant="viewer"
|
|
55
|
+
label="Save"
|
|
56
|
+
iconOnly={false}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
expect(screen.getByRole('button', { name: 'Save' })).not.toHaveTextContent(
|
|
60
|
+
'Save'
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('stops propagation and calls the download once', async () => {
|
|
65
|
+
const outerClick = vi.fn()
|
|
66
|
+
renderWithProviders(
|
|
67
|
+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
|
68
|
+
<div onClick={outerClick}>
|
|
69
|
+
<DownloadAction
|
|
70
|
+
url="/file.pdf"
|
|
71
|
+
filename="report.pdf"
|
|
72
|
+
onTriggered={vi.fn()}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: 'Download' }))
|
|
77
|
+
await waitFor(() =>
|
|
78
|
+
expect(mockedTriggerDownload).toHaveBeenCalledWith(
|
|
79
|
+
'/file.pdf',
|
|
80
|
+
'report.pdf'
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
expect(mockedTriggerDownload).toHaveBeenCalledOnce()
|
|
84
|
+
expect(outerClick).not.toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('disables repeated clicks while downloading and settles on success', async () => {
|
|
88
|
+
let resolve: () => void = () => undefined
|
|
89
|
+
mockedTriggerDownload.mockReturnValueOnce(
|
|
90
|
+
new Promise<void>((done) => {
|
|
91
|
+
resolve = done
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
const onTriggered = vi.fn()
|
|
95
|
+
renderWithProviders(
|
|
96
|
+
<DownloadAction url="/file.pdf" onTriggered={onTriggered} />
|
|
97
|
+
)
|
|
98
|
+
const button = screen.getByRole('button', { name: 'Download' })
|
|
99
|
+
fireEvent.click(button)
|
|
100
|
+
fireEvent.click(button)
|
|
101
|
+
expect(button).toBeDisabled()
|
|
102
|
+
expect(mockedTriggerDownload).toHaveBeenCalledOnce()
|
|
103
|
+
resolve()
|
|
104
|
+
await waitFor(() => expect(button).toBeEnabled())
|
|
105
|
+
expect(onTriggered).toHaveBeenCalledOnce()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('settles and notifies even when downloading rejects', async () => {
|
|
109
|
+
mockedTriggerDownload.mockRejectedValueOnce(new Error('offline'))
|
|
110
|
+
const onTriggered = vi.fn()
|
|
111
|
+
renderWithProviders(
|
|
112
|
+
<DownloadAction url="/file.pdf" onTriggered={onTriggered} />
|
|
113
|
+
)
|
|
114
|
+
const button = screen.getByRole('button', { name: 'Download' })
|
|
115
|
+
fireEvent.click(button)
|
|
116
|
+
await waitFor(() => expect(button).toBeEnabled())
|
|
117
|
+
expect(onTriggered).toHaveBeenCalledOnce()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('maps tone classes for pill and inline variants', () => {
|
|
121
|
+
const { rerender } = renderWithProviders(
|
|
122
|
+
<DownloadAction url="/file.pdf" tone="dark" />
|
|
123
|
+
)
|
|
124
|
+
expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
|
|
125
|
+
'bg-[#121110]',
|
|
126
|
+
'text-white'
|
|
127
|
+
)
|
|
128
|
+
rerender(<DownloadAction url="/file.pdf" tone="light" />)
|
|
129
|
+
expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
|
|
130
|
+
'bg-white',
|
|
131
|
+
'text-[#121110]'
|
|
132
|
+
)
|
|
133
|
+
rerender(<DownloadAction url="/file.pdf" variant="inline" tone="dark" />)
|
|
134
|
+
expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
|
|
135
|
+
'text-white/70'
|
|
136
|
+
)
|
|
137
|
+
rerender(<DownloadAction url="/file.pdf" variant="inline" tone="light" />)
|
|
138
|
+
expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
|
|
139
|
+
'text-black/70'
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('uses the shared viewer action class', () => {
|
|
144
|
+
renderWithProviders(<DownloadAction url="/file.pdf" variant="viewer" />)
|
|
145
|
+
expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
|
|
146
|
+
VIEWER_ACTION_CLASS
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import ImageViewer from './ImageViewer'
|
|
6
|
+
|
|
7
|
+
describe('ImageViewer', () => {
|
|
8
|
+
const items = [
|
|
9
|
+
{ src: 'https://cdn.example.com/one.jpg', alt: 'One' },
|
|
10
|
+
{ src: 'https://cdn.example.com/two.jpg', filename: 'two.png' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
it('renders nothing without an active item', () => {
|
|
14
|
+
const { container } = renderWithProviders(
|
|
15
|
+
<ImageViewer open onClose={vi.fn()} items={[]} />
|
|
16
|
+
)
|
|
17
|
+
expect(container.firstChild).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('gates the image on open and resolves filename labels', () => {
|
|
21
|
+
const { rerender } = renderWithProviders(
|
|
22
|
+
<ImageViewer open={false} onClose={vi.fn()} items={[items[0]]} />
|
|
23
|
+
)
|
|
24
|
+
expect(screen.queryByRole('img')).toBeNull()
|
|
25
|
+
expect(screen.getByTestId('image-viewer')).toHaveAttribute(
|
|
26
|
+
'aria-label',
|
|
27
|
+
'one.jpg'
|
|
28
|
+
)
|
|
29
|
+
expect(
|
|
30
|
+
screen
|
|
31
|
+
.getByTestId('image-viewer')
|
|
32
|
+
.querySelector('button[aria-label="Download one.jpg"]')
|
|
33
|
+
).toBeInTheDocument()
|
|
34
|
+
rerender(<ImageViewer open onClose={vi.fn()} items={[items[0]]} />)
|
|
35
|
+
expect(screen.getByRole('img')).toHaveAttribute('alt', 'One')
|
|
36
|
+
expect(screen.getByRole('img')).toHaveAttribute('draggable', 'false')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('renders counter and navigation only for multiple items', () => {
|
|
40
|
+
renderWithProviders(
|
|
41
|
+
<ImageViewer open onClose={vi.fn()} items={items} initialIndex={99} />
|
|
42
|
+
)
|
|
43
|
+
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
|
44
|
+
expect(
|
|
45
|
+
screen.getByRole('button', { name: 'Previous image' })
|
|
46
|
+
).toBeInTheDocument()
|
|
47
|
+
expect(screen.getByRole('img')).toHaveAttribute('src', items[1].src)
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: 'Previous image' }))
|
|
49
|
+
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
|
50
|
+
expect(screen.getByRole('img')).toHaveAttribute('src', items[0].src)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import MediaStackGrid from './MediaStackGrid'
|
|
6
|
+
|
|
7
|
+
const tiles = (count: number) =>
|
|
8
|
+
Array.from({ length: count }, (_, index) => ({
|
|
9
|
+
content: <span>tile {index + 1}</span>,
|
|
10
|
+
ariaLabel: index === 1 ? 'Second tile' : undefined,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
describe('MediaStackGrid', () => {
|
|
14
|
+
it('renders nothing for empty tiles', () => {
|
|
15
|
+
const { container } = renderWithProviders(<MediaStackGrid tiles={[]} />)
|
|
16
|
+
expect(container.firstChild).toBeNull()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it.each([
|
|
20
|
+
[1, 'aspect-square'],
|
|
21
|
+
[2, 'aspect-[16/9]'],
|
|
22
|
+
[3, 'aspect-[4/3]'],
|
|
23
|
+
[4, 'aspect-[379/286]'],
|
|
24
|
+
])('renders the %s-tile layout', (count, layoutClass) => {
|
|
25
|
+
const { container } = renderWithProviders(
|
|
26
|
+
<MediaStackGrid tiles={tiles(count)} className="custom-grid" />
|
|
27
|
+
)
|
|
28
|
+
expect(container.firstElementChild).toHaveClass(layoutClass, 'custom-grid')
|
|
29
|
+
expect(screen.getAllByText(/tile/)).toHaveLength(count)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('clamps visible tiles and places overflow on the last tile', () => {
|
|
33
|
+
const { container, rerender } = renderWithProviders(
|
|
34
|
+
<MediaStackGrid tiles={tiles(6)} maxVisible={4} />
|
|
35
|
+
)
|
|
36
|
+
expect(container.querySelectorAll('span')).toHaveLength(4)
|
|
37
|
+
expect(screen.getByText('+2')).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByText('+2').parentElement).toHaveTextContent('tile 4')
|
|
39
|
+
rerender(<MediaStackGrid tiles={tiles(2)} maxVisible={4} />)
|
|
40
|
+
expect(container.querySelectorAll('span')).toHaveLength(2)
|
|
41
|
+
expect(screen.queryByText(/^\+/)).toBeNull()
|
|
42
|
+
rerender(<MediaStackGrid tiles={tiles(1)} singleAspectRatio={0.5} />)
|
|
43
|
+
expect(
|
|
44
|
+
screen.getByText('tile 1').closest('div')?.parentElement
|
|
45
|
+
).toHaveStyle({
|
|
46
|
+
aspectRatio: '0.5',
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renders activatable buttons with labels and indexes', () => {
|
|
51
|
+
const onActivate = vi.fn()
|
|
52
|
+
renderWithProviders(
|
|
53
|
+
<MediaStackGrid tiles={tiles(3)} onTileActivate={onActivate} />
|
|
54
|
+
)
|
|
55
|
+
const buttons = screen.getAllByRole('button')
|
|
56
|
+
expect(buttons).toHaveLength(3)
|
|
57
|
+
expect(buttons[0]).toHaveAttribute('aria-label', 'Open media 1')
|
|
58
|
+
expect(buttons[1]).toHaveAttribute('aria-label', 'Second tile')
|
|
59
|
+
fireEvent.click(buttons[0])
|
|
60
|
+
fireEvent.click(buttons[2])
|
|
61
|
+
expect(onActivate).toHaveBeenNthCalledWith(1, 0)
|
|
62
|
+
expect(onActivate).toHaveBeenNthCalledWith(2, 2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('uses non-interactive shells when no activation handler is supplied', () => {
|
|
66
|
+
renderWithProviders(<MediaStackGrid tiles={tiles(2)} />)
|
|
67
|
+
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import PdfViewer from './PdfViewer'
|
|
6
|
+
|
|
7
|
+
describe('PdfViewer', () => {
|
|
8
|
+
const items = [
|
|
9
|
+
{ src: 'https://cdn.example.com/one.pdf#page=3' },
|
|
10
|
+
{ src: 'https://cdn.example.com/two.pdf', filename: 'second.pdf' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
it('renders nothing with no active document', () => {
|
|
14
|
+
const { container } = renderWithProviders(
|
|
15
|
+
<PdfViewer open onClose={vi.fn()} items={[]} />
|
|
16
|
+
)
|
|
17
|
+
expect(container.firstChild).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('merges viewer parameters into existing fragments', () => {
|
|
21
|
+
renderWithProviders(<PdfViewer open onClose={vi.fn()} items={[items[0]]} />)
|
|
22
|
+
const iframe = screen.getByTitle('one.pdf')
|
|
23
|
+
expect(iframe).toHaveAttribute(
|
|
24
|
+
'src',
|
|
25
|
+
'https://cdn.example.com/one.pdf#page=3&toolbar=0&navpanes=0'
|
|
26
|
+
)
|
|
27
|
+
expect(iframe).toHaveAttribute(
|
|
28
|
+
'sandbox',
|
|
29
|
+
'allow-scripts allow-forms allow-popups allow-downloads'
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('uses filename precedence and navigates stacked documents', () => {
|
|
34
|
+
renderWithProviders(
|
|
35
|
+
<PdfViewer open onClose={vi.fn()} items={items} initialIndex={1} />
|
|
36
|
+
)
|
|
37
|
+
expect(screen.getByRole('dialog')).toHaveAttribute(
|
|
38
|
+
'aria-label',
|
|
39
|
+
'second.pdf'
|
|
40
|
+
)
|
|
41
|
+
expect(screen.getByText('2 / 2')).toBeInTheDocument()
|
|
42
|
+
fireEvent.click(screen.getByRole('button', { name: 'Previous document' }))
|
|
43
|
+
expect(screen.getByTitle('one.pdf')).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import VideoViewer from './VideoViewer'
|
|
6
|
+
|
|
7
|
+
describe('VideoViewer', () => {
|
|
8
|
+
const items = [
|
|
9
|
+
{
|
|
10
|
+
src: 'https://cdn.example.com/one.mp4',
|
|
11
|
+
poster: '/one.jpg',
|
|
12
|
+
mimeType: 'video/mp4',
|
|
13
|
+
filename: 'one.mp4',
|
|
14
|
+
preload: 'auto' as const,
|
|
15
|
+
},
|
|
16
|
+
{ src: 'https://cdn.example.com/two.webm', mimeType: 'video/webm' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
it('renders nothing with no active video', () => {
|
|
20
|
+
const { container } = renderWithProviders(
|
|
21
|
+
<VideoViewer open onClose={vi.fn()} items={[]} />
|
|
22
|
+
)
|
|
23
|
+
expect(container.firstChild).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('renders media attributes and an optional source type', () => {
|
|
27
|
+
renderWithProviders(
|
|
28
|
+
<VideoViewer open onClose={vi.fn()} items={[items[0]]} />
|
|
29
|
+
)
|
|
30
|
+
const video = screen.getByTestId('video-viewer').querySelector('video')
|
|
31
|
+
expect(video).not.toBeNull()
|
|
32
|
+
if (!video) throw new Error('Expected video element')
|
|
33
|
+
expect(video).toHaveAttribute('src', items[0].src)
|
|
34
|
+
expect(video).toHaveAttribute('poster', items[0].poster)
|
|
35
|
+
expect(video).toHaveAttribute('preload', 'auto')
|
|
36
|
+
expect(video).toHaveAttribute('autoplay')
|
|
37
|
+
expect(video?.muted).toBe(true)
|
|
38
|
+
expect(video.querySelector('source')).toHaveAttribute('type', 'video/mp4')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('defaults preload and navigates stacked items', () => {
|
|
42
|
+
renderWithProviders(<VideoViewer open onClose={vi.fn()} items={items} />)
|
|
43
|
+
expect(screen.getByText('1 / 2')).toBeInTheDocument()
|
|
44
|
+
expect(
|
|
45
|
+
screen.getByTestId('video-viewer').querySelector('video')
|
|
46
|
+
).toHaveAttribute('preload', 'auto')
|
|
47
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next video' }))
|
|
48
|
+
expect(
|
|
49
|
+
screen.getByTestId('video-viewer').querySelector('video')
|
|
50
|
+
).toHaveAttribute('src', items[1].src)
|
|
51
|
+
expect(
|
|
52
|
+
screen.getByTestId('video-viewer').querySelector('video')
|
|
53
|
+
).toHaveAttribute('preload', 'metadata')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'react'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
5
|
+
|
|
6
|
+
import { VIEWER_ACTION_CLASS } from './viewerChromeClasses'
|
|
7
|
+
import ViewerShell from './ViewerShell'
|
|
8
|
+
|
|
9
|
+
const dialogPrototype = HTMLDialogElement.prototype
|
|
10
|
+
const originalDialogDescriptors = {
|
|
11
|
+
close: Object.getOwnPropertyDescriptor(dialogPrototype, 'close'),
|
|
12
|
+
showModal: Object.getOwnPropertyDescriptor(dialogPrototype, 'showModal'),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defineDialogMethod = (
|
|
16
|
+
name: 'close' | 'showModal',
|
|
17
|
+
implementation: () => void
|
|
18
|
+
) => {
|
|
19
|
+
Object.defineProperty(dialogPrototype, name, {
|
|
20
|
+
configurable: true,
|
|
21
|
+
value: implementation,
|
|
22
|
+
writable: true,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const restoreDialogMethod = (
|
|
27
|
+
name: 'close' | 'showModal',
|
|
28
|
+
descriptor: PropertyDescriptor | undefined
|
|
29
|
+
) => {
|
|
30
|
+
if (descriptor) Object.defineProperty(dialogPrototype, name, descriptor)
|
|
31
|
+
else Reflect.deleteProperty(dialogPrototype, name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PreOpenDialog = () => {
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
document.querySelector('[data-testid="viewer"]')?.setAttribute('open', '')
|
|
37
|
+
}, [])
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
restoreDialogMethod('close', originalDialogDescriptors.close)
|
|
43
|
+
restoreDialogMethod('showModal', originalDialogDescriptors.showModal)
|
|
44
|
+
vi.restoreAllMocks()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('ViewerShell', () => {
|
|
48
|
+
it('drives open state, chrome content, and forwarded attributes', () => {
|
|
49
|
+
const { rerender } = renderWithProviders(
|
|
50
|
+
<ViewerShell
|
|
51
|
+
open
|
|
52
|
+
onClose={vi.fn()}
|
|
53
|
+
counter="2 / 3"
|
|
54
|
+
actions={<button type="button">Action</button>}
|
|
55
|
+
ariaLabel="report.pdf"
|
|
56
|
+
data-testid="viewer"
|
|
57
|
+
>
|
|
58
|
+
content
|
|
59
|
+
</ViewerShell>
|
|
60
|
+
)
|
|
61
|
+
const dialog = screen.getByTestId('viewer')
|
|
62
|
+
expect(dialog).toHaveAttribute('open')
|
|
63
|
+
expect(dialog).toHaveAttribute('aria-label', 'report.pdf')
|
|
64
|
+
expect(dialog).toHaveTextContent('2 / 3')
|
|
65
|
+
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument()
|
|
66
|
+
expect(screen.getByRole('button', { name: 'Close viewer' })).toHaveClass(
|
|
67
|
+
VIEWER_ACTION_CLASS
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
rerender(
|
|
71
|
+
<ViewerShell open={false} onClose={vi.fn()} data-testid="viewer">
|
|
72
|
+
content
|
|
73
|
+
</ViewerShell>
|
|
74
|
+
)
|
|
75
|
+
expect(dialog).not.toHaveAttribute('open')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('focuses close on open and restores the previous focus on close', () => {
|
|
79
|
+
const onClose = vi.fn()
|
|
80
|
+
const previouslyFocused = document.createElement('button')
|
|
81
|
+
document.body.appendChild(previouslyFocused)
|
|
82
|
+
previouslyFocused.focus()
|
|
83
|
+
const { rerender } = renderWithProviders(
|
|
84
|
+
<ViewerShell open={false} onClose={onClose} data-testid="viewer">
|
|
85
|
+
content
|
|
86
|
+
</ViewerShell>
|
|
87
|
+
)
|
|
88
|
+
rerender(
|
|
89
|
+
<ViewerShell open onClose={onClose} data-testid="viewer">
|
|
90
|
+
content
|
|
91
|
+
</ViewerShell>
|
|
92
|
+
)
|
|
93
|
+
expect(screen.getByRole('button', { name: 'Close viewer' })).toHaveFocus()
|
|
94
|
+
rerender(
|
|
95
|
+
<ViewerShell open={false} onClose={onClose} data-testid="viewer">
|
|
96
|
+
content
|
|
97
|
+
</ViewerShell>
|
|
98
|
+
)
|
|
99
|
+
expect(previouslyFocused).toHaveFocus()
|
|
100
|
+
previouslyFocused.remove()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('falls back to the open attribute when dialog methods are absent', () => {
|
|
104
|
+
Reflect.deleteProperty(dialogPrototype, 'showModal')
|
|
105
|
+
Reflect.deleteProperty(dialogPrototype, 'close')
|
|
106
|
+
const { rerender } = renderWithProviders(
|
|
107
|
+
<ViewerShell open onClose={vi.fn()} data-testid="viewer">
|
|
108
|
+
content
|
|
109
|
+
</ViewerShell>
|
|
110
|
+
)
|
|
111
|
+
const dialog = screen.getByTestId('viewer')
|
|
112
|
+
expect(dialog).toHaveAttribute('open')
|
|
113
|
+
rerender(
|
|
114
|
+
<ViewerShell open={false} onClose={vi.fn()} data-testid="viewer">
|
|
115
|
+
content
|
|
116
|
+
</ViewerShell>
|
|
117
|
+
)
|
|
118
|
+
expect(dialog).not.toHaveAttribute('open')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('falls back when showModal throws', () => {
|
|
122
|
+
const showModal = vi.fn(() => {
|
|
123
|
+
throw new Error('not supported')
|
|
124
|
+
})
|
|
125
|
+
defineDialogMethod('showModal', showModal)
|
|
126
|
+
const { container } = renderWithProviders(
|
|
127
|
+
<ViewerShell open onClose={vi.fn()} data-testid="viewer">
|
|
128
|
+
content
|
|
129
|
+
</ViewerShell>
|
|
130
|
+
)
|
|
131
|
+
expect(container.querySelector('dialog')).toHaveAttribute('open')
|
|
132
|
+
expect(showModal).toHaveBeenCalledOnce()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('falls back when close throws during cleanup', () => {
|
|
136
|
+
const close = vi.fn(() => {
|
|
137
|
+
throw new Error('not supported')
|
|
138
|
+
})
|
|
139
|
+
defineDialogMethod('close', close)
|
|
140
|
+
Reflect.deleteProperty(dialogPrototype, 'showModal')
|
|
141
|
+
const { rerender } = renderWithProviders(
|
|
142
|
+
<ViewerShell open onClose={vi.fn()} data-testid="viewer">
|
|
143
|
+
content
|
|
144
|
+
</ViewerShell>
|
|
145
|
+
)
|
|
146
|
+
const dialog = screen.getByTestId('viewer')
|
|
147
|
+
rerender(
|
|
148
|
+
<ViewerShell open={false} onClose={vi.fn()} data-testid="viewer">
|
|
149
|
+
content
|
|
150
|
+
</ViewerShell>
|
|
151
|
+
)
|
|
152
|
+
expect(close).toHaveBeenCalledOnce()
|
|
153
|
+
expect(dialog).not.toHaveAttribute('open')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('falls back when close throws in the closed branch', () => {
|
|
157
|
+
const close = vi.fn(() => {
|
|
158
|
+
throw new Error('not supported')
|
|
159
|
+
})
|
|
160
|
+
defineDialogMethod('close', close)
|
|
161
|
+
renderWithProviders(
|
|
162
|
+
<ViewerShell open={false} onClose={vi.fn()} data-testid="viewer">
|
|
163
|
+
<PreOpenDialog />
|
|
164
|
+
</ViewerShell>
|
|
165
|
+
)
|
|
166
|
+
expect(close).toHaveBeenCalledOnce()
|
|
167
|
+
expect(screen.getByTestId('viewer')).not.toHaveAttribute('open')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('does not call showModal when the dialog is already open', () => {
|
|
171
|
+
const showModal = vi.fn()
|
|
172
|
+
defineDialogMethod('showModal', showModal)
|
|
173
|
+
renderWithProviders(
|
|
174
|
+
<ViewerShell open onClose={vi.fn()} data-testid="viewer">
|
|
175
|
+
<PreOpenDialog />
|
|
176
|
+
</ViewerShell>
|
|
177
|
+
)
|
|
178
|
+
expect(screen.getByTestId('viewer')).toHaveAttribute('open')
|
|
179
|
+
expect(showModal).not.toHaveBeenCalled()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('does not restore focus to a detached element', () => {
|
|
183
|
+
const previouslyFocused = document.createElement('button')
|
|
184
|
+
document.body.appendChild(previouslyFocused)
|
|
185
|
+
previouslyFocused.focus()
|
|
186
|
+
const { rerender } = renderWithProviders(
|
|
187
|
+
<ViewerShell open={false} onClose={vi.fn()} data-testid="viewer">
|
|
188
|
+
content
|
|
189
|
+
</ViewerShell>
|
|
190
|
+
)
|
|
191
|
+
rerender(
|
|
192
|
+
<ViewerShell open onClose={vi.fn()} data-testid="viewer">
|
|
193
|
+
content
|
|
194
|
+
</ViewerShell>
|
|
195
|
+
)
|
|
196
|
+
previouslyFocused.remove()
|
|
197
|
+
expect(() =>
|
|
198
|
+
rerender(
|
|
199
|
+
<ViewerShell open={false} onClose={vi.fn()} data-testid="viewer">
|
|
200
|
+
content
|
|
201
|
+
</ViewerShell>
|
|
202
|
+
)
|
|
203
|
+
).not.toThrow()
|
|
204
|
+
expect(document.activeElement).not.toBe(previouslyFocused)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('uses the default accessible label', () => {
|
|
208
|
+
renderWithProviders(
|
|
209
|
+
<ViewerShell open onClose={vi.fn()} data-testid="viewer">
|
|
210
|
+
content
|
|
211
|
+
</ViewerShell>
|
|
212
|
+
)
|
|
213
|
+
expect(screen.getByTestId('viewer')).toHaveAttribute(
|
|
214
|
+
'aria-label',
|
|
215
|
+
'Attachment viewer'
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not close a dialog that was never opened', () => {
|
|
220
|
+
const close = vi.fn()
|
|
221
|
+
defineDialogMethod('close', close)
|
|
222
|
+
renderWithProviders(
|
|
223
|
+
<ViewerShell open={false} onClose={vi.fn()} data-testid="viewer">
|
|
224
|
+
content
|
|
225
|
+
</ViewerShell>
|
|
226
|
+
)
|
|
227
|
+
expect(close).not.toHaveBeenCalled()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('closes from the close, native close, and backdrop events', () => {
|
|
231
|
+
const onClose = vi.fn()
|
|
232
|
+
renderWithProviders(
|
|
233
|
+
<ViewerShell open onClose={onClose} data-testid="viewer">
|
|
234
|
+
<span>content</span>
|
|
235
|
+
</ViewerShell>
|
|
236
|
+
)
|
|
237
|
+
const dialog = screen.getByTestId('viewer')
|
|
238
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close viewer' }))
|
|
239
|
+
fireEvent(dialog, new Event('close'))
|
|
240
|
+
fireEvent.click(dialog)
|
|
241
|
+
expect(onClose).toHaveBeenCalledTimes(3)
|
|
242
|
+
onClose.mockClear()
|
|
243
|
+
fireEvent.click(screen.getByText('content'))
|
|
244
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
245
|
+
})
|
|
246
|
+
})
|
|
@@ -25,6 +25,14 @@ describe('formatFileSize', () => {
|
|
|
25
25
|
expect(formatFileSize(Infinity)).toBe('')
|
|
26
26
|
expect(formatFileSize(-1)).toBe('')
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
it('handles unit boundaries without crossing into the next unit early', () => {
|
|
30
|
+
expect(formatFileSize(1023)).toBe('1023 B')
|
|
31
|
+
expect(formatFileSize(1024 * 1024 - 1)).toBe('1024.0 KB')
|
|
32
|
+
expect(formatFileSize(1024 * 1024)).toBe('1.00 MB')
|
|
33
|
+
expect(formatFileSize(1024 ** 3 - 1)).toBe('1024.00 MB')
|
|
34
|
+
expect(formatFileSize(1024 ** 3)).toBe('1.00 GB')
|
|
35
|
+
})
|
|
28
36
|
})
|
|
29
37
|
|
|
30
38
|
describe('getFileExtensionLabel', () => {
|
|
@@ -43,6 +51,18 @@ describe('getFileExtensionLabel', () => {
|
|
|
43
51
|
expect(getFileExtensionLabel('application/octet-stream')).toBeUndefined()
|
|
44
52
|
expect(getFileExtensionLabel()).toBeUndefined()
|
|
45
53
|
})
|
|
54
|
+
|
|
55
|
+
it('handles dot boundaries, extension length, and MIME fallbacks', () => {
|
|
56
|
+
expect(getFileExtensionLabel('text/plain', '.gitignore')).toBe('TXT')
|
|
57
|
+
expect(getFileExtensionLabel('text/plain', 'notes.')).toBe('TXT')
|
|
58
|
+
expect(getFileExtensionLabel('image/webp', 'photo.webp')).toBe('WEBP')
|
|
59
|
+
expect(getFileExtensionLabel('image/webp', 'photo.abcdef')).toBe('WEBP')
|
|
60
|
+
expect(getFileExtensionLabel('application/json', 'data.12345')).toBe(
|
|
61
|
+
'12345'
|
|
62
|
+
)
|
|
63
|
+
expect(getFileExtensionLabel('image/*')).toBeUndefined()
|
|
64
|
+
expect(getFileExtensionLabel('application/octet-stream')).toBeUndefined()
|
|
65
|
+
})
|
|
46
66
|
})
|
|
47
67
|
|
|
48
68
|
describe('buildCompactMetaLabel', () => {
|
|
@@ -59,6 +79,23 @@ describe('buildCompactMetaLabel', () => {
|
|
|
59
79
|
it('returns undefined when nothing is renderable', () => {
|
|
60
80
|
expect(buildCompactMetaLabel()).toBeUndefined()
|
|
61
81
|
})
|
|
82
|
+
|
|
83
|
+
it('drops non-positive and non-number sizes while retaining size-only labels', () => {
|
|
84
|
+
expect(buildCompactMetaLabel('application/pdf', 'report.pdf', 0)).toBe(
|
|
85
|
+
'PDF'
|
|
86
|
+
)
|
|
87
|
+
expect(buildCompactMetaLabel('application/pdf', 'report.pdf', -1)).toBe(
|
|
88
|
+
'PDF'
|
|
89
|
+
)
|
|
90
|
+
expect(buildCompactMetaLabel(undefined, undefined, 1024)).toBe('1.0 KB')
|
|
91
|
+
expect(
|
|
92
|
+
buildCompactMetaLabel(
|
|
93
|
+
'application/octet-stream',
|
|
94
|
+
undefined,
|
|
95
|
+
'1024' as never
|
|
96
|
+
)
|
|
97
|
+
).toBeUndefined()
|
|
98
|
+
})
|
|
62
99
|
})
|
|
63
100
|
|
|
64
101
|
describe('filenameFromUrl', () => {
|
|
@@ -77,4 +114,11 @@ describe('filenameFromUrl', () => {
|
|
|
77
114
|
it('falls back to a default when the URL cannot be parsed', () => {
|
|
78
115
|
expect(filenameFromUrl('not a url at all')).toBe('download')
|
|
79
116
|
})
|
|
117
|
+
|
|
118
|
+
it('handles trailing slashes, deep paths, and query strings', () => {
|
|
119
|
+
expect(filenameFromUrl('https://cdn.example.com/folder/')).toBe('download')
|
|
120
|
+
expect(
|
|
121
|
+
filenameFromUrl('https://cdn.example.com/a/b/c/report.pdf?download=1')
|
|
122
|
+
).toBe('report.pdf')
|
|
123
|
+
})
|
|
80
124
|
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { triggerDownload } from './triggerDownload'
|
|
4
|
+
|
|
5
|
+
describe('triggerDownload', () => {
|
|
6
|
+
const fetchMock = vi.fn()
|
|
7
|
+
const openMock = vi.fn()
|
|
8
|
+
const createObjectURLMock = vi.fn()
|
|
9
|
+
const revokeObjectURLMock = vi.fn()
|
|
10
|
+
const clickMock = vi
|
|
11
|
+
.spyOn(HTMLAnchorElement.prototype, 'click')
|
|
12
|
+
.mockImplementation(() => undefined)
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
16
|
+
vi.spyOn(window, 'open').mockImplementation(openMock)
|
|
17
|
+
Object.defineProperty(URL, 'createObjectURL', {
|
|
18
|
+
configurable: true,
|
|
19
|
+
value: createObjectURLMock,
|
|
20
|
+
})
|
|
21
|
+
Object.defineProperty(URL, 'revokeObjectURL', {
|
|
22
|
+
configurable: true,
|
|
23
|
+
value: revokeObjectURLMock,
|
|
24
|
+
})
|
|
25
|
+
fetchMock.mockReset()
|
|
26
|
+
openMock.mockReset()
|
|
27
|
+
createObjectURLMock.mockReset()
|
|
28
|
+
revokeObjectURLMock.mockReset()
|
|
29
|
+
clickMock.mockClear()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('downloads a successful response with the supplied filename', async () => {
|
|
33
|
+
const blob = new Blob(['file'])
|
|
34
|
+
fetchMock.mockResolvedValue({
|
|
35
|
+
ok: true,
|
|
36
|
+
blob: () => Promise.resolve(blob),
|
|
37
|
+
})
|
|
38
|
+
createObjectURLMock.mockReturnValue('blob:attachment')
|
|
39
|
+
const appendSpy = vi.spyOn(document.body, 'appendChild')
|
|
40
|
+
const removeSpy = vi.spyOn(document.body, 'removeChild')
|
|
41
|
+
|
|
42
|
+
await expect(
|
|
43
|
+
triggerDownload('https://cdn.example.com/file.pdf', 'report.pdf')
|
|
44
|
+
).resolves.toBeUndefined()
|
|
45
|
+
|
|
46
|
+
expect(fetchMock).toHaveBeenCalledWith('https://cdn.example.com/file.pdf', {
|
|
47
|
+
mode: 'cors',
|
|
48
|
+
})
|
|
49
|
+
expect(createObjectURLMock).toHaveBeenCalledWith(blob)
|
|
50
|
+
expect(clickMock).toHaveBeenCalledOnce()
|
|
51
|
+
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:attachment')
|
|
52
|
+
const anchor = appendSpy.mock.calls.at(-1)?.[0] as HTMLAnchorElement
|
|
53
|
+
expect(anchor.href).toBe('blob:attachment')
|
|
54
|
+
expect(anchor.download).toBe('report.pdf')
|
|
55
|
+
expect(anchor.style.display).toBe('none')
|
|
56
|
+
expect(removeSpy).toHaveBeenCalledWith(anchor)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('uses the URL filename when no filename is supplied', async () => {
|
|
60
|
+
fetchMock.mockResolvedValue({
|
|
61
|
+
ok: true,
|
|
62
|
+
blob: () => Promise.resolve(new Blob()),
|
|
63
|
+
})
|
|
64
|
+
createObjectURLMock.mockReturnValue('blob:attachment')
|
|
65
|
+
const appendSpy = vi.spyOn(document.body, 'appendChild')
|
|
66
|
+
|
|
67
|
+
await triggerDownload('https://cdn.example.com/a/My%20file.pdf')
|
|
68
|
+
|
|
69
|
+
expect(
|
|
70
|
+
(appendSpy.mock.calls.at(-1)?.[0] as HTMLAnchorElement).download
|
|
71
|
+
).toBe('My file.pdf')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it.each(['a non-ok response', 'a rejected fetch'])(
|
|
75
|
+
'falls back to a new window for %s',
|
|
76
|
+
async (label) => {
|
|
77
|
+
if (label === 'a non-ok response') {
|
|
78
|
+
fetchMock.mockResolvedValue({ ok: false, status: 404 })
|
|
79
|
+
} else {
|
|
80
|
+
fetchMock.mockRejectedValue(new Error('offline'))
|
|
81
|
+
}
|
|
82
|
+
openMock.mockReturnValue({})
|
|
83
|
+
|
|
84
|
+
await expect(
|
|
85
|
+
triggerDownload('https://cdn.example.com/file.pdf')
|
|
86
|
+
).resolves.toBeUndefined()
|
|
87
|
+
|
|
88
|
+
expect(openMock).toHaveBeenCalledWith(
|
|
89
|
+
'https://cdn.example.com/file.pdf',
|
|
90
|
+
'_blank',
|
|
91
|
+
'noopener,noreferrer'
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
it('uses a raw-url anchor when the popup is blocked', async () => {
|
|
97
|
+
fetchMock.mockRejectedValue(new Error('offline'))
|
|
98
|
+
openMock.mockReturnValue(null)
|
|
99
|
+
const appendSpy = vi.spyOn(document.body, 'appendChild')
|
|
100
|
+
const removeSpy = vi.spyOn(document.body, 'removeChild')
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
triggerDownload('https://cdn.example.com/file.pdf')
|
|
104
|
+
).resolves.toBeUndefined()
|
|
105
|
+
|
|
106
|
+
const anchor = appendSpy.mock.calls.at(-1)?.[0] as HTMLAnchorElement
|
|
107
|
+
expect(anchor.href).toBe('https://cdn.example.com/file.pdf')
|
|
108
|
+
expect(anchor.download).toBe('file.pdf')
|
|
109
|
+
expect(anchor.target).toBe('_blank')
|
|
110
|
+
expect(anchor.rel).toBe('noopener noreferrer')
|
|
111
|
+
expect(removeSpy).toHaveBeenCalledWith(anchor)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { fireEvent, renderWithProviders, screen } from '../../../test/utils'
|
|
4
|
+
|
|
5
|
+
import { useCarousel } from './useCarousel'
|
|
6
|
+
|
|
7
|
+
const Harness = ({
|
|
8
|
+
open,
|
|
9
|
+
length,
|
|
10
|
+
initialIndex = 0,
|
|
11
|
+
}: {
|
|
12
|
+
open: boolean
|
|
13
|
+
length: number
|
|
14
|
+
initialIndex?: number
|
|
15
|
+
}) => {
|
|
16
|
+
const { index, prev, next } = useCarousel({ open, length, initialIndex })
|
|
17
|
+
return (
|
|
18
|
+
<div>
|
|
19
|
+
<span data-testid="index">{index}</span>
|
|
20
|
+
<button type="button" onClick={prev}>
|
|
21
|
+
Prev
|
|
22
|
+
</button>
|
|
23
|
+
<button type="button" onClick={next}>
|
|
24
|
+
Next
|
|
25
|
+
</button>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('useCarousel', () => {
|
|
31
|
+
it('clamps initial index, wraps, and resets on reopen', () => {
|
|
32
|
+
const { rerender } = renderWithProviders(
|
|
33
|
+
<Harness open length={3} initialIndex={99} />
|
|
34
|
+
)
|
|
35
|
+
expect(screen.getByTestId('index')).toHaveTextContent('2')
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
|
37
|
+
expect(screen.getByTestId('index')).toHaveTextContent('0')
|
|
38
|
+
fireEvent.click(screen.getByRole('button', { name: 'Prev' }))
|
|
39
|
+
expect(screen.getByTestId('index')).toHaveTextContent('2')
|
|
40
|
+
rerender(<Harness open={false} length={3} initialIndex={1} />)
|
|
41
|
+
rerender(<Harness open length={3} initialIndex={1} />)
|
|
42
|
+
expect(screen.getByTestId('index')).toHaveTextContent('1')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('handles arrows only when open with multiple items', () => {
|
|
46
|
+
const { rerender } = renderWithProviders(
|
|
47
|
+
<Harness open length={2} initialIndex={0} />
|
|
48
|
+
)
|
|
49
|
+
const right = fireEvent.keyDown(window, { key: 'ArrowRight' })
|
|
50
|
+
expect(screen.getByTestId('index')).toHaveTextContent('1')
|
|
51
|
+
expect(right).toBe(false)
|
|
52
|
+
for (const tagName of ['video', 'audio'] as const) {
|
|
53
|
+
const media = document.createElement(tagName)
|
|
54
|
+
media.tabIndex = 0
|
|
55
|
+
document.body.appendChild(media)
|
|
56
|
+
media.focus()
|
|
57
|
+
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
|
58
|
+
expect(screen.getByTestId('index')).toHaveTextContent('1')
|
|
59
|
+
media.remove()
|
|
60
|
+
}
|
|
61
|
+
rerender(<Harness open length={1} />)
|
|
62
|
+
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
|
63
|
+
expect(screen.getByTestId('index')).toHaveTextContent('0')
|
|
64
|
+
rerender(<Harness open={false} length={2} />)
|
|
65
|
+
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
|
66
|
+
expect(screen.getByTestId('index')).toHaveTextContent('0')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { VIEWER_ACTION_CLASS, VIEWER_NAV_CLASS } from './viewerChromeClasses'
|
|
4
|
+
|
|
5
|
+
describe('viewer chrome classes', () => {
|
|
6
|
+
it('keeps the load-bearing action button tokens', () => {
|
|
7
|
+
expect(VIEWER_ACTION_CLASS).toContain('size-10')
|
|
8
|
+
expect(VIEWER_ACTION_CLASS).toContain('rounded-full')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('keeps the load-bearing navigation button tokens', () => {
|
|
12
|
+
expect(VIEWER_NAV_CLASS).toContain('absolute')
|
|
13
|
+
expect(VIEWER_NAV_CLASS).toContain('size-12')
|
|
14
|
+
expect(VIEWER_NAV_CLASS).toContain('-translate-y-1/2')
|
|
15
|
+
})
|
|
16
|
+
})
|