@jant/core 0.6.0 → 0.6.2
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/app-CUZaVgsC.js +6 -0
- package/dist/{app-BIkkbVQk.js → app-Ct9c4zYF.js} +298 -59
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-Bp2IPjDe.js +275 -0
- package/dist/client/_assets/client-YVrRjAid.css +2 -0
- package/dist/client/_assets/{client-auth-D1jDQgbH.js → client-auth-C4hQWqH1.js} +4 -4
- package/dist/{env-C7e2Nlnt.js → env-CoSe-1y4.js} +1 -1
- package/dist/{export-Bbn86HmS.js → export-O2w3AsZX.js} +4 -4
- package/dist/{github-api-Bh0PH3zr.js → github-api-UD4u_7fa.js} +1 -1
- package/dist/{github-app-D0GvNnqp.js → github-app-DeX6Td1O.js} +1 -1
- package/dist/{github-sync-dXsiZa_e.js → github-sync-BUzIYouS.js} +3 -3
- package/dist/{github-sync-CBQPRZ8H.js → github-sync-D49RADci.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-umUptr5z.js → url-XF0GbKGO.js} +22 -1
- package/package.json +1 -1
- package/src/client/__tests__/image-processor.test.ts +64 -0
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +79 -8
- package/src/client/components/jant-compose-editor.ts +2 -2
- package/src/client/components/jant-media-lightbox.ts +33 -5
- package/src/client/image-processor.ts +89 -30
- package/src/client/media-scroll-hint.ts +62 -9
- package/src/i18n/coverage.generated.ts +2 -2
- package/src/i18n/locales/settings/zh-Hans.po +24 -24
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +24 -24
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/structured-data.test.ts +87 -0
- package/src/lib/post-display.ts +78 -1
- package/src/lib/render.tsx +28 -0
- package/src/lib/structured-data.ts +113 -0
- package/src/lib/url.ts +26 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +65 -0
- package/src/routes/api/internal/sites.ts +19 -0
- package/src/routes/pages/home.tsx +21 -1
- package/src/routes/pages/page.tsx +53 -2
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +30 -29
- package/src/services/export-theme/layouts/partials/media-gallery.html +16 -7
- package/src/services/site-admin.ts +53 -1
- package/src/styles/site-media.css +70 -24
- package/src/styles/ui.css +24 -18
- package/src/ui/feed/ThreadPreview.tsx +0 -1
- package/src/ui/feed/__tests__/thread-preview.test.ts +0 -1
- package/src/ui/layouts/BaseLayout.tsx +110 -16
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +146 -0
- package/src/ui/pages/PostPage.tsx +0 -1
- package/src/ui/shared/MediaGallery.tsx +50 -7
- package/src/ui/shared/__tests__/media-gallery.test.ts +31 -0
- package/dist/app-Bcr5_wZI.js +0 -6
- package/dist/client/_assets/client-Bo7sKkAQ.js +0 -274
- package/dist/client/_assets/client-QHRvzZwk.css +0 -2
|
@@ -230,7 +230,6 @@ describe("getThreadPreviewState", () => {
|
|
|
230
230
|
expect(html).toContain("thread-context-shell");
|
|
231
231
|
expect(html).toContain("data-thread-context");
|
|
232
232
|
expect(html).toContain("data-collapsed");
|
|
233
|
-
expect(html).toContain("thread-context-fade");
|
|
234
233
|
expect(html).toContain("data-thread-context-toggle");
|
|
235
234
|
expect(html).toContain('aria-expanded="false"');
|
|
236
235
|
expect(html).toMatch(/data-label-more="[^"]+"/);
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "../../lib/asset-path.js";
|
|
20
20
|
import { getJantIconHref } from "../../lib/jant-branding.js";
|
|
21
21
|
import { getThemeBrowserColors, resolveBuiltinTheme } from "../../lib/theme.js";
|
|
22
|
-
import {
|
|
22
|
+
import { toAbsoluteAssetUrl, toPublicPath } from "../../lib/url.js";
|
|
23
23
|
import {
|
|
24
24
|
CLIENT_AUTH_JS_FILE,
|
|
25
25
|
CLIENT_CJK_CSS_FILE,
|
|
@@ -52,6 +52,26 @@ export interface BaseLayoutProps {
|
|
|
52
52
|
faviconUrl?: string;
|
|
53
53
|
faviconVersion?: string;
|
|
54
54
|
socialImageUrl?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Alt text describing an explicitly provided `socialImageUrl`. Ignored when
|
|
57
|
+
* the social image falls back to the site avatar or the Jant default.
|
|
58
|
+
*/
|
|
59
|
+
socialImageAlt?: string;
|
|
60
|
+
/** Pixel width of an explicitly provided `socialImageUrl`, when known. */
|
|
61
|
+
socialImageWidth?: number;
|
|
62
|
+
/** Pixel height of an explicitly provided `socialImageUrl`, when known. */
|
|
63
|
+
socialImageHeight?: number;
|
|
64
|
+
/**
|
|
65
|
+
* JSON-LD structured data object (or array of objects) rendered as a
|
|
66
|
+
* `<script type="application/ld+json">`. Skipped when the page is noindex.
|
|
67
|
+
*/
|
|
68
|
+
jsonLd?: unknown;
|
|
69
|
+
/** Open Graph object type. Defaults to "website". */
|
|
70
|
+
ogType?: "website" | "article";
|
|
71
|
+
/** ISO 8601 publish time, rendered as `article:published_time` for articles. */
|
|
72
|
+
articlePublishedTime?: string;
|
|
73
|
+
/** ISO 8601 modified time, rendered as `article:modified_time` for articles. */
|
|
74
|
+
articleModifiedTime?: string;
|
|
55
75
|
/**
|
|
56
76
|
* Absolute canonical URL for the current page. Rendered as
|
|
57
77
|
* `<link rel="canonical">` when set. Use on pages whose primary content is
|
|
@@ -75,6 +95,13 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
75
95
|
faviconUrl,
|
|
76
96
|
faviconVersion,
|
|
77
97
|
socialImageUrl,
|
|
98
|
+
socialImageAlt,
|
|
99
|
+
socialImageWidth,
|
|
100
|
+
socialImageHeight,
|
|
101
|
+
jsonLd,
|
|
102
|
+
ogType,
|
|
103
|
+
articlePublishedTime,
|
|
104
|
+
articleModifiedTime,
|
|
78
105
|
canonicalHref,
|
|
79
106
|
noindex,
|
|
80
107
|
isAuthenticated = false,
|
|
@@ -91,10 +118,13 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
91
118
|
|
|
92
119
|
// Read favicon/noindex from appConfig when not provided as prop
|
|
93
120
|
const appConfig = c ? c.get("appConfig") : undefined;
|
|
121
|
+
// Use `||` instead of `??` so empty strings (the unset state for
|
|
122
|
+
// `appConfig.siteAvatarUrl`) fall through to the Jant default; otherwise
|
|
123
|
+
// sites without a custom avatar render no og:image / twitter:image at all.
|
|
94
124
|
const resolvedSocialImagePath =
|
|
95
|
-
socialImageUrl
|
|
96
|
-
faviconUrl
|
|
97
|
-
appConfig?.siteAvatarUrl
|
|
125
|
+
socialImageUrl ||
|
|
126
|
+
faviconUrl ||
|
|
127
|
+
appConfig?.siteAvatarUrl ||
|
|
98
128
|
getJantIconHref("socialImage", appConfig?.sitePathPrefix || "");
|
|
99
129
|
const resolvedFaviconVersion =
|
|
100
130
|
faviconVersion ?? (appConfig?.faviconVersion || undefined);
|
|
@@ -179,16 +209,34 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
179
209
|
sitePathPrefix,
|
|
180
210
|
)
|
|
181
211
|
: toPublicPath("/apple-touch-icon.png", sitePathPrefix));
|
|
182
|
-
const socialImageHref =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
212
|
+
const socialImageHref = resolvedSocialImagePath
|
|
213
|
+
? toAbsoluteAssetUrl(
|
|
214
|
+
resolvedSocialImagePath,
|
|
215
|
+
appConfig?.siteUrl || "",
|
|
216
|
+
sitePathPrefix,
|
|
217
|
+
)
|
|
218
|
+
: "";
|
|
219
|
+
// Dimensions / alt only describe an explicitly provided social image. The
|
|
220
|
+
// fallbacks (site avatar, Jant default) are square branding marks, so they
|
|
221
|
+
// keep the small `summary` card and a generic site-name alt.
|
|
222
|
+
const hasExplicitSocialImage = Boolean(socialImageUrl);
|
|
223
|
+
const socialImageAltText = hasExplicitSocialImage
|
|
224
|
+
? socialImageAlt
|
|
225
|
+
: siteName || undefined;
|
|
226
|
+
const socialImageWidthValue = hasExplicitSocialImage
|
|
227
|
+
? socialImageWidth
|
|
228
|
+
: undefined;
|
|
229
|
+
const socialImageHeightValue = hasExplicitSocialImage
|
|
230
|
+
? socialImageHeight
|
|
231
|
+
: undefined;
|
|
232
|
+
// `summary_large_image` only looks good for genuine landscape content; a
|
|
233
|
+
// portrait or square image gets center-cropped into a thin banner.
|
|
234
|
+
const useLargeTwitterCard =
|
|
235
|
+
hasExplicitSocialImage &&
|
|
236
|
+
socialImageWidthValue !== undefined &&
|
|
237
|
+
socialImageHeightValue !== undefined &&
|
|
238
|
+
socialImageWidthValue > socialImageHeightValue &&
|
|
239
|
+
socialImageWidthValue >= 300;
|
|
192
240
|
const mainFeedHref = appConfig ? toPublicPath("/feed", sitePathPrefix) : null;
|
|
193
241
|
const latestFeedHref = appConfig
|
|
194
242
|
? toPublicPath("/feed/latest", sitePathPrefix)
|
|
@@ -261,16 +309,46 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
261
309
|
<title>{title}</title>
|
|
262
310
|
{description && <meta name="description" content={description} />}
|
|
263
311
|
<meta property="og:title" content={title} />
|
|
264
|
-
<meta property="og:type" content="website" />
|
|
312
|
+
<meta property="og:type" content={ogType ?? "website"} />
|
|
313
|
+
{ogType === "article" && articlePublishedTime && (
|
|
314
|
+
<meta
|
|
315
|
+
property="article:published_time"
|
|
316
|
+
content={articlePublishedTime}
|
|
317
|
+
/>
|
|
318
|
+
)}
|
|
319
|
+
{ogType === "article" && articleModifiedTime && (
|
|
320
|
+
<meta
|
|
321
|
+
property="article:modified_time"
|
|
322
|
+
content={articleModifiedTime}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
265
325
|
{description && (
|
|
266
326
|
<meta property="og:description" content={description} />
|
|
267
327
|
)}
|
|
268
328
|
{socialImageHref && (
|
|
269
329
|
<meta property="og:image" content={socialImageHref} />
|
|
270
330
|
)}
|
|
331
|
+
{socialImageHref && socialImageWidthValue !== undefined && (
|
|
332
|
+
<meta
|
|
333
|
+
property="og:image:width"
|
|
334
|
+
content={String(socialImageWidthValue)}
|
|
335
|
+
/>
|
|
336
|
+
)}
|
|
337
|
+
{socialImageHref && socialImageHeightValue !== undefined && (
|
|
338
|
+
<meta
|
|
339
|
+
property="og:image:height"
|
|
340
|
+
content={String(socialImageHeightValue)}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
{socialImageHref && socialImageAltText && (
|
|
344
|
+
<meta property="og:image:alt" content={socialImageAltText} />
|
|
345
|
+
)}
|
|
271
346
|
{siteName && <meta property="og:site_name" content={siteName} />}
|
|
272
347
|
{currentUrl && <meta property="og:url" content={currentUrl} />}
|
|
273
|
-
<meta
|
|
348
|
+
<meta
|
|
349
|
+
name="twitter:card"
|
|
350
|
+
content={useLargeTwitterCard ? "summary_large_image" : "summary"}
|
|
351
|
+
/>
|
|
274
352
|
<meta name="twitter:title" content={title} />
|
|
275
353
|
{description && (
|
|
276
354
|
<meta name="twitter:description" content={description} />
|
|
@@ -278,9 +356,25 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
278
356
|
{socialImageHref && (
|
|
279
357
|
<meta name="twitter:image" content={socialImageHref} />
|
|
280
358
|
)}
|
|
359
|
+
{socialImageHref && socialImageAltText && (
|
|
360
|
+
<meta name="twitter:image:alt" content={socialImageAltText} />
|
|
361
|
+
)}
|
|
281
362
|
{resolvedNoindex && (
|
|
282
363
|
<meta name="robots" content="noindex, nofollow" />
|
|
283
364
|
)}
|
|
365
|
+
{!resolvedNoindex && jsonLd != null && (
|
|
366
|
+
<script
|
|
367
|
+
type="application/ld+json"
|
|
368
|
+
// JSON.stringify output with `<` / `>` escaped to \u-sequences
|
|
369
|
+
// so a value containing `</script>` cannot break out of the tag.
|
|
370
|
+
// JSON parsers decode the escapes transparently.
|
|
371
|
+
dangerouslySetInnerHTML={{
|
|
372
|
+
__html: JSON.stringify(jsonLd)
|
|
373
|
+
.replace(/</g, "\\u003c")
|
|
374
|
+
.replace(/>/g, "\\u003e"),
|
|
375
|
+
}}
|
|
376
|
+
/>
|
|
377
|
+
)}
|
|
284
378
|
{canonicalHref && <link rel="canonical" href={canonicalHref} />}
|
|
285
379
|
<link rel="icon" href={resolvedFaviconHref} sizes="16x16 32x32" />
|
|
286
380
|
<link rel="apple-touch-icon" href={resolvedAppleTouchHref} />
|
|
@@ -12,6 +12,7 @@ function createContext(
|
|
|
12
12
|
assetBasePath?: string;
|
|
13
13
|
sitePathPrefix?: string;
|
|
14
14
|
siteUrl?: string;
|
|
15
|
+
siteAvatarUrl?: string;
|
|
15
16
|
themeMode?: "auto" | "light" | "dark";
|
|
16
17
|
themeId?: string;
|
|
17
18
|
defaultThemeId?: string;
|
|
@@ -22,6 +23,7 @@ function createContext(
|
|
|
22
23
|
mainRssFeed,
|
|
23
24
|
sitePathPrefix: overrides?.sitePathPrefix ?? "",
|
|
24
25
|
siteUrl: overrides?.siteUrl ?? "https://example.com",
|
|
26
|
+
siteAvatarUrl: overrides?.siteAvatarUrl,
|
|
25
27
|
siteLanguage: "en",
|
|
26
28
|
noindex: false,
|
|
27
29
|
customCSS: "",
|
|
@@ -101,6 +103,27 @@ describe("BaseLayout", () => {
|
|
|
101
103
|
);
|
|
102
104
|
});
|
|
103
105
|
|
|
106
|
+
it("falls back to the bundled social image when appConfig.siteAvatarUrl is an empty string", async () => {
|
|
107
|
+
// resolve-config initializes siteAvatarUrl to "" (not undefined) when no
|
|
108
|
+
// avatar is configured. Regression test: the fallback chain must skip
|
|
109
|
+
// empty strings so og:image / twitter:image are never blank.
|
|
110
|
+
const { BaseLayout } = await loadBaseLayout();
|
|
111
|
+
const html = renderToString(
|
|
112
|
+
BaseLayout({
|
|
113
|
+
title: "Jant",
|
|
114
|
+
c: createContext("featured", { siteAvatarUrl: "" }),
|
|
115
|
+
children: "Test",
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(html).toContain(
|
|
120
|
+
'meta property="og:image" content="https://example.com/_/brand/assets/jant-social-preview.png"',
|
|
121
|
+
);
|
|
122
|
+
expect(html).toContain(
|
|
123
|
+
'meta name="twitter:image" content="https://example.com/_/brand/assets/jant-social-preview.png"',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
104
127
|
it("uses an explicit social image when provided", async () => {
|
|
105
128
|
const { BaseLayout } = await loadBaseLayout();
|
|
106
129
|
const html = renderToString(
|
|
@@ -119,6 +142,129 @@ describe("BaseLayout", () => {
|
|
|
119
142
|
);
|
|
120
143
|
});
|
|
121
144
|
|
|
145
|
+
it("defaults og:type to website without article timestamps", async () => {
|
|
146
|
+
const { BaseLayout } = await loadBaseLayout();
|
|
147
|
+
const html = renderToString(
|
|
148
|
+
BaseLayout({
|
|
149
|
+
title: "Jant",
|
|
150
|
+
children: "Test",
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(html).toContain('meta property="og:type" content="website"');
|
|
155
|
+
expect(html).not.toContain("article:published_time");
|
|
156
|
+
expect(html).not.toContain("article:modified_time");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("renders article og:type with published and modified timestamps", async () => {
|
|
160
|
+
const { BaseLayout } = await loadBaseLayout();
|
|
161
|
+
const html = renderToString(
|
|
162
|
+
BaseLayout({
|
|
163
|
+
title: "A post",
|
|
164
|
+
ogType: "article",
|
|
165
|
+
articlePublishedTime: "2026-01-02T03:04:05.000Z",
|
|
166
|
+
articleModifiedTime: "2026-03-04T05:06:07.000Z",
|
|
167
|
+
children: "Test",
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(html).toContain('meta property="og:type" content="article"');
|
|
172
|
+
expect(html).toContain(
|
|
173
|
+
'meta property="article:published_time" content="2026-01-02T03:04:05.000Z"',
|
|
174
|
+
);
|
|
175
|
+
expect(html).toContain(
|
|
176
|
+
'meta property="article:modified_time" content="2026-03-04T05:06:07.000Z"',
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("keeps the small twitter card and omits dimensions for the default social image", async () => {
|
|
181
|
+
const { BaseLayout } = await loadBaseLayout();
|
|
182
|
+
const html = renderToString(
|
|
183
|
+
BaseLayout({
|
|
184
|
+
title: "Jant",
|
|
185
|
+
children: "Test",
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(html).toContain('meta name="twitter:card" content="summary"');
|
|
190
|
+
expect(html).not.toContain('content="summary_large_image"');
|
|
191
|
+
expect(html).not.toContain("og:image:width");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("renders dimensions, alt, and a large twitter card for a landscape post image", async () => {
|
|
195
|
+
const { BaseLayout } = await loadBaseLayout();
|
|
196
|
+
const html = renderToString(
|
|
197
|
+
BaseLayout({
|
|
198
|
+
title: "A post",
|
|
199
|
+
socialImageUrl: "https://cdn.example.com/photo.jpg",
|
|
200
|
+
socialImageWidth: 1600,
|
|
201
|
+
socialImageHeight: 900,
|
|
202
|
+
socialImageAlt: "A wide landscape photo",
|
|
203
|
+
children: "Test",
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(html).toContain('meta property="og:image:width" content="1600"');
|
|
208
|
+
expect(html).toContain('meta property="og:image:height" content="900"');
|
|
209
|
+
expect(html).toContain(
|
|
210
|
+
'meta property="og:image:alt" content="A wide landscape photo"',
|
|
211
|
+
);
|
|
212
|
+
expect(html).toContain(
|
|
213
|
+
'meta name="twitter:image:alt" content="A wide landscape photo"',
|
|
214
|
+
);
|
|
215
|
+
expect(html).toContain(
|
|
216
|
+
'meta name="twitter:card" content="summary_large_image"',
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("keeps the small twitter card for a portrait post image", async () => {
|
|
221
|
+
const { BaseLayout } = await loadBaseLayout();
|
|
222
|
+
const html = renderToString(
|
|
223
|
+
BaseLayout({
|
|
224
|
+
title: "A post",
|
|
225
|
+
socialImageUrl: "https://cdn.example.com/tall.jpg",
|
|
226
|
+
socialImageWidth: 600,
|
|
227
|
+
socialImageHeight: 900,
|
|
228
|
+
children: "Test",
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(html).toContain('meta name="twitter:card" content="summary"');
|
|
233
|
+
expect(html).not.toContain('content="summary_large_image"');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("renders JSON-LD structured data and escapes script-breaking characters", async () => {
|
|
237
|
+
const { BaseLayout } = await loadBaseLayout();
|
|
238
|
+
const html = renderToString(
|
|
239
|
+
BaseLayout({
|
|
240
|
+
title: "A post",
|
|
241
|
+
jsonLd: {
|
|
242
|
+
"@type": "BlogPosting",
|
|
243
|
+
headline: "Mind the </script> gap",
|
|
244
|
+
},
|
|
245
|
+
children: "Test",
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(html).toContain('<script type="application/ld+json">');
|
|
250
|
+
expect(html).toContain("\\u003c/script\\u003e");
|
|
251
|
+
expect(html).not.toContain("</script> gap");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("skips JSON-LD when the page is noindex", async () => {
|
|
255
|
+
const { BaseLayout } = await loadBaseLayout();
|
|
256
|
+
const html = renderToString(
|
|
257
|
+
BaseLayout({
|
|
258
|
+
title: "A post",
|
|
259
|
+
noindex: true,
|
|
260
|
+
jsonLd: { "@type": "WebSite" },
|
|
261
|
+
children: "Test",
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(html).not.toContain("application/ld+json");
|
|
266
|
+
});
|
|
267
|
+
|
|
122
268
|
it("exposes the main and alternate feed links without duplicating featured", async () => {
|
|
123
269
|
const { BaseLayout } = await loadBaseLayout();
|
|
124
270
|
const html = renderToString(
|
|
@@ -289,12 +289,15 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
|
|
|
289
289
|
? JSON.stringify(lightboxItems)
|
|
290
290
|
: undefined
|
|
291
291
|
}
|
|
292
|
-
class={`flex gap-2 ${singleVisual ? "" : "overflow-x-auto scroll-smooth
|
|
292
|
+
class={`flex gap-2 ${singleVisual ? "" : "overflow-x-auto scroll-smooth"}`}
|
|
293
293
|
style={
|
|
294
294
|
singleVisual
|
|
295
295
|
? undefined
|
|
296
296
|
: "scrollbar-width: none; -ms-overflow-style: none;"
|
|
297
297
|
}
|
|
298
|
+
tabindex={singleVisual ? undefined : 0}
|
|
299
|
+
role={singleVisual ? undefined : "group"}
|
|
300
|
+
aria-label={singleVisual ? undefined : "Media gallery"}
|
|
298
301
|
>
|
|
299
302
|
{galleryItems.map((item) => {
|
|
300
303
|
if (item._kind === "image") {
|
|
@@ -329,7 +332,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
|
|
|
329
332
|
key={item.id}
|
|
330
333
|
href={item.url}
|
|
331
334
|
data-lightbox-index={item._lbIdx}
|
|
332
|
-
class={`${singleVisual ? "" : "shrink-0
|
|
335
|
+
class={`${singleVisual ? "" : "shrink-0"} media-visual-frame`}
|
|
333
336
|
style={{
|
|
334
337
|
...(singleVisual
|
|
335
338
|
? {
|
|
@@ -391,7 +394,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
|
|
|
391
394
|
return (
|
|
392
395
|
<div
|
|
393
396
|
key={item.id}
|
|
394
|
-
class={`${singleVisual ? "" : "shrink-0
|
|
397
|
+
class={`${singleVisual ? "" : "shrink-0"} media-video-wrap media-video-wrap-short`}
|
|
395
398
|
style={
|
|
396
399
|
singleVisual
|
|
397
400
|
? {
|
|
@@ -464,7 +467,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
|
|
|
464
467
|
key={item.id}
|
|
465
468
|
href={item.url}
|
|
466
469
|
data-lightbox-index={item._lbIdx}
|
|
467
|
-
class={`${singleVisual ? "" : "shrink-0
|
|
470
|
+
class={`${singleVisual ? "" : "shrink-0"} media-video-wrap media-visual-frame`}
|
|
468
471
|
style={
|
|
469
472
|
singleVisual
|
|
470
473
|
? {
|
|
@@ -502,7 +505,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
|
|
|
502
505
|
return (
|
|
503
506
|
<div
|
|
504
507
|
key={item.id}
|
|
505
|
-
class={`media-gallery-card media-audio-card shrink-0
|
|
508
|
+
class={`media-gallery-card media-audio-card shrink-0${item.waveform ? " has-waveform" : ""}`}
|
|
506
509
|
style={{
|
|
507
510
|
width: `${docCardWidth}px`,
|
|
508
511
|
height: `${rowHeight}px`,
|
|
@@ -593,7 +596,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
|
|
|
593
596
|
href={item.url}
|
|
594
597
|
target="_blank"
|
|
595
598
|
rel="noopener noreferrer"
|
|
596
|
-
class="media-gallery-card shrink-0
|
|
599
|
+
class="media-gallery-card shrink-0"
|
|
597
600
|
style={{
|
|
598
601
|
width: `${docCardWidth}px`,
|
|
599
602
|
height: `${rowHeight}px`,
|
|
@@ -627,7 +630,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
|
|
|
627
630
|
? `${postPermalink}/text/${item.id}`
|
|
628
631
|
: undefined
|
|
629
632
|
}
|
|
630
|
-
class="media-gallery-card shrink-0
|
|
633
|
+
class="media-gallery-card shrink-0"
|
|
631
634
|
style={{
|
|
632
635
|
width: `${docCardWidth}px`,
|
|
633
636
|
height: `${rowHeight}px`,
|
|
@@ -650,6 +653,46 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
|
|
|
650
653
|
);
|
|
651
654
|
})}
|
|
652
655
|
</div>
|
|
656
|
+
{!singleVisual && (
|
|
657
|
+
<>
|
|
658
|
+
<button
|
|
659
|
+
type="button"
|
|
660
|
+
class="media-gallery-nav media-gallery-nav-prev"
|
|
661
|
+
tabindex={-1}
|
|
662
|
+
aria-label="Scroll to previous media"
|
|
663
|
+
>
|
|
664
|
+
<svg
|
|
665
|
+
viewBox="0 0 24 24"
|
|
666
|
+
fill="none"
|
|
667
|
+
stroke="currentColor"
|
|
668
|
+
stroke-width="2.5"
|
|
669
|
+
stroke-linecap="round"
|
|
670
|
+
stroke-linejoin="round"
|
|
671
|
+
aria-hidden="true"
|
|
672
|
+
>
|
|
673
|
+
<path d="M15 18l-6-6 6-6" />
|
|
674
|
+
</svg>
|
|
675
|
+
</button>
|
|
676
|
+
<button
|
|
677
|
+
type="button"
|
|
678
|
+
class="media-gallery-nav media-gallery-nav-next"
|
|
679
|
+
tabindex={-1}
|
|
680
|
+
aria-label="Scroll to next media"
|
|
681
|
+
>
|
|
682
|
+
<svg
|
|
683
|
+
viewBox="0 0 24 24"
|
|
684
|
+
fill="none"
|
|
685
|
+
stroke="currentColor"
|
|
686
|
+
stroke-width="2.5"
|
|
687
|
+
stroke-linecap="round"
|
|
688
|
+
stroke-linejoin="round"
|
|
689
|
+
aria-hidden="true"
|
|
690
|
+
>
|
|
691
|
+
<path d="M9 18l6-6-6-6" />
|
|
692
|
+
</svg>
|
|
693
|
+
</button>
|
|
694
|
+
</>
|
|
695
|
+
)}
|
|
653
696
|
</div>
|
|
654
697
|
)}
|
|
655
698
|
</>
|
|
@@ -108,4 +108,35 @@ describe("MediaGallery", () => {
|
|
|
108
108
|
expect(html).not.toContain("media-video-play-overlay");
|
|
109
109
|
expect(html).not.toContain("media-short-video-progress");
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
it("adds scroll arrows and a focusable strip for multi-item galleries", () => {
|
|
113
|
+
const html = renderToString(
|
|
114
|
+
MediaGallery({
|
|
115
|
+
attachments: [
|
|
116
|
+
createMediaView({ id: "m-1", width: 1600, height: 900 }),
|
|
117
|
+
createMediaView({ id: "m-2", width: 1600, height: 900 }),
|
|
118
|
+
createMediaView({ id: "m-3", width: 1600, height: 900 }),
|
|
119
|
+
],
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(html).toContain("media-gallery-nav-prev");
|
|
124
|
+
expect(html).toContain("media-gallery-nav-next");
|
|
125
|
+
expect(html).toContain('aria-label="Scroll to previous media"');
|
|
126
|
+
expect(html).toContain('aria-label="Scroll to next media"');
|
|
127
|
+
// The strip is a keyboard tab stop so Arrow keys can scroll it.
|
|
128
|
+
expect(html).toContain('tabindex="0"');
|
|
129
|
+
expect(html).toContain('role="group"');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("omits scroll arrows and the tab stop for a single visual", () => {
|
|
133
|
+
const html = renderToString(
|
|
134
|
+
MediaGallery({
|
|
135
|
+
attachments: [createMediaView({ width: 1600, height: 900 })],
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(html).not.toContain("media-gallery-nav");
|
|
140
|
+
expect(html).not.toContain('tabindex="0"');
|
|
141
|
+
});
|
|
111
142
|
});
|