diff --git a/.changeset/few-bats-wink.md b/.changeset/few-bats-wink.md new file mode 100644 index 0000000000..8c50dff468 --- /dev/null +++ b/.changeset/few-bats-wink.md @@ -0,0 +1,7 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes locale switcher menus appearing with a light background in dark mode. The selected locale, default-locale indicator, and `All locales` label remain visible when the menu is closed. + +Reselecting the current locale preserves unsaved byline edits. diff --git a/e2e/fixtures/admin.ts b/e2e/fixtures/admin.ts index d269acd5ec..8f282ac090 100644 --- a/e2e/fixtures/admin.ts +++ b/e2e/fixtures/admin.ts @@ -12,6 +12,7 @@ const ADMIN_DASHBOARD_PATTERN = /\/_emdash\/admin\/?$/; const CONTENT_ID_EXTRACTION_PATTERN = /\/content\/[^/]+\/([^/]+)$/; const MENU_URL_PATTERN = /\/_emdash\/admin\/menus\//; const SETUP_PAGE_PATTERN = /\/_emdash\/admin\/setup/; +const DEFAULT_LOCALE_SUFFIX_PATTERN = / \(default\)$/; export class AdminPage { readonly page: Page; @@ -559,12 +560,15 @@ export class AdminPage { } /** - * Get the locale switcher select value from the content list. + * Get the locale selected in the content list. */ async getLocaleFilterValue(): Promise { - const select = this.page.locator("select").first(); + const select = this.page.getByRole("combobox", { name: "Locale" }); if (await select.isVisible({ timeout: 3000 }).catch(() => false)) { - return select.inputValue(); + const label = (await select.innerText()).trim(); + return label === "All locales" + ? "" + : label.replace(DEFAULT_LOCALE_SUFFIX_PATTERN, "").toLowerCase(); } return null; } @@ -573,7 +577,11 @@ export class AdminPage { * Change the locale filter in the content list. */ async setLocaleFilter(locale: string): Promise { - await this.page.locator("select").first().selectOption(locale); + await this.page.getByRole("combobox", { name: "Locale" }).click(); + const label = locale + ? new RegExp(`^${locale.toUpperCase()}(?: \\(default\\))?$`) + : "All locales"; + await this.page.getByRole("option", { name: label }).click(); await this.waitForLoading(); } diff --git a/e2e/tests/i18n.spec.ts b/e2e/tests/i18n.spec.ts index f2fcf302f2..ab82b7fc65 100644 --- a/e2e/tests/i18n.spec.ts +++ b/e2e/tests/i18n.spec.ts @@ -17,12 +17,92 @@ * - pages: "About" (en, published), "Contact" (en, draft) */ -import { test, expect } from "../fixtures"; +import { test, expect } from "../fixtures/index.js"; // The edit route preserves the entry's locale as a `?locale=` search param // (see #1242), so the URL may carry a query string after the ULID. const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+(?:\?.*)?$/; +const localeTest = test.extend<{ + localeCollection: { + collection: string; + createPost: (locale: string, title: string) => Promise; + }; +}>({ + localeCollection: async ({ request, serverInfo }, use) => { + const headers = { + Authorization: `Bearer ${serverInfo.token}`, + "X-EmDash-Request": "1", + Origin: serverInfo.baseUrl, + }; + const collection = `locale_switcher_${crypto.randomUUID().replaceAll("-", "")}`; + const collectionPath = `${serverInfo.baseUrl}/_emdash/api/schema/collections/${collection}`; + const contentPath = `${serverInfo.baseUrl}/_emdash/api/content/${collection}`; + const postIds: string[] = []; + const pendingPosts = new Map(); + + try { + const created = await request.post(`${serverInfo.baseUrl}/_emdash/api/schema/collections`, { + headers, + data: { slug: collection, label: "Posts", labelSingular: "Post", supports: ["drafts"] }, + }); + await expect(created).toBeOK(); + + const field = await request.post(`${collectionPath}/fields`, { + headers, + data: { slug: "title", label: "Title", type: "string", required: true }, + }); + await expect(field).toBeOK(); + + await use({ + collection, + createPost: async (locale, title) => { + const slug = `post-${locale}`; + pendingPosts.set(slug, locale); + const response = await request.post(contentPath, { + headers, + data: { data: { title }, slug, locale }, + }); + await expect(response).toBeOK(); + const body = await response.json(); + postIds.push(body.data.item.id); + pendingPosts.delete(slug); + }, + }); + } finally { + for (const [slug, locale] of pendingPosts) { + await expect + .soft( + request + .get(`${contentPath}/${slug}`, { headers, params: { locale } }) + .then(async (response) => { + if (response.status() === 404) return true; + await expect(response).toBeOK(); + const body = await response.json(); + postIds.push(body.data.item.id); + return true; + }), + ) + .resolves.toBe(true); + } + const paths = postIds.flatMap((id) => [ + `${contentPath}/${id}`, + `${contentPath}/${id}/permanent`, + ]); + for (const path of paths) { + await expect.soft(request.delete(path, { headers })).resolves.toBeOK(); + } + await expect + .soft( + request + .delete(collectionPath, { headers }) + .then((response) => response.ok() || response.status() === 404), + ) + .resolves.toBe(true); + } + }, +}); + interface CreatePostInput { title: string; slug: string; @@ -179,22 +259,70 @@ test.describe("i18n", () => { } }); - test("has a locale filter switcher", async ({ admin }) => { - await admin.goToContent("posts"); + localeTest("has a locale filter switcher", async ({ admin, serverInfo, localeCollection }) => { + const { collection, createPost } = localeCollection; + await createPost("en", "First Post"); + await admin.goToContent(collection); await admin.waitForLoading(); + await expect(admin.page.getByRole("searchbox", { name: "Search posts" })).toHaveValue(""); - // Should have a select element for locale filtering - const select = admin.page.locator("select").first(); + const select = admin.page.getByRole("combobox", { name: "Locale" }); + const englishPost = admin.page.getByRole("link", { name: "First Post", exact: true }); + const emptyMessage = admin.page.getByText("No posts yet."); await expect(select).toBeVisible(); - - // Should show available locale options - const options = select.locator("option"); - const optionTexts = await options.allTextContents(); - // Expect EN, FR, ES options (may also have "All locales") - expect(optionTexts.some((t) => t.includes("EN"))).toBe(true); - expect(optionTexts.some((t) => t.includes("FR"))).toBe(true); - expect(optionTexts.some((t) => t.includes("ES"))).toBe(true); + await expect(select).toHaveText("EN (default)"); + await expect(englishPost).toBeVisible(); + await select.click(); + + await expect(admin.page.getByRole("option", { name: "EN (default)" })).toBeVisible(); + await expect(admin.page.getByRole("option", { name: "FR" })).toBeVisible(); + await expect(admin.page.getByRole("option", { name: "ES" })).toBeVisible(); + await admin.page.keyboard.press("Escape"); + + await admin.setLocaleFilter("fr"); + await expect(select).toHaveText("FR"); + await expect(admin.page).toHaveURL( + `${serverInfo.baseUrl}/_emdash/admin/content/${collection}?locale=fr`, + ); + expect(await admin.getLocaleFilterValue()).toBe("fr"); + await expect(emptyMessage).toBeVisible(); + await expect(englishPost).toHaveCount(0); + + await admin.setLocaleFilter("en"); + await expect(select).toHaveText("EN (default)"); + expect(await admin.getLocaleFilterValue()).toBe("en"); + await expect(englishPost).toBeVisible(); + await expect(emptyMessage).toHaveCount(0); }); + + localeTest( + "shows only content from the selected locale", + async ({ admin, localeCollection }) => { + const { collection, createPost } = localeCollection; + await createPost("en", "First Post"); + await createPost("fr", "French Post"); + await admin.goToContent(collection); + await admin.waitForLoading(); + await expect(admin.page.getByRole("searchbox", { name: "Search posts" })).toHaveValue(""); + + const select = admin.page.getByRole("combobox", { name: "Locale" }); + const englishPost = admin.page.getByRole("link", { name: "First Post", exact: true }); + const frenchPost = admin.page.getByRole("link", { name: "French Post", exact: true }); + await expect(select).toHaveText("EN (default)"); + await expect(englishPost).toBeVisible(); + await expect(frenchPost).toHaveCount(0); + + await admin.setLocaleFilter("fr"); + await expect(select).toHaveText("FR"); + await expect(frenchPost).toBeVisible(); + await expect(englishPost).toHaveCount(0); + + await admin.setLocaleFilter("en"); + await expect(select).toHaveText("EN (default)"); + await expect(englishPost).toBeVisible(); + await expect(frenchPost).toHaveCount(0); + }, + ); }); test.describe("Content Editor", () => { diff --git a/e2e/tests/visual-regression.spec.ts b/e2e/tests/visual-regression.spec.ts index c5d781028f..585fce04a9 100644 --- a/e2e/tests/visual-regression.spec.ts +++ b/e2e/tests/visual-regression.spec.ts @@ -56,6 +56,7 @@ interface PageCase { name: string; path: (info: ServerInfo) => string; viewport?: { width: number; height: number }; + theme?: "light" | "dark"; prepare?: (admin: AdminPage) => Promise; } @@ -97,6 +98,17 @@ const PAGES: PageCase[] = [ { name: "media", path: () => "/media" }, { name: "media-mobile", path: () => "/media", viewport: { width: 320, height: 800 } }, { name: "menus", path: () => "/menus" }, + { + name: "menus-locale-switcher-open", + path: () => "/menus", + prepare: openFilter(".emdash-locale-switcher-trigger", '[role="listbox"]:visible'), + }, + { + name: "menus-locale-switcher-open-dark", + path: () => "/menus", + theme: "dark", + prepare: openFilter(".emdash-locale-switcher-trigger", '[role="listbox"]:visible'), + }, { name: "settings", path: () => "/settings" }, ]; @@ -229,8 +241,16 @@ test.describe("visual regression", () => { pageCase.name === "content-editor" ? serverInfo.contentIds.posts[0] : undefined, ); await setLocale(admin, locale.code); + if (pageCase.theme) { + await admin.page.addInitScript((theme) => { + localStorage.setItem("emdash-theme", theme); + }, pageCase.theme); + } if (pageCase.viewport) await admin.page.setViewportSize(pageCase.viewport); await openAdmin(admin, pageCase.path(serverInfo), locale.dir); + if (pageCase.theme) { + await expect(admin.page.locator("html")).toHaveAttribute("data-mode", pageCase.theme); + } await stabilize(admin); await pageCase.prepare?.(admin); diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-byline-filter-ltr-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-byline-filter-ltr-chromium-linux.png index 92ad6a0663..77fcaa90b5 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-byline-filter-ltr-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-byline-filter-ltr-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-byline-filter-rtl-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-byline-filter-rtl-chromium-linux.png index 3a747fb80a..5db32eaa36 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-byline-filter-rtl-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-byline-filter-rtl-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-field-filter-ltr-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-field-filter-ltr-chromium-linux.png index c5400181d2..f9b23cc4bc 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-field-filter-ltr-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-field-filter-ltr-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-field-filter-rtl-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-field-filter-rtl-chromium-linux.png index 3351c7fac0..99ba0892a2 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-field-filter-rtl-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-field-filter-rtl-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-range-filter-ltr-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-range-filter-ltr-chromium-linux.png index 5fa5243521..8ed39ba125 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-range-filter-ltr-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-range-filter-ltr-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-range-filter-rtl-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-range-filter-rtl-chromium-linux.png index 6ceac5b737..d08d5ee417 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-range-filter-rtl-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-date-range-filter-rtl-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-ltr-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-ltr-chromium-linux.png index 60b6f6ddb6..438735e9a3 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-ltr-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-ltr-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-rtl-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-rtl-chromium-linux.png index a5da628a60..93a24f4cf1 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-rtl-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-rtl-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-status-filter-ltr-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-status-filter-ltr-chromium-linux.png index 54880994fd..c643351330 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-status-filter-ltr-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-status-filter-ltr-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-status-filter-rtl-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-status-filter-rtl-chromium-linux.png index 7a6c0f8a38..ac13dc0b61 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/content-list-status-filter-rtl-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/content-list-status-filter-rtl-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-dark-ltr-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-dark-ltr-chromium-linux.png new file mode 100644 index 0000000000..ddbe0e9a33 Binary files /dev/null and b/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-dark-ltr-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-dark-rtl-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-dark-rtl-chromium-linux.png new file mode 100644 index 0000000000..3885f7a3ea Binary files /dev/null and b/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-dark-rtl-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-ltr-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-ltr-chromium-linux.png new file mode 100644 index 0000000000..8fb118987c Binary files /dev/null and b/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-ltr-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-rtl-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-rtl-chromium-linux.png new file mode 100644 index 0000000000..50aaae5648 Binary files /dev/null and b/e2e/tests/visual-regression.spec.ts-snapshots/menus-locale-switcher-open-rtl-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/menus-ltr-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/menus-ltr-chromium-linux.png index e2a1cac322..9daaa47ad6 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/menus-ltr-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/menus-ltr-chromium-linux.png differ diff --git a/e2e/tests/visual-regression.spec.ts-snapshots/menus-rtl-chromium-linux.png b/e2e/tests/visual-regression.spec.ts-snapshots/menus-rtl-chromium-linux.png index 07322f7455..8ce6ae9c7e 100644 Binary files a/e2e/tests/visual-regression.spec.ts-snapshots/menus-rtl-chromium-linux.png and b/e2e/tests/visual-regression.spec.ts-snapshots/menus-rtl-chromium-linux.png differ diff --git a/packages/admin/src/components/LocaleSwitcher.tsx b/packages/admin/src/components/LocaleSwitcher.tsx index 73fab07b48..c31978ec55 100644 --- a/packages/admin/src/components/LocaleSwitcher.tsx +++ b/packages/admin/src/components/LocaleSwitcher.tsx @@ -7,6 +7,7 @@ * Only renders when i18n is configured (manifest.i18n is present). */ +import { Select } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { GlobeSimple } from "@phosphor-icons/react"; import React from "react"; @@ -48,31 +49,42 @@ export function LocaleSwitcher({ size = "md", }: LocaleSwitcherProps) { const { t } = useLingui(); + const formatLocaleLabel = (locale: string) => { + const code = locale.toUpperCase(); + return locale === defaultLocale ? ( + <> + {code} + {t` (default)`} + + ) : ( + code + ); + }; + return (
- +
); } diff --git a/packages/admin/tests/components/LocaleSwitcher.test.tsx b/packages/admin/tests/components/LocaleSwitcher.test.tsx new file mode 100644 index 0000000000..8b0d70a926 --- /dev/null +++ b/packages/admin/tests/components/LocaleSwitcher.test.tsx @@ -0,0 +1,141 @@ +import { setupI18n } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; +import { I18nProvider } from "@lingui/react"; +import * as React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { LocaleSwitcher } from "../../src/components/LocaleSwitcher.js"; +// oxlint-disable-next-line import/no-unassigned-import -- Browser test verifies Kumo's computed theme surface. +import "@cloudflare/kumo/styles/standalone"; + +import { render } from "../utils/render.js"; + +const defaultLocaleIndicator = msg` (default)`; + +function ControlledSwitcher({ initialValue = "en", showAll = false }) { + const [value, setValue] = React.useState(initialValue); + return ( + + ); +} + +describe("LocaleSwitcher", () => { + it("opens the locale menu and displays the selected locale", async () => { + const screen = await render(); + const localeSwitcher = screen.getByRole("combobox", { name: "Locale" }); + await expect.element(localeSwitcher).toHaveTextContent(/^EN \(default\)$/); + await userEvent.click(localeSwitcher); + + await expect.element(screen.getByRole("option", { name: "EN (default)" })).toBeVisible(); + await expect + .element(screen.getByRole("option", { name: "All locales" })) + .not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("option", { name: "FR" })); + await expect.element(localeSwitcher).toHaveTextContent(/^FR$/); + await expect.element(screen.getByRole("listbox")).not.toBeInTheDocument(); + }); + + it("displays all locales initially and after selecting it again", async () => { + const screen = await render(); + const localeSwitcher = screen.getByRole("combobox", { name: "Locale" }); + await expect.element(localeSwitcher).toHaveTextContent(/^All locales$/); + + await userEvent.click(localeSwitcher); + await userEvent.click(screen.getByRole("option", { name: "EN (default)" })); + await expect.element(localeSwitcher).toHaveTextContent(/^EN \(default\)$/); + + await userEvent.click(localeSwitcher); + await userEvent.click(screen.getByRole("option", { name: "All locales" })); + await expect.element(localeSwitcher).toHaveTextContent(/^All locales$/); + }); + + it("supports keyboard selection and cancels without changing the locale", async () => { + const screen = await render(); + const localeSwitcher = screen.getByRole("combobox", { name: "Locale" }); + await userEvent.tab(); + await expect.element(localeSwitcher).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect.element(screen.getByRole("listbox")).toBeVisible(); + await expect.element(screen.getByRole("option", { name: "EN (default)" })).toHaveFocus(); + await userEvent.keyboard("{End}"); + await expect.element(screen.getByRole("option", { name: "FR" })).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect.element(localeSwitcher).toHaveTextContent(/^FR$/); + await expect.element(localeSwitcher).toHaveFocus(); + + await userEvent.keyboard("{Enter}"); + await expect.element(screen.getByRole("listbox")).toBeVisible(); + await expect.element(screen.getByRole("option", { name: "FR" })).toHaveFocus(); + await userEvent.keyboard("{Home}"); + await expect.element(screen.getByRole("option", { name: "EN (default)" })).toHaveFocus(); + await userEvent.keyboard("{Escape}"); + await expect.element(localeSwitcher).toHaveTextContent(/^FR$/); + await expect.element(screen.getByRole("listbox")).not.toBeInTheDocument(); + await expect.element(localeSwitcher).toHaveFocus(); + }); + + it("uses the existing translation for the default-locale indicator", async () => { + const arabicI18n = setupI18n(); + arabicI18n.load("ar", { [defaultLocaleIndicator.id]: " الافتراضي" }); + arabicI18n.activate("ar"); + const screen = await render(, { + wrapper: ({ children }) => {children}, + }); + + const localeSwitcher = screen.getByRole("combobox", { name: "Locale" }); + await expect.element(localeSwitcher).toHaveTextContent(/^EN الافتراضي$/); + await userEvent.click(localeSwitcher); + await expect.element(screen.getByRole("option", { name: "EN الافتراضي" })).toBeVisible(); + }); + + it.each(["sm", "md"] as const)( + "renders the open %s locale menu with the dark theme surface", + async (size) => { + const previousMode = document.documentElement.getAttribute("data-mode"); + const surfaceProbe = document.createElement("div"); + surfaceProbe.style.backgroundColor = "var(--color-kumo-base)"; + document.body.append(surfaceProbe); + document.documentElement.setAttribute("data-mode", "dark"); + + try { + const screen = await render( + , + ); + + await userEvent.click(screen.getByRole("combobox", { name: "Locale" })); + const listbox = screen.getByRole("listbox"); + await expect.element(listbox).toBeVisible(); + const popup = listbox.element().parentElement; + expect(popup).not.toBeNull(); + + const darkSurface = getComputedStyle(surfaceProbe).backgroundColor; + document.documentElement.setAttribute("data-mode", "light"); + const lightSurface = getComputedStyle(surfaceProbe).backgroundColor; + document.documentElement.setAttribute("data-mode", "dark"); + + expect(darkSurface).not.toBe(lightSurface); + expect(getComputedStyle(popup!).backgroundColor).toBe(darkSurface); + } finally { + surfaceProbe.remove(); + if (previousMode) { + document.documentElement.setAttribute("data-mode", previousMode); + } else { + document.documentElement.removeAttribute("data-mode"); + } + } + }, + ); +}); diff --git a/packages/admin/tests/routes/bylines.test.tsx b/packages/admin/tests/routes/bylines.test.tsx index a4ff15530b..e7343238e7 100644 --- a/packages/admin/tests/routes/bylines.test.tsx +++ b/packages/admin/tests/routes/bylines.test.tsx @@ -2,13 +2,11 @@ import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { fetchBylines } from "../../src/lib/api"; +import { fetchManifest } from "../../src/lib/api/client.js"; import { BylinesPage } from "../../src/routes/bylines"; import { render } from "../utils/render.tsx"; import { QueryWrapper } from "../utils/test-helpers.tsx"; -// The bylines page reads the active locale from the URL and navigates on -// locale switches; neither matters for the search-debounce behaviour, so we -// stub the router hooks to a single-locale, no-op shape. vi.mock("@tanstack/react-router", async () => { const actual = await vi.importActual("@tanstack/react-router"); return { @@ -44,12 +42,61 @@ function searchArgs(): (string | undefined)[] { return fetchBylinesMock.mock.calls.map((call) => call[0]?.search); } -describe("BylinesPage search", () => { +describe("BylinesPage", () => { beforeEach(() => { vi.clearAllMocks(); fetchBylinesMock.mockResolvedValue({ items: [], nextCursor: undefined }); }); + it("preserves unsaved edits when the active locale is selected again", async () => { + vi.mocked(fetchManifest).mockResolvedValueOnce({ + version: "1.0.0", + hash: "test-manifest", + authMode: "passkey", + collections: {}, + plugins: {}, + taxonomies: [], + i18n: { defaultLocale: "en", locales: ["en", "fr"] }, + }); + fetchBylinesMock.mockResolvedValue({ + items: [ + { + id: "byline-1", + slug: "alice", + displayName: "Alice Example", + bio: null, + avatarMediaId: null, + websiteUrl: null, + userId: null, + isGuest: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + locale: "en", + translationGroup: "group-1", + }, + ], + }); + const screen = await render( + + + , + ); + await screen.getByRole("button", { name: "Alice Example alice" }).click(); + const displayName = screen.getByRole("textbox", { name: "Display name", exact: true }); + await expect.element(displayName).toHaveValue("Alice Example"); + await displayName.fill("Unsaved display name"); + + await screen.getByRole("combobox", { name: "Locale", exact: true }).click(); + await screen.getByRole("option", { name: "EN (default)", exact: true }).click(); + + await expect.element(screen.getByRole("listbox")).not.toBeInTheDocument(); + await expect.element(displayName).toHaveValue("Unsaved display name"); + await expect.element(screen.getByRole("heading", { name: "Edit Alice Example" })).toBeVisible(); + await expect + .element(screen.getByRole("textbox", { name: "Slug", exact: true })) + .toHaveValue("alice"); + }); + it("debounces rapid typing into a single refetch and keeps the input mounted", async () => { vi.useFakeTimers(); try {