@charcuterie/ui 4.12.1 → 4.13.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/MediaTile/MediaTile.d.ts +8 -1
- package/dist/MediaTile/MediaTile.d.ts.map +1 -1
- package/dist/MediaTile/MediaTile.js +20 -6
- package/dist/MediaTile/MediaTile.js.map +1 -1
- package/package.json +1 -1
- package/src/MediaTile/MediaTile.mdx +11 -0
- package/src/MediaTile/MediaTile.stories.tsx +38 -4
- package/src/MediaTile/MediaTile.test.tsx +37 -1
- package/src/MediaTile/MediaTile.tsx +58 -10
|
@@ -12,6 +12,13 @@ export type MediaTileProps = Omit<ComponentPropsWithRef<"figure">, "children"> &
|
|
|
12
12
|
badge?: ReactNode;
|
|
13
13
|
/** Shown in place of the image when it fails. */
|
|
14
14
|
fallback?: ReactNode;
|
|
15
|
+
/** Untruncated content beneath the title and subtitle. No nested controls in an interactive tile. */
|
|
16
|
+
details?: ReactNode;
|
|
17
|
+
/** Keep the entire object visible, or fill the frame. */
|
|
18
|
+
imageFit?: "cover" | "contain";
|
|
19
|
+
/** Put a thumbnail beside the caption for a compact list. */
|
|
20
|
+
layout?: "vertical" | "horizontal";
|
|
21
|
+
isTitleTruncated?: boolean;
|
|
15
22
|
href?: string;
|
|
16
23
|
/**
|
|
17
24
|
* Makes the tile a button. Hover, focus-visible and
|
|
@@ -50,5 +57,5 @@ export type MediaTileProps = Omit<ComponentPropsWithRef<"figure">, "children"> &
|
|
|
50
57
|
* `useState<string>`, which is what makes `loaded → error`
|
|
51
58
|
* impossible instead of merely unlikely — see `mediaStatus.ts`.
|
|
52
59
|
*/
|
|
53
|
-
export declare const MediaTile: ({ alt, aspectRatio, badge, className, fallback, href, isDisabled, onClick, src, subtitle, title, ...figureProps }: MediaTileProps) => ReactNode;
|
|
60
|
+
export declare const MediaTile: ({ alt, aspectRatio, badge, className, fallback, details, imageFit, layout, isTitleTruncated, href, isDisabled, onClick, src, subtitle, title, ...figureProps }: MediaTileProps) => ReactNode;
|
|
54
61
|
//# sourceMappingURL=MediaTile.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MediaTile.d.ts","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"MediaTile.d.ts","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAQd,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAA;AAE1D,MAAM,MAAM,cAAc,GAAG,IAAI,CAC/B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,CACX,GAAG;IACF;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,uDAAuD;IACvD,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,qGAAqG;IACrG,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IAC9B,6DAA6D;IAC7D,MAAM,CAAC,EAAE,UAAU,GAAG,YAAY,CAAA;IAClC,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,qBAAqB,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,CAAA;IACpD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAkBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,SAAS,GAAI,gKAiBvB,cAAc,KAAG,SA+MnB,CAAA"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useStatus } from "@charcuterie/logic";
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
|
+
import { UnstyledLink } from "../RouterLink/UnstyledLink.js";
|
|
4
5
|
import { Skeleton } from "../Skeleton/Skeleton.js";
|
|
5
6
|
import { toClassName } from "../toClassName.js";
|
|
6
7
|
import { mediaTransitions } from "./mediaStatus.js";
|
|
@@ -9,7 +10,7 @@ import { mediaTransitions } from "./mediaStatus.js";
|
|
|
9
10
|
* className="block">` does not get these, which is why a
|
|
10
11
|
* Collection thumbnail looked inert under the pointer.
|
|
11
12
|
*/
|
|
12
|
-
const INTERACTIVE_CLASS = "group flex cursor-pointer
|
|
13
|
+
const INTERACTIVE_CLASS = "group flex cursor-pointer rounded-md outline-offset-2 hover:opacity-90 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring";
|
|
13
14
|
const RATIO_CLASS = {
|
|
14
15
|
// 2:3 is the standard poster trim, which is what Plex, Kavita, and
|
|
15
16
|
// every physical sleeve in the collection actually are.
|
|
@@ -42,7 +43,7 @@ const RATIO_CLASS = {
|
|
|
42
43
|
* `useState<string>`, which is what makes `loaded → error`
|
|
43
44
|
* impossible instead of merely unlikely — see `mediaStatus.ts`.
|
|
44
45
|
*/
|
|
45
|
-
export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallback, href, isDisabled = false, onClick, src, subtitle, title, ...figureProps }) => {
|
|
46
|
+
export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallback, details, imageFit = "cover", layout = "vertical", isTitleTruncated = true, href, isDisabled = false, onClick, src, subtitle, title, ...figureProps }) => {
|
|
46
47
|
const imageRef = useRef(null);
|
|
47
48
|
const { can, reset, status, transitionTo } = useStatus({
|
|
48
49
|
initialState: "loading",
|
|
@@ -51,6 +52,8 @@ export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallb
|
|
|
51
52
|
useEffect(() => {
|
|
52
53
|
// A new `src` is a new load, including after a failure.
|
|
53
54
|
reset();
|
|
55
|
+
if (!src)
|
|
56
|
+
return;
|
|
54
57
|
const image = imageRef.current;
|
|
55
58
|
if (!image?.complete) {
|
|
56
59
|
return;
|
|
@@ -67,8 +70,11 @@ export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallb
|
|
|
67
70
|
if (can("error")) {
|
|
68
71
|
transitionTo("error");
|
|
69
72
|
}
|
|
70
|
-
}, [can, reset, transitionTo]);
|
|
71
|
-
const media = (_jsxs("div", { className: toClassName("relative overflow-hidden rounded-md bg-surface-sunken", RATIO_CLASS[aspectRatio]
|
|
73
|
+
}, [can, reset, src, transitionTo]);
|
|
74
|
+
const media = (_jsxs("div", { className: toClassName("relative overflow-hidden rounded-md bg-surface-sunken", RATIO_CLASS[aspectRatio], layout === "horizontal" &&
|
|
75
|
+
"w-24 shrink-0 self-start cq-sm:w-32"), children: [!src || status === "error" ? (_jsx("div", { "aria-label": alt, className: "flex size-full flex-col items-center justify-center gap-1 p-2 text-center text-content-muted text-xs", role: "img", children: fallback ?? _jsx("span", { children: "Image unavailable" }) })) : (_jsxs(_Fragment, { children: [status === "loading" ? (_jsx(Skeleton, { className: "absolute inset-0 size-full" })) : null, _jsx("img", { alt: href || onClick ? "" : alt, className: toClassName("size-full transition-opacity duration-(--duration-normal) ease-standard", imageFit === "contain"
|
|
76
|
+
? "object-contain"
|
|
77
|
+
: "object-cover", status === "loaded"
|
|
72
78
|
? "opacity-100"
|
|
73
79
|
: "opacity-0"), decoding: "async", loading: "lazy", onError: () => {
|
|
74
80
|
if (can("error")) {
|
|
@@ -79,13 +85,21 @@ export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallb
|
|
|
79
85
|
transitionTo("loaded");
|
|
80
86
|
}
|
|
81
87
|
}, ref: imageRef, src: src })] })), badge ? (_jsx("div", { className: "absolute top-2 start-2", children: badge })) : null] }));
|
|
82
|
-
const caption = title === "" ? null : (_jsxs("figcaption", { className: "flex flex-col gap-
|
|
88
|
+
const caption = title === "" ? null : (_jsxs("figcaption", { className: toClassName("min-w-0 flex-1 flex flex-col gap-1", layout === "vertical" && "pt-2"), children: [_jsx("span", { className: toClassName("font-medium text-content-primary text-sm cq-sm:text-md", isTitleTruncated ? "truncate" : "wrap-anywhere"), title: isTitleTruncated ? title : undefined, children: title }), subtitle ? (_jsx("span", { className: "truncate text-content-muted text-xs", children: subtitle })) : null, details ? (_jsx("div", { className: "min-w-0 wrap-anywhere text-sm text-content-secondary", children: details })) : null] }));
|
|
83
89
|
// An empty `title` is how a tile sits next to a name the
|
|
84
90
|
// parent already printed. The control still needs a name, so
|
|
85
91
|
// the alt is the fallback — wrapping it in a nameless
|
|
86
92
|
// `<button>` was how Collection's cover thumbnail became
|
|
87
93
|
// unfindable as well as un-hoverable.
|
|
88
94
|
const accessibleName = title === "" ? alt : title;
|
|
89
|
-
return (_jsx("figure", { ...figureProps, className: toClassName("@container flex
|
|
95
|
+
return (_jsx("figure", { ...figureProps, className: toClassName("@container flex", !href && !onClick
|
|
96
|
+
? layout === "horizontal"
|
|
97
|
+
? "flex-row gap-4"
|
|
98
|
+
: "flex-col"
|
|
99
|
+
: "flex-col", className), children: href ? (_jsxs(UnstyledLink, { "aria-label": accessibleName, className: toClassName(INTERACTIVE_CLASS, layout === "horizontal"
|
|
100
|
+
? "flex-row gap-4"
|
|
101
|
+
: "flex-col"), href: href, children: [media, caption] })) : onClick ? (_jsxs("button", { "aria-label": accessibleName, className: toClassName(INTERACTIVE_CLASS, layout === "horizontal"
|
|
102
|
+
? "flex-row gap-4"
|
|
103
|
+
: "flex-col", "w-full border-0 bg-transparent p-0 text-start disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:opacity-50"), disabled: isDisabled, onClick: onClick, type: "button", children: [media, caption] })) : (_jsxs(_Fragment, { children: [media, caption] })) }));
|
|
90
104
|
};
|
|
91
105
|
//# sourceMappingURL=MediaTile.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MediaTile.js","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAK9C,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAEzC,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAA0B,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"MediaTile.js","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -54,3 +54,14 @@ The caption's type steps up past `--cq-sm`, so a tile in a dense 8-across grid a
|
|
|
54
54
|
tile in a 3-across one are both legible.
|
|
55
55
|
|
|
56
56
|
<Canvas of={MediaTileStories.Responsive} />
|
|
57
|
+
|
|
58
|
+
## Cards and compact rows
|
|
59
|
+
|
|
60
|
+
`layout="horizontal"` places a bounded thumbnail beside the caption. Use
|
|
61
|
+
`imageFit="contain"` for product images that must remain uncropped, and
|
|
62
|
+
`isTitleTruncated={false}` to wrap distinguishing model names. `details` accepts
|
|
63
|
+
wrapped metadata beneath the caption; it must not contain controls when the tile is a link
|
|
64
|
+
or button. Links use the shared router adapter. An absent source shows the fallback
|
|
65
|
+
immediately, while a supplied source retains the loading and failure states.
|
|
66
|
+
|
|
67
|
+
<Canvas of={MediaTileStories.Compact} />
|
|
@@ -103,12 +103,12 @@ export const AllStates: Story = {
|
|
|
103
103
|
|
|
104
104
|
<StoryCell
|
|
105
105
|
align="stretch"
|
|
106
|
-
label="
|
|
106
|
+
label="missing — honest fallback"
|
|
107
107
|
>
|
|
108
108
|
<MediaTile
|
|
109
|
-
alt="
|
|
110
|
-
subtitle="
|
|
111
|
-
title="
|
|
109
|
+
alt="No cover added"
|
|
110
|
+
subtitle="Add a cover when one is available"
|
|
111
|
+
title="No cover"
|
|
112
112
|
/>
|
|
113
113
|
</StoryCell>
|
|
114
114
|
|
|
@@ -225,3 +225,37 @@ export const InteractiveButton: Story = {
|
|
|
225
225
|
title: "",
|
|
226
226
|
},
|
|
227
227
|
}
|
|
228
|
+
|
|
229
|
+
export const Compact: Story = {
|
|
230
|
+
args: {
|
|
231
|
+
alt: "Portable field recorder",
|
|
232
|
+
aspectRatio: "square",
|
|
233
|
+
details: (
|
|
234
|
+
<>
|
|
235
|
+
<p>3 here</p>
|
|
236
|
+
<p>Audio · Portable</p>
|
|
237
|
+
</>
|
|
238
|
+
),
|
|
239
|
+
href: "#recorder",
|
|
240
|
+
imageFit: "contain",
|
|
241
|
+
isTitleTruncated: false,
|
|
242
|
+
layout: "horizontal",
|
|
243
|
+
src: toPosterSrc("Recorder"),
|
|
244
|
+
title:
|
|
245
|
+
"Portable field recorder with interchangeable microphones",
|
|
246
|
+
},
|
|
247
|
+
render: (args) => (
|
|
248
|
+
<ContainerBoard>
|
|
249
|
+
<MediaTile {...args} />
|
|
250
|
+
</ContainerBoard>
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const MissingImage: Story = {
|
|
255
|
+
args: {
|
|
256
|
+
alt: "No photo of field recorder",
|
|
257
|
+
fallback: "No photo yet",
|
|
258
|
+
title: "Field recorder",
|
|
259
|
+
aspectRatio: "video",
|
|
260
|
+
},
|
|
261
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { composeStories } from "@storybook/react"
|
|
2
|
-
import { expect, userEvent } from "storybook/test"
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test"
|
|
3
3
|
import { test } from "vitest"
|
|
4
4
|
|
|
5
5
|
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
@@ -55,3 +55,39 @@ test("a clickable tile is one button with a pointer cursor", async () => {
|
|
|
55
55
|
|
|
56
56
|
await expect(button).toHaveFocus()
|
|
57
57
|
})
|
|
58
|
+
|
|
59
|
+
test("compact tiles contain the whole image and keep long names readable", async () => {
|
|
60
|
+
const { canvas } = await mountStory(
|
|
61
|
+
composeStories(stories).Compact,
|
|
62
|
+
)
|
|
63
|
+
const links = canvas.getAllByRole("link", {
|
|
64
|
+
name: "Portable field recorder with interchangeable microphones",
|
|
65
|
+
})
|
|
66
|
+
for (const link of links) {
|
|
67
|
+
expectAgentDrivable(
|
|
68
|
+
within(link.parentElement as HTMLElement),
|
|
69
|
+
{
|
|
70
|
+
role: "link",
|
|
71
|
+
name: "Portable field recorder with interchangeable microphones",
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
const image = link.querySelector("img")
|
|
75
|
+
await expect(image).toHaveStyle({
|
|
76
|
+
objectFit: "contain",
|
|
77
|
+
})
|
|
78
|
+
expect(link.scrollWidth).toBeLessThanOrEqual(
|
|
79
|
+
link.clientWidth,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("an absent image shows the fallback without waiting for a failed request", async () => {
|
|
85
|
+
const { canvas } = await mountStory(
|
|
86
|
+
composeStories(stories).MissingImage,
|
|
87
|
+
)
|
|
88
|
+
const fallback = expectAgentDrivable(canvas, {
|
|
89
|
+
role: "img",
|
|
90
|
+
name: "No photo of field recorder",
|
|
91
|
+
})
|
|
92
|
+
await expect(fallback).toHaveTextContent("No photo yet")
|
|
93
|
+
})
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "react"
|
|
6
6
|
import { useEffect, useRef } from "react"
|
|
7
7
|
|
|
8
|
+
import { UnstyledLink } from "../RouterLink/UnstyledLink.tsx"
|
|
8
9
|
import { Skeleton } from "../Skeleton/Skeleton.tsx"
|
|
9
10
|
import { toClassName } from "../toClassName.ts"
|
|
10
11
|
import { mediaTransitions } from "./mediaStatus.ts"
|
|
@@ -26,6 +27,13 @@ export type MediaTileProps = Omit<
|
|
|
26
27
|
badge?: ReactNode
|
|
27
28
|
/** Shown in place of the image when it fails. */
|
|
28
29
|
fallback?: ReactNode
|
|
30
|
+
/** Untruncated content beneath the title and subtitle. No nested controls in an interactive tile. */
|
|
31
|
+
details?: ReactNode
|
|
32
|
+
/** Keep the entire object visible, or fill the frame. */
|
|
33
|
+
imageFit?: "cover" | "contain"
|
|
34
|
+
/** Put a thumbnail beside the caption for a compact list. */
|
|
35
|
+
layout?: "vertical" | "horizontal"
|
|
36
|
+
isTitleTruncated?: boolean
|
|
29
37
|
href?: string
|
|
30
38
|
/**
|
|
31
39
|
* Makes the tile a button. Hover, focus-visible and
|
|
@@ -46,7 +54,7 @@ export type MediaTileProps = Omit<
|
|
|
46
54
|
* Collection thumbnail looked inert under the pointer.
|
|
47
55
|
*/
|
|
48
56
|
const INTERACTIVE_CLASS =
|
|
49
|
-
"group flex cursor-pointer
|
|
57
|
+
"group flex cursor-pointer rounded-md outline-offset-2 hover:opacity-90 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring"
|
|
50
58
|
|
|
51
59
|
const RATIO_CLASS: Record<MediaTileRatio, string> = {
|
|
52
60
|
// 2:3 is the standard poster trim, which is what Plex, Kavita, and
|
|
@@ -87,6 +95,10 @@ export const MediaTile = ({
|
|
|
87
95
|
badge,
|
|
88
96
|
className,
|
|
89
97
|
fallback,
|
|
98
|
+
details,
|
|
99
|
+
imageFit = "cover",
|
|
100
|
+
layout = "vertical",
|
|
101
|
+
isTitleTruncated = true,
|
|
90
102
|
href,
|
|
91
103
|
isDisabled = false,
|
|
92
104
|
onClick,
|
|
@@ -106,6 +118,8 @@ export const MediaTile = ({
|
|
|
106
118
|
// A new `src` is a new load, including after a failure.
|
|
107
119
|
reset()
|
|
108
120
|
|
|
121
|
+
if (!src) return
|
|
122
|
+
|
|
109
123
|
const image = imageRef.current
|
|
110
124
|
|
|
111
125
|
if (!image?.complete) {
|
|
@@ -126,16 +140,18 @@ export const MediaTile = ({
|
|
|
126
140
|
if (can("error")) {
|
|
127
141
|
transitionTo("error")
|
|
128
142
|
}
|
|
129
|
-
}, [can, reset, transitionTo])
|
|
143
|
+
}, [can, reset, src, transitionTo])
|
|
130
144
|
|
|
131
145
|
const media = (
|
|
132
146
|
<div
|
|
133
147
|
className={toClassName(
|
|
134
148
|
"relative overflow-hidden rounded-md bg-surface-sunken",
|
|
135
149
|
RATIO_CLASS[aspectRatio],
|
|
150
|
+
layout === "horizontal" &&
|
|
151
|
+
"w-24 shrink-0 self-start cq-sm:w-32",
|
|
136
152
|
)}
|
|
137
153
|
>
|
|
138
|
-
{status === "error" ? (
|
|
154
|
+
{!src || status === "error" ? (
|
|
139
155
|
<div
|
|
140
156
|
// Unconditionally an `img` carrying the same `alt`, even
|
|
141
157
|
// inside a link. The link's own `aria-label` is a name from
|
|
@@ -164,7 +180,10 @@ export const MediaTile = ({
|
|
|
164
180
|
<img
|
|
165
181
|
alt={href || onClick ? "" : alt}
|
|
166
182
|
className={toClassName(
|
|
167
|
-
"size-full
|
|
183
|
+
"size-full transition-opacity duration-(--duration-normal) ease-standard",
|
|
184
|
+
imageFit === "contain"
|
|
185
|
+
? "object-contain"
|
|
186
|
+
: "object-cover",
|
|
168
187
|
status === "loaded"
|
|
169
188
|
? "opacity-100"
|
|
170
189
|
: "opacity-0",
|
|
@@ -197,8 +216,19 @@ export const MediaTile = ({
|
|
|
197
216
|
|
|
198
217
|
const caption =
|
|
199
218
|
title === "" ? null : (
|
|
200
|
-
<figcaption
|
|
201
|
-
|
|
219
|
+
<figcaption
|
|
220
|
+
className={toClassName(
|
|
221
|
+
"min-w-0 flex-1 flex flex-col gap-1",
|
|
222
|
+
layout === "vertical" && "pt-2",
|
|
223
|
+
)}
|
|
224
|
+
>
|
|
225
|
+
<span
|
|
226
|
+
className={toClassName(
|
|
227
|
+
"font-medium text-content-primary text-sm cq-sm:text-md",
|
|
228
|
+
isTitleTruncated ? "truncate" : "wrap-anywhere",
|
|
229
|
+
)}
|
|
230
|
+
title={isTitleTruncated ? title : undefined}
|
|
231
|
+
>
|
|
202
232
|
{title}
|
|
203
233
|
</span>
|
|
204
234
|
|
|
@@ -207,6 +237,11 @@ export const MediaTile = ({
|
|
|
207
237
|
{subtitle}
|
|
208
238
|
</span>
|
|
209
239
|
) : null}
|
|
240
|
+
{details ? (
|
|
241
|
+
<div className="min-w-0 wrap-anywhere text-sm text-content-secondary">
|
|
242
|
+
{details}
|
|
243
|
+
</div>
|
|
244
|
+
) : null}
|
|
210
245
|
</figcaption>
|
|
211
246
|
)
|
|
212
247
|
|
|
@@ -221,7 +256,12 @@ export const MediaTile = ({
|
|
|
221
256
|
<figure
|
|
222
257
|
{...figureProps}
|
|
223
258
|
className={toClassName(
|
|
224
|
-
"@container flex
|
|
259
|
+
"@container flex",
|
|
260
|
+
!href && !onClick
|
|
261
|
+
? layout === "horizontal"
|
|
262
|
+
? "flex-row gap-4"
|
|
263
|
+
: "flex-col"
|
|
264
|
+
: "flex-col",
|
|
225
265
|
className,
|
|
226
266
|
)}
|
|
227
267
|
>
|
|
@@ -232,20 +272,28 @@ export const MediaTile = ({
|
|
|
232
272
|
// have to know whether a subtitle happened to be rendered.
|
|
233
273
|
// The `<img>` inside goes `alt=""` for the same reason: two
|
|
234
274
|
// names for one link is a screen reader reading it twice.
|
|
235
|
-
<
|
|
275
|
+
<UnstyledLink
|
|
236
276
|
aria-label={accessibleName}
|
|
237
|
-
className={
|
|
277
|
+
className={toClassName(
|
|
278
|
+
INTERACTIVE_CLASS,
|
|
279
|
+
layout === "horizontal"
|
|
280
|
+
? "flex-row gap-4"
|
|
281
|
+
: "flex-col",
|
|
282
|
+
)}
|
|
238
283
|
href={href}
|
|
239
284
|
>
|
|
240
285
|
{media}
|
|
241
286
|
|
|
242
287
|
{caption}
|
|
243
|
-
</
|
|
288
|
+
</UnstyledLink>
|
|
244
289
|
) : onClick ? (
|
|
245
290
|
<button
|
|
246
291
|
aria-label={accessibleName}
|
|
247
292
|
className={toClassName(
|
|
248
293
|
INTERACTIVE_CLASS,
|
|
294
|
+
layout === "horizontal"
|
|
295
|
+
? "flex-row gap-4"
|
|
296
|
+
: "flex-col",
|
|
249
297
|
"w-full border-0 bg-transparent p-0 text-start disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:opacity-50",
|
|
250
298
|
)}
|
|
251
299
|
disabled={isDisabled}
|