@charcuterie/ui 4.12.1 → 4.13.1

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.
@@ -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;AAOd,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,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,mHAavB,cAAc,KAAG,SA2KnB,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 flex-col 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
+ 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]), children: [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 object-cover transition-opacity duration-(--duration-normal) ease-standard", status === "loaded"
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-0.5 pt-2", children: [_jsx("span", { className: "truncate font-medium text-content-primary text-sm cq-sm:text-md", children: title }), subtitle ? (_jsx("span", { className: "truncate text-content-muted text-xs", children: subtitle })) : null] }));
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 flex-col", className), children: href ? (_jsxs("a", { "aria-label": accessibleName, className: INTERACTIVE_CLASS, href: href, children: [media, caption] })) : onClick ? (_jsxs("button", { "aria-label": accessibleName, className: toClassName(INTERACTIVE_CLASS, "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] })) }));
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":"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"}
1
+ {"version":3,"file":"MediaTile.js","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "4.12.1",
3
+ "version": "4.13.1",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,7 +53,7 @@
53
53
  "typecheck": "tsc --noEmit"
54
54
  },
55
55
  "dependencies": {
56
- "@charcuterie/logic": "2.4.0",
56
+ "@charcuterie/logic": "2.5.0",
57
57
  "@charcuterie/tokens": "1.10.2",
58
58
  "@floating-ui/react": "^0.27.20",
59
59
  "@tanstack/react-virtual": "^3.13.12",
@@ -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="loading — skeleton holds the box"
106
+ label="missing — honest fallback"
107
107
  >
108
108
  <MediaTile
109
- alt="Poster loading"
110
- subtitle="waiting for the image"
111
- title="Still loading"
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 flex-col rounded-md outline-offset-2 hover:opacity-90 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring"
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 object-cover transition-opacity duration-(--duration-normal) ease-standard",
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 className="flex flex-col gap-0.5 pt-2">
201
- <span className="truncate font-medium text-content-primary text-sm cq-sm:text-md">
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 flex-col",
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
- <a
275
+ <UnstyledLink
236
276
  aria-label={accessibleName}
237
- className={INTERACTIVE_CLASS}
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
- </a>
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}
@@ -157,6 +157,10 @@ test("it renders what the editor renders", async () => {
157
157
  test("a link is a real anchor in the tab order", async () => {
158
158
  const { canvas } = await mountStory(Interactive)
159
159
 
160
+ // Markdown decorations arrive after the editor's asynchronous parse.
161
+ // Wait for the link before checking its accessibility and keyboard behavior.
162
+ await canvas.findByRole("link", { name: "the runbook" })
163
+
160
164
  const link = expectAgentDrivable(canvas, {
161
165
  name: "the runbook",
162
166
  role: "link",