mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add theme mode dropdown (#25183)
## Summary - Wire the Appearance settings page to the new theme mode dropdown and sync or single theme selectors. - Update Appearance page tests and stories for theme mode behavior. - Update the user settings e2e test to exercise single theme selection. ## Dependencies - Depends on #25076, #25180, #25181, and #25182. - This PR targets helper branch `pr25077/05-theme-mode-dropdown-base`, which contains dependency commits only, so this PR diff stays focused on final dropdown wiring. Rebase and retarget after the dependency PRs merge. ## Validation - `pnpm -C site exec vitest run --project=unit src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx src/theme/themeMode.test.ts src/api/queries/users.test.ts` - `pnpm -C site lint:types` - `pnpm -C site storybook:ci` - `pnpm -C site build` - `pnpm -C site playwright:test -- e2e/tests/users/userSettings.spec.ts` - Pre-commit hook passed on the branch commit.
This commit is contained in:
@@ -12,15 +12,19 @@ const rootClassNames = async (page: Page) => {
|
||||
return page.locator("html").evaluate((it) => Array.from(it.classList));
|
||||
};
|
||||
|
||||
// Assert the light theme without rejecting unrelated root classes.
|
||||
const expectLightThemeClasses = (classes: string[]) => {
|
||||
const className = "light";
|
||||
expect(classes).toContain(className);
|
||||
for (const themeClassName of CONCRETE_THEMES.filter(
|
||||
(it) => it !== className,
|
||||
)) {
|
||||
expect(classes).not.toContain(themeClassName);
|
||||
}
|
||||
const expectLightThemeClasses = async (page: Page) => {
|
||||
await expect(async () => {
|
||||
const classes = await rootClassNames(page);
|
||||
const className = "light";
|
||||
|
||||
// Assert the light theme without rejecting unrelated root classes.
|
||||
expect(classes).toContain(className);
|
||||
for (const themeClassName of CONCRETE_THEMES.filter(
|
||||
(it) => it !== className,
|
||||
)) {
|
||||
expect(classes).not.toContain(themeClassName);
|
||||
}
|
||||
}).toPass({ timeout: 10_000 });
|
||||
};
|
||||
|
||||
test("adjust user theme preference", async ({ page }) => {
|
||||
@@ -28,14 +32,18 @@ test("adjust user theme preference", async ({ page }) => {
|
||||
|
||||
await page.goto("/settings/appearance", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await page.getByText("Light", { exact: true }).click();
|
||||
await expect(page.getByLabel("Light")).toBeChecked();
|
||||
await page.getByRole("combobox", { name: /theme mode/i }).click();
|
||||
await page.getByRole("option", { name: /single theme/i }).click();
|
||||
|
||||
expectLightThemeClasses(await rootClassNames(page));
|
||||
const singleThemeGroup = page.getByRole("group", { name: "Theme" });
|
||||
await expect(singleThemeGroup).toBeVisible();
|
||||
await singleThemeGroup.getByText("Light default", { exact: true }).click();
|
||||
|
||||
await expectLightThemeClasses(page);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Make sure the page is still using the light theme after reloading and
|
||||
// navigating away from the settings page.
|
||||
expectLightThemeClasses(await rootClassNames(page));
|
||||
await expectLightThemeClasses(page);
|
||||
});
|
||||
|
||||
@@ -1,25 +1,510 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { type FC, useState } from "react";
|
||||
import { action } from "storybook/actions";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type {
|
||||
UpdateUserAppearanceSettingsRequest,
|
||||
UserAppearanceSettings,
|
||||
} from "#/api/typesGenerated";
|
||||
import { CONCRETE_THEMES } from "#/theme";
|
||||
import { AppearanceForm } from "./AppearanceForm";
|
||||
|
||||
const onUpdateTheme = action("update");
|
||||
|
||||
const baseSettings: UserAppearanceSettings = {
|
||||
theme_preference: "dark",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
terminal_font: "",
|
||||
};
|
||||
|
||||
const resolvedSubmit = () =>
|
||||
fn((update: UpdateUserAppearanceSettingsRequest) => {
|
||||
onUpdateTheme(update);
|
||||
return Promise.resolve({ ...baseSettings, ...update });
|
||||
});
|
||||
|
||||
interface ResyncHarnessProps {
|
||||
initialValues: UserAppearanceSettings;
|
||||
onSubmit: (update: UpdateUserAppearanceSettingsRequest) => void;
|
||||
}
|
||||
|
||||
const ResyncHarness: FC<ResyncHarnessProps> = ({ initialValues, onSubmit }) => {
|
||||
const [settings, setSettings] = useState(initialValues);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<button
|
||||
type="button"
|
||||
className="w-fit rounded-md border border-solid border-border px-3 py-2"
|
||||
onClick={() =>
|
||||
setSettings({ ...baseSettings, theme_preference: "light" })
|
||||
}
|
||||
>
|
||||
Load light default
|
||||
</button>
|
||||
<AppearanceForm
|
||||
activeScheme="dark"
|
||||
initialValues={settings}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PendingUpdateHarnessProps {
|
||||
initialValues: UserAppearanceSettings;
|
||||
onSubmit: (update: UpdateUserAppearanceSettingsRequest) => void;
|
||||
}
|
||||
|
||||
const PendingUpdateHarness: FC<PendingUpdateHarnessProps> = ({
|
||||
initialValues,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [isUpdating, setIsUpdating] = useState(true);
|
||||
const [renderCount, setRenderCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-solid border-border px-3 py-2"
|
||||
onClick={() => setRenderCount((current) => current + 1)}
|
||||
>
|
||||
Rerender pending update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-solid border-border px-3 py-2"
|
||||
onClick={() => setIsUpdating(false)}
|
||||
>
|
||||
Complete update
|
||||
</button>
|
||||
</div>
|
||||
<div hidden>Render count: {renderCount}</div>
|
||||
<AppearanceForm
|
||||
activeScheme="dark"
|
||||
initialValues={initialValues}
|
||||
isUpdating={isUpdating}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof AppearanceForm> = {
|
||||
title: "pages/UserSettingsPage/AppearanceForm",
|
||||
component: AppearanceForm,
|
||||
args: {
|
||||
onSubmit: (update) =>
|
||||
Promise.resolve(onUpdateTheme(update.theme_preference)),
|
||||
activeScheme: "dark",
|
||||
onSubmit: (update) => Promise.resolve(onUpdateTheme(update)),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AppearanceForm>;
|
||||
|
||||
export const Example: Story = {
|
||||
export const SingleDarkDefault: Story = {
|
||||
args: {
|
||||
initialValues: { ...baseSettings, theme_preference: "dark" },
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleLightDefault: Story = {
|
||||
args: {
|
||||
activeScheme: "light",
|
||||
initialValues: { ...baseSettings, theme_preference: "light" },
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectSingleLightDefault: Story = {
|
||||
args: {
|
||||
initialValues: { ...baseSettings, theme_preference: "dark" },
|
||||
onSubmit: resolvedSubmit(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
await user.click(
|
||||
await canvas.findByRole("radio", { name: /light default/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSubmit).toHaveBeenCalledWith({
|
||||
theme_preference: "light",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
terminal_font: "geist-mono",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
await canvas.findByRole("radio", { name: /light default/i }),
|
||||
).toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleDarkProtanDeuter: Story = {
|
||||
args: {
|
||||
initialValues: { ...baseSettings, theme_preference: "dark-protan-deuter" },
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleLightTritan: Story = {
|
||||
args: {
|
||||
activeScheme: "light",
|
||||
initialValues: { ...baseSettings, theme_preference: "light-tritan" },
|
||||
},
|
||||
};
|
||||
|
||||
export const SyncDefault: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
theme_preference: "",
|
||||
...baseSettings,
|
||||
theme_mode: "sync",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const lightOptions = await canvas.findByRole("group", {
|
||||
name: "Light theme options",
|
||||
});
|
||||
const darkOptions = await canvas.findByRole("group", {
|
||||
name: "Dark theme options",
|
||||
});
|
||||
|
||||
expect(within(lightOptions).getAllByRole("radio")).toHaveLength(
|
||||
CONCRETE_THEMES.length,
|
||||
);
|
||||
expect(within(darkOptions).getAllByRole("radio")).toHaveLength(
|
||||
CONCRETE_THEMES.length,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SyncActiveLight: Story = {
|
||||
args: {
|
||||
activeScheme: "light",
|
||||
initialValues: {
|
||||
...baseSettings,
|
||||
theme_mode: "sync",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectSyncMode: Story = {
|
||||
args: {
|
||||
initialValues: { ...baseSettings, theme_preference: "dark" },
|
||||
onSubmit: resolvedSubmit(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
const dropdown = await canvas.findByRole("combobox", {
|
||||
name: "Theme mode",
|
||||
});
|
||||
|
||||
await user.click(dropdown);
|
||||
await user.click(
|
||||
await within(document.body).findByRole("option", {
|
||||
name: "Sync with system",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSubmit).toHaveBeenCalledWith({
|
||||
theme_preference: "dark",
|
||||
theme_mode: "sync",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
terminal_font: "geist-mono",
|
||||
});
|
||||
});
|
||||
expect(dropdown).toHaveTextContent("Sync with system");
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectSingleFromSync: Story = {
|
||||
args: {
|
||||
activeScheme: "light",
|
||||
initialValues: {
|
||||
...baseSettings,
|
||||
theme_preference: "light-protan-deuter",
|
||||
theme_mode: "sync",
|
||||
theme_light: "light-protan-deuter",
|
||||
theme_dark: "dark-tritan",
|
||||
},
|
||||
onSubmit: resolvedSubmit(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
const dropdown = await canvas.findByRole("combobox", {
|
||||
name: "Theme mode",
|
||||
});
|
||||
|
||||
await user.click(dropdown);
|
||||
await user.click(
|
||||
await within(document.body).findByRole("option", {
|
||||
name: "Single theme",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSubmit).toHaveBeenCalledWith({
|
||||
theme_preference: "light-protan-deuter",
|
||||
theme_mode: "single",
|
||||
theme_light: "light-protan-deuter",
|
||||
theme_dark: "dark-tritan",
|
||||
terminal_font: "geist-mono",
|
||||
});
|
||||
});
|
||||
expect(dropdown).toHaveTextContent("Single theme");
|
||||
expect(
|
||||
await canvas.findByRole("radio", {
|
||||
name: /light protanopia and deuteranopia/i,
|
||||
}),
|
||||
).toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
export const SyncProtanDeuter: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
...baseSettings,
|
||||
theme_mode: "sync",
|
||||
theme_light: "light-protan-deuter",
|
||||
theme_dark: "dark-protan-deuter",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SyncTritan: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
...baseSettings,
|
||||
theme_mode: "sync",
|
||||
theme_light: "light-tritan",
|
||||
theme_dark: "dark-tritan",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SyncMixed: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
...baseSettings,
|
||||
theme_mode: "sync",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark-tritan",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectDarkThemeInLightSyncSlot: Story = {
|
||||
args: {
|
||||
activeScheme: "light",
|
||||
initialValues: {
|
||||
...baseSettings,
|
||||
theme_preference: "light",
|
||||
theme_mode: "sync",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
},
|
||||
onSubmit: resolvedSubmit(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
const lightOptions = await canvas.findByRole("group", {
|
||||
name: "Light theme options",
|
||||
});
|
||||
const darkTritanopia = await within(lightOptions).findByRole("radio", {
|
||||
name: /dark tritanopia/i,
|
||||
});
|
||||
|
||||
await user.click(darkTritanopia);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSubmit).toHaveBeenCalledWith({
|
||||
theme_preference: "dark-tritan",
|
||||
theme_mode: "sync",
|
||||
theme_light: "dark-tritan",
|
||||
theme_dark: "dark",
|
||||
terminal_font: "geist-mono",
|
||||
});
|
||||
});
|
||||
expect(darkTritanopia).toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
export const SyncHoverPreview: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
...baseSettings,
|
||||
theme_mode: "sync",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
const lightOptions = await canvas.findByRole("group", {
|
||||
name: "Light theme options",
|
||||
});
|
||||
const radio = await within(lightOptions).findByRole("radio", {
|
||||
name: "Light tritanopia",
|
||||
});
|
||||
const swatch = radio.closest("label");
|
||||
if (swatch === null) {
|
||||
throw new Error("Expected the theme radio to be inside a swatch label.");
|
||||
}
|
||||
await user.hover(swatch);
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectTerminalFont: Story = {
|
||||
args: {
|
||||
initialValues: { ...baseSettings, theme_preference: "dark" },
|
||||
onSubmit: resolvedSubmit(),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
await user.click(await canvas.findByText("Fira Code"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSubmit).toHaveBeenCalledWith({
|
||||
theme_preference: "dark",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
terminal_font: "fira-code",
|
||||
});
|
||||
});
|
||||
|
||||
await user.click(
|
||||
await canvas.findByRole("radio", { name: /light default/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSubmit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(args.onSubmit).toHaveBeenLastCalledWith({
|
||||
theme_preference: "light",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
terminal_font: "fira-code",
|
||||
});
|
||||
expect(
|
||||
await canvas.findByRole("radio", { name: /light default/i }),
|
||||
).toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
export const ResyncsWhenInitialValuesChange: Story = {
|
||||
args: {
|
||||
initialValues: { ...baseSettings, theme_preference: "dark" },
|
||||
onSubmit: fn(),
|
||||
},
|
||||
render: (args) => (
|
||||
<ResyncHarness
|
||||
initialValues={args.initialValues ?? baseSettings}
|
||||
onSubmit={args.onSubmit ?? onUpdateTheme}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(
|
||||
await canvas.findByRole("radio", { name: /dark default/i }),
|
||||
).toBeChecked();
|
||||
|
||||
await user.click(
|
||||
await canvas.findByRole("button", { name: "Load light default" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("radio", { name: /light default/i }),
|
||||
).toBeChecked();
|
||||
});
|
||||
expect(args.onSubmit).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const PreservesDraftWhileUpdating: Story = {
|
||||
args: {
|
||||
initialValues: { ...baseSettings, theme_preference: "dark" },
|
||||
onSubmit: resolvedSubmit(),
|
||||
},
|
||||
render: (args) => (
|
||||
<PendingUpdateHarness
|
||||
initialValues={args.initialValues ?? baseSettings}
|
||||
onSubmit={args.onSubmit ?? onUpdateTheme}
|
||||
/>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await user.click(
|
||||
await canvas.findByRole("radio", { name: /light default/i }),
|
||||
);
|
||||
expect(
|
||||
await canvas.findByRole("radio", { name: /light default/i }),
|
||||
).toBeChecked();
|
||||
|
||||
await user.click(
|
||||
await canvas.findByRole("button", {
|
||||
name: "Rerender pending update",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("radio", { name: /light default/i }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
await user.click(
|
||||
await canvas.findByRole("button", { name: "Complete update" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("radio", { name: /dark default/i }),
|
||||
).toBeChecked();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Migration paths: settings without the new fields but with a legacy
|
||||
// `auto-*` theme_preference should render in sync mode on mount.
|
||||
export const LegacyAutoTritan: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
theme_preference: "auto-tritan",
|
||||
// Legacy rows predate the theme_mode field.
|
||||
theme_mode: "",
|
||||
theme_light: "",
|
||||
theme_dark: "",
|
||||
terminal_font: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LegacyAuto: Story = {
|
||||
args: {
|
||||
initialValues: {
|
||||
theme_preference: "auto",
|
||||
theme_mode: "",
|
||||
theme_light: "",
|
||||
theme_dark: "",
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import type { FC } from "react";
|
||||
import { type FC, useEffect, useId, useState } from "react";
|
||||
import {
|
||||
type TerminalFontName,
|
||||
TerminalFontNames,
|
||||
type UpdateUserAppearanceSettingsRequest,
|
||||
type UserAppearanceSettings,
|
||||
} from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { PreviewBadge } from "#/components/Badges/Badges";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "#/components/Select/Select";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { DEFAULT_THEME } from "#/theme";
|
||||
import { Section } from "#/pages/UserSettingsPage/Section";
|
||||
import type { ConcreteThemeName } from "#/theme";
|
||||
import {
|
||||
DEFAULT_TERMINAL_FONT,
|
||||
terminalFontLabels,
|
||||
terminalFonts,
|
||||
} from "#/theme/constants";
|
||||
import { cn } from "#/utils/cn";
|
||||
import {
|
||||
draftFromState,
|
||||
draftToUpdate,
|
||||
migrateLegacyPreference,
|
||||
switchToSingle,
|
||||
type ThemeModeDraft,
|
||||
} from "#/theme/themeMode";
|
||||
import { SingleModeSection } from "./SingleModeSection";
|
||||
import { SyncModeSection } from "./SyncModeSection";
|
||||
|
||||
// Display Geist Mono (the default monospace font) first, then the rest
|
||||
// alphabetically. TerminalFontNames is auto-generated in alphabetical
|
||||
@@ -29,11 +41,19 @@ const sortedTerminalFontNames = [
|
||||
...TerminalFontNames.filter((name) => name !== "" && name !== "geist-mono"),
|
||||
];
|
||||
|
||||
interface AppearanceFormValues {
|
||||
draft: ThemeModeDraft;
|
||||
terminalFont: TerminalFontName;
|
||||
}
|
||||
|
||||
type AppearanceThemeMode = ThemeModeDraft["mode"];
|
||||
|
||||
interface AppearanceFormProps {
|
||||
isUpdating?: boolean;
|
||||
error?: unknown;
|
||||
initialValues: UpdateUserAppearanceSettingsRequest;
|
||||
onSubmit: (values: UpdateUserAppearanceSettingsRequest) => Promise<unknown>;
|
||||
initialValues: UserAppearanceSettings;
|
||||
activeScheme: "dark" | "light"; // The OS color scheme currently in effect
|
||||
onSubmit: (values: UpdateUserAppearanceSettingsRequest) => void;
|
||||
}
|
||||
|
||||
export const AppearanceForm: FC<AppearanceFormProps> = ({
|
||||
@@ -41,92 +61,180 @@ export const AppearanceForm: FC<AppearanceFormProps> = ({
|
||||
error,
|
||||
onSubmit,
|
||||
initialValues,
|
||||
activeScheme,
|
||||
}) => {
|
||||
const currentTheme = initialValues.theme_preference || DEFAULT_THEME;
|
||||
const currentTerminalFont =
|
||||
initialValues.terminal_font || DEFAULT_TERMINAL_FONT;
|
||||
const [values, setValues] = useState(() => toFormValues(initialValues));
|
||||
const themeModeId = useId();
|
||||
const fontGroupId = useId();
|
||||
const fontGroupLabelId = `${fontGroupId}-label`;
|
||||
const fontGroupName = `${fontGroupId}-fonts`;
|
||||
const singleThemeGroupName = `${themeModeId}-single`;
|
||||
const syncThemeGroupNamePrefix = `${themeModeId}-sync`;
|
||||
|
||||
const onChangeTheme = async (theme: string) => {
|
||||
const {
|
||||
theme_preference,
|
||||
theme_mode,
|
||||
theme_light,
|
||||
theme_dark,
|
||||
terminal_font,
|
||||
} = initialValues;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdating) {
|
||||
return;
|
||||
}
|
||||
await onSubmit({
|
||||
theme_preference: theme,
|
||||
theme_mode: "",
|
||||
theme_light: "",
|
||||
theme_dark: "",
|
||||
terminal_font: currentTerminalFont,
|
||||
const next = toFormValues({
|
||||
theme_preference,
|
||||
theme_mode,
|
||||
theme_light,
|
||||
theme_dark,
|
||||
terminal_font,
|
||||
});
|
||||
setValues(next);
|
||||
}, [
|
||||
isUpdating,
|
||||
theme_preference,
|
||||
theme_mode,
|
||||
theme_light,
|
||||
theme_dark,
|
||||
terminal_font,
|
||||
]);
|
||||
|
||||
const { draft, terminalFont } = values;
|
||||
|
||||
const submit = (next: AppearanceFormValues) => {
|
||||
setValues(next);
|
||||
onSubmit(toUpdateRequest(next, activeScheme));
|
||||
};
|
||||
|
||||
const onChangeMode = (mode: AppearanceThemeMode) => {
|
||||
if (mode === draft.mode) {
|
||||
return;
|
||||
}
|
||||
const next: ThemeModeDraft =
|
||||
mode === "single"
|
||||
? {
|
||||
mode: "single",
|
||||
single: switchToSingle(
|
||||
{ mode: "sync", light: draft.light, dark: draft.dark },
|
||||
activeScheme,
|
||||
).theme,
|
||||
light: draft.light,
|
||||
dark: draft.dark,
|
||||
}
|
||||
: {
|
||||
mode: "sync",
|
||||
single: draft.single,
|
||||
light: draft.light,
|
||||
dark: draft.dark,
|
||||
};
|
||||
submit({ draft: next, terminalFont });
|
||||
};
|
||||
|
||||
const onSelectSyncSlot = (
|
||||
scheme: "light" | "dark",
|
||||
theme: ConcreteThemeName,
|
||||
) => {
|
||||
const next: ThemeModeDraft =
|
||||
scheme === "light"
|
||||
? { ...draft, light: theme }
|
||||
: { ...draft, dark: theme };
|
||||
submit({ draft: next, terminalFont });
|
||||
};
|
||||
|
||||
const onSelectSingle = (theme: ConcreteThemeName) => {
|
||||
submit({
|
||||
draft: { ...draft, single: theme, mode: "single" },
|
||||
terminalFont,
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeTerminalFont = async (terminalFont: TerminalFontName) => {
|
||||
if (isUpdating) {
|
||||
return;
|
||||
}
|
||||
await onSubmit({
|
||||
theme_preference: currentTheme,
|
||||
theme_mode: "",
|
||||
theme_light: "",
|
||||
theme_dark: "",
|
||||
terminal_font: terminalFont,
|
||||
});
|
||||
const onChangeTerminalFont = (nextTerminalFont: TerminalFontName) => {
|
||||
submit({ draft, terminalFont: nextTerminalFont });
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-12">
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
|
||||
<div>
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>
|
||||
<Section
|
||||
title={
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>Theme</span>
|
||||
<Spinner loading={isUpdating} size="sm" />
|
||||
</SettingsHeaderTitle>
|
||||
</SettingsHeader>
|
||||
</div>
|
||||
}
|
||||
layout="fluid"
|
||||
className="mb-12"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={themeModeId} className="text-sm font-medium">
|
||||
Theme mode
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(value) => {
|
||||
if (isThemeMode(value)) {
|
||||
onChangeMode(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={themeModeId}
|
||||
className="w-48 text-content-primary"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sync">Sync with system</SelectItem>
|
||||
<SelectItem value="single">Single theme</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-4">
|
||||
<AutoThemePreviewButton
|
||||
displayName="Auto"
|
||||
active={currentTheme === "auto"}
|
||||
themes={["dark", "light"]}
|
||||
onSelect={() => onChangeTheme("auto")}
|
||||
/>
|
||||
<ThemePreviewButton
|
||||
displayName="Dark"
|
||||
active={currentTheme === "dark"}
|
||||
theme="dark"
|
||||
onSelect={() => onChangeTheme("dark")}
|
||||
/>
|
||||
<ThemePreviewButton
|
||||
displayName="Light"
|
||||
active={currentTheme === "light"}
|
||||
theme="light"
|
||||
onSelect={() => onChangeTheme("light")}
|
||||
/>
|
||||
{draft.mode === "sync" ? (
|
||||
<SyncModeSection
|
||||
light={draft.light}
|
||||
dark={draft.dark}
|
||||
activeScheme={activeScheme}
|
||||
namePrefix={syncThemeGroupNamePrefix}
|
||||
onSelect={onSelectSyncSlot}
|
||||
/>
|
||||
) : (
|
||||
<SingleModeSection
|
||||
selected={draft.single}
|
||||
name={singleThemeGroupName}
|
||||
onSelect={onSelectSingle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div>
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle hierarchy="secondary">
|
||||
<span id="fonts-radio-buttons-group-label">Terminal Font</span>
|
||||
<Section
|
||||
title={
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span id={fontGroupLabelId}>Terminal Font</span>
|
||||
<Spinner loading={isUpdating} size="sm" />
|
||||
</SettingsHeaderTitle>
|
||||
</SettingsHeader>
|
||||
|
||||
</div>
|
||||
}
|
||||
layout="fluid"
|
||||
>
|
||||
<RadioGroup
|
||||
aria-labelledby="fonts-radio-buttons-group-label"
|
||||
defaultValue={currentTerminalFont}
|
||||
name="fonts-radio-buttons-group"
|
||||
aria-labelledby={fontGroupLabelId}
|
||||
value={terminalFont}
|
||||
name={fontGroupName}
|
||||
onValueChange={(value) =>
|
||||
onChangeTerminalFont(toTerminalFontName(value))
|
||||
}
|
||||
>
|
||||
{sortedTerminalFontNames.map((name) => (
|
||||
<div key={name} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={name} id={name} />
|
||||
<RadioGroupItem value={name} id={`${fontGroupId}-${name}`} />
|
||||
<Label
|
||||
htmlFor={name}
|
||||
htmlFor={`${fontGroupId}-${name}`}
|
||||
className="cursor-pointer font-normal"
|
||||
style={{ fontFamily: terminalFonts[name] }}
|
||||
>
|
||||
@@ -135,173 +243,34 @@ export const AppearanceForm: FC<AppearanceFormProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</Section>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
function toFormValues(settings: UserAppearanceSettings): AppearanceFormValues {
|
||||
return {
|
||||
draft: draftFromState(migrateLegacyPreference(settings), {
|
||||
light: settings.theme_light,
|
||||
dark: settings.theme_dark,
|
||||
}),
|
||||
terminalFont: settings.terminal_font || DEFAULT_TERMINAL_FONT,
|
||||
};
|
||||
}
|
||||
|
||||
function toUpdateRequest(
|
||||
values: AppearanceFormValues,
|
||||
activeScheme: "dark" | "light",
|
||||
): UpdateUserAppearanceSettingsRequest {
|
||||
return draftToUpdate(values.draft, values.terminalFont, activeScheme);
|
||||
}
|
||||
|
||||
function isThemeMode(value: string): value is AppearanceThemeMode {
|
||||
return value === "sync" || value === "single";
|
||||
}
|
||||
|
||||
function toTerminalFontName(value: string): TerminalFontName {
|
||||
return TerminalFontNames.includes(value as TerminalFontName)
|
||||
? (value as TerminalFontName)
|
||||
: "";
|
||||
}
|
||||
|
||||
type ThemeMode = "dark" | "light";
|
||||
|
||||
interface AutoThemePreviewButtonProps extends Omit<ThemePreviewProps, "theme"> {
|
||||
themes: [ThemeMode, ThemeMode];
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
const AutoThemePreviewButton: FC<AutoThemePreviewButtonProps> = ({
|
||||
active,
|
||||
preview,
|
||||
className,
|
||||
displayName,
|
||||
themes,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [leftTheme, rightTheme] = themes;
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
id={displayName}
|
||||
value={displayName}
|
||||
checked={active}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={displayName}
|
||||
className={cn("relative cursor-pointer", className)}
|
||||
>
|
||||
<ThemePreview
|
||||
className="absolute"
|
||||
style={{
|
||||
// Slightly past the bounding box to avoid cutting off the outline
|
||||
clipPath: "polygon(-5% -5%, 50% -5%, 50% 105%, -5% 105%)",
|
||||
}}
|
||||
active={active}
|
||||
preview={preview}
|
||||
displayName={displayName}
|
||||
theme={leftTheme}
|
||||
/>
|
||||
<ThemePreview
|
||||
active={active}
|
||||
preview={preview}
|
||||
displayName={displayName}
|
||||
theme={rightTheme}
|
||||
style={{
|
||||
// Slightly past the bounding box to avoid cutting off the outline
|
||||
clipPath: "polygon(50% -5%, 105% -5%, 105% 105%, 50% 105%)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ThemePreviewButtonProps extends ThemePreviewProps {
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
const ThemePreviewButton: FC<ThemePreviewButtonProps> = ({
|
||||
active,
|
||||
preview,
|
||||
className,
|
||||
displayName,
|
||||
theme,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
id={displayName}
|
||||
value={displayName}
|
||||
checked={active}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label htmlFor={displayName} className={cn("cursor-pointer", className)}>
|
||||
<ThemePreview
|
||||
active={active}
|
||||
preview={preview}
|
||||
displayName={displayName}
|
||||
theme={theme}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ThemePreviewProps {
|
||||
active?: boolean;
|
||||
preview?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
displayName: string;
|
||||
theme: ThemeMode;
|
||||
}
|
||||
|
||||
const ThemePreview: FC<ThemePreviewProps> = ({
|
||||
active,
|
||||
preview,
|
||||
className,
|
||||
style,
|
||||
displayName,
|
||||
theme,
|
||||
}) => {
|
||||
return (
|
||||
<div className={theme}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-56 overflow-clip rounded-md border border-border border-solid bg-surface-primary text-content-primary select-none",
|
||||
active && "outline outline-2 outline-content-link",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<div className="bg-surface-primary text-content-primary">
|
||||
<div className="bg-surface-primary flex items-center justify-between px-2.5 py-1.5 mb-2 border-0 border-b border-border border-solid">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="bg-content-primary h-1.5 w-5 rounded" />
|
||||
<div className="bg-content-secondary h-1.5 w-5 rounded" />
|
||||
<div className="bg-content-secondary h-1.5 w-5 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="bg-green-400 h-1.5 w-3 rounded" />
|
||||
<div className="bg-content-primary h-2 w-2 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-32 mx-auto">
|
||||
<div className="bg-content-primary h-2 w-11 rounded mb-1.5" />
|
||||
<div className="border border-solid rounded-t overflow-clip">
|
||||
<div className="bg-surface-secondary h-2.5 -m-px" />
|
||||
<div className="h-4 border-0 border-t border-border border-solid">
|
||||
<div className="bg-content-disabled h-1.5 w-8 rounded mt-1 ml-1" />
|
||||
</div>
|
||||
<div className="h-4 border-0 border-t border-border border-solid">
|
||||
<div className="bg-content-disabled h-1.5 w-8 rounded mt-1 ml-1" />
|
||||
</div>
|
||||
<div className="h-4 border-0 border-t border-border border-solid">
|
||||
<div className="bg-content-disabled h-1.5 w-8 rounded mt-1 ml-1" />
|
||||
</div>
|
||||
<div className="h-4 border-0 border-t border-border border-solid">
|
||||
<div className="bg-content-disabled h-1.5 w-8 rounded mt-1 ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-0 border-t border-border border-solid px-3 py-1 text-sm">
|
||||
<span>{displayName}</span>
|
||||
{preview && <PreviewBadge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,127 +1,60 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { API } from "#/api/api";
|
||||
import { MockUserOwner } from "#/testHelpers/entities";
|
||||
import { renderWithAuth } from "#/testHelpers/renderHelpers";
|
||||
import AppearancePage from "./AppearancePage";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { UpdateUserAppearanceSettingsRequest } from "#/api/typesGenerated";
|
||||
import { useQueuedAppearanceSubmit } from "./AppearancePage";
|
||||
|
||||
describe("appearance page", () => {
|
||||
it("does nothing when selecting current theme", async () => {
|
||||
renderWithAuth(<AppearancePage />);
|
||||
const updateRequest = (
|
||||
overrides: Partial<UpdateUserAppearanceSettingsRequest> = {},
|
||||
): UpdateUserAppearanceSettingsRequest => ({
|
||||
theme_preference: "dark",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
terminal_font: "geist-mono",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
|
||||
...MockUserOwner,
|
||||
theme_preference: "dark",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
terminal_font: "fira-code",
|
||||
describe("useQueuedAppearanceSubmit", () => {
|
||||
it("submits one request at a time and keeps only the latest pending update", () => {
|
||||
const mutations: Array<{
|
||||
values: UpdateUserAppearanceSettingsRequest;
|
||||
onSettled: () => void;
|
||||
}> = [];
|
||||
const mutate = vi.fn(
|
||||
(
|
||||
values: UpdateUserAppearanceSettingsRequest,
|
||||
options: { onSettled: () => void },
|
||||
) => {
|
||||
mutations.push({ values, onSettled: options.onSettled });
|
||||
},
|
||||
);
|
||||
const { result } = renderHook(() => useQueuedAppearanceSubmit(mutate));
|
||||
|
||||
const first = updateRequest({ terminal_font: "fira-code" });
|
||||
const overwritten = updateRequest({ theme_preference: "dark-tritan" });
|
||||
const latest = updateRequest({ theme_preference: "light" });
|
||||
|
||||
act(() => {
|
||||
result.current(first);
|
||||
result.current(overwritten);
|
||||
result.current(latest);
|
||||
});
|
||||
|
||||
const dark = await screen.findByText("Dark");
|
||||
await userEvent.click(dark);
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
expect(mutations[0]?.values).toEqual(first);
|
||||
|
||||
// Check if the API was called correctly
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("changes theme to light", async () => {
|
||||
renderWithAuth(<AppearancePage />);
|
||||
|
||||
vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
|
||||
...MockUserOwner,
|
||||
terminal_font: "geist-mono",
|
||||
theme_preference: "light",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
act(() => {
|
||||
mutations[0]?.onSettled();
|
||||
});
|
||||
|
||||
const light = await screen.findByText("Light");
|
||||
await userEvent.click(light);
|
||||
expect(mutate).toHaveBeenCalledTimes(2);
|
||||
expect(mutations[1]?.values).toEqual(latest);
|
||||
|
||||
// Check if the API was called correctly
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1);
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
terminal_font: "geist-mono",
|
||||
theme_preference: "light",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes font to fira code", async () => {
|
||||
renderWithAuth(<AppearancePage />);
|
||||
|
||||
vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
|
||||
...MockUserOwner,
|
||||
terminal_font: "fira-code",
|
||||
theme_preference: "dark",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
act(() => {
|
||||
mutations[1]?.onSettled();
|
||||
result.current(overwritten);
|
||||
});
|
||||
|
||||
const firaCode = await screen.findByText("Fira Code");
|
||||
await userEvent.click(firaCode);
|
||||
|
||||
// Check if the API was called correctly
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1);
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
terminal_font: "fira-code",
|
||||
theme_preference: "dark",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes font to fira code, then back to geist mono", async () => {
|
||||
renderWithAuth(<AppearancePage />);
|
||||
|
||||
// given
|
||||
vi.spyOn(API, "updateAppearanceSettings")
|
||||
.mockResolvedValueOnce({
|
||||
...MockUserOwner,
|
||||
terminal_font: "fira-code",
|
||||
theme_preference: "dark",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
...MockUserOwner,
|
||||
terminal_font: "geist-mono",
|
||||
theme_preference: "dark",
|
||||
theme_mode: "single",
|
||||
theme_light: "light",
|
||||
theme_dark: "dark",
|
||||
});
|
||||
|
||||
// when
|
||||
const firaCode = await screen.findByText("Fira Code");
|
||||
await userEvent.click(firaCode);
|
||||
|
||||
// then
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1);
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
terminal_font: "fira-code",
|
||||
theme_preference: "dark",
|
||||
}),
|
||||
);
|
||||
|
||||
// when
|
||||
const geistMono = await screen.findByText("Geist Mono");
|
||||
await userEvent.click(geistMono);
|
||||
|
||||
// then
|
||||
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(2);
|
||||
expect(API.updateAppearanceSettings).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
terminal_font: "geist-mono",
|
||||
theme_preference: "dark",
|
||||
}),
|
||||
);
|
||||
expect(mutate).toHaveBeenCalledTimes(3);
|
||||
expect(mutations[2]?.values).toEqual(overwritten);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
import type { FC } from "react";
|
||||
import { type FC, useRef } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import {
|
||||
appearanceSettings,
|
||||
updateAppearanceSettings,
|
||||
} from "#/api/queries/users";
|
||||
import type { UpdateUserAppearanceSettingsRequest } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
|
||||
import { usePreferredColorScheme } from "#/theme/usePreferredColorScheme";
|
||||
import { AppearanceForm } from "./AppearanceForm";
|
||||
|
||||
type MutateAppearanceSettings = (
|
||||
values: UpdateUserAppearanceSettingsRequest,
|
||||
options: { onSettled: () => void },
|
||||
) => void;
|
||||
|
||||
export const useQueuedAppearanceSubmit = (mutate: MutateAppearanceSettings) => {
|
||||
const submitInFlightRef = useRef(false);
|
||||
const pendingSubmitRef = useRef<UpdateUserAppearanceSettingsRequest | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const fireSubmit = (values: UpdateUserAppearanceSettingsRequest) => {
|
||||
submitInFlightRef.current = true;
|
||||
mutate(values, {
|
||||
onSettled: () => {
|
||||
const queued = pendingSubmitRef.current;
|
||||
pendingSubmitRef.current = null;
|
||||
if (queued !== null) {
|
||||
fireSubmit(queued);
|
||||
return;
|
||||
}
|
||||
submitInFlightRef.current = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (values: UpdateUserAppearanceSettingsRequest) => {
|
||||
if (submitInFlightRef.current) {
|
||||
pendingSubmitRef.current = values;
|
||||
return;
|
||||
}
|
||||
|
||||
fireSubmit(values);
|
||||
};
|
||||
};
|
||||
|
||||
const AppearancePage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const updateAppearanceSettingsMutation = useMutation(
|
||||
@@ -19,6 +57,14 @@ const AppearancePage: FC = () => {
|
||||
const appearanceSettingsQuery = useQuery(
|
||||
appearanceSettings(metadata.userAppearance),
|
||||
);
|
||||
const osColorScheme = usePreferredColorScheme();
|
||||
const submitAppearanceSettings = useQueuedAppearanceSubmit(
|
||||
(values, options) => {
|
||||
updateAppearanceSettingsMutation.mutate(values, {
|
||||
onSettled: options.onSettled,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (appearanceSettingsQuery.isLoading) {
|
||||
return <Loader />;
|
||||
@@ -32,14 +78,9 @@ const AppearancePage: FC = () => {
|
||||
<AppearanceForm
|
||||
isUpdating={updateAppearanceSettingsMutation.isPending}
|
||||
error={updateAppearanceSettingsMutation.error}
|
||||
initialValues={{
|
||||
theme_preference: appearanceSettingsQuery.data.theme_preference,
|
||||
theme_mode: appearanceSettingsQuery.data.theme_mode,
|
||||
theme_light: appearanceSettingsQuery.data.theme_light,
|
||||
theme_dark: appearanceSettingsQuery.data.theme_dark,
|
||||
terminal_font: appearanceSettingsQuery.data.terminal_font,
|
||||
}}
|
||||
onSubmit={updateAppearanceSettingsMutation.mutateAsync}
|
||||
initialValues={appearanceSettingsQuery.data}
|
||||
activeScheme={osColorScheme}
|
||||
onSubmit={submitAppearanceSettings}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DARK_THEMES, LIGHT_THEMES, THEME_COPY } from "./themeCopy";
|
||||
|
||||
interface SingleModeSectionProps {
|
||||
selected: ConcreteThemeName;
|
||||
name?: string;
|
||||
onSelect: (theme: ConcreteThemeName) => void;
|
||||
}
|
||||
|
||||
@@ -16,6 +17,7 @@ const SINGLE_MODE_ORDER: ConcreteThemeName[] = [
|
||||
|
||||
export const SingleModeSection: FC<SingleModeSectionProps> = ({
|
||||
selected,
|
||||
name = "theme-single",
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
@@ -25,6 +27,7 @@ export const SingleModeSection: FC<SingleModeSectionProps> = ({
|
||||
{SINGLE_MODE_ORDER.map((theme) => (
|
||||
<SingleTile
|
||||
key={theme}
|
||||
name={name}
|
||||
theme={theme}
|
||||
selected={theme === selected}
|
||||
onSelect={() => onSelect(theme)}
|
||||
@@ -36,12 +39,18 @@ export const SingleModeSection: FC<SingleModeSectionProps> = ({
|
||||
};
|
||||
|
||||
interface SingleTileProps {
|
||||
name: string;
|
||||
theme: ConcreteThemeName;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const SingleTile: FC<SingleTileProps> = ({ theme, selected, onSelect }) => {
|
||||
const SingleTile: FC<SingleTileProps> = ({
|
||||
name,
|
||||
theme,
|
||||
selected,
|
||||
onSelect,
|
||||
}) => {
|
||||
const copy = THEME_COPY[theme];
|
||||
return (
|
||||
<label
|
||||
@@ -53,7 +62,7 @@ const SingleTile: FC<SingleTileProps> = ({ theme, selected, onSelect }) => {
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme-single"
|
||||
name={name}
|
||||
value={theme}
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
@@ -66,8 +75,8 @@ const SingleTile: FC<SingleTileProps> = ({ theme, selected, onSelect }) => {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border border-solid bg-surface-primary",
|
||||
selected ? "border-border-secondary" : "border-border",
|
||||
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border border-solid border-border bg-surface-primary",
|
||||
selected && "border-border-secondary",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SyncModeSectionProps {
|
||||
light: ConcreteThemeName;
|
||||
dark: ConcreteThemeName;
|
||||
activeScheme: "dark" | "light"; // The OS color scheme currently in effect
|
||||
namePrefix?: string;
|
||||
onSelect: (scheme: "light" | "dark", theme: ConcreteThemeName) => void;
|
||||
}
|
||||
|
||||
@@ -18,6 +19,7 @@ export const SyncModeSection: FC<SyncModeSectionProps> = ({
|
||||
light,
|
||||
dark,
|
||||
activeScheme,
|
||||
namePrefix = "theme-sync",
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
@@ -27,6 +29,7 @@ export const SyncModeSection: FC<SyncModeSectionProps> = ({
|
||||
scheme="light"
|
||||
selected={light}
|
||||
active={activeScheme === "light"}
|
||||
name={`${namePrefix}-light`}
|
||||
onSelect={(theme) => onSelect("light", theme)}
|
||||
/>
|
||||
<SyncCard
|
||||
@@ -34,6 +37,7 @@ export const SyncModeSection: FC<SyncModeSectionProps> = ({
|
||||
scheme="dark"
|
||||
selected={dark}
|
||||
active={activeScheme === "dark"}
|
||||
name={`${namePrefix}-dark`}
|
||||
onSelect={(theme) => onSelect("dark", theme)}
|
||||
/>
|
||||
</div>
|
||||
@@ -44,6 +48,7 @@ interface SyncCardProps {
|
||||
scheme: "light" | "dark";
|
||||
selected: ConcreteThemeName;
|
||||
active: boolean;
|
||||
name: string;
|
||||
onSelect: (theme: ConcreteThemeName) => void;
|
||||
}
|
||||
|
||||
@@ -51,6 +56,7 @@ const SyncCard: FC<SyncCardProps> = ({
|
||||
scheme,
|
||||
selected,
|
||||
active,
|
||||
name,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [previewTheme, setPreviewTheme] = useState<
|
||||
@@ -94,7 +100,7 @@ const SyncCard: FC<SyncCardProps> = ({
|
||||
{SYNC_MODE_THEMES.map((theme) => (
|
||||
<ThemeSwatch
|
||||
key={theme}
|
||||
name={`theme-sync-${scheme}`}
|
||||
name={name}
|
||||
theme={theme}
|
||||
selected={theme === selected}
|
||||
onSelect={() => onSelect(theme)}
|
||||
|
||||
Reference in New Issue
Block a user