@linktr.ee/messaging-react 2.0.1 → 2.1.0-rc-1778695491

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 (64) hide show
  1. package/dist/Card-CFFNq49v.js +163 -0
  2. package/dist/Card-CFFNq49v.js.map +1 -0
  3. package/dist/Card-CsJvUF_b.js +107 -0
  4. package/dist/Card-CsJvUF_b.js.map +1 -0
  5. package/dist/Card-D32U6KfZ.js +85 -0
  6. package/dist/Card-D32U6KfZ.js.map +1 -0
  7. package/dist/Card-DlMSDSdm.js +132 -0
  8. package/dist/Card-DlMSDSdm.js.map +1 -0
  9. package/dist/Card-DlSSJPip.js +60 -0
  10. package/dist/Card-DlSSJPip.js.map +1 -0
  11. package/dist/Card-zGbhRBwv.js +48 -0
  12. package/dist/Card-zGbhRBwv.js.map +1 -0
  13. package/dist/CardThumbnail-DTBuRQHF.js +239 -0
  14. package/dist/CardThumbnail-DTBuRQHF.js.map +1 -0
  15. package/dist/LockedThumbnail-DpJx169C.js +220 -0
  16. package/dist/LockedThumbnail-DpJx169C.js.map +1 -0
  17. package/dist/{index-Bex7eg3v.js → index-DfcRe-Hj.js} +618 -607
  18. package/dist/index-DfcRe-Hj.js.map +1 -0
  19. package/dist/index.d.ts +217 -28
  20. package/dist/index.js +16 -15
  21. package/package.json +1 -1
  22. package/src/components/CustomMessage/index.tsx +2 -3
  23. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +307 -0
  24. package/src/components/LinkAttachment/components/Composer/Card.tsx +117 -0
  25. package/src/components/LinkAttachment/components/Composer/index.ts +2 -0
  26. package/src/components/LinkAttachment/components/Received/Card.tsx +132 -0
  27. package/src/components/LinkAttachment/components/Received/index.ts +2 -0
  28. package/src/components/LinkAttachment/components/Sent/Card.tsx +57 -0
  29. package/src/components/LinkAttachment/components/Sent/index.ts +2 -0
  30. package/src/components/LinkAttachment/components/_shared/CardBody.tsx +117 -0
  31. package/src/components/LinkAttachment/components/_shared/CardCta.tsx +69 -0
  32. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +120 -0
  33. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +156 -0
  34. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.ts +56 -0
  35. package/src/components/LinkAttachment/index.tsx +68 -0
  36. package/src/components/LinkAttachment/types.ts +69 -0
  37. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
  38. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  39. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  40. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  41. package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
  42. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  43. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  44. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  45. package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
  46. package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
  47. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  48. package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
  49. package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
  50. package/src/components/LockedAttachment/index.tsx +43 -12
  51. package/src/components/LockedAttachment/types.ts +17 -0
  52. package/src/components/MediaMessage/index.tsx +8 -2
  53. package/src/index.ts +15 -1
  54. package/dist/Card-BKP9ml9O.js +0 -138
  55. package/dist/Card-BKP9ml9O.js.map +0 -1
  56. package/dist/Card-Bk_4lVzP.js +0 -127
  57. package/dist/Card-Bk_4lVzP.js.map +0 -1
  58. package/dist/index-Bex7eg3v.js.map +0 -1
  59. package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
  60. package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
  61. package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
  62. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
  63. package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
  64. package/src/components/LockedAttachment/components/Visitor/index.ts +0 -2
@@ -0,0 +1,117 @@
1
+ import { PencilSimpleIcon, XIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
3
+
4
+ import type { LinkAttachmentBaseProps } from '../../types'
5
+ import CardBody from '../_shared/CardBody'
6
+ import CardShell from '../_shared/CardShell'
7
+ import CardThumbnail, {
8
+ AUDIO_BG_CLASS,
9
+ isPlayableAudio,
10
+ } from '../_shared/CardThumbnail'
11
+
12
+ export interface ComposerCardProps extends LinkAttachmentBaseProps {
13
+ /**
14
+ * When provided, renders a dismiss X in the thumbnail corner. Called when
15
+ * the composer clicks it to remove the attachment.
16
+ */
17
+ onDismiss?: () => void
18
+ /**
19
+ * When provided, renders a pencil button to the right of the description
20
+ * that the composer can use to edit the attachment metadata.
21
+ */
22
+ onEditClick?: () => void
23
+ }
24
+
25
+ /**
26
+ * The card the composer sees while drafting a link attachment.
27
+ * Matches the Composer column of the messaging design system in Figma.
28
+ */
29
+ const ComposerCard: React.FC<ComposerCardProps> = ({
30
+ title,
31
+ placeholderTitle,
32
+ description,
33
+ url,
34
+ mimeType,
35
+ thumbnailUrl,
36
+ sourceUrl,
37
+ layout = 'featured',
38
+ appIcon,
39
+ cta,
40
+ onDismiss,
41
+ onEditClick,
42
+ }) => {
43
+ const isClassic = layout === 'classic'
44
+ const isAudio = isPlayableAudio(mimeType, sourceUrl)
45
+ const dismissButton = onDismiss ? (
46
+ <button
47
+ type="button"
48
+ onClick={onDismiss}
49
+ aria-label="Dismiss attachment"
50
+ className="flex size-6 items-center justify-center rounded-full bg-[#121110] text-white"
51
+ >
52
+ <XIcon className="size-3" weight="bold" />
53
+ </button>
54
+ ) : undefined
55
+
56
+ const editButton = onEditClick ? (
57
+ <button
58
+ type="button"
59
+ onClick={onEditClick}
60
+ aria-label="Edit attachment"
61
+ className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/15"
62
+ >
63
+ <PencilSimpleIcon className="size-5" weight="regular" />
64
+ </button>
65
+ ) : undefined
66
+
67
+ // Audio cards collapse to just the native player — render the dismiss
68
+ // button as an inline sibling so it always has its own space and never
69
+ // overlaps the audio control's volume/menu buttons.
70
+ if (isAudio) {
71
+ return (
72
+ <CardShell variant="dark" bgClassName={AUDIO_BG_CLASS}>
73
+ <div className="flex items-center gap-2 pr-3">
74
+ <div className="min-w-0 flex-1">
75
+ <CardThumbnail
76
+ variant="dark"
77
+ sourceUrl={sourceUrl}
78
+ title={title}
79
+ mimeType={mimeType}
80
+ />
81
+ </div>
82
+ {dismissButton && <div className="shrink-0">{dismissButton}</div>}
83
+ </div>
84
+ </CardShell>
85
+ )
86
+ }
87
+
88
+ return (
89
+ <CardShell
90
+ variant="dark"
91
+ topRight={isClassic ? dismissButton : undefined}
92
+ >
93
+ {!isClassic && (
94
+ <CardThumbnail
95
+ variant="dark"
96
+ thumbnailUrl={thumbnailUrl}
97
+ sourceUrl={sourceUrl}
98
+ title={title}
99
+ mimeType={mimeType}
100
+ topRight={dismissButton}
101
+ />
102
+ )}
103
+ <CardBody
104
+ variant="dark"
105
+ title={title}
106
+ placeholderTitle={placeholderTitle}
107
+ description={description}
108
+ url={url}
109
+ appIcon={appIcon}
110
+ cta={cta}
111
+ trailingAction={editButton}
112
+ />
113
+ </CardShell>
114
+ )
115
+ }
116
+
117
+ export default ComposerCard
@@ -0,0 +1,2 @@
1
+ export { default as ComposerCard } from './Card'
2
+ export type { ComposerCardProps } from './Card'
@@ -0,0 +1,132 @@
1
+ import React from 'react'
2
+
3
+ import type { LinkAttachmentBaseProps } from '../../types'
4
+ import CardBody from '../_shared/CardBody'
5
+ import CardShell from '../_shared/CardShell'
6
+ import CardThumbnail, {
7
+ AUDIO_BG_CLASS,
8
+ isPlayableAudio,
9
+ isPlayableMedia,
10
+ } from '../_shared/CardThumbnail'
11
+ import { normalizeExternalHref } from '../_shared/normalizeExternalHref'
12
+
13
+ export interface ReceivedCardProps extends LinkAttachmentBaseProps {
14
+ /**
15
+ * Fired when the recipient activates the card. Behavior depends on how
16
+ * the card is configured:
17
+ * - **Link app with a CTA** (FAQ / Form): the CTA owns navigation;
18
+ * `onClick` fires when the recipient taps the CTA itself, alongside
19
+ * `cta.onClick` (use for analytics).
20
+ * - **Link app with a URL** (Spotify / TikTok / generic link): the card
21
+ * chrome is an `<a target="_blank">` opening `url` — `onClick` fires
22
+ * alongside the navigation (use for analytics).
23
+ * - **Image / file / placeholder attachment**: the card has no URL, so
24
+ * it renders as a button. `onClick` is the consumer's hook for
25
+ * opening an image / file preview (lightbox).
26
+ * - **Video / audio attachment**: the shell stays non-interactive so
27
+ * the native media controls remain operable — `onClick` is ignored
28
+ * in this configuration.
29
+ */
30
+ onClick?: () => void
31
+ }
32
+
33
+ /**
34
+ * The card the recipient sees in chat for a link attachment. Matches the
35
+ * Received column of the messaging design system in Figma.
36
+ *
37
+ * The chrome adapts to its content:
38
+ * - Link previews / link apps render the light card body and either
39
+ * navigate via `url` or surface a CTA button when `cta` is set.
40
+ * - Image / file attachments render as media-only cards — the 180px
41
+ * thumbnail (or type-icon placeholder) fills the card with no title /
42
+ * description body. `onClick` fires when the recipient taps the card
43
+ * so consumers can open an image preview / lightbox.
44
+ */
45
+ const ReceivedCard: React.FC<ReceivedCardProps> = ({
46
+ title,
47
+ description,
48
+ url,
49
+ mimeType,
50
+ thumbnailUrl,
51
+ sourceUrl,
52
+ layout = 'featured',
53
+ appIcon,
54
+ cta,
55
+ onClick,
56
+ }) => {
57
+ // The Received card is opened by either the CTA (FAQ / Form), the URL
58
+ // (link previews / Spotify / TikTok), or — for media-only attachments —
59
+ // by tapping the entire card chrome to open a preview. We hand the
60
+ // anchor behavior off to CardShell only when there's no CTA so we don't
61
+ // end up with nested anchors when a CTA is present.
62
+ //
63
+ // Video / audio attachments are an exception: wrapping the shell in
64
+ // either a `<button>` or an `<a>` around `<video controls>` /
65
+ // `<audio controls>` creates nested interactive elements, and clicks on
66
+ // the native media controls can bubble up to fire the outer card
67
+ // action (preview / link navigation). For those, we render a plain
68
+ // non-interactive shell and let the media element own clicks — both
69
+ // `shellHref` and `shellOnClick` are suppressed even when `url` is set.
70
+ const isPlayingMedia = isPlayableMedia(mimeType, sourceUrl)
71
+ // Normalize the URL so a bare hostname like `tr.ee/briemix` (used in
72
+ // our own docs / stories) is treated as an external link instead of a
73
+ // relative path. Returns `undefined` for empty / whitespace-only
74
+ // values, so those fall through to the media-preview path instead of
75
+ // producing an empty `href` on the shell anchor.
76
+ const normalizedUrl = normalizeExternalHref(url)
77
+ const shellHref =
78
+ cta == null && normalizedUrl != null && !isPlayingMedia
79
+ ? normalizedUrl
80
+ : undefined
81
+ const shellOnClick =
82
+ cta == null && !isPlayingMedia ? onClick : undefined
83
+ const audioBg = isPlayableAudio(mimeType, sourceUrl)
84
+ ? AUDIO_BG_CLASS
85
+ : undefined
86
+
87
+ // When a CTA is set the shell isn't interactive — the CTA owns the
88
+ // click target. Forward the card-level `onClick` to the CTA so
89
+ // analytics / side-effect consumers still fire on activation while
90
+ // preserving the CTA's own `onClick` callback.
91
+ const wrappedCta =
92
+ cta && onClick
93
+ ? {
94
+ ...cta,
95
+ onClick: () => {
96
+ onClick()
97
+ cta.onClick?.()
98
+ },
99
+ }
100
+ : cta
101
+
102
+ return (
103
+ <CardShell
104
+ variant="light"
105
+ href={shellHref}
106
+ onClick={shellOnClick}
107
+ ariaLabel={title ?? 'Open attachment preview'}
108
+ bgClassName={audioBg}
109
+ data-testid="link-attachment"
110
+ >
111
+ {layout === 'featured' && (
112
+ <CardThumbnail
113
+ variant="light"
114
+ thumbnailUrl={thumbnailUrl}
115
+ sourceUrl={sourceUrl}
116
+ title={title}
117
+ mimeType={mimeType}
118
+ />
119
+ )}
120
+ <CardBody
121
+ variant="light"
122
+ title={title}
123
+ description={description}
124
+ url={url}
125
+ appIcon={appIcon}
126
+ cta={wrappedCta}
127
+ />
128
+ </CardShell>
129
+ )
130
+ }
131
+
132
+ export default ReceivedCard
@@ -0,0 +1,2 @@
1
+ export { default as ReceivedCard } from './Card'
2
+ export type { ReceivedCardProps } from './Card'
@@ -0,0 +1,57 @@
1
+ import React from 'react'
2
+
3
+ import type { LinkAttachmentBaseProps } from '../../types'
4
+ import CardBody from '../_shared/CardBody'
5
+ import CardShell from '../_shared/CardShell'
6
+ import CardThumbnail, {
7
+ AUDIO_BG_CLASS,
8
+ isPlayableAudio,
9
+ } from '../_shared/CardThumbnail'
10
+
11
+ export interface SentCardProps extends LinkAttachmentBaseProps {}
12
+
13
+ /**
14
+ * The card the sender sees in chat after a link attachment has been posted.
15
+ * Matches the Sent column of the messaging design system in Figma — same
16
+ * dark chrome as the Composer card minus the dismiss / edit affordances.
17
+ */
18
+ const SentCard: React.FC<SentCardProps> = ({
19
+ title,
20
+ placeholderTitle,
21
+ description,
22
+ url,
23
+ mimeType,
24
+ thumbnailUrl,
25
+ sourceUrl,
26
+ layout = 'featured',
27
+ appIcon,
28
+ cta,
29
+ }) => (
30
+ <CardShell
31
+ variant="dark"
32
+ bgClassName={
33
+ isPlayableAudio(mimeType, sourceUrl) ? AUDIO_BG_CLASS : undefined
34
+ }
35
+ >
36
+ {layout === 'featured' && (
37
+ <CardThumbnail
38
+ variant="dark"
39
+ thumbnailUrl={thumbnailUrl}
40
+ sourceUrl={sourceUrl}
41
+ title={title}
42
+ mimeType={mimeType}
43
+ />
44
+ )}
45
+ <CardBody
46
+ variant="dark"
47
+ title={title}
48
+ placeholderTitle={placeholderTitle}
49
+ description={description}
50
+ url={url}
51
+ appIcon={appIcon}
52
+ cta={cta}
53
+ />
54
+ </CardShell>
55
+ )
56
+
57
+ export default SentCard
@@ -0,0 +1,2 @@
1
+ export { default as SentCard } from './Card'
2
+ export type { SentCardProps } from './Card'
@@ -0,0 +1,117 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ import type { LinkAttachmentCta } from '../../types'
5
+
6
+ import CardCta from './CardCta'
7
+ import type { LinkAttachmentVariant } from './CardShell'
8
+
9
+ export interface CardBodyProps {
10
+ variant: LinkAttachmentVariant
11
+ title?: string
12
+ /** Placeholder shown in the title slot when no title is set (dark variants only). */
13
+ placeholderTitle?: string
14
+ description?: string
15
+ /** Footer URL shown below the description. Ignored when `cta` is set. */
16
+ url?: string
17
+ /**
18
+ * Optional 16x16 brand badge rendered before the title (used by Link Apps:
19
+ * Spotify, TikTok, FAQ, Form, etc.).
20
+ */
21
+ appIcon?: React.ReactNode
22
+ /** Optional CTA rendered in place of the URL footer. */
23
+ cta?: LinkAttachmentCta
24
+ /** Trailing action rendered on the right of the title/description block. */
25
+ trailingAction?: React.ReactNode
26
+ }
27
+
28
+ const TITLE_CLASS_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
29
+ dark: 'text-white',
30
+ light: 'text-black/90',
31
+ }
32
+
33
+ const TITLE_DIMMED_CLASS = 'text-white/30'
34
+
35
+ const SECONDARY_CLASS_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
36
+ dark: 'text-white/55',
37
+ light: 'text-black/55',
38
+ }
39
+
40
+ /**
41
+ * Body of a `LinkAttachment.*` card. Matches the Figma `Container > Labels`
42
+ * group: 16px horizontal padding, 12px vertical padding, 8px gap between
43
+ * the title/description group and the URL/CTA footer, 4px gap within the
44
+ * title group.
45
+ *
46
+ * Returns `null` when there's nothing to render so plain image / file
47
+ * attachments collapse to a thumbnail-only card.
48
+ */
49
+ const CardBody: React.FC<CardBodyProps> = ({
50
+ variant,
51
+ title,
52
+ placeholderTitle,
53
+ description,
54
+ url,
55
+ appIcon,
56
+ cta,
57
+ trailingAction,
58
+ }) => {
59
+ const isDark = variant === 'dark'
60
+ const displayTitle = title ?? (isDark ? placeholderTitle : undefined) ?? ''
61
+ const hasTitle = displayTitle.trim() !== ''
62
+ const hasDescription =
63
+ description != null && description.trim() !== ''
64
+ // Mirror the trimming applied by `ReceivedCard` so a whitespace-only
65
+ // `url` collapses the body footer (and the whole body, for media-only
66
+ // cards) instead of rendering an empty line.
67
+ const trimmedUrl = typeof url === 'string' ? url.trim() : ''
68
+ const hasUrl = trimmedUrl !== ''
69
+ const hasCta = cta != null
70
+
71
+ if (!hasTitle && !hasDescription && !hasUrl && !hasCta) return null
72
+
73
+ const titleDimmed = isDark && !title
74
+
75
+ const titleClass = classNames(
76
+ 'truncate text-base font-medium leading-6',
77
+ titleDimmed ? TITLE_DIMMED_CLASS : TITLE_CLASS_BY_VARIANT[variant]
78
+ )
79
+
80
+ const secondaryClass = classNames(
81
+ 'truncate text-xs leading-4',
82
+ SECONDARY_CLASS_BY_VARIANT[variant]
83
+ )
84
+
85
+ return (
86
+ <div className="px-4 py-3">
87
+ <div className="flex items-end gap-3">
88
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
89
+ <div className="flex min-w-0 flex-col gap-1">
90
+ {hasTitle && (
91
+ <div className="flex min-w-0 items-center gap-2">
92
+ {appIcon ? <span className="shrink-0">{appIcon}</span> : null}
93
+ <p className={classNames('min-w-0', titleClass)}>
94
+ {displayTitle}
95
+ </p>
96
+ </div>
97
+ )}
98
+
99
+ {hasDescription && (
100
+ <p className={secondaryClass}>{description}</p>
101
+ )}
102
+ </div>
103
+
104
+ {!hasCta && hasUrl && (
105
+ <p className={secondaryClass}>{trimmedUrl}</p>
106
+ )}
107
+ </div>
108
+
109
+ {trailingAction && <div className="shrink-0">{trailingAction}</div>}
110
+ </div>
111
+
112
+ {cta && <CardCta variant={variant} cta={cta} />}
113
+ </div>
114
+ )
115
+ }
116
+
117
+ export default CardBody
@@ -0,0 +1,69 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ import type { LinkAttachmentCta } from '../../types'
5
+
6
+ import type { LinkAttachmentVariant } from './CardShell'
7
+ import { normalizeExternalHref } from './normalizeExternalHref'
8
+
9
+ export interface CardCtaProps {
10
+ variant: LinkAttachmentVariant
11
+ cta: LinkAttachmentCta
12
+ }
13
+
14
+ const BUTTON_CLASS_BY_VARIANT: Record<LinkAttachmentVariant, string> = {
15
+ dark: 'bg-white text-[#121110] hover:bg-white/90',
16
+ light: 'bg-[#121110] text-white hover:bg-[#2a2928]',
17
+ }
18
+
19
+ /**
20
+ * Pill-shaped CTA rendered below the description on Link App cards that
21
+ * surface an action instead of a URL (e.g. FAQ "View FAQs", Form "Complete form").
22
+ * Renders as `<a target="_blank">` when `cta.href` is set, otherwise as a
23
+ * plain `<button>`.
24
+ */
25
+ const CardCta: React.FC<CardCtaProps> = ({ variant, cta }) => {
26
+ const className = classNames(
27
+ 'mt-2 inline-flex h-10 w-full items-center justify-center rounded-full px-4 text-sm font-medium leading-none transition-colors',
28
+ BUTTON_CLASS_BY_VARIANT[variant]
29
+ )
30
+
31
+ // Mirror the URL normalization used by the shell anchor so bare
32
+ // hostnames (e.g. `tr.ee/foo`) open as external links rather than
33
+ // resolving against the current host.
34
+ const normalizedHref = normalizeExternalHref(cta.href)
35
+
36
+ if (normalizedHref) {
37
+ return (
38
+ <a
39
+ href={normalizedHref}
40
+ target="_blank"
41
+ rel="noopener noreferrer"
42
+ onClick={(e) => {
43
+ // Stop the click from bubbling up to the card's anchor wrapper
44
+ // (Received variant) so we don't navigate twice.
45
+ e.stopPropagation()
46
+ cta.onClick?.()
47
+ }}
48
+ className={`${className} no-underline`}
49
+ >
50
+ {cta.label}
51
+ </a>
52
+ )
53
+ }
54
+
55
+ return (
56
+ <button
57
+ type="button"
58
+ onClick={(e) => {
59
+ e.stopPropagation()
60
+ cta.onClick?.()
61
+ }}
62
+ className={className}
63
+ >
64
+ {cta.label}
65
+ </button>
66
+ )
67
+ }
68
+
69
+ export default CardCta
@@ -0,0 +1,120 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ export type LinkAttachmentVariant = 'dark' | 'light'
5
+
6
+ export interface CardShellProps {
7
+ variant: LinkAttachmentVariant
8
+ children: React.ReactNode
9
+ /**
10
+ * When provided, the entire card chrome is rendered as an anchor (used by
11
+ * the Received card to open the link target on click). Falls back to a
12
+ * `<div>` when omitted so Composer / Sent cards stay non-navigational.
13
+ */
14
+ href?: string
15
+ /**
16
+ * Click handler for the card chrome. When `href` is set the shell is an
17
+ * anchor and `onClick` is invoked in addition to navigation. When `href`
18
+ * is omitted but `onClick` is set, the shell renders as a clickable
19
+ * button (used by media-only Received cards to open an image preview).
20
+ */
21
+ onClick?: () => void
22
+ /** Accessible label for the clickable variant (when `onClick` is set without `href`). */
23
+ ariaLabel?: string
24
+ rootRef?: React.Ref<HTMLElement>
25
+ /**
26
+ * Absolutely-positioned slot rendered in the top-right corner of the
27
+ * shell. Used by the Composer card to surface its dismiss affordance
28
+ * when there's no hero thumbnail to anchor it to.
29
+ */
30
+ topRight?: React.ReactNode
31
+ /**
32
+ * Overrides the variant-derived background colour (e.g. audio cards
33
+ * use `bg-[#F2F3F4]` regardless of the dark/light variant).
34
+ */
35
+ bgClassName?: string
36
+ 'data-testid'?: string
37
+ }
38
+
39
+ const SHELL_CLASS = classNames(
40
+ 'relative block w-[280px] select-none overflow-hidden rounded-md',
41
+ 'shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_1px_2px_rgba(0,0,0,0.04),0_8px_32px_rgba(0,0,0,0.1)]'
42
+ )
43
+
44
+ /**
45
+ * Outer chrome for every `LinkAttachment.*` card. Matches the 280px width,
46
+ * 16px corner radius, and shadow-400 treatment from the Figma design system.
47
+ */
48
+ const CardShell: React.FC<CardShellProps> = ({
49
+ variant,
50
+ children,
51
+ href,
52
+ onClick,
53
+ ariaLabel,
54
+ rootRef,
55
+ topRight,
56
+ bgClassName,
57
+ 'data-testid': dataTestId,
58
+ }) => {
59
+ const isInteractive = href != null || onClick != null
60
+ const className = classNames(
61
+ SHELL_CLASS,
62
+ bgClassName ?? (variant === 'dark' ? 'bg-[#121110]' : 'bg-white'),
63
+ // `focus-ring` is a design-system utility from the component-library
64
+ // tailwind preset — outline-none + a black 2px focus-visible ring
65
+ // with offset, so keyboard users can see the focused card.
66
+ isInteractive ? 'cursor-pointer no-underline focus-ring' : null
67
+ )
68
+
69
+ const corner = topRight ? (
70
+ <div className="pointer-events-auto absolute right-3 top-3 z-10">
71
+ {topRight}
72
+ </div>
73
+ ) : null
74
+
75
+ if (href) {
76
+ return (
77
+ <a
78
+ ref={rootRef as React.Ref<HTMLAnchorElement>}
79
+ href={href}
80
+ target="_blank"
81
+ rel="noopener noreferrer"
82
+ onClick={onClick}
83
+ data-testid={dataTestId}
84
+ className={className}
85
+ >
86
+ {children}
87
+ {corner}
88
+ </a>
89
+ )
90
+ }
91
+
92
+ if (onClick) {
93
+ return (
94
+ <button
95
+ ref={rootRef as React.Ref<HTMLButtonElement>}
96
+ type="button"
97
+ onClick={onClick}
98
+ aria-label={ariaLabel}
99
+ data-testid={dataTestId}
100
+ className={classNames(className, 'text-left')}
101
+ >
102
+ {children}
103
+ {corner}
104
+ </button>
105
+ )
106
+ }
107
+
108
+ return (
109
+ <div
110
+ ref={rootRef as React.Ref<HTMLDivElement>}
111
+ data-testid={dataTestId}
112
+ className={className}
113
+ >
114
+ {children}
115
+ {corner}
116
+ </div>
117
+ )
118
+ }
119
+
120
+ export default CardShell