@linktr.ee/messaging-react 4.1.7-rc-1787195185 → 4.1.7-rc-1787202545

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "4.1.7-rc-1787195185",
3
+ "version": "4.1.7-rc-1787202545",
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-1787195185",
53
+ "@linktr.ee/messaging-core": "2.5.0-rc-1787202545",
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,170 @@
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
+ 'bg-[#121110]',
147
+ 'text-white'
148
+ )
149
+ rerender(<DownloadAction url="/file.pdf" tone="light" />)
150
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
151
+ 'bg-white',
152
+ 'text-[#121110]'
153
+ )
154
+ rerender(<DownloadAction url="/file.pdf" variant="inline" tone="dark" />)
155
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
156
+ 'text-white/70'
157
+ )
158
+ rerender(<DownloadAction url="/file.pdf" variant="inline" tone="light" />)
159
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
160
+ 'text-black/70'
161
+ )
162
+ })
163
+
164
+ it('uses the shared viewer action class', () => {
165
+ renderWithProviders(<DownloadAction url="/file.pdf" variant="viewer" />)
166
+ expect(screen.getByRole('button', { name: 'Download' })).toHaveClass(
167
+ VIEWER_ACTION_CLASS
168
+ )
169
+ })
170
+ })
@@ -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
+ })