@linktr.ee/messaging-react 4.2.0-rc-1787083791 → 4.2.0-rc-1787181113
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/dist/{Card-B4lMv-0o.js → Card-BOudQBd6.js} +2 -2
- package/dist/{Card-B4lMv-0o.js.map → Card-BOudQBd6.js.map} +1 -1
- package/dist/{Card-DWL5I62S.cjs → Card-k2959zNX.cjs} +2 -2
- package/dist/{Card-DWL5I62S.cjs.map → Card-k2959zNX.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/{index-C93LTGLc.js → index-6RdyZh6F.js} +1809 -1912
- package/dist/index-6RdyZh6F.js.map +1 -0
- package/dist/index-BcU18EjV.cjs +3 -0
- package/dist/index-BcU18EjV.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +78 -59
- package/dist/index.js +30 -26
- package/dist/testing.cjs +1 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.js +2 -2
- package/dist/testing.js.map +1 -1
- package/package.json +2 -2
- package/src/components/AttachmentCard/MediaPlayer.test.tsx +346 -0
- package/src/components/AttachmentCard/Thumbnail.test.tsx +215 -0
- package/src/components/AttachmentCard/index.test.tsx +160 -0
- package/src/components/Avatar/Avatar.test.tsx +126 -0
- package/src/components/Avatar/getAvatarEmoji.test.ts +39 -0
- package/src/components/ChannelList/ChannelListContext.test.tsx +87 -0
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +410 -3
- package/src/components/ChannelList/index.test.tsx +132 -1
- package/src/components/CustomMessage/CustomMessage.stories.tsx +6 -314
- package/src/components/CustomMessage/CustomMessage.test.tsx +56 -2
- package/src/components/CustomMessage/Locked.stories.tsx +108 -0
- package/src/components/CustomMessage/MessageTag.stories.tsx +17 -53
- package/src/components/CustomMessage/MessageTag.test.tsx +7 -40
- package/src/components/CustomMessage/MessageTag.tsx +7 -39
- package/src/components/CustomMessage/PaidMessage.stories.tsx +42 -0
- package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +8 -7
- package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +2 -1
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -6
- package/src/components/CustomMessage/Tip.stories.tsx +48 -0
- package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +51 -0
- package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +65 -0
- package/src/components/CustomMessage/TipMessage/index.tsx +27 -0
- package/src/components/CustomMessage/index.tsx +22 -19
- package/src/components/FaqList/FaqList.test.tsx +204 -0
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +327 -0
- package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +76 -0
- package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +248 -0
- package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +123 -0
- package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +193 -0
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +242 -0
- package/src/components/LinkAttachment/components/_shared/hex.test.ts +37 -0
- package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +72 -0
- package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +162 -0
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +58 -165
- package/src/components/MessageAttachment/Audio/index.tsx +77 -71
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +74 -176
- package/src/components/MessageAttachment/index.tsx +9 -25
- package/src/components/MessageAttachment/types.ts +9 -19
- package/src/components/MessagingShell/MessagingShell.test.tsx +20 -0
- package/src/components/MessagingShell/index.tsx +2 -0
- package/src/index.ts +4 -0
- package/src/logging/index.test.tsx +136 -0
- package/src/logging/index.tsx +48 -0
- package/src/providers/MessagingProvider.test.tsx +113 -1
- package/src/providers/MessagingProvider.tsx +25 -15
- package/src/stories/customMessageStoryTemplate.tsx +199 -0
- package/src/styles.css +10 -121
- package/src/testing/createMockMessagingClient.ts +2 -2
- package/src/types.ts +9 -0
- package/dist/index-B-9LkWep.cjs +0 -3
- package/dist/index-B-9LkWep.cjs.map +0 -1
- package/dist/index-C93LTGLc.js.map +0 -1
- package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { renderWithProviders, screen, fireEvent } from '../../../../test/utils'
|
|
5
|
+
|
|
6
|
+
import CardBody from './CardBody'
|
|
7
|
+
|
|
8
|
+
describe('CardBody', () => {
|
|
9
|
+
it('collapses to nothing when there is no text, url or cta', () => {
|
|
10
|
+
const { container } = renderWithProviders(<CardBody variant="light" />)
|
|
11
|
+
expect(container).toBeEmptyDOMElement()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('treats whitespace-only text as absent', () => {
|
|
15
|
+
const { container } = renderWithProviders(
|
|
16
|
+
<CardBody variant="light" title=" " description=" " url=" " />
|
|
17
|
+
)
|
|
18
|
+
expect(container).toBeEmptyDOMElement()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders the chin when any single slot has content', () => {
|
|
22
|
+
const { container: withUrl } = renderWithProviders(
|
|
23
|
+
<CardBody variant="light" url="tr.ee/a" />
|
|
24
|
+
)
|
|
25
|
+
expect(withUrl).not.toBeEmptyDOMElement()
|
|
26
|
+
|
|
27
|
+
const { container: withDescription } = renderWithProviders(
|
|
28
|
+
<CardBody variant="light" description="Get answers" />
|
|
29
|
+
)
|
|
30
|
+
expect(withDescription).not.toBeEmptyDOMElement()
|
|
31
|
+
|
|
32
|
+
const { container: withCta } = renderWithProviders(
|
|
33
|
+
<CardBody variant="light" cta={{ label: 'View FAQs' }} />
|
|
34
|
+
)
|
|
35
|
+
expect(withCta).not.toBeEmptyDOMElement()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shows the placeholder title on dark cards only', () => {
|
|
39
|
+
renderWithProviders(
|
|
40
|
+
<CardBody variant="dark" placeholderTitle="Add a title" />
|
|
41
|
+
)
|
|
42
|
+
expect(screen.getByText('Add a title')).toBeInTheDocument()
|
|
43
|
+
|
|
44
|
+
const { container } = renderWithProviders(
|
|
45
|
+
<CardBody variant="light" placeholderTitle="Add a title" />
|
|
46
|
+
)
|
|
47
|
+
expect(container).toBeEmptyDOMElement()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('dims the placeholder title but not a real one', () => {
|
|
51
|
+
renderWithProviders(
|
|
52
|
+
<CardBody variant="dark" placeholderTitle="Add a title" />
|
|
53
|
+
)
|
|
54
|
+
expect(screen.getByText('Add a title').className).toContain('text-white/30')
|
|
55
|
+
|
|
56
|
+
renderWithProviders(<CardBody variant="dark" title="Brie’s FAQ" />)
|
|
57
|
+
const title = screen.getByText('Brie’s FAQ')
|
|
58
|
+
expect(title.className).toContain('text-white')
|
|
59
|
+
expect(title.className).not.toContain('text-white/30')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('inks the light variant against its pale surface', () => {
|
|
63
|
+
renderWithProviders(
|
|
64
|
+
<CardBody variant="light" title="Brie’s FAQ" url="tr.ee/a" />
|
|
65
|
+
)
|
|
66
|
+
expect(screen.getByText('Brie’s FAQ').className).toContain('text-black/90')
|
|
67
|
+
expect(screen.getByTestId('link-attachment-url').className).toContain(
|
|
68
|
+
'text-black/55'
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('inks the dark variant against its dark surface', () => {
|
|
73
|
+
renderWithProviders(
|
|
74
|
+
<CardBody variant="dark" title="Brie’s FAQ" url="tr.ee/a" />
|
|
75
|
+
)
|
|
76
|
+
expect(screen.getByTestId('link-attachment-url').className).toContain(
|
|
77
|
+
'text-white/55'
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('replaces the variant ink with chinTextColor on every chin line', () => {
|
|
82
|
+
renderWithProviders(
|
|
83
|
+
<CardBody
|
|
84
|
+
variant="dark"
|
|
85
|
+
chinTextColor="#040403"
|
|
86
|
+
title="Brie’s FAQ"
|
|
87
|
+
description="Get answers"
|
|
88
|
+
url="tr.ee/a"
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
const title = screen.getByText('Brie’s FAQ')
|
|
92
|
+
const description = screen.getByText('Get answers')
|
|
93
|
+
const url = screen.getByTestId('link-attachment-url')
|
|
94
|
+
|
|
95
|
+
for (const node of [title, description, url]) {
|
|
96
|
+
expect(node.style.color).toBe('rgb(4, 4, 3)')
|
|
97
|
+
expect(node.className).not.toContain('text-white')
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('truncates the description to one line by default and clamps beyond that', () => {
|
|
102
|
+
renderWithProviders(
|
|
103
|
+
<CardBody variant="light" description="Get answers to every question" />
|
|
104
|
+
)
|
|
105
|
+
const single = screen.getByText('Get answers to every question')
|
|
106
|
+
expect(single.className).toContain('truncate')
|
|
107
|
+
expect(single.style.display).toBe('')
|
|
108
|
+
|
|
109
|
+
renderWithProviders(
|
|
110
|
+
<CardBody
|
|
111
|
+
variant="light"
|
|
112
|
+
description="A long prose body"
|
|
113
|
+
descriptionLines={3}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
const clamped = screen.getByText('A long prose body')
|
|
117
|
+
expect(clamped.className).not.toContain('truncate')
|
|
118
|
+
expect(clamped.style.display).toBe('-webkit-box')
|
|
119
|
+
expect(clamped.style.getPropertyValue('-webkit-line-clamp')).toBe('3')
|
|
120
|
+
expect(clamped.style.overflow).toBe('hidden')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('keeps the chin ink while clamping a multi-line description', () => {
|
|
124
|
+
renderWithProviders(
|
|
125
|
+
<CardBody
|
|
126
|
+
variant="dark"
|
|
127
|
+
chinTextColor="#FFFFFF"
|
|
128
|
+
description="A long prose body"
|
|
129
|
+
descriptionLines={2}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
const clamped = screen.getByText('A long prose body')
|
|
133
|
+
expect(clamped.style.color).toBe('rgb(255, 255, 255)')
|
|
134
|
+
expect(clamped.style.getPropertyValue('-webkit-line-clamp')).toBe('2')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('renders the trimmed url footer, and drops it entirely for a cta', () => {
|
|
138
|
+
renderWithProviders(<CardBody variant="light" url=" tr.ee/briemix " />)
|
|
139
|
+
expect(screen.getByTestId('link-attachment-url').textContent).toBe(
|
|
140
|
+
'tr.ee/briemix'
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const { container } = renderWithProviders(
|
|
144
|
+
<CardBody
|
|
145
|
+
variant="light"
|
|
146
|
+
url="tr.ee/briemix"
|
|
147
|
+
cta={{ label: 'View FAQs' }}
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
expect(
|
|
151
|
+
container.querySelector('[data-testid="link-attachment-url"]')
|
|
152
|
+
).toBeNull()
|
|
153
|
+
expect(screen.getByText('View FAQs')).toBeInTheDocument()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('paints the cta against ctaSurface rather than the card variant', () => {
|
|
157
|
+
renderWithProviders(
|
|
158
|
+
<CardBody
|
|
159
|
+
variant="dark"
|
|
160
|
+
ctaSurface="light"
|
|
161
|
+
cta={{ label: 'Play', variant: 'secondary' }}
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
// The audio strip is a light surface under a nominally dark card.
|
|
165
|
+
expect(screen.getByText('Play').className).toContain('bg-black/[0.06]')
|
|
166
|
+
|
|
167
|
+
renderWithProviders(
|
|
168
|
+
<CardBody variant="dark" cta={{ label: 'View FAQs' }} />
|
|
169
|
+
)
|
|
170
|
+
expect(screen.getByText('View FAQs').className).toContain('bg-white')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('renders the app icon before the title and the trailing action beside it', () => {
|
|
174
|
+
renderWithProviders(
|
|
175
|
+
<CardBody
|
|
176
|
+
variant="dark"
|
|
177
|
+
title="Spotify"
|
|
178
|
+
appIcon={<span data-testid="app-icon">icon</span>}
|
|
179
|
+
trailingAction={<button type="button">Edit</button>}
|
|
180
|
+
/>
|
|
181
|
+
)
|
|
182
|
+
const icon = screen.getByTestId('app-icon')
|
|
183
|
+
const title = screen.getByText('Spotify')
|
|
184
|
+
expect(icon.parentElement?.className).toContain('shrink-0')
|
|
185
|
+
expect(
|
|
186
|
+
icon.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
187
|
+
).toBeTruthy()
|
|
188
|
+
expect(screen.getByText('Edit').parentElement?.className).toContain(
|
|
189
|
+
'shrink-0'
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('omits the title row entirely when there is no title', () => {
|
|
194
|
+
renderWithProviders(
|
|
195
|
+
<CardBody
|
|
196
|
+
variant="light"
|
|
197
|
+
description="Get answers"
|
|
198
|
+
appIcon={<span data-testid="app-icon">icon</span>}
|
|
199
|
+
/>
|
|
200
|
+
)
|
|
201
|
+
expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('renders two variant-aware skeleton bars instead of text while loading', () => {
|
|
205
|
+
const { container: dark } = renderWithProviders(
|
|
206
|
+
<CardBody variant="dark" title="Brie’s FAQ" url="tr.ee/a" isLoading />
|
|
207
|
+
)
|
|
208
|
+
expect(screen.queryByText('Brie’s FAQ')).not.toBeInTheDocument()
|
|
209
|
+
expect(screen.queryByTestId('link-attachment-url')).not.toBeInTheDocument()
|
|
210
|
+
|
|
211
|
+
const darkBars = dark.querySelectorAll('div.animate-pulse')
|
|
212
|
+
expect(darkBars).toHaveLength(2)
|
|
213
|
+
expect(darkBars[0].className).toContain('h-5 w-3/4')
|
|
214
|
+
expect(darkBars[1].className).toContain('h-4 w-1/2')
|
|
215
|
+
for (const bar of darkBars) {
|
|
216
|
+
expect(bar.className).toContain('rounded')
|
|
217
|
+
expect(bar.className).toContain('bg-white/25')
|
|
218
|
+
expect(bar.getAttribute('aria-hidden')).toBe('true')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { container: light } = renderWithProviders(
|
|
222
|
+
<CardBody variant="light" isLoading />
|
|
223
|
+
)
|
|
224
|
+
for (const bar of light.querySelectorAll('div.animate-pulse')) {
|
|
225
|
+
expect(bar.className).toContain('bg-black/15')
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('renders the skeleton even when there is nothing else to show', () => {
|
|
230
|
+
const { container } = renderWithProviders(
|
|
231
|
+
<CardBody variant="light" isLoading />
|
|
232
|
+
)
|
|
233
|
+
expect(container).not.toBeEmptyDOMElement()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('keeps the cta interactive alongside the chin text', () => {
|
|
237
|
+
const onClick = vi.fn()
|
|
238
|
+
renderWithProviders(
|
|
239
|
+
<CardBody
|
|
240
|
+
variant="light"
|
|
241
|
+
title="FAQ"
|
|
242
|
+
cta={{ label: 'View FAQs', onClick }}
|
|
243
|
+
/>
|
|
244
|
+
)
|
|
245
|
+
fireEvent.click(screen.getByText('View FAQs'))
|
|
246
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { renderWithProviders, screen, fireEvent } from '../../../../test/utils'
|
|
5
|
+
|
|
6
|
+
import CardCta from './CardCta'
|
|
7
|
+
|
|
8
|
+
describe('CardCta', () => {
|
|
9
|
+
it('renders an external anchor when the cta carries an href', () => {
|
|
10
|
+
renderWithProviders(
|
|
11
|
+
<CardCta variant="dark" cta={{ label: 'View FAQs', href: 'tr.ee/faq' }} />
|
|
12
|
+
)
|
|
13
|
+
const cta = screen.getByText('View FAQs')
|
|
14
|
+
expect(cta.tagName).toBe('A')
|
|
15
|
+
// Normalized like the shell anchor so a bare host opens externally.
|
|
16
|
+
expect(cta.getAttribute('href')).toBe('https://tr.ee/faq')
|
|
17
|
+
expect(cta.getAttribute('target')).toBe('_blank')
|
|
18
|
+
expect(cta.getAttribute('rel')).toBe('noopener noreferrer')
|
|
19
|
+
expect(cta.className).toContain('no-underline')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders a button when the href is missing or unsafe', () => {
|
|
23
|
+
renderWithProviders(<CardCta variant="dark" cta={{ label: 'Plain' }} />)
|
|
24
|
+
expect(screen.getByText('Plain').tagName).toBe('BUTTON')
|
|
25
|
+
expect(screen.getByText('Plain').getAttribute('type')).toBe('button')
|
|
26
|
+
|
|
27
|
+
renderWithProviders(
|
|
28
|
+
<CardCta
|
|
29
|
+
variant="dark"
|
|
30
|
+
cta={{ label: 'Unsafe', href: 'javascript:alert(1)' }}
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
expect(screen.getByText('Unsafe').tagName).toBe('BUTTON')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it.each([
|
|
37
|
+
['anchor', 'tr.ee/faq'],
|
|
38
|
+
['button', undefined],
|
|
39
|
+
])(
|
|
40
|
+
'fires the cta callback from the %s without bubbling to the card',
|
|
41
|
+
(_shape, href) => {
|
|
42
|
+
const ctaOnClick = vi.fn()
|
|
43
|
+
const cardOnClick = vi.fn()
|
|
44
|
+
renderWithProviders(
|
|
45
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- stands in for the card anchor the CTA must not bubble to
|
|
46
|
+
<div onClick={cardOnClick}>
|
|
47
|
+
<CardCta
|
|
48
|
+
variant="light"
|
|
49
|
+
cta={{ label: 'Act', href, onClick: ctaOnClick }}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
fireEvent.click(screen.getByText('Act'))
|
|
54
|
+
expect(ctaOnClick).toHaveBeenCalledTimes(1)
|
|
55
|
+
expect(cardOnClick).not.toHaveBeenCalled()
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
['anchor', 'tr.ee/faq'],
|
|
61
|
+
['button', undefined],
|
|
62
|
+
])('tolerates a %s cta with no callback', (_shape, href) => {
|
|
63
|
+
const cardOnClick = vi.fn()
|
|
64
|
+
renderWithProviders(
|
|
65
|
+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- stands in for the card anchor the CTA must not bubble to
|
|
66
|
+
<div onClick={cardOnClick}>
|
|
67
|
+
<CardCta variant="light" cta={{ label: 'Act', href }} />
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
fireEvent.click(screen.getByText('Act'))
|
|
71
|
+
expect(cardOnClick).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it.each([
|
|
75
|
+
['dark', 'primary', 'bg-white text-[#121110]'],
|
|
76
|
+
['dark', 'secondary', 'bg-white/15 text-white'],
|
|
77
|
+
['light', 'primary', 'bg-[#121110] text-white'],
|
|
78
|
+
['light', 'secondary', 'bg-black/[0.06] text-[#121110]'],
|
|
79
|
+
] as const)(
|
|
80
|
+
'paints the %s surface %s emphasis',
|
|
81
|
+
(variant, emphasis, expected) => {
|
|
82
|
+
renderWithProviders(
|
|
83
|
+
<CardCta variant={variant} cta={{ label: 'Act', variant: emphasis }} />
|
|
84
|
+
)
|
|
85
|
+
expect(screen.getByText('Act').className).toContain(expected)
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
it('defaults to the filled primary emphasis', () => {
|
|
90
|
+
renderWithProviders(<CardCta variant="light" cta={{ label: 'Act' }} />)
|
|
91
|
+
const cta = screen.getByText('Act')
|
|
92
|
+
expect(cta.className).toContain('bg-[#121110] text-white')
|
|
93
|
+
expect(cta.className).not.toContain('bg-black/[0.06]')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('spans its row by default and renders the label verbatim', () => {
|
|
97
|
+
renderWithProviders(<CardCta variant="light" cta={{ label: 'Act' }} />)
|
|
98
|
+
const cta = screen.getByText('Act')
|
|
99
|
+
expect(cta.className).toContain('w-full')
|
|
100
|
+
expect(cta.querySelector('span')).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('sizes to its content and truncates the label when fullWidth is off', () => {
|
|
104
|
+
renderWithProviders(
|
|
105
|
+
<CardCta variant="light" cta={{ label: 'Act' }} fullWidth={false} />
|
|
106
|
+
)
|
|
107
|
+
const label = screen.getByText('Act')
|
|
108
|
+
expect(label.tagName).toBe('SPAN')
|
|
109
|
+
expect(label.className).toContain('min-w-0 truncate')
|
|
110
|
+
expect(label.parentElement?.className).toContain('min-w-0')
|
|
111
|
+
expect(label.parentElement?.className).not.toContain('w-full')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('offsets itself below the description unless the caller owns spacing', () => {
|
|
115
|
+
renderWithProviders(<CardCta variant="light" cta={{ label: 'Spaced' }} />)
|
|
116
|
+
expect(screen.getByText('Spaced').className).toContain('mt-2')
|
|
117
|
+
|
|
118
|
+
renderWithProviders(
|
|
119
|
+
<CardCta variant="light" cta={{ label: 'Standalone' }} standalone />
|
|
120
|
+
)
|
|
121
|
+
expect(screen.getByText('Standalone').className).not.toContain('mt-2')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { renderWithProviders, screen, fireEvent } from '../../../../test/utils'
|
|
5
|
+
|
|
6
|
+
import CardShell, { LOADING_LABEL } from './CardShell'
|
|
7
|
+
|
|
8
|
+
const shellOf = (testId = 'shell') => screen.getByTestId(testId)
|
|
9
|
+
|
|
10
|
+
describe('CardShell', () => {
|
|
11
|
+
it('renders a non-interactive div when neither href nor onClick is set', () => {
|
|
12
|
+
renderWithProviders(
|
|
13
|
+
<CardShell variant="light" data-testid="shell">
|
|
14
|
+
body
|
|
15
|
+
</CardShell>
|
|
16
|
+
)
|
|
17
|
+
const shell = shellOf()
|
|
18
|
+
expect(shell.tagName).toBe('DIV')
|
|
19
|
+
expect(shell.className).not.toContain('cursor-pointer')
|
|
20
|
+
expect(shell.className).not.toContain('focus-ring')
|
|
21
|
+
expect(shell.getAttribute('data-variant')).toBe('light')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders an external anchor when href is set and fires onClick alongside navigation', () => {
|
|
25
|
+
const onClick = vi.fn()
|
|
26
|
+
renderWithProviders(
|
|
27
|
+
<CardShell
|
|
28
|
+
variant="dark"
|
|
29
|
+
href="https://tr.ee/briemix"
|
|
30
|
+
onClick={onClick}
|
|
31
|
+
data-testid="shell"
|
|
32
|
+
>
|
|
33
|
+
body
|
|
34
|
+
</CardShell>
|
|
35
|
+
)
|
|
36
|
+
const shell = shellOf()
|
|
37
|
+
expect(shell.tagName).toBe('A')
|
|
38
|
+
expect(shell.getAttribute('href')).toBe('https://tr.ee/briemix')
|
|
39
|
+
expect(shell.getAttribute('target')).toBe('_blank')
|
|
40
|
+
expect(shell.getAttribute('rel')).toBe('noopener noreferrer')
|
|
41
|
+
expect(shell.className).toContain('cursor-pointer')
|
|
42
|
+
expect(shell.className).toContain('no-underline')
|
|
43
|
+
expect(shell.className).toContain('focus-ring')
|
|
44
|
+
fireEvent.click(shell)
|
|
45
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders a labelled button when onClick is set without href', () => {
|
|
49
|
+
const onClick = vi.fn()
|
|
50
|
+
renderWithProviders(
|
|
51
|
+
<CardShell
|
|
52
|
+
variant="light"
|
|
53
|
+
onClick={onClick}
|
|
54
|
+
ariaLabel="Open attachment preview"
|
|
55
|
+
data-testid="shell"
|
|
56
|
+
>
|
|
57
|
+
body
|
|
58
|
+
</CardShell>
|
|
59
|
+
)
|
|
60
|
+
const shell = shellOf()
|
|
61
|
+
expect(shell.tagName).toBe('BUTTON')
|
|
62
|
+
expect(shell.getAttribute('type')).toBe('button')
|
|
63
|
+
expect(shell.getAttribute('aria-label')).toBe('Open attachment preview')
|
|
64
|
+
// The button's own centring is overridden so the chin still reads left-aligned.
|
|
65
|
+
expect(shell.className).toContain('text-left')
|
|
66
|
+
fireEvent.click(shell)
|
|
67
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('paints the variant bubble fill and lets bgClassName override it', () => {
|
|
71
|
+
const { unmount } = renderWithProviders(
|
|
72
|
+
<CardShell variant="dark" data-testid="shell">
|
|
73
|
+
body
|
|
74
|
+
</CardShell>
|
|
75
|
+
)
|
|
76
|
+
expect(shellOf().className).toContain('bg-[#1e2330]')
|
|
77
|
+
expect(shellOf().className).toContain('rounded-[20px]')
|
|
78
|
+
unmount()
|
|
79
|
+
|
|
80
|
+
renderWithProviders(
|
|
81
|
+
<CardShell variant="light" data-testid="light">
|
|
82
|
+
body
|
|
83
|
+
</CardShell>
|
|
84
|
+
)
|
|
85
|
+
expect(shellOf('light').className).toContain('bg-[#f1f0ee]')
|
|
86
|
+
|
|
87
|
+
renderWithProviders(
|
|
88
|
+
<CardShell variant="dark" bgClassName="bg-[#F2F3F4]" data-testid="audio">
|
|
89
|
+
body
|
|
90
|
+
</CardShell>
|
|
91
|
+
)
|
|
92
|
+
const audio = shellOf('audio')
|
|
93
|
+
expect(audio.className).toContain('bg-[#F2F3F4]')
|
|
94
|
+
expect(audio.className).not.toContain('bg-[#1e2330]')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('locks to the 250px column only when fixedHeight is set', () => {
|
|
98
|
+
renderWithProviders(
|
|
99
|
+
<CardShell variant="dark" fixedHeight data-testid="fixed">
|
|
100
|
+
body
|
|
101
|
+
</CardShell>
|
|
102
|
+
)
|
|
103
|
+
expect(shellOf('fixed').className).toContain('flex h-[250px] flex-col')
|
|
104
|
+
expect(shellOf('fixed').className).not.toContain('block')
|
|
105
|
+
|
|
106
|
+
renderWithProviders(
|
|
107
|
+
<CardShell variant="dark" data-testid="intrinsic">
|
|
108
|
+
body
|
|
109
|
+
</CardShell>
|
|
110
|
+
)
|
|
111
|
+
expect(shellOf('intrinsic').className).toContain('block')
|
|
112
|
+
expect(shellOf('intrinsic').className).not.toContain('h-[250px]')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('exposes a labelled busy region only while busy', () => {
|
|
116
|
+
renderWithProviders(
|
|
117
|
+
<CardShell variant="dark" busy data-testid="busy">
|
|
118
|
+
body
|
|
119
|
+
</CardShell>
|
|
120
|
+
)
|
|
121
|
+
const busy = shellOf('busy')
|
|
122
|
+
expect(busy.getAttribute('role')).toBe('status')
|
|
123
|
+
expect(busy.getAttribute('aria-busy')).toBe('true')
|
|
124
|
+
expect(busy.getAttribute('aria-label')).toBe(LOADING_LABEL)
|
|
125
|
+
expect(LOADING_LABEL).toBe('Loading link preview')
|
|
126
|
+
|
|
127
|
+
renderWithProviders(
|
|
128
|
+
<CardShell variant="dark" data-testid="ready">
|
|
129
|
+
body
|
|
130
|
+
</CardShell>
|
|
131
|
+
)
|
|
132
|
+
const ready = shellOf('ready')
|
|
133
|
+
expect(ready.getAttribute('role')).toBeNull()
|
|
134
|
+
expect(ready.getAttribute('aria-busy')).toBeNull()
|
|
135
|
+
expect(ready.getAttribute('aria-label')).toBeNull()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('publishes the accent as a custom property, and nothing without one', () => {
|
|
139
|
+
renderWithProviders(
|
|
140
|
+
<CardShell variant="dark" accentHex="#7008E7" data-testid="accented">
|
|
141
|
+
body
|
|
142
|
+
</CardShell>
|
|
143
|
+
)
|
|
144
|
+
expect(shellOf('accented').style.getPropertyValue('--accent')).toBe(
|
|
145
|
+
'#7008E7'
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
renderWithProviders(
|
|
149
|
+
<CardShell variant="dark" data-testid="plain">
|
|
150
|
+
body
|
|
151
|
+
</CardShell>
|
|
152
|
+
)
|
|
153
|
+
expect(shellOf('plain').style.getPropertyValue('--accent')).toBe('')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('renders the topRight slot pinned to the corner above the content', () => {
|
|
157
|
+
renderWithProviders(
|
|
158
|
+
<CardShell
|
|
159
|
+
variant="dark"
|
|
160
|
+
topRight={<button type="button">Dismiss</button>}
|
|
161
|
+
data-testid="shell"
|
|
162
|
+
>
|
|
163
|
+
body
|
|
164
|
+
</CardShell>
|
|
165
|
+
)
|
|
166
|
+
const corner = screen.getByText('Dismiss').parentElement
|
|
167
|
+
expect(corner?.className).toContain('absolute right-3 top-3')
|
|
168
|
+
expect(corner?.className).toContain('z-10')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('omits the corner wrapper when no topRight is provided', () => {
|
|
172
|
+
const { container } = renderWithProviders(
|
|
173
|
+
<CardShell variant="dark" data-testid="shell">
|
|
174
|
+
body
|
|
175
|
+
</CardShell>
|
|
176
|
+
)
|
|
177
|
+
expect(container.querySelectorAll('div.absolute')).toHaveLength(0)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it.each([
|
|
181
|
+
['anchor', { href: 'https://tr.ee/a' }],
|
|
182
|
+
['button', { onClick: () => {} }],
|
|
183
|
+
['div', {}],
|
|
184
|
+
])('forwards rootRef for the %s shape', (_shape, props) => {
|
|
185
|
+
const ref = React.createRef<HTMLElement>()
|
|
186
|
+
renderWithProviders(
|
|
187
|
+
<CardShell variant="dark" rootRef={ref} data-testid="shell" {...props}>
|
|
188
|
+
body
|
|
189
|
+
</CardShell>
|
|
190
|
+
)
|
|
191
|
+
expect(ref.current).toBe(shellOf())
|
|
192
|
+
})
|
|
193
|
+
})
|