@linktr.ee/messaging-react 4.1.6 → 4.1.7-rc-1787179679

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.
@@ -483,3 +483,330 @@ describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
483
483
  )
484
484
  })
485
485
  })
486
+
487
+ const AUDIO = {
488
+ mimeType: 'audio/mpeg',
489
+ sourceUrl: 'https://cdn.example.com/clip.mp3',
490
+ }
491
+ const VIDEO = {
492
+ mimeType: 'video/mp4',
493
+ sourceUrl: 'https://cdn.example.com/clip.mp4',
494
+ }
495
+ const HERO = 'https://cdn.example.com/yoga.jpg'
496
+
497
+ // Sent / Composer cards carry no test hook of their own; the shell is the
498
+ // element that owns the card chrome classes.
499
+ const shellOf = (container: HTMLElement) =>
500
+ container.querySelector('.messaging-link-card') as HTMLElement
501
+
502
+ describe('LinkAttachment.Composer chrome', () => {
503
+ it('anchors the dismiss control to the hero on a featured card', () => {
504
+ const onDismiss = vi.fn()
505
+ const { container } = renderWithProviders(
506
+ <LinkAttachment.Composer
507
+ title="Brie’s FAQ"
508
+ thumbnailUrl={HERO}
509
+ onDismiss={onDismiss}
510
+ />
511
+ )
512
+ const dismiss = screen.getByLabelText('Dismiss attachment')
513
+ expect(dismiss.parentElement?.className).toContain('absolute right-3 top-3')
514
+ // Sits inside the hero region, not the shell corner.
515
+ expect(container.querySelector('div.flex-1')?.contains(dismiss)).toBe(true)
516
+ // 44x44 touch target without growing the 24px circle.
517
+ expect(dismiss.className).toContain('before:-inset-[10px]')
518
+ fireEvent.click(dismiss)
519
+ expect(onDismiss).toHaveBeenCalledTimes(1)
520
+ })
521
+
522
+ it('moves the dismiss control to the shell corner on a classic card', () => {
523
+ const { container } = renderWithProviders(
524
+ <LinkAttachment.Composer
525
+ title="Brie’s FAQ"
526
+ thumbnailUrl={HERO}
527
+ layout="classic"
528
+ onDismiss={() => {}}
529
+ />
530
+ )
531
+ expect(container.querySelector('img')).toBeNull()
532
+ const shell = shellOf(container)
533
+ expect(shell.contains(screen.getByLabelText('Dismiss attachment'))).toBe(
534
+ true
535
+ )
536
+ // Classic cards size to their content.
537
+ expect(shell.className).not.toContain('h-[250px]')
538
+ expect(shell.className).toContain('block')
539
+ })
540
+
541
+ it('defaults to the featured layout with its hero and fixed box', () => {
542
+ const { container } = renderWithProviders(
543
+ <LinkAttachment.Composer title="Brie’s FAQ" thumbnailUrl={HERO} />
544
+ )
545
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(HERO)
546
+ expect(shellOf(container).className).toContain('h-[250px]')
547
+ })
548
+
549
+ it('collapses an audio composer card to the player plus an inline dismiss', () => {
550
+ const { container } = renderWithProviders(
551
+ <LinkAttachment.Composer
552
+ title="My track"
553
+ url="tr.ee/briemix"
554
+ {...AUDIO}
555
+ onDismiss={() => {}}
556
+ />
557
+ )
558
+ const shell = shellOf(container)
559
+ expect(shell.className).toContain(AUDIO_BG_CLASS)
560
+ expect(container.querySelector('audio')).not.toBeNull()
561
+ // No chin on this path, so the URL line goes with it.
562
+ expect(screen.queryByTestId('link-attachment-url')).toBeNull()
563
+ const dismiss = screen.getByLabelText('Dismiss attachment')
564
+ // Inline sibling of the native controls: no overlay halo on top of them.
565
+ expect(dismiss.className).not.toContain('before:-inset-[10px]')
566
+ expect(dismiss.parentElement?.className).not.toContain('absolute')
567
+ })
568
+
569
+ it('lets the consumer name the dismiss control', () => {
570
+ renderWithProviders(
571
+ <LinkAttachment.Composer
572
+ title="Brie’s FAQ"
573
+ onDismiss={() => {}}
574
+ dismissLabel="Remove link"
575
+ />
576
+ )
577
+ expect(screen.getByLabelText('Remove link')).toBeInTheDocument()
578
+ expect(screen.queryByLabelText('Dismiss attachment')).toBeNull()
579
+ })
580
+
581
+ it('renders the edit affordance only when a handler is given', () => {
582
+ const onEditClick = vi.fn()
583
+ renderWithProviders(
584
+ <LinkAttachment.Composer title="Brie’s FAQ" onEditClick={onEditClick} />
585
+ )
586
+ fireEvent.click(screen.getByLabelText('Edit attachment'))
587
+ expect(onEditClick).toHaveBeenCalledTimes(1)
588
+ expect(screen.queryByLabelText('Dismiss attachment')).toBeNull()
589
+ })
590
+
591
+ it('renders no affordances when the composer passes no handlers', () => {
592
+ renderWithProviders(<LinkAttachment.Composer title="Brie’s FAQ" />)
593
+ expect(screen.queryByRole('button')).toBeNull()
594
+ })
595
+ })
596
+
597
+ describe('LinkAttachment interactivity contract', () => {
598
+ it('keeps the Received shell non-interactive around playable media', () => {
599
+ const onClick = vi.fn()
600
+ renderWithProviders(
601
+ <LinkAttachment.Received
602
+ title="Clip"
603
+ url="tr.ee/briemix"
604
+ thumbnailUrl={HERO}
605
+ {...VIDEO}
606
+ onClick={onClick}
607
+ />
608
+ )
609
+ // The native controls own clicks, so the shell is neither anchor nor button.
610
+ const root = screen.getByTestId('link-attachment')
611
+ expect(root.tagName).toBe('DIV')
612
+ fireEvent.click(root)
613
+ expect(onClick).not.toHaveBeenCalled()
614
+ })
615
+
616
+ it('keeps the Received shell non-interactive when a CTA owns the click', () => {
617
+ renderWithProviders(
618
+ <LinkAttachment.Received
619
+ title="FAQ"
620
+ url="tr.ee/briemix"
621
+ cta={{ label: 'View FAQs' }}
622
+ />
623
+ )
624
+ expect(screen.getByTestId('link-attachment').tagName).toBe('DIV')
625
+ })
626
+
627
+ it('treats a whitespace-only Received url as no url at all', () => {
628
+ renderWithProviders(
629
+ <LinkAttachment.Received title="Brie’s FAQ" url=" " />
630
+ )
631
+ const root = screen.getByTestId('link-attachment')
632
+ expect(root.tagName).toBe('DIV')
633
+ expect(root.hasAttribute('href')).toBe(false)
634
+ expect(screen.queryByTestId('link-attachment-url')).toBeNull()
635
+ })
636
+
637
+ it('names the Received preview button after the title, then falls back', () => {
638
+ const { unmount } = renderWithProviders(
639
+ <LinkAttachment.Received
640
+ title="Featured image"
641
+ thumbnailUrl={HERO}
642
+ onClick={() => {}}
643
+ />
644
+ )
645
+ expect(screen.getByTestId('link-attachment')).toHaveAttribute(
646
+ 'aria-label',
647
+ 'Featured image'
648
+ )
649
+ unmount()
650
+
651
+ renderWithProviders(
652
+ <LinkAttachment.Received thumbnailUrl={HERO} onClick={() => {}} />
653
+ )
654
+ expect(screen.getByTestId('link-attachment')).toHaveAttribute(
655
+ 'aria-label',
656
+ 'Open attachment preview'
657
+ )
658
+ })
659
+
660
+ it('ignores the Sent onClick when there is nothing to open', () => {
661
+ const onClick = vi.fn()
662
+ const { container } = renderWithProviders(
663
+ <LinkAttachment.Sent title="My Playlist" onClick={onClick} />
664
+ )
665
+ const shell = shellOf(container)
666
+ expect(shell.tagName).toBe('DIV')
667
+ fireEvent.click(shell)
668
+ expect(onClick).not.toHaveBeenCalled()
669
+ })
670
+
671
+ it('ignores the Sent onClick around playable media', () => {
672
+ const onClick = vi.fn()
673
+ const { container } = renderWithProviders(
674
+ <LinkAttachment.Sent
675
+ title="Clip"
676
+ url="tr.ee/briemix"
677
+ thumbnailUrl={HERO}
678
+ {...VIDEO}
679
+ onClick={onClick}
680
+ />
681
+ )
682
+ const shell = shellOf(container)
683
+ expect(shell.tagName).toBe('DIV')
684
+ fireEvent.click(shell)
685
+ expect(onClick).not.toHaveBeenCalled()
686
+ })
687
+ })
688
+
689
+ describe('LinkAttachment bubble tail grouping', () => {
690
+ it.each([
691
+ ['single', true],
692
+ ['end', true],
693
+ ['first', false],
694
+ ['middle', false],
695
+ ] as const)(
696
+ 'paints the Received tail for groupPosition %s: %s',
697
+ (groupPosition, expected) => {
698
+ renderWithProviders(
699
+ <LinkAttachment.Received
700
+ title="Brie’s FAQ"
701
+ url="tr.ee/briemix"
702
+ groupPosition={groupPosition}
703
+ />
704
+ )
705
+ expect(screen.queryByTestId('link-card-tail') != null).toBe(expected)
706
+ }
707
+ )
708
+
709
+ it.each([
710
+ ['single', true],
711
+ ['end', true],
712
+ ['first', false],
713
+ ] as const)(
714
+ 'paints the Sent tail for groupPosition %s: %s',
715
+ (groupPosition, expected) => {
716
+ renderWithProviders(
717
+ <LinkAttachment.Sent
718
+ title="Brie’s FAQ"
719
+ url="tr.ee/briemix"
720
+ groupPosition={groupPosition}
721
+ />
722
+ )
723
+ expect(screen.queryByTestId('link-card-tail') != null).toBe(expected)
724
+ }
725
+ )
726
+
727
+ it('defaults both chat cards to the tail-bearing single position', () => {
728
+ const { unmount } = renderWithProviders(
729
+ <LinkAttachment.Received title="Brie’s FAQ" url="tr.ee/briemix" />
730
+ )
731
+ expect(screen.getByTestId('link-card-tail')).toHaveAttribute(
732
+ 'data-variant',
733
+ 'light'
734
+ )
735
+ unmount()
736
+
737
+ renderWithProviders(
738
+ <LinkAttachment.Sent title="Brie’s FAQ" url="tr.ee/briemix" />
739
+ )
740
+ expect(screen.getByTestId('link-card-tail')).toHaveAttribute(
741
+ 'data-variant',
742
+ 'dark'
743
+ )
744
+ })
745
+
746
+ it('gives the audio player card no tail — it is not a bubble', () => {
747
+ const { unmount } = renderWithProviders(
748
+ <LinkAttachment.Sent title="My track" {...AUDIO} groupPosition="end" />
749
+ )
750
+ expect(screen.queryByTestId('link-card-tail')).toBeNull()
751
+ unmount()
752
+
753
+ renderWithProviders(
754
+ <LinkAttachment.Received
755
+ title="My track"
756
+ {...AUDIO}
757
+ groupPosition="end"
758
+ />
759
+ )
760
+ expect(screen.queryByTestId('link-card-tail')).toBeNull()
761
+ })
762
+ })
763
+
764
+ describe('LinkAttachment layout and surface', () => {
765
+ it.each([
766
+ ['Sent', LinkAttachment.Sent],
767
+ ['Received', LinkAttachment.Received],
768
+ ] as const)(
769
+ 'drops the %s hero and the fixed box for classic',
770
+ (_side, Card) => {
771
+ const { container } = renderWithProviders(
772
+ <Card
773
+ title="Brie’s FAQ"
774
+ url="tr.ee/briemix"
775
+ thumbnailUrl={HERO}
776
+ layout="classic"
777
+ />
778
+ )
779
+ expect(container.querySelector('img')).toBeNull()
780
+ const shell = shellOf(container)
781
+ expect(shell.className).not.toContain('h-[250px]')
782
+ expect(shell.className).toContain('block')
783
+ }
784
+ )
785
+
786
+ it.each([
787
+ ['Sent', LinkAttachment.Sent],
788
+ ['Received', LinkAttachment.Received],
789
+ ] as const)('locks the featured %s card to the 250px box', (_side, Card) => {
790
+ const { container } = renderWithProviders(
791
+ <Card title="Brie’s FAQ" url="tr.ee/briemix" thumbnailUrl={HERO} />
792
+ )
793
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(HERO)
794
+ expect(shellOf(container).className).toContain('h-[250px]')
795
+ })
796
+
797
+ it.each([
798
+ ['Sent', LinkAttachment.Sent],
799
+ ['Received', LinkAttachment.Received],
800
+ ] as const)(
801
+ 'sizes the %s audio card to its content on the audio surface',
802
+ (_side, Card) => {
803
+ const { container } = renderWithProviders(
804
+ <Card title="My track" url="tr.ee/briemix" {...AUDIO} />
805
+ )
806
+ const shell = shellOf(container)
807
+ expect(shell.className).toContain(AUDIO_BG_CLASS)
808
+ expect(shell.className).not.toContain('h-[250px]')
809
+ expect(container.querySelector('audio')).not.toBeNull()
810
+ }
811
+ )
812
+ })
@@ -0,0 +1,76 @@
1
+ import React from 'react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { renderWithProviders, screen } from '../../../../test/utils'
5
+
6
+ import BubbleTail from './BubbleTail'
7
+
8
+ describe('BubbleTail', () => {
9
+ it('renders the card untouched when the tail is not visible', () => {
10
+ const { container } = renderWithProviders(
11
+ <BubbleTail side="sender" visible={false}>
12
+ <span>card</span>
13
+ </BubbleTail>
14
+ )
15
+ expect(screen.queryByTestId('link-card-tail')).not.toBeInTheDocument()
16
+ expect(container.firstElementChild?.tagName).toBe('SPAN')
17
+ })
18
+
19
+ it('wraps the card so the tail can hang outside its box', () => {
20
+ renderWithProviders(
21
+ <BubbleTail side="sender" visible>
22
+ <span>card</span>
23
+ </BubbleTail>
24
+ )
25
+ const tail = screen.getByTestId('link-card-tail')
26
+ expect(tail.parentElement?.className).toBe('relative isolate w-fit')
27
+ expect(tail.getAttribute('aria-hidden')).toBe('true')
28
+ // The tail follows the card so it paints over the card's bottom edge.
29
+ expect(screen.getByText('card').nextElementSibling).toBe(tail)
30
+ })
31
+
32
+ it('sits on the dark stop for the sender and stays unmirrored', () => {
33
+ renderWithProviders(
34
+ <BubbleTail side="sender" visible>
35
+ <span>card</span>
36
+ </BubbleTail>
37
+ )
38
+ const tail = screen.getByTestId('link-card-tail')
39
+ expect(tail.getAttribute('data-variant')).toBe('dark')
40
+ expect(tail.className).toContain('messaging-link-card-tail')
41
+ expect(tail.className).not.toContain('messaging-link-card-tail--received')
42
+ })
43
+
44
+ it('mirrors to the pale stop for the receiver', () => {
45
+ renderWithProviders(
46
+ <BubbleTail side="receiver" visible>
47
+ <span>card</span>
48
+ </BubbleTail>
49
+ )
50
+ const tail = screen.getByTestId('link-card-tail')
51
+ expect(tail.getAttribute('data-variant')).toBe('light')
52
+ expect(tail.className).toContain('messaging-link-card-tail--received')
53
+ })
54
+
55
+ it('publishes the accent so the tail derives the card surface in CSS', () => {
56
+ renderWithProviders(
57
+ <BubbleTail side="sender" visible accentHex="#7008E7">
58
+ <span>card</span>
59
+ </BubbleTail>
60
+ )
61
+ expect(
62
+ screen.getByTestId('link-card-tail').style.getPropertyValue('--accent')
63
+ ).toBe('#7008E7')
64
+ })
65
+
66
+ it('falls back to the plain bubble fill without an accent', () => {
67
+ renderWithProviders(
68
+ <BubbleTail side="sender" visible>
69
+ <span>card</span>
70
+ </BubbleTail>
71
+ )
72
+ expect(
73
+ screen.getByTestId('link-card-tail').style.getPropertyValue('--accent')
74
+ ).toBe('')
75
+ })
76
+ })
@@ -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
+ })