@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.
Files changed (71) hide show
  1. package/dist/{Card-B4lMv-0o.js → Card-BOudQBd6.js} +2 -2
  2. package/dist/{Card-B4lMv-0o.js.map → Card-BOudQBd6.js.map} +1 -1
  3. package/dist/{Card-DWL5I62S.cjs → Card-k2959zNX.cjs} +2 -2
  4. package/dist/{Card-DWL5I62S.cjs.map → Card-k2959zNX.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-C93LTGLc.js → index-6RdyZh6F.js} +1809 -1912
  7. package/dist/index-6RdyZh6F.js.map +1 -0
  8. package/dist/index-BcU18EjV.cjs +3 -0
  9. package/dist/index-BcU18EjV.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +78 -59
  12. package/dist/index.js +30 -26
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.js +2 -2
  16. package/dist/testing.js.map +1 -1
  17. package/package.json +2 -2
  18. package/src/components/AttachmentCard/MediaPlayer.test.tsx +346 -0
  19. package/src/components/AttachmentCard/Thumbnail.test.tsx +215 -0
  20. package/src/components/AttachmentCard/index.test.tsx +160 -0
  21. package/src/components/Avatar/Avatar.test.tsx +126 -0
  22. package/src/components/Avatar/getAvatarEmoji.test.ts +39 -0
  23. package/src/components/ChannelList/ChannelListContext.test.tsx +87 -0
  24. package/src/components/ChannelList/CustomChannelPreview.test.tsx +410 -3
  25. package/src/components/ChannelList/index.test.tsx +132 -1
  26. package/src/components/CustomMessage/CustomMessage.stories.tsx +6 -314
  27. package/src/components/CustomMessage/CustomMessage.test.tsx +56 -2
  28. package/src/components/CustomMessage/Locked.stories.tsx +108 -0
  29. package/src/components/CustomMessage/MessageTag.stories.tsx +17 -53
  30. package/src/components/CustomMessage/MessageTag.test.tsx +7 -40
  31. package/src/components/CustomMessage/MessageTag.tsx +7 -39
  32. package/src/components/CustomMessage/PaidMessage.stories.tsx +42 -0
  33. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +8 -7
  34. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +2 -1
  35. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
  36. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -6
  37. package/src/components/CustomMessage/Tip.stories.tsx +48 -0
  38. package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +51 -0
  39. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +65 -0
  40. package/src/components/CustomMessage/TipMessage/index.tsx +27 -0
  41. package/src/components/CustomMessage/index.tsx +22 -19
  42. package/src/components/FaqList/FaqList.test.tsx +204 -0
  43. package/src/components/LinkAttachment/LinkAttachment.test.tsx +327 -0
  44. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +76 -0
  45. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +248 -0
  46. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +123 -0
  47. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +193 -0
  48. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +242 -0
  49. package/src/components/LinkAttachment/components/_shared/hex.test.ts +37 -0
  50. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +72 -0
  51. package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +162 -0
  52. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +58 -165
  53. package/src/components/MessageAttachment/Audio/index.tsx +77 -71
  54. package/src/components/MessageAttachment/MessageAttachment.test.tsx +74 -176
  55. package/src/components/MessageAttachment/index.tsx +9 -25
  56. package/src/components/MessageAttachment/types.ts +9 -19
  57. package/src/components/MessagingShell/MessagingShell.test.tsx +20 -0
  58. package/src/components/MessagingShell/index.tsx +2 -0
  59. package/src/index.ts +4 -0
  60. package/src/logging/index.test.tsx +136 -0
  61. package/src/logging/index.tsx +48 -0
  62. package/src/providers/MessagingProvider.test.tsx +113 -1
  63. package/src/providers/MessagingProvider.tsx +25 -15
  64. package/src/stories/customMessageStoryTemplate.tsx +199 -0
  65. package/src/styles.css +10 -121
  66. package/src/testing/createMockMessagingClient.ts +2 -2
  67. package/src/types.ts +9 -0
  68. package/dist/index-B-9LkWep.cjs +0 -3
  69. package/dist/index-B-9LkWep.cjs.map +0 -1
  70. package/dist/index-C93LTGLc.js.map +0 -1
  71. 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
+ })