@linktr.ee/messaging-react 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Card-CFFNq49v.js +163 -0
- package/dist/Card-CFFNq49v.js.map +1 -0
- package/dist/Card-CsJvUF_b.js +107 -0
- package/dist/Card-CsJvUF_b.js.map +1 -0
- package/dist/Card-D32U6KfZ.js +85 -0
- package/dist/Card-D32U6KfZ.js.map +1 -0
- package/dist/Card-DlMSDSdm.js +132 -0
- package/dist/Card-DlMSDSdm.js.map +1 -0
- package/dist/Card-DlSSJPip.js +60 -0
- package/dist/Card-DlSSJPip.js.map +1 -0
- package/dist/Card-zGbhRBwv.js +48 -0
- package/dist/Card-zGbhRBwv.js.map +1 -0
- package/dist/CardThumbnail-DTBuRQHF.js +239 -0
- package/dist/CardThumbnail-DTBuRQHF.js.map +1 -0
- package/dist/LockedThumbnail-DpJx169C.js +220 -0
- package/dist/LockedThumbnail-DpJx169C.js.map +1 -0
- package/dist/{index-Bex7eg3v.js → index-DfcRe-Hj.js} +618 -607
- package/dist/index-DfcRe-Hj.js.map +1 -0
- package/dist/index.d.ts +217 -28
- package/dist/index.js +16 -15
- package/package.json +1 -1
- package/src/components/CustomMessage/index.tsx +2 -3
- package/src/components/LinkAttachment/LinkAttachment.stories.tsx +307 -0
- package/src/components/LinkAttachment/components/Composer/Card.tsx +117 -0
- package/src/components/LinkAttachment/components/Composer/index.ts +2 -0
- package/src/components/LinkAttachment/components/Received/Card.tsx +132 -0
- package/src/components/LinkAttachment/components/Received/index.ts +2 -0
- package/src/components/LinkAttachment/components/Sent/Card.tsx +57 -0
- package/src/components/LinkAttachment/components/Sent/index.ts +2 -0
- package/src/components/LinkAttachment/components/_shared/CardBody.tsx +117 -0
- package/src/components/LinkAttachment/components/_shared/CardCta.tsx +69 -0
- package/src/components/LinkAttachment/components/_shared/CardShell.tsx +120 -0
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +156 -0
- package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.ts +56 -0
- package/src/components/LinkAttachment/index.tsx +68 -0
- package/src/components/LinkAttachment/types.ts +69 -0
- package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
- package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
- package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
- package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
- package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
- package/src/components/LockedAttachment/components/Received/index.ts +2 -0
- package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
- package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
- package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
- package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
- package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
- package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
- package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
- package/src/components/LockedAttachment/index.tsx +43 -12
- package/src/components/LockedAttachment/types.ts +17 -0
- package/src/components/MediaMessage/index.tsx +8 -2
- package/src/index.ts +15 -1
- package/dist/Card-BKP9ml9O.js +0 -138
- package/dist/Card-BKP9ml9O.js.map +0 -1
- package/dist/Card-Bk_4lVzP.js +0 -127
- package/dist/Card-Bk_4lVzP.js.map +0 -1
- package/dist/index-Bex7eg3v.js.map +0 -1
- package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
- package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
- package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
- package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
- package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
- 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,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,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,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
|