@linktr.ee/messaging-react 2.1.0 → 2.2.0-rc-1778753733

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 (58) hide show
  1. package/dist/{Card-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
  2. package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
  3. package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
  4. package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
  5. package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
  6. package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
  7. package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
  8. package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
  9. package/dist/index-Dn7BC9xK.js +4748 -0
  10. package/dist/index-Dn7BC9xK.js.map +1 -0
  11. package/dist/index.d.ts +591 -25
  12. package/dist/index.js +24 -19
  13. package/package.json +1 -1
  14. package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
  15. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
  16. package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
  17. package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
  18. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
  19. package/src/components/LinkAttachment/index.tsx +24 -50
  20. package/src/components/LinkAttachment/types.ts +12 -5
  21. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
  22. package/src/components/MessageAttachment/Audio/index.tsx +189 -0
  23. package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
  24. package/src/components/MessageAttachment/File/index.tsx +240 -0
  25. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
  26. package/src/components/MessageAttachment/Image/index.tsx +257 -0
  27. package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
  28. package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
  29. package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
  30. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
  31. package/src/components/MessageAttachment/Video/index.tsx +281 -0
  32. package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
  33. package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
  34. package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
  35. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
  36. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
  37. package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
  38. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
  39. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
  40. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
  41. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
  42. package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
  43. package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
  44. package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
  45. package/src/components/MessageAttachment/index.tsx +149 -0
  46. package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
  47. package/src/components/MessageAttachment/types.ts +178 -0
  48. package/src/index.ts +32 -0
  49. package/dist/Card-D32U6KfZ.js +0 -85
  50. package/dist/Card-D32U6KfZ.js.map +0 -1
  51. package/dist/Card-DlSSJPip.js +0 -60
  52. package/dist/Card-DlSSJPip.js.map +0 -1
  53. package/dist/Card-zGbhRBwv.js +0 -48
  54. package/dist/Card-zGbhRBwv.js.map +0 -1
  55. package/dist/CardThumbnail-DTBuRQHF.js +0 -239
  56. package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
  57. package/dist/index-DfcRe-Hj.js +0 -3103
  58. package/dist/index-DfcRe-Hj.js.map +0 -1
@@ -0,0 +1,173 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ import type { BubbleGroupPosition, BubbleVariant } from '../types'
5
+
6
+ export interface BubbleProps {
7
+ variant: BubbleVariant
8
+ /** Optional message text rendered below the attachment slot. */
9
+ text?: React.ReactNode
10
+ /**
11
+ * Renders a hairline border around the bubble. Defaults to `true` —
12
+ * matches the design system's "small border around link / message
13
+ * attachments" treatment from the mobile spec.
14
+ */
15
+ bordered?: boolean
16
+ /**
17
+ * Position of this bubble inside a same-author message run. Drives
18
+ * the corner-flattening that visually merges consecutive bubbles
19
+ * (matches the grouping stream-chat-react applies to text bubbles).
20
+ * Defaults to `'single'` so standalone usage keeps every corner
21
+ * fully rounded.
22
+ */
23
+ groupPosition?: BubbleGroupPosition
24
+ className?: string
25
+ children: React.ReactNode
26
+ 'data-testid'?: string
27
+ }
28
+
29
+ // Colors and metrics tracked from `stream-chat-react`'s default
30
+ // `.str-chat__message-bubble` token set so attachments visually drop
31
+ // into a normal `CustomMessage` thread without any seams:
32
+ // - light → `--str-chat__secondary-surface-color` = grey200 (#e9eaed)
33
+ // - dark → `.str-chat__message--me` override in `styles.css` (#121110)
34
+ // - text padding → `--str-chat__spacing-2 --str-chat__spacing-4` = 8px 16px
35
+ // - border-radius → `--str-chat__border-radius-md` = 18px
36
+ //
37
+ // TODO: migrate to Tailwind theme tokens once they exist for the
38
+ // `linktree.dark` / `linktree.surface.secondary` palette — these hex
39
+ // literals are repeated across LinkAttachment / LockedAttachment /
40
+ // MediaMessage / CustomMessageInput / DownloadAction etc., so a
41
+ // rebrand would need to touch every site. A central token would
42
+ // collapse the migration to one definition.
43
+ const BUBBLE_BG_BY_VARIANT: Record<BubbleVariant, string> = {
44
+ dark: 'bg-[#121110]',
45
+ light: 'bg-[#e9eaed]',
46
+ }
47
+
48
+ const BUBBLE_TEXT_BY_VARIANT: Record<BubbleVariant, string> = {
49
+ dark: 'text-white',
50
+ light: 'text-[#080707]',
51
+ }
52
+
53
+ const BUBBLE_BORDER_BY_VARIANT: Record<BubbleVariant, string> = {
54
+ dark: 'border-white/[0.08]',
55
+ light: 'border-black/[0.08]',
56
+ }
57
+
58
+ /**
59
+ * Per-corner rounding tables that mirror stream-chat-react's default
60
+ * bubble grouping. Sender bubbles cluster against the right edge so
61
+ * the right corners get flattened in a run; receiver bubbles cluster
62
+ * against the left edge so the left corners get flattened.
63
+ *
64
+ * Rounded corners use `--str-chat__border-radius-md` (18px) and
65
+ * flattened corners use `--str-chat__border-radius-sm` (4px) — same
66
+ * tokens stream's stylesheet uses for `.str-chat__message-bubble`
67
+ * inside a `--first` / `--group` / `--end` wrapper.
68
+ */
69
+ type BubbleSide = 'sender' | 'receiver'
70
+
71
+ const sideForVariant = (variant: BubbleVariant): BubbleSide =>
72
+ variant === 'dark' ? 'sender' : 'receiver'
73
+
74
+ const CORNER_CLASSES_BY_SIDE_AND_POSITION: Record<
75
+ BubbleSide,
76
+ Record<BubbleGroupPosition, string>
77
+ > = {
78
+ sender: {
79
+ single:
80
+ 'rounded-tl-[18px] rounded-tr-[18px] rounded-bl-[18px] rounded-br-[18px]',
81
+ first:
82
+ 'rounded-tl-[18px] rounded-tr-[18px] rounded-bl-[18px] rounded-br-[4px]',
83
+ middle:
84
+ 'rounded-tl-[18px] rounded-tr-[4px] rounded-bl-[18px] rounded-br-[4px]',
85
+ end: 'rounded-tl-[18px] rounded-tr-[4px] rounded-bl-[18px] rounded-br-[18px]',
86
+ },
87
+ receiver: {
88
+ single:
89
+ 'rounded-tl-[18px] rounded-tr-[18px] rounded-bl-[18px] rounded-br-[18px]',
90
+ first:
91
+ 'rounded-tl-[18px] rounded-tr-[18px] rounded-bl-[4px] rounded-br-[18px]',
92
+ middle:
93
+ 'rounded-tl-[4px] rounded-tr-[18px] rounded-bl-[4px] rounded-br-[18px]',
94
+ end: 'rounded-tl-[4px] rounded-tr-[18px] rounded-bl-[18px] rounded-br-[18px]',
95
+ },
96
+ }
97
+
98
+ /**
99
+ * Chat-bubble container shared by every `MessageAttachment.*` card.
100
+ *
101
+ * Holds the attachment slot (image / video / row) and an optional
102
+ * `text` caption rendered below it — mirrors the mobile chat layout
103
+ * where a sender can attach a caption alongside an attachment ('Here
104
+ * is the file', 'Here is the image').
105
+ *
106
+ * Two visual variants:
107
+ * - `dark` — sender-side (Composer / Sent), white text on `#121110`.
108
+ * - `light` — recipient-side (Received), dark text on `#e9eaed`.
109
+ *
110
+ * Background colors, padding, border-radius, and inherited font come
111
+ * from the stream-chat-react `.str-chat__message-bubble` token set so
112
+ * attachments line up with the `CustomMessage` text bubbles in a real
113
+ * conversation. Padding is uniform across every variant — text rows,
114
+ * audio rows, document rows, and media bubbles all get the same
115
+ * 8px / 16px inset, with media inside getting its own rounded corners.
116
+ */
117
+ const Bubble: React.FC<BubbleProps> = ({
118
+ variant,
119
+ text,
120
+ bordered = true,
121
+ groupPosition = 'single',
122
+ className,
123
+ children,
124
+ 'data-testid': dataTestId,
125
+ }) => {
126
+ const hasText = text != null && text !== ''
127
+ const cornerClasses =
128
+ CORNER_CLASSES_BY_SIDE_AND_POSITION[sideForVariant(variant)][groupPosition]
129
+
130
+ return (
131
+ <div
132
+ data-testid={dataTestId}
133
+ data-group-position={groupPosition}
134
+ className={classNames(
135
+ // 280px-wide bubble — matches the mobile chat attachment width
136
+ // and keeps the document / image / audio bubbles visually
137
+ // consistent inside the conversation timeline. The 8px / 16px
138
+ // inset matches `--str-chat__spacing-2 --str-chat__spacing-4`
139
+ // so attachments share the same hit / negative-space rhythm
140
+ // as the surrounding `CustomMessage` text bubbles.
141
+ 'relative w-[280px] overflow-hidden px-2 py-2',
142
+ cornerClasses,
143
+ BUBBLE_BG_BY_VARIANT[variant],
144
+ BUBBLE_TEXT_BY_VARIANT[variant],
145
+ bordered && 'border',
146
+ bordered && BUBBLE_BORDER_BY_VARIANT[variant],
147
+ className
148
+ )}
149
+ >
150
+ {children}
151
+
152
+ {hasText ? (
153
+ <p
154
+ className={classNames(
155
+ // No `text-*` / `font-*` overrides here — caption inherits
156
+ // the same font family + size as `.str-chat__message-text`
157
+ // so it matches the surrounding `CustomMessage` bubbles.
158
+ 'whitespace-pre-wrap break-words leading-snug',
159
+ // Top gutter only — bubble's `py-2` already supplies the
160
+ // bottom inset, and the children above already render
161
+ // flush against their own bottom edge.
162
+ 'pt-2',
163
+ 'px-2'
164
+ )}
165
+ >
166
+ {text}
167
+ </p>
168
+ ) : null}
169
+ </div>
170
+ )
171
+ }
172
+
173
+ export default Bubble
@@ -0,0 +1,152 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ import { renderTypeIcon } from '../../AttachmentCard'
5
+ import type { BubbleVariant } from '../types'
6
+
7
+ import { buildCompactMetaLabel } from './fileMeta'
8
+
9
+ export interface CompactDocumentRowProps {
10
+ variant: BubbleVariant
11
+ /** Filename — drives the title (when no `title` set) and the `EXT` label. */
12
+ filename?: string
13
+ /** Override displayed title. Defaults to `filename`. */
14
+ title?: string
15
+ mimeType?: string
16
+ fileSize?: number
17
+ /**
18
+ * When set, the icon + filename area becomes a `<button>` that fires
19
+ * this handler. Lets the row's primary activation (e.g. opening the
20
+ * PDF viewer) live separately from the trailing slot's own button
21
+ * (e.g. a download icon) without nesting `<button>` inside `<button>`.
22
+ * When omitted, the row renders as a plain presentational `<div>`
23
+ * and the caller is responsible for any outer activation wrapper.
24
+ */
25
+ onActivate?: () => void
26
+ /**
27
+ * Required when `onActivate` is set. Visible to assistive tech as
28
+ * the inner button's label (e.g. `'Open ESOP-summary.pdf'`).
29
+ */
30
+ activateLabel?: string
31
+ /**
32
+ * Optional trailing slot — sits outside the activation button so it
33
+ * can host its own interactive controls (e.g. a download `<button>`,
34
+ * the Composer's `DismissButton`) without nesting one button inside
35
+ * another.
36
+ */
37
+ trailingAction?: React.ReactNode
38
+ }
39
+
40
+ // Meta line + icon tile are tinted relative to the bubble background,
41
+ // so they stay variant-aware. The title inherits the bubble's text
42
+ // color (set on the parent `Bubble`).
43
+ //
44
+ // `text-black/55` on the `#e9eaed` light bubble lands right at the
45
+ // WCAG AA 4.5:1 small-text threshold (≈4.5:1) — bump to `/65` so
46
+ // the meta line has a comfortable margin on the light bubble. The
47
+ // dark variant (`text-white/55` on `#121110`) already passes AA at
48
+ // ~6:1, so we leave it alone.
49
+ const META_CLASS_BY_VARIANT: Record<BubbleVariant, string> = {
50
+ dark: 'text-white/55',
51
+ light: 'text-black/65',
52
+ }
53
+
54
+ const ICON_BG_BY_VARIANT: Record<BubbleVariant, string> = {
55
+ dark: 'bg-white/10',
56
+ light: 'bg-black/5',
57
+ }
58
+
59
+ const ICON_COLOR_BY_VARIANT: Record<BubbleVariant, string> = {
60
+ dark: 'text-white/85',
61
+ light: 'text-black/85',
62
+ }
63
+
64
+ /**
65
+ * Compact icon-row used by `Pdf` / `File` `MessageAttachment` variants.
66
+ * Mirrors the mobile chat document attachment from
67
+ * `EARN-8448/pr-2-chat-bubbles-and-file-icons` — a 40x40 typed icon
68
+ * tile on the left, filename + `EXT · SIZE` meta on the right.
69
+ *
70
+ * The row is always wrapped by `Bubble`, which contributes the bubble
71
+ * background, padding, border, and (when the parent wires it up) the
72
+ * click target.
73
+ */
74
+ const CompactDocumentRow: React.FC<CompactDocumentRowProps> = ({
75
+ variant,
76
+ filename,
77
+ title,
78
+ mimeType = 'application/octet-stream',
79
+ fileSize,
80
+ onActivate,
81
+ activateLabel,
82
+ trailingAction,
83
+ }) => {
84
+ const resolvedTitle = title ?? filename ?? 'File'
85
+ const metaLabel = buildCompactMetaLabel(mimeType, filename, fileSize)
86
+
87
+ const iconTile = (
88
+ <div
89
+ className={classNames(
90
+ 'flex size-10 shrink-0 items-center justify-center rounded-sm',
91
+ ICON_BG_BY_VARIANT[variant]
92
+ )}
93
+ aria-hidden
94
+ >
95
+ {renderTypeIcon(mimeType, {
96
+ className: classNames('size-6', ICON_COLOR_BY_VARIANT[variant]),
97
+ weight: 'regular',
98
+ })}
99
+ </div>
100
+ )
101
+
102
+ const textBlock = (
103
+ <div className="flex min-w-0 flex-1 flex-col text-left">
104
+ <p className="truncate font-medium leading-snug">{resolvedTitle}</p>
105
+ {metaLabel ? (
106
+ <p
107
+ className={classNames(
108
+ 'truncate text-xs leading-4',
109
+ META_CLASS_BY_VARIANT[variant]
110
+ )}
111
+ >
112
+ {metaLabel}
113
+ </p>
114
+ ) : null}
115
+ </div>
116
+ )
117
+
118
+ // When the row is activatable, its body (icon + text) becomes the
119
+ // click target — wrapping it in a `<button>` rather than wrapping
120
+ // the entire row preserves the trailing slot for its own buttons
121
+ // (download / dismiss) instead of nesting them inside one.
122
+ const body = onActivate ? (
123
+ <button
124
+ type="button"
125
+ onClick={onActivate}
126
+ aria-label={activateLabel}
127
+ className={classNames(
128
+ 'flex min-w-0 flex-1 items-center gap-3 rounded-sm text-left transition-colors',
129
+ variant === 'dark'
130
+ ? 'hover:bg-white/[0.04]'
131
+ : 'hover:bg-black/[0.04]'
132
+ )}
133
+ >
134
+ {iconTile}
135
+ {textBlock}
136
+ </button>
137
+ ) : (
138
+ <>
139
+ {iconTile}
140
+ {textBlock}
141
+ </>
142
+ )
143
+
144
+ return (
145
+ <div className="flex items-center gap-3 px-3 py-2">
146
+ {body}
147
+ {trailingAction ? <div className="shrink-0">{trailingAction}</div> : null}
148
+ </div>
149
+ )
150
+ }
151
+
152
+ export default CompactDocumentRow
@@ -0,0 +1,39 @@
1
+ import { XIcon } from '@phosphor-icons/react'
2
+ import classNames from 'classnames'
3
+ import React from 'react'
4
+
5
+ export interface DismissButtonProps {
6
+ onClick: () => void
7
+ /**
8
+ * `'overlay'` — translucent button sitting on top of media (image /
9
+ * video corner). `'inline'` — opaque button placed in the row of a
10
+ * compact attachment alongside other content.
11
+ */
12
+ variant?: 'overlay' | 'inline'
13
+ ariaLabel?: string
14
+ }
15
+
16
+ const DismissButton: React.FC<DismissButtonProps> = ({
17
+ onClick,
18
+ variant = 'overlay',
19
+ ariaLabel = 'Dismiss attachment',
20
+ }) => (
21
+ <button
22
+ type="button"
23
+ onClick={(e) => {
24
+ e.stopPropagation()
25
+ onClick()
26
+ }}
27
+ aria-label={ariaLabel}
28
+ className={classNames(
29
+ 'flex size-6 items-center justify-center rounded-full text-white',
30
+ variant === 'overlay'
31
+ ? 'bg-[#121110]/85 backdrop-blur'
32
+ : 'bg-white/15 hover:bg-white/25'
33
+ )}
34
+ >
35
+ <XIcon className="size-3" weight="bold" />
36
+ </button>
37
+ )
38
+
39
+ export default DismissButton
@@ -0,0 +1,175 @@
1
+ import {
2
+ CircleNotchIcon,
3
+ DownloadSimpleIcon,
4
+ IconProps,
5
+ } from '@phosphor-icons/react'
6
+ import classNames from 'classnames'
7
+ import React, { useCallback, useState } from 'react'
8
+
9
+ import { triggerDownload } from './triggerDownload'
10
+
11
+ export type DownloadActionVariant =
12
+ /** Solid pill button used inside compact / file rows. */
13
+ | 'pill'
14
+ /** Round translucent overlay button used over media (image / video viewers). */
15
+ | 'overlay'
16
+ /** Flat icon button used in viewer toolbars. */
17
+ | 'toolbar'
18
+ /**
19
+ * Compact round icon button sized for the trailing slot of a
20
+ * `CompactDocumentRow` (PDF / File rows). Adopts the row's tone so
21
+ * it sits inline next to the filename without competing with it.
22
+ */
23
+ | 'inline'
24
+
25
+ export interface DownloadActionProps {
26
+ url: string
27
+ filename?: string
28
+ variant?: DownloadActionVariant
29
+ /**
30
+ * Override the visible label on `pill` variants. Defaults to
31
+ * `'Download'`. Hidden on `overlay` / `toolbar` variants.
32
+ */
33
+ label?: string
34
+ /** Hide the label, keeping just the icon. Defaults to `true` for non-pill variants. */
35
+ iconOnly?: boolean
36
+ /** Tone of the surface. Used by the `pill` variant. */
37
+ tone?: 'dark' | 'light'
38
+ /**
39
+ * Triggered after the download starts so consumers can fire analytics
40
+ * or close a viewer. Errors during download don't suppress the call.
41
+ */
42
+ onTriggered?: () => void
43
+ }
44
+
45
+ const DownloadAction: React.FC<DownloadActionProps> = ({
46
+ url,
47
+ filename,
48
+ variant = 'pill',
49
+ label = 'Download',
50
+ iconOnly,
51
+ tone = 'dark',
52
+ onTriggered,
53
+ }) => {
54
+ const [busy, setBusy] = useState(false)
55
+
56
+ const handleClick = useCallback(
57
+ (e: React.MouseEvent) => {
58
+ e.stopPropagation()
59
+ if (busy) return
60
+ setBusy(true)
61
+ triggerDownload(url, filename)
62
+ .catch(() => {
63
+ /* swallowed — fallback path inside `triggerDownload` */
64
+ })
65
+ .finally(() => {
66
+ setBusy(false)
67
+ onTriggered?.()
68
+ })
69
+ },
70
+ [busy, url, filename, onTriggered]
71
+ )
72
+
73
+ const showIconOnly = iconOnly ?? variant !== 'pill'
74
+
75
+ const iconClass = classNames(
76
+ variant === 'pill' ? 'size-4' : 'size-5',
77
+ 'shrink-0'
78
+ )
79
+ const iconProps: IconProps = { className: iconClass, weight: 'bold' }
80
+
81
+ if (variant === 'inline') {
82
+ // Sized to match the existing trailing slot used by `DismissButton`
83
+ // and the decorative download span in `FileAttachment`. Tone keys
84
+ // off the surrounding `Bubble` variant so a sender (dark) bubble
85
+ // gets a lighter icon and a receiver (light) bubble gets a darker
86
+ // one — same approach `CompactDocumentRow` already uses for the
87
+ // type icon.
88
+ const inlineToneClasses: Record<'dark' | 'light', string> = {
89
+ dark: 'text-white/70 hover:bg-white/[0.08] hover:text-white',
90
+ light: 'text-black/70 hover:bg-black/[0.08] hover:text-black',
91
+ }
92
+ return (
93
+ <button
94
+ type="button"
95
+ onClick={handleClick}
96
+ disabled={busy}
97
+ aria-label={label}
98
+ className={classNames(
99
+ 'flex size-8 shrink-0 items-center justify-center rounded-full transition-colors disabled:opacity-70',
100
+ inlineToneClasses[tone]
101
+ )}
102
+ >
103
+ {busy ? (
104
+ <CircleNotchIcon
105
+ className="size-4 animate-spin"
106
+ weight="bold"
107
+ aria-hidden
108
+ />
109
+ ) : (
110
+ <DownloadSimpleIcon
111
+ className="size-5 shrink-0"
112
+ weight="bold"
113
+ aria-hidden
114
+ />
115
+ )}
116
+ </button>
117
+ )
118
+ }
119
+
120
+ if (variant === 'pill') {
121
+ return (
122
+ <button
123
+ type="button"
124
+ onClick={handleClick}
125
+ disabled={busy}
126
+ aria-label={showIconOnly ? label : undefined}
127
+ className={classNames(
128
+ 'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full px-4 text-sm font-medium leading-none transition-colors disabled:opacity-70',
129
+ tone === 'dark'
130
+ ? 'bg-[#121110] text-white hover:bg-[#2a2928]'
131
+ : 'bg-white text-[#121110] hover:bg-white/90'
132
+ )}
133
+ >
134
+ {busy ? (
135
+ <CircleNotchIcon
136
+ className="size-4 animate-spin"
137
+ weight="bold"
138
+ aria-hidden
139
+ />
140
+ ) : (
141
+ <DownloadSimpleIcon {...iconProps} aria-hidden />
142
+ )}
143
+ {showIconOnly ? null : label}
144
+ </button>
145
+ )
146
+ }
147
+
148
+ // overlay / toolbar — round translucent button with just the icon
149
+ return (
150
+ <button
151
+ type="button"
152
+ onClick={handleClick}
153
+ disabled={busy}
154
+ aria-label={label}
155
+ className={classNames(
156
+ 'flex size-10 shrink-0 items-center justify-center rounded-full text-white transition-colors disabled:opacity-70',
157
+ variant === 'overlay'
158
+ ? 'bg-black/55 backdrop-blur hover:bg-black/70'
159
+ : 'bg-white/10 hover:bg-white/20'
160
+ )}
161
+ >
162
+ {busy ? (
163
+ <CircleNotchIcon
164
+ className="size-5 animate-spin"
165
+ weight="bold"
166
+ aria-hidden
167
+ />
168
+ ) : (
169
+ <DownloadSimpleIcon {...iconProps} aria-hidden />
170
+ )}
171
+ </button>
172
+ )
173
+ }
174
+
175
+ export default DownloadAction