diff --git a/site/e2e/tests/users/userSettings.spec.ts b/site/e2e/tests/users/userSettings.spec.ts index ff419f89ea..601086b911 100644 --- a/site/e2e/tests/users/userSettings.spec.ts +++ b/site/e2e/tests/users/userSettings.spec.ts @@ -32,8 +32,12 @@ test("adjust user theme preference", async ({ page }) => { await page.goto("/settings/appearance", { waitUntil: "domcontentloaded" }); - await page.getByRole("combobox", { name: /theme mode/i }).click(); - await page.getByRole("option", { name: /single theme/i }).click(); + // Precondition: the theme mode must start on "Single theme" so that picking + // a single theme below takes effect immediately. A fresh member defaults to + // single mode; assert it so the test fails loudly if that default changes. + await expect( + page.getByRole("combobox", { name: /theme mode/i }), + ).toContainText("Single theme"); const singleThemeGroup = page.getByRole("group", { name: "Theme" }); await expect(singleThemeGroup).toBeVisible(); @@ -41,6 +45,16 @@ test("adjust user theme preference", async ({ page }) => { await expectLightThemeClasses(page); + // The theme is saved optimistically, so the DOM turns light before the + // preference is persisted. The form shows a spinner while the save is in + // flight; wait for it to clear so the save has completed (and was not + // canceled by navigation) before the hard reload. Asserting the optimistic + // class first guarantees the spinner is already showing if a save started, + // and a repeat run that is already light simply never shows it. + await expect( + page.getByRole("status", { name: "Saving theme preference" }), + ).toBeHidden(); + await page.goto("/", { waitUntil: "domcontentloaded" }); // Make sure the page is still using the light theme after reloading and diff --git a/site/src/components/Loader/Loader.tsx b/site/src/components/Loader/Loader.tsx index 3ba241dcf3..684e9532d2 100644 --- a/site/src/components/Loader/Loader.tsx +++ b/site/src/components/Loader/Loader.tsx @@ -25,6 +25,7 @@ export const Loader: FC = ({ {...attrs} role="status" aria-live="polite" + aria-label={resolvedLabel} data-testid="loader" className={cn( "flex items-center justify-center", @@ -32,7 +33,7 @@ export const Loader: FC = ({ className, )} > - + ); }; diff --git a/site/src/components/Spinner/Spinner.tsx b/site/src/components/Spinner/Spinner.tsx index 847aa26ab7..5a69afd7fd 100644 --- a/site/src/components/Spinner/Spinner.tsx +++ b/site/src/components/Spinner/Spinner.tsx @@ -27,6 +27,11 @@ type SpinnerProps = React.SVGProps & VariantProps & { children?: ReactNode; loading?: boolean; + /** + * Exposes the spinner as an accessible live region labelled with this text. Leave undefined for + * decorative spinners, e.g. inside a component that already provides its own status region. + */ + label?: string; }; export function Spinner({ @@ -34,6 +39,7 @@ export function Spinner({ size, loading, children, + label, ...props }: SpinnerProps) { if (!loading) { @@ -45,6 +51,8 @@ export function Spinner({ viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor" + role={label ? "status" : undefined} + aria-label={label} className={cn(spinnerVariants({ size, className }))} {...props} > diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx index 06752bf4c1..15ac0c5da0 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx @@ -161,7 +161,11 @@ export const AppearanceForm: FC = ({ title={
Theme - +
} layout="fluid" @@ -217,7 +221,11 @@ export const AppearanceForm: FC = ({ title={
Terminal Font - +
} layout="fluid"