@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.
- 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/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/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/testing/createMockMessagingClient.ts +2 -2
|
@@ -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
|
+
})
|