FN-8471: add color theme filtering
Add a searchable shared color-theme picker for Settings and Command Center. - Filter translated theme labels case- and diacritic-insensitively - Preserve keyboard navigation, focus management, and selection behavior - Document the picker and add a minor release changeset Files changed: .changeset/fn-8471-theme-filter.md | 7 + docs/dashboard-guide.md | 3 +- .../dashboard/app/components/ThemeDropdown.css | 32 ++++ .../dashboard/app/components/ThemeDropdown.tsx | 172 +++++++++++++++------ .../components/__tests__/ThemeDropdown.test.tsx | 106 +++++++++++++ .../components/__tests__/ThemeSelector.test.tsx | 1 + .../__tests__/CommandCenterControls.test.tsx | 1 + 7 files changed, 277 insertions(+), 45 deletions(-) Fusion-Task-Id: FN-8471 Fusion-Task-Lineage: 1f73dfb8-18bb-48e6-a3ed-0ee3a200cf03 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8471-theme-filter.md
Normal file
7
.changeset/fn-8471-theme-filter.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Filter dashboard color themes by name in Settings and Command Center.
|
||||
category: feature
|
||||
dev: Uses the shared ThemeDropdown filter without changing persisted theme IDs or selection behavior.
|
||||
@@ -1961,10 +1961,11 @@ Non-Command-Center dashboard CSS uses `--text` as the canonical primary text tok
|
||||
<!-- FNXC:DashboardTheming 2026-07-20-00:00: Aurora is a built-in persisted palette with navy/teal/violet dark surfaces and a misty light counterpart; keep this inventory and its count synchronized with COLOR_THEMES. -->
|
||||
<!-- FNXC:DashboardTheming 2026-07-20-00:00: Calm is a built-in persisted palette with slate/sage dark surfaces and a misty blue-white light counterpart; keep this inventory and its count synchronized with COLOR_THEMES. -->
|
||||
<!-- FNXC:DashboardTheming 2026-07-20-00:00: Dawn is a built-in persisted palette with indigo/amber dark surfaces and a dawn-white light counterpart; keep this inventory and its count synchronized with COLOR_THEMES. -->
|
||||
<!-- FNXC:DashboardTheming 2026-07-21-12:20: FN-8471 keeps the Settings current-theme row and Command Center compact trigger on one filterable shared list so operators can discover any rendered theme name without changing the persisted selection until they choose an option. -->
|
||||
|
||||
Dark/light modes via `data-theme`; fresh installs default to System mode so the resolved theme follows `prefers-color-scheme` until the user explicitly chooses Light, Dark, or System. 84 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including Cobalt (saturated blue), Clay (warm terracotta), Moss (muted forest green), Aurora (navy/teal/violet dark surfaces with a misty blue-white light counterpart), Calm (low-stimulation slate/sage dark surfaces with a misty blue-white light counterpart), and Dawn (indigo/plum dark surfaces with muted amber accents and a dawn-white light counterpart), the Shadcn zinc-neutral theme with an orange default highlight/accent, Shadcn Custom (the same base with sanitized per-token color-picker overrides), and its color family: Shadcn Blue/Green/Red/Purple/Pink/Orange/Yellow, Shadcn Mono and Shadcn Mono Red/Blue/Green/Purple/Pink/Orange/Yellow (grayscale surfaces with color-specific accents; Shadcn Mono retains its selected id), Shadcn Black (pure black and white), Shadcn Gray (fully neutral zinc-gray accent), and Shadcn Gray Blue (blue-gray slate neutral surfaces with a muted slate-blue accent). Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. Glass Silver preserves the Glass theme's frosted translucent surfaces and transparent modal overlay behavior while using silver and graphite accents instead of purple/pink.
|
||||
|
||||
Choose color themes from **Settings → Appearance** or from the Command Center **Overview** theme card. Settings merges its selector into the current-theme row, which opens the full color-theme list; Command Center keeps the compact `ThemeDropdown` trigger. Both list every selectable color theme with the same labels and color-chip swatches. The left sidebar active-item highlight and resize accent use the active theme's `--accent`, so they follow the selected Shadcn accent instead of staying fixed blue.
|
||||
Choose color themes from **Settings → Appearance** or from the Command Center **Overview** theme card. Settings merges its selector into the current-theme row, which opens the full color-theme list; Command Center keeps the compact `ThemeDropdown` trigger. Both use the same filterable list: type a visible theme name to narrow the displayed labels and color-chip swatches, then select an option to change the theme. Filtering alone never changes the chosen theme. The left sidebar active-item highlight and resize accent use the active theme's `--accent`, so they follow the selected Shadcn accent instead of staying fixed blue.
|
||||
|
||||
- **Base tokens** (`--bg`, `--surface`, etc.) — redefine in `:root`, `[data-theme="light"]`, and every theme block.
|
||||
- **Semantic tokens** (`--autopilot-pulse`, `--event-error-text`, `--badge-mission-*`, `--fab-*`) — `:root` + `[data-theme="light"]` only; no per-color-theme overrides.
|
||||
|
||||
@@ -108,6 +108,10 @@ FN-8200 gives the Settings-only current-theme selector breathing room before the
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:DashboardTheming 2026-07-21-12:10:
|
||||
FN-8471 keeps the shared filter inside the existing popover so the Settings and Command Center variants retain identical desktop and mobile containment. The global input primitive supplies the tokenized field baseline; these selectors add only component layout, focus, and empty-state behavior.
|
||||
*/
|
||||
.theme-dropdown-popover {
|
||||
position: absolute;
|
||||
z-index: 10002;
|
||||
@@ -118,6 +122,33 @@ FN-8200 gives the Settings-only current-theme selector breathing room before the
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-1);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.theme-dropdown-filter {
|
||||
display: block;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.theme-dropdown-filter .input {
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.theme-dropdown-filter .input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.theme-dropdown-no-results {
|
||||
margin: 0;
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.theme-dropdown-list {
|
||||
@@ -126,6 +157,7 @@ FN-8200 gives the Settings-only current-theme selector breathing room before the
|
||||
max-block-size: min(60vh, calc(var(--space-2xl) * 12));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.theme-dropdown-option {
|
||||
|
||||
@@ -22,6 +22,10 @@ export function resolveColorTheme(colorTheme: ColorTheme) {
|
||||
return COLOR_THEMES.find((theme) => theme.value === colorTheme) ?? COLOR_THEMES[0];
|
||||
}
|
||||
|
||||
function normalizeThemeQuery(value: string) {
|
||||
return value.trim().normalize("NFD").replace(/\p{Diacritic}/gu, "").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function ThemeSwatch({ className }: { className: string }) {
|
||||
return (
|
||||
<span className={`theme-option-swatch ${className}`} aria-hidden="true">
|
||||
@@ -52,8 +56,12 @@ export function ThemeDropdown({
|
||||
}: ThemeDropdownProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(() => Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme)));
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeValue, setActiveValue] = useState<ColorTheme | null>(colorTheme);
|
||||
const [openOrigin, setOpenOrigin] = useState<"pointer" | "keyboard" | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const filterRef = useRef<HTMLInputElement | null>(null);
|
||||
const shouldFocusFilterRef = useRef(false);
|
||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
const currentTheme = useMemo(() => resolveColorTheme(colorTheme), [colorTheme]);
|
||||
@@ -65,62 +73,125 @@ export function ThemeDropdown({
|
||||
const currentThemeLabel = t(`theme.colorTheme.${currentTheme.value}`, currentTheme.label);
|
||||
const currentRowLabel = `${t("theme.currentTheme", "Current theme")} ${modeLabel} / ${currentThemeLabel}`;
|
||||
const listboxId = "theme-dropdown-listbox";
|
||||
const themesWithDisplayLabels = useMemo(
|
||||
() => COLOR_THEMES.map((theme) => ({ ...theme, displayLabel: t(`theme.colorTheme.${theme.value}`, theme.label) })),
|
||||
[t],
|
||||
);
|
||||
const normalizedQuery = normalizeThemeQuery(query);
|
||||
const filteredThemes = useMemo(
|
||||
() => themesWithDisplayLabels.filter((theme) => normalizeThemeQuery(theme.displayLabel).includes(normalizedQuery)),
|
||||
[normalizedQuery, themesWithDisplayLabels],
|
||||
);
|
||||
const activeIndex = filteredThemes.findIndex((theme) => theme.value === activeValue);
|
||||
|
||||
/*
|
||||
FNXC:DashboardTheming 2026-07-21-12:00:
|
||||
FN-8471 makes the 84-theme catalog discoverable without changing its persisted order or IDs. Filtering always uses the translated label rendered to operators (with metadata fallback), while pointer opens focus search and trigger keys deliberately focus filtered options: Down first, Up last, Enter/Space selected-or-first. Roving focus therefore only addresses the visible derived list.
|
||||
*/
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setActiveValue(colorTheme);
|
||||
setOpenOrigin(null);
|
||||
shouldFocusFilterRef.current = false;
|
||||
optionRefs.current = [];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [open]);
|
||||
}, [open, colorTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme)));
|
||||
}, [colorTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
optionRefs.current[activeIndex]?.focus();
|
||||
if (!open) return;
|
||||
if (!filteredThemes.some((theme) => theme.value === activeValue)) {
|
||||
setActiveValue(filteredThemes[0]?.value ?? null);
|
||||
}
|
||||
}, [activeIndex, open]);
|
||||
}, [activeValue, filteredThemes, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (openOrigin === "pointer" && shouldFocusFilterRef.current) {
|
||||
filterRef.current?.focus();
|
||||
shouldFocusFilterRef.current = false;
|
||||
} else if (openOrigin === "keyboard") {
|
||||
const index = filteredThemes.findIndex((theme) => theme.value === activeValue);
|
||||
optionRefs.current[index]?.focus();
|
||||
}
|
||||
}, [activeValue, filteredThemes, open, openOrigin]);
|
||||
|
||||
const chooseTheme = (theme: ColorTheme) => {
|
||||
onColorThemeChange(theme);
|
||||
setOpen(false);
|
||||
close();
|
||||
};
|
||||
|
||||
const openFromTrigger = (origin: "pointer" | "keyboard", key?: string) => {
|
||||
if (origin === "pointer") {
|
||||
shouldFocusFilterRef.current = true;
|
||||
setActiveValue(colorTheme);
|
||||
} else {
|
||||
const selectedVisible = filteredThemes.some((theme) => theme.value === colorTheme);
|
||||
const target = key === "ArrowUp"
|
||||
? filteredThemes.at(-1)?.value ?? null
|
||||
: (key === "ArrowDown" ? filteredThemes[0]?.value ?? null : (selectedVisible ? colorTheme : filteredThemes[0]?.value ?? null));
|
||||
setActiveValue(target);
|
||||
}
|
||||
setOpenOrigin(origin);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
const selectedIndex = Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme));
|
||||
setActiveIndex(event.key === "ArrowUp" ? Math.max(0, selectedIndex - 1) : selectedIndex);
|
||||
setOpen(true);
|
||||
openFromTrigger("keyboard", event.key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
const targetIndex = event.key === "ArrowUp"
|
||||
? filteredThemes.length - 1
|
||||
: Math.max(0, activeIndex);
|
||||
if (targetIndex < 0 || !filteredThemes[targetIndex]) return;
|
||||
event.preventDefault();
|
||||
setActiveValue(filteredThemes[targetIndex].value);
|
||||
optionRefs.current[targetIndex]?.focus();
|
||||
};
|
||||
|
||||
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number, theme: ColorTheme) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||
setActiveIndex((index + delta + COLOR_THEMES.length) % COLOR_THEMES.length);
|
||||
const nextIndex = (index + delta + filteredThemes.length) % filteredThemes.length;
|
||||
setActiveValue(filteredThemes[nextIndex]?.value ?? null);
|
||||
optionRefs.current[nextIndex]?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
setActiveValue(filteredThemes[0]?.value ?? null);
|
||||
optionRefs.current[0]?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setActiveIndex(COLOR_THEMES.length - 1);
|
||||
const lastIndex = filteredThemes.length - 1;
|
||||
setActiveValue(filteredThemes[lastIndex]?.value ?? null);
|
||||
optionRefs.current[lastIndex]?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -137,8 +208,8 @@ export function ThemeDropdown({
|
||||
aria-label={triggerVariant === "current-row" ? currentRowLabel : undefined}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
onClick={() => open ? close() : openFromTrigger("pointer")}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
>
|
||||
{triggerVariant === "current-row" ? (
|
||||
@@ -194,30 +265,43 @@ export function ThemeDropdown({
|
||||
|
||||
{open ? (
|
||||
<div className="theme-dropdown-popover" role="presentation">
|
||||
<div id={listboxId} className="theme-dropdown-list" role="listbox" aria-label={t("theme.colorThemeLabel", "Color theme")}>
|
||||
{COLOR_THEMES.map(({ value, label, className }, index) => {
|
||||
const selected = colorTheme === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
ref={(element) => {
|
||||
optionRefs.current[index] = element;
|
||||
}}
|
||||
type="button"
|
||||
className={`theme-dropdown-option${selected ? " active" : ""}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={index === activeIndex ? 0 : -1}
|
||||
onClick={() => chooseTheme(value)}
|
||||
onKeyDown={(event) => handleOptionKeyDown(event, index, value)}
|
||||
>
|
||||
<ThemeSwatch className={className} />
|
||||
<span className="theme-dropdown-option-label">{t(`theme.colorTheme.${value}`, label)}</span>
|
||||
{selected ? <Check size={16} aria-hidden="true" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="theme-dropdown-filter">
|
||||
<span className="sr-only">{t("theme.filterColorThemes", "Filter color themes")}</span>
|
||||
<input
|
||||
ref={filterRef}
|
||||
type="search"
|
||||
className="input"
|
||||
value={query}
|
||||
placeholder={t("theme.filterColorThemesPlaceholder", "Filter color themes")}
|
||||
aria-label={t("theme.filterColorThemes", "Filter color themes")}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
/>
|
||||
</label>
|
||||
{filteredThemes.length ? (
|
||||
<div id={listboxId} className="theme-dropdown-list" role="listbox" aria-label={t("theme.colorThemeLabel", "Color theme")}>
|
||||
{filteredThemes.map(({ value, displayLabel, className }, index) => {
|
||||
const selected = colorTheme === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
ref={(element) => { optionRefs.current[index] = element; }}
|
||||
type="button"
|
||||
className={`theme-dropdown-option${selected ? " active" : ""}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={index === activeIndex ? 0 : -1}
|
||||
onClick={() => chooseTheme(value)}
|
||||
onKeyDown={(event) => handleOptionKeyDown(event, index, value)}
|
||||
>
|
||||
<ThemeSwatch className={className} />
|
||||
<span className="theme-dropdown-option-label">{displayLabel}</span>
|
||||
{selected ? <Check size={16} aria-hidden="true" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <p className="theme-dropdown-no-results">{t("theme.noColorThemes", "No color themes found")}</p>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { ThemeDropdown } from "../ThemeDropdown";
|
||||
|
||||
// FNXC:Theme 2026-07-16-14:30: FN-8146 pins the historical Settings-grid set, including restored shadcn-mono, so a removal from COLOR_THEMES cannot make the all-themes checks pass circularly.
|
||||
@@ -253,6 +254,111 @@ describe("ThemeDropdown", () => {
|
||||
expect(css).not.toContain('[data-theme="light"] .theme-swatch-shadcn-mono,\n.theme-swatch-shadcn-mono-red');
|
||||
});
|
||||
|
||||
it("filters rendered labels case- and diacritic-insensitively without changing canonical order", () => {
|
||||
render(<ThemeDropdown colorTheme="ocean" onColorThemeChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ocean/i }));
|
||||
const filter = screen.getByRole("searchbox", { name: /filter color themes/i });
|
||||
|
||||
fireEvent.change(filter, { target: { value: " shadcn mono red " } });
|
||||
expect(renderedThemeIds(screen.getByRole("listbox"))).toEqual(["shadcn-mono-red"]);
|
||||
|
||||
fireEvent.change(filter, { target: { value: "rose pine" } });
|
||||
expect(renderedThemeIds(screen.getByRole("listbox"))).toEqual(["rose-pine"]);
|
||||
|
||||
fireEvent.change(filter, { target: { value: " " } });
|
||||
expect(renderedThemeIds(screen.getByRole("listbox"))).toEqual(EXPECTED_THEME_IDS);
|
||||
expect(new Set(renderedThemeIds(screen.getByRole("listbox"))).size).toBe(EXPECTED_THEME_IDS.length);
|
||||
});
|
||||
|
||||
it("filters the translated display label and falls back to theme metadata", () => {
|
||||
i18next.addResourceBundle("en", "app", { theme: { colorTheme: { ocean: "Marée" } } }, true, true);
|
||||
const { unmount } = render(<ThemeDropdown colorTheme="ocean" onColorThemeChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /marée/i }));
|
||||
const filter = screen.getByRole("searchbox", { name: /filter color themes/i });
|
||||
fireEvent.change(filter, { target: { value: "maree" } });
|
||||
expect(screen.getByRole("option", { name: "Marée" })).toBeDefined();
|
||||
fireEvent.change(filter, { target: { value: "forest" } });
|
||||
expect(screen.getByRole("option", { name: "Forest" })).toBeDefined();
|
||||
unmount();
|
||||
i18next.removeResourceBundle("en", "app");
|
||||
});
|
||||
|
||||
it("keeps no-result filtering non-selectable and restores a valid filtered roving option", () => {
|
||||
const onColorThemeChange = vi.fn();
|
||||
render(<ThemeDropdown colorTheme="ocean" onColorThemeChange={onColorThemeChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ocean/i }));
|
||||
const filter = screen.getByRole("searchbox", { name: /filter color themes/i });
|
||||
fireEvent.change(filter, { target: { value: "missing theme" } });
|
||||
expect(screen.getByText(/no color themes found/i)).toBeDefined();
|
||||
expect(screen.queryAllByRole("option")).toHaveLength(0);
|
||||
fireEvent.keyDown(filter, { key: "ArrowDown" });
|
||||
expect(onColorThemeChange).not.toHaveBeenCalled();
|
||||
fireEvent.change(filter, { target: { value: "ocean" } });
|
||||
expect(screen.getByRole("option", { name: "Ocean" })).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
it("uses pointer and trigger keyboard origins to focus the specified targets", () => {
|
||||
const { unmount } = render(<ThemeDropdown colorTheme="forest" onColorThemeChange={vi.fn()} />);
|
||||
const pointerTrigger = screen.getByRole("button", { name: /forest/i });
|
||||
expect(pointerTrigger).not.toHaveAttribute("aria-controls");
|
||||
fireEvent.click(pointerTrigger);
|
||||
expect(screen.getByRole("searchbox", { name: /filter color themes/i })).toHaveFocus();
|
||||
expect(pointerTrigger).toHaveAttribute("aria-controls", "theme-dropdown-listbox");
|
||||
unmount();
|
||||
|
||||
for (const [key, expected] of [["ArrowDown", "Fusion Legacy"], ["ArrowUp", "Shadcn Gray Blue"], ["Enter", "Forest"], [" ", "Forest"]]) {
|
||||
const { unmount: close } = render(<ThemeDropdown colorTheme="forest" onColorThemeChange={vi.fn()} />);
|
||||
const trigger = screen.getByRole("button", { name: /forest/i });
|
||||
fireEvent.keyDown(trigger, { key });
|
||||
expect(screen.getByRole("option", { name: expected })).toHaveFocus();
|
||||
expect(screen.getByRole("searchbox", { name: /filter color themes/i })).not.toHaveFocus();
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
it("transfers input focus and navigates only filtered options before selecting once", () => {
|
||||
const onColorThemeChange = vi.fn();
|
||||
render(<ThemeDropdown colorTheme="ocean" onColorThemeChange={onColorThemeChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ocean/i }));
|
||||
const filter = screen.getByRole("searchbox", { name: /filter color themes/i });
|
||||
fireEvent.change(filter, { target: { value: "shadcn mono" } });
|
||||
fireEvent.keyDown(filter, { key: "ArrowDown" });
|
||||
expect(screen.getByRole("option", { name: "Shadcn Mono" })).toHaveFocus();
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: "Shadcn Mono" }), { key: "ArrowUp" });
|
||||
expect(screen.getByRole("option", { name: "Shadcn Mono Yellow" })).toHaveFocus();
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: "Shadcn Mono Yellow" }), { key: "Home" });
|
||||
expect(screen.getByRole("option", { name: "Shadcn Mono" })).toHaveFocus();
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: "Shadcn Mono" }), { key: "End" });
|
||||
expect(screen.getByRole("option", { name: "Shadcn Mono Yellow" })).toHaveFocus();
|
||||
fireEvent.keyDown(screen.getByRole("option", { name: "Shadcn Mono Yellow" }), { key: "Enter" });
|
||||
expect(onColorThemeChange).toHaveBeenCalledTimes(1);
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("shadcn-mono-yellow");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(screen.queryByRole("searchbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes conditional listbox linkage and filter shell for all close paths", () => {
|
||||
render(<ThemeDropdown colorTheme="ocean" onColorThemeChange={vi.fn()} />);
|
||||
const trigger = screen.getByRole("button", { name: /ocean/i });
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.keyDown(screen.getByRole("searchbox", { name: /filter color themes/i }), { key: "Escape" });
|
||||
expect(trigger).not.toHaveAttribute("aria-controls");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(screen.queryByRole("searchbox")).toBeNull();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(trigger).not.toHaveAttribute("aria-controls");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
expect(screen.queryByRole("searchbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses tokenized filter styles within the existing mobile static popover", () => {
|
||||
const css = readFileSync("app/components/ThemeDropdown.css", "utf8");
|
||||
expect(css).toMatch(/\.theme-dropdown-filter \.input:focus-visible[\s\S]*?var\(--accent\)[\s\S]*?var\(--focus-ring\)/);
|
||||
expect(css).toMatch(/\.theme-dropdown-no-results[\s\S]*?var\(--space-sm\)[\s\S]*?var\(--text-muted\)/);
|
||||
});
|
||||
|
||||
it("preserves the mobile static in-flow popover branch without dropdown elevation", () => {
|
||||
const css = readFileSync("app/components/ThemeDropdown.css", "utf8");
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ describe("ThemeSelector", () => {
|
||||
|
||||
const listbox = screen.getByRole("listbox", { name: "Color theme" });
|
||||
expect(renderedThemeIds(listbox)).toEqual(EXPECTED_THEME_IDS);
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: /filter color themes/i }), { target: { value: "ocean" } });
|
||||
const oceanOption = within(listbox).getByRole("option", { name: "Ocean" });
|
||||
expect(oceanOption.querySelector(".theme-swatch-ocean")).toBeTruthy();
|
||||
fireEvent.click(oceanOption);
|
||||
|
||||
@@ -113,6 +113,7 @@ describe("CommandCenterControls concurrency markers", () => {
|
||||
fireEvent.click(trigger);
|
||||
const listbox = screen.getByRole("listbox", { name: "Color theme" });
|
||||
expect(renderedThemeIds(listbox)).toEqual(EXPECTED_THEME_IDS);
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: /filter color themes/i }), { target: { value: "dawn" } });
|
||||
fireEvent.click(screen.getByRole("option", { name: "Dawn" }));
|
||||
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("dawn");
|
||||
|
||||
Reference in New Issue
Block a user