FN-7287: collapse custom shadcn color controls
Collapse dashboard shadcn color overrides behind an accessible shared toggle by default. - Add an icon-only expand/collapse affordance with aria state for the shared shadcn color picker. - Move reset and color override inputs into the expanded controls panel with responsive styling. - Cover collapsed and expanded picker behavior across theme selector surfaces. - Add a patch changeset for the dashboard theme control fix. Files changed: .changeset/fn-7287-collapse-shadcn-colors.md | 7 ++ .../dashboard/app/components/ShadcnColorPicker.css | 26 +++++- .../dashboard/app/components/ShadcnColorPicker.tsx | 104 +++++++++++++-------- .../__tests__/ShadcnColorPicker.test.tsx | 48 +++++++++- .../components/__tests__/ThemeDropdown.test.tsx | 11 +++ .../components/__tests__/ThemeSelector.test.tsx | 11 +++ 6 files changed, 163 insertions(+), 44 deletions(-) Fusion-Task-Id: FN-7287 Fusion-Task-Lineage: 315ef23e-07e1-473c-8187-a40d0516960e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7287-collapse-shadcn-colors.md
Normal file
7
.changeset/fn-7287-collapse-shadcn-colors.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Collapse custom shadcn color controls by default in dashboard theme surfaces.
|
||||
category: fix
|
||||
dev: Adds an accessible shared show/collapse affordance for the custom shadcn color picker.
|
||||
@@ -14,6 +14,10 @@
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.shadcn-color-picker-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shadcn-color-picker-title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
@@ -27,6 +31,21 @@
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.shadcn-color-picker-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.shadcn-color-picker-controls-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.shadcn-color-picker-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.shadcn-color-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 7), 1fr));
|
||||
@@ -85,11 +104,16 @@
|
||||
@media (max-width: 768px) {
|
||||
.shadcn-color-picker-header,
|
||||
.shadcn-color-picker-row,
|
||||
.shadcn-color-picker-controls {
|
||||
.shadcn-color-picker-controls,
|
||||
.shadcn-color-picker-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shadcn-color-picker-toggle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.shadcn-color-picker-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SHADCN_CUSTOM_COLOR_TOKENS,
|
||||
@@ -28,6 +29,9 @@ function toColorInputValue(value: string): string {
|
||||
/*
|
||||
FNXC:Theme 2026-06-20-18:38:
|
||||
The shadcn custom picker is shared by Settings and Command Center; it only edits the sanitized token→hex override map while the parent surfaces decide visibility for shadcn-custom so no other theme receives inline overrides.
|
||||
|
||||
FNXC:Theme 2026-06-30-00:00:
|
||||
Custom shadcn controls default collapsed to keep dashboard theme surfaces compact while leaving the override editor discoverable through an accessible icon affordance wherever this shared picker renders.
|
||||
*/
|
||||
export function ShadcnColorPicker({
|
||||
value = {},
|
||||
@@ -35,16 +39,23 @@ export function ShadcnColorPicker({
|
||||
resolvedThemeMode = "dark",
|
||||
}: ShadcnColorPickerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const controlsId = useId();
|
||||
const sanitizedValue = useMemo(() => sanitizeShadcnCustomColors(value), [value]);
|
||||
|
||||
const updateToken = (cssVar: string, nextValue: string) => {
|
||||
onChange(sanitizeShadcnCustomColors({ ...sanitizedValue, [cssVar]: nextValue }));
|
||||
};
|
||||
|
||||
const toggleLabel = expanded
|
||||
? t("theme.shadcnCustom.collapse", "Collapse custom colors")
|
||||
: t("theme.shadcnCustom.show", "Show custom colors");
|
||||
const ToggleIcon = expanded ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<section className="shadcn-color-picker card" data-testid="shadcn-color-picker" aria-labelledby="shadcn-color-picker-title">
|
||||
<div className="shadcn-color-picker-header">
|
||||
<div>
|
||||
<div className="shadcn-color-picker-heading">
|
||||
<h3 id="shadcn-color-picker-title" className="shadcn-color-picker-title">
|
||||
{t("theme.shadcnCustom.title", "Custom shadcn colors")}
|
||||
</h3>
|
||||
@@ -52,44 +63,61 @@ export function ShadcnColorPicker({
|
||||
{t("theme.shadcnCustom.description", "Override shadcn design tokens with hex colors. Blank tokens use the theme defaults.")}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn" onClick={() => onChange({})}>
|
||||
{t("theme.shadcnCustom.reset", "Reset custom colors")}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm shadcn-color-picker-toggle"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={controlsId}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<ToggleIcon size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="shadcn-color-picker-grid">
|
||||
{SHADCN_CUSTOM_COLOR_TOKENS.map((token) => {
|
||||
const fallback = getShadcnCustomDefaultValue(token, resolvedThemeMode);
|
||||
const currentValue = sanitizedValue[token.cssVar] ?? fallback;
|
||||
const inputId = `shadcn-color-${token.cssVar.replace(/^--/, "").replace(/[^a-z0-9]+/gi, "-")}`;
|
||||
return (
|
||||
<div className="shadcn-color-picker-row" key={token.cssVar} data-testid={`shadcn-color-${token.cssVar}`}>
|
||||
<label className="shadcn-color-picker-label" htmlFor={inputId}>
|
||||
<span>{t(`theme.shadcnCustom.token.${token.cssVar}`, token.label)}</span>
|
||||
<code>{token.cssVar}</code>
|
||||
</label>
|
||||
<div className="shadcn-color-picker-controls">
|
||||
<input
|
||||
aria-label={t("theme.shadcnCustom.colorInput", "Pick {{label}} color", { label: token.label })}
|
||||
className="shadcn-color-picker-native"
|
||||
type="color"
|
||||
value={toColorInputValue(currentValue)}
|
||||
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
id={inputId}
|
||||
aria-label={t("theme.shadcnCustom.hexInput", "{{label}} hex color", { label: token.label })}
|
||||
className="input shadcn-color-picker-hex"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
value={currentValue}
|
||||
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div id={controlsId} className="shadcn-color-picker-controls-panel" data-testid="shadcn-color-picker-controls">
|
||||
<div className="shadcn-color-picker-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => onChange({})}>
|
||||
{t("theme.shadcnCustom.reset", "Reset custom colors")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="shadcn-color-picker-grid">
|
||||
{SHADCN_CUSTOM_COLOR_TOKENS.map((token) => {
|
||||
const fallback = getShadcnCustomDefaultValue(token, resolvedThemeMode);
|
||||
const currentValue = sanitizedValue[token.cssVar] ?? fallback;
|
||||
const inputId = `shadcn-color-${token.cssVar.replace(/^--/, "").replace(/[^a-z0-9]+/gi, "-")}`;
|
||||
return (
|
||||
<div className="shadcn-color-picker-row" key={token.cssVar} data-testid={`shadcn-color-${token.cssVar}`}>
|
||||
<label className="shadcn-color-picker-label" htmlFor={inputId}>
|
||||
<span>{t(`theme.shadcnCustom.token.${token.cssVar}`, token.label)}</span>
|
||||
<code>{token.cssVar}</code>
|
||||
</label>
|
||||
<div className="shadcn-color-picker-controls">
|
||||
<input
|
||||
aria-label={t("theme.shadcnCustom.colorInput", "Pick {{label}} color", { label: token.label })}
|
||||
className="shadcn-color-picker-native"
|
||||
type="color"
|
||||
value={toColorInputValue(currentValue)}
|
||||
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
id={inputId}
|
||||
aria-label={t("theme.shadcnCustom.hexInput", "{{label}} hex color", { label: token.label })}
|
||||
className="input shadcn-color-picker-hex"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
value={currentValue}
|
||||
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,57 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ShadcnColorPicker } from "../ShadcnColorPicker";
|
||||
import { SHADCN_CUSTOM_COLOR_TOKENS } from "../shadcnCustomColors";
|
||||
|
||||
function expandCustomColors() {
|
||||
const toggle = screen.getByRole("button", { name: "Show custom colors" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(toggle).toHaveAttribute("aria-controls");
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.getByRole("button", { name: "Collapse custom colors" })).toHaveAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
describe("ShadcnColorPicker", () => {
|
||||
it("renders one color control row per customizable token", () => {
|
||||
it("is collapsed by default and toggles all custom color controls without dangling shells", () => {
|
||||
render(<ShadcnColorPicker value={{}} onChange={vi.fn()} resolvedThemeMode="dark" />);
|
||||
|
||||
expect(screen.getByTestId("shadcn-color-picker")).toBeDefined();
|
||||
expect(screen.getByText("Custom shadcn colors")).toBeDefined();
|
||||
expect(screen.getByText("Override shadcn design tokens with hex colors. Blank tokens use the theme defaults.")).toBeDefined();
|
||||
expect(screen.queryByTestId("shadcn-color-picker-controls")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
|
||||
for (const token of SHADCN_CUSTOM_COLOR_TOKENS) {
|
||||
expect(screen.queryByTestId(`shadcn-color-${token.cssVar}`)).toBeNull();
|
||||
}
|
||||
|
||||
expandCustomColors();
|
||||
expect(screen.getByTestId("shadcn-color-picker-controls")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Reset custom colors" })).toBeDefined();
|
||||
for (const token of SHADCN_CUSTOM_COLOR_TOKENS) {
|
||||
expect(screen.getByTestId(`shadcn-color-${token.cssVar}`)).toBeDefined();
|
||||
expect(screen.getByText(token.cssVar)).toBeDefined();
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Collapse custom colors" }));
|
||||
expect(screen.getByRole("button", { name: "Show custom colors" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByTestId("shadcn-color-picker-controls")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
|
||||
});
|
||||
|
||||
it("uses light defaults when no override exists", () => {
|
||||
it("uses light defaults when no override exists after expansion", () => {
|
||||
render(<ShadcnColorPicker value={{}} onChange={vi.fn()} resolvedThemeMode="light" />);
|
||||
|
||||
expandCustomColors();
|
||||
const bgRow = screen.getByTestId("shadcn-color---bg");
|
||||
expect(within(bgRow).getByRole("textbox")).toHaveValue("#ffffff");
|
||||
});
|
||||
|
||||
it("emits sanitized changes and rejects invalid hex input", () => {
|
||||
it("emits sanitized changes and rejects invalid hex input after expansion", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ShadcnColorPicker value={{}} onChange={onChange} resolvedThemeMode="dark" />);
|
||||
|
||||
expandCustomColors();
|
||||
const accentRow = screen.getByTestId("shadcn-color---accent");
|
||||
fireEvent.change(within(accentRow).getByRole("textbox"), { target: { value: "red" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({});
|
||||
@@ -33,18 +60,29 @@ describe("ShadcnColorPicker", () => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({ "--accent": "#FF8800" });
|
||||
});
|
||||
|
||||
it("normalizes short hex values for the native color input", () => {
|
||||
it("normalizes short hex values for the native color input after expansion", () => {
|
||||
render(<ShadcnColorPicker value={{ "--accent": "#fff" }} onChange={vi.fn()} resolvedThemeMode="dark" />);
|
||||
|
||||
expandCustomColors();
|
||||
const accentRow = screen.getByTestId("shadcn-color---accent");
|
||||
expect(within(accentRow).getByLabelText("Pick Accent color")).toHaveValue("#ffffff");
|
||||
});
|
||||
|
||||
it("reset clears all custom color overrides", () => {
|
||||
it("reset clears all custom color overrides after expansion", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ShadcnColorPicker value={{ "--accent": "#123456" }} onChange={onChange} resolvedThemeMode="dark" />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
|
||||
expandCustomColors();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset custom colors" }));
|
||||
expect(onChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("keeps mobile collapsed and expanded layout rules tokenized", () => {
|
||||
const css = readFileSync("app/components/ShadcnColorPicker.css", "utf8");
|
||||
|
||||
expect(css).toMatch(/@media \(max-width: 768px\) \{[\s\S]*?\.shadcn-color-picker-toggle \{[\s\S]*?align-self: flex-start;/);
|
||||
expect(css).toMatch(/\.shadcn-color-picker-controls-panel \{[\s\S]*?gap: var\(--space-sm\);/);
|
||||
expect(css).not.toMatch(/#[\da-f]{3,8}\b|rgb\(/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,17 @@ describe("ThemeDropdown", () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("shadcn-color-picker")).toBeDefined();
|
||||
const showCustomColors = screen.getByRole("button", { name: "Show custom colors" });
|
||||
expect(showCustomColors).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByTestId("shadcn-color-picker-controls")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
|
||||
expect(screen.queryByTestId("shadcn-color---accent")).toBeNull();
|
||||
|
||||
fireEvent.click(showCustomColors);
|
||||
expect(screen.getByRole("button", { name: "Collapse custom colors" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByTestId("shadcn-color-picker-controls")).toBeDefined();
|
||||
const accentRow = screen.getByTestId("shadcn-color---accent");
|
||||
expect(within(accentRow).getByRole("textbox")).toHaveValue("#123456");
|
||||
});
|
||||
|
||||
it("renders compact theme mode controls when mode props are supplied", () => {
|
||||
|
||||
@@ -644,6 +644,17 @@ describe("ThemeSelector", () => {
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("shadcn-color-picker")).toBeDefined();
|
||||
const showCustomColors = screen.getByRole("button", { name: "Show custom colors" });
|
||||
expect(showCustomColors).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByTestId("shadcn-color-picker-controls")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
|
||||
expect(screen.queryByTestId("shadcn-color---accent")).toBeNull();
|
||||
|
||||
fireEvent.click(showCustomColors);
|
||||
expect(screen.getByRole("button", { name: "Collapse custom colors" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByTestId("shadcn-color-picker-controls")).toBeDefined();
|
||||
const accentRow = screen.getByTestId("shadcn-color---accent");
|
||||
expect(within(accentRow).getByRole("textbox")).toHaveValue("#123456");
|
||||
expect(screen.getByLabelText("Shadcn Custom theme").getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user