@linktr.ee/messaging-react 4.1.7-rc-1787240421 → 4.1.7-rc-1787247439

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.
@@ -0,0 +1,168 @@
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 control the pill label', () => {
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(<DownloadAction url="/file.pdf" label="Save" iconOnly={false} />)
52
+ expect(screen.getByRole('button', { name: 'Save' })).toHaveTextContent(
53
+ 'Save'
54
+ )
55
+ })
56
+
57
+ it.each(['inline', 'viewer'] as const)(
58
+ 'keeps the %s label visually hidden regardless of iconOnly',
59
+ (variant) => {
60
+ const { rerender } = renderWithProviders(
61
+ <DownloadAction
62
+ url="/file.pdf"
63
+ variant={variant}
64
+ label="Save"
65
+ iconOnly={false}
66
+ />
67
+ )
68
+ expect(
69
+ screen.getByRole('button', { name: 'Save' })
70
+ ).not.toHaveTextContent('Save')
71
+ rerender(
72
+ <DownloadAction
73
+ url="/file.pdf"
74
+ variant={variant}
75
+ label="Save"
76
+ iconOnly
77
+ />
78
+ )
79
+ expect(
80
+ screen.getByRole('button', { name: 'Save' })
81
+ ).not.toHaveTextContent('Save')
82
+ }
83
+ )
84
+
85
+ it('stops propagation and calls the download once', async () => {
86
+ const outerClick = vi.fn()
87
+ renderWithProviders(
88
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
89
+ <div onClick={outerClick}>
90
+ <DownloadAction
91
+ url="/file.pdf"
92
+ filename="report.pdf"
93
+ onTriggered={vi.fn()}
94
+ />
95
+ </div>
96
+ )
97
+ fireEvent.click(screen.getByRole('button', { name: 'Download' }))
98
+ await waitFor(() =>
99
+ expect(mockedTriggerDownload).toHaveBeenCalledWith(
100
+ '/file.pdf',
101
+ 'report.pdf'
102
+ )
103
+ )
104
+ expect(mockedTriggerDownload).toHaveBeenCalledOnce()
105
+ expect(outerClick).not.toHaveBeenCalled()
106
+ })
107
+
108
+ it('disables repeated clicks while downloading and settles on success', async () => {
109
+ let resolve: () => void = () => undefined
110
+ mockedTriggerDownload.mockReturnValueOnce(
111
+ new Promise<void>((done) => {
112
+ resolve = done
113
+ })
114
+ )
115
+ const onTriggered = vi.fn()
116
+ renderWithProviders(
117
+ <DownloadAction url="/file.pdf" onTriggered={onTriggered} />
118
+ )
119
+ const button = screen.getByRole('button', { name: 'Download' })
120
+ fireEvent.click(button)
121
+ fireEvent.click(button)
122
+ expect(button).toBeDisabled()
123
+ expect(mockedTriggerDownload).toHaveBeenCalledOnce()
124
+ resolve()
125
+ await waitFor(() => expect(button).toBeEnabled())
126
+ expect(onTriggered).toHaveBeenCalledOnce()
127
+ })
128
+
129
+ it('settles and notifies even when downloading rejects', async () => {
130
+ mockedTriggerDownload.mockRejectedValueOnce(new Error('offline'))
131
+ const onTriggered = vi.fn()
132
+ renderWithProviders(
133
+ <DownloadAction url="/file.pdf" onTriggered={onTriggered} />
134
+ )
135
+ const button = screen.getByRole('button', { name: 'Download' })
136
+ fireEvent.click(button)
137
+ await waitFor(() => expect(button).toBeEnabled())
138
+ expect(onTriggered).toHaveBeenCalledOnce()
139
+ })
140
+
141
+ it('maps tone classes for pill and inline variants', () => {
142
+ const { rerender } = renderWithProviders(
143
+ <DownloadAction url="/file.pdf" tone="dark" />
144
+ )
145
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
146
+ 'hover:bg-[#2a2928]'
147
+ )
148
+ rerender(<DownloadAction url="/file.pdf" tone="light" />)
149
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
150
+ 'hover:bg-white/90'
151
+ )
152
+ rerender(<DownloadAction url="/file.pdf" variant="inline" tone="dark" />)
153
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
154
+ 'hover:text-white'
155
+ )
156
+ rerender(<DownloadAction url="/file.pdf" variant="inline" tone="light" />)
157
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
158
+ 'hover:text-black'
159
+ )
160
+ })
161
+
162
+ it('uses the shared viewer action class', () => {
163
+ renderWithProviders(<DownloadAction url="/file.pdf" variant="viewer" />)
164
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
165
+ VIEWER_ACTION_CLASS
166
+ )
167
+ })
168
+ })
@@ -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
+ })