Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions __tests__/dark-mode-switcher-empty.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import DarkModeSwitcher, { DarkModeSwitcherEmptyState } from "@/app/components/DarkModeSwitcher";

describe("DarkModeSwitcher - empty list display views #313", () => {
describe("empty state when isDarkMode is null/undefined", () => {
it("renders empty placeholder when isDarkMode is null", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument();
});

it("renders empty placeholder when isDarkMode is undefined", () => {
render(<DarkModeSwitcher isDarkMode={undefined} onToggle={vi.fn()} />);
expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument();
});

it("does NOT render switch when empty", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
});

it("does not render empty state when isDarkMode is false (valid light mode)", () => {
render(<DarkModeSwitcher isDarkMode={false} onToggle={vi.fn()} />);
expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument();
expect(screen.getByRole("switch")).toBeInTheDocument();
});

it("does not render empty state when isDarkMode is true", () => {
render(<DarkModeSwitcher isDarkMode={true} onToggle={vi.fn()} />);
expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument();
});

it("does not render empty when loading (loading takes precedence)", () => {
render(<DarkModeSwitcher isDarkMode={null} loading onToggle={vi.fn()} />);
expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument();
expect(screen.getByRole("status")).toBeInTheDocument();
});
});

describe("empty state UI elements - descriptive placeholder", () => {
it("has region role with aria-label No theme preferences", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
expect(screen.getByRole("region", { name: "No theme preferences" })).toBeInTheDocument();
});

it("shows title No theme preferences available", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
expect(screen.getByText("No theme preferences available")).toBeInTheDocument();
});

it("shows descriptive copy about theme data is empty", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
expect(screen.getByText(/Theme data is empty/)).toBeInTheDocument();
});

it("shows illustrative copy about default light theme", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
expect(screen.getByText(/default light theme/)).toBeInTheDocument();
});

it("shows Waiting for theme data badge", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
expect(screen.getByText("Waiting for theme data")).toBeInTheDocument();
});

it("has decorative icon hidden from AT (aria-hidden)", () => {
const { container } = render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
const hidden = container.querySelector("[aria-hidden=\"true\"]");
expect(hidden).toBeInTheDocument();
});

it("uses design tokens: border-border-strong bg-surface-card rounded-xl", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
const el = screen.getByTestId("dark-mode-switcher-empty-state");
expect(el.className).toContain("border-border-strong");
expect(el.className).toContain("bg-surface-card");
expect(el.className).toContain("rounded-xl");
});

it("uses text-text-primary and text-text-muted for contrast", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
expect(screen.getByText("No theme preferences available").className).toContain("text-text-primary");
expect(screen.getByText(/Theme data is empty/).className).toContain("text-text-muted");
});

it("has centered layout (items-center justify-center text-center)", () => {
render(<DarkModeSwitcher isDarkMode={null} onToggle={vi.fn()} />);
const el = screen.getByTestId("dark-mode-switcher-empty-state");
expect(el.className).toContain("items-center");
expect(el.className).toContain("justify-center");
expect(el.className).toContain("text-center");
});
});

describe("DarkModeSwitcherEmptyState component directly", () => {
it("renders standalone empty state", () => {
render(<DarkModeSwitcherEmptyState />);
expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument();
});

it("accepts custom className", () => {
render(<DarkModeSwitcherEmptyState className="mt-4" />);
expect(screen.getByTestId("dark-mode-switcher-empty-state").className).toContain("mt-4");
});

it("has correct test id and roles", () => {
render(<DarkModeSwitcherEmptyState />);
const el = screen.getByTestId("dark-mode-switcher-empty-state");
expect(el).toHaveAttribute("role", "region");
expect(el).toHaveAttribute("aria-label", "No theme preferences");
});
});

describe("not empty - normal rendering", () => {
it("renders switch for light mode with correct aria", () => {
render(<DarkModeSwitcher isDarkMode={false} onToggle={vi.fn()} />);
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false");
});

it("renders switch for dark mode with correct aria", () => {
render(<DarkModeSwitcher isDarkMode={true} onToggle={vi.fn()} />);
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
});
});
});
169 changes: 169 additions & 0 deletions app/components/DarkModeSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"use client";

import ButtonSpinner from "./ButtonSpinner";

export interface DarkModeSwitcherProps {
/** Current theme state: true = dark, false = light, null/undefined = empty/no data */
isDarkMode?: boolean | null;
/** Toggle handler */
onToggle?: () => void;
/** Whether the switch is disabled */
disabled?: boolean;
/** Loading state - shows spinner */
loading?: boolean;
/** Optional id for the control */
id?: string;
/** Additional className */
className?: string;
/** Accessible label override */
ariaLabel?: string;
}

/**
* Empty state view for DarkModeSwitcher.
* Displayed when theme data is unavailable (isDarkMode is null/undefined).
* Uses design tokens and is fully accessible.
*/
export function DarkModeSwitcherEmptyState({
className = "",
}: {
className?: string;
}) {
return (
<div
data-testid="dark-mode-switcher-empty-state"
role="region"
aria-label="No theme preferences"
className={`flex flex-col items-center justify-center gap-2 rounded-xl border border-border-strong bg-surface-card p-6 text-center ${className}`}
>
<div
aria-hidden="true"
className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-field border border-border-subtle text-text-muted text-lg"
>
&#9790;
</div>
<p className="text-sm font-semibold text-text-primary">
No theme preferences available
</p>
<p className="max-w-xs text-xs text-text-muted">
Theme data is empty. Once theme preferences are configured, the
dark/light toggle will appear here. You can still browse in the default
light theme.
</p>
<span
aria-hidden="true"
className="mt-1 text-xs px-2 py-1 rounded-full border border-border-subtle bg-surface-field text-text-muted"
>
Waiting for theme data
</span>
</div>
);
}

export default function DarkModeSwitcher({
isDarkMode,
onToggle,
disabled = false,
loading = false,
id,
className = "",
ariaLabel,
}: DarkModeSwitcherProps) {
const checked = Boolean(isDarkMode);
const isEmpty = isDarkMode === null || isDarkMode === undefined;
const isDisabled = disabled || loading;

// Empty state - descriptive placeholder when no data
if (isEmpty && !loading) {
return <DarkModeSwitcherEmptyState className={className} />;
}

// Loading state
if (loading) {
return (
<span
data-testid="dark-mode-switcher"
data-state="loading"
role="status"
aria-live="polite"
aria-label="Loading theme"
className={`inline-flex items-center gap-2 rounded-full border border-border-subtle bg-surface-field px-3 py-1.5 text-sm text-text-muted ${className}`}
>
<ButtonSpinner className="h-3.5 w-3.5" />
<span>Loading theme...</span>
</span>
);
}

const label = ariaLabel ?? (checked ? "Switch to light mode" : "Switch to dark mode");

const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (isDisabled) return;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
onToggle?.();
}
};

const handleClick = () => {
if (isDisabled) return;
onToggle?.();
};

return (
<button
id={id}
data-testid="dark-mode-switcher"
data-state={checked ? "dark" : "light"}
role="switch"
aria-checked={checked}
aria-label={label}
aria-disabled={isDisabled}
disabled={isDisabled}
tabIndex={isDisabled ? -1 : 0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={[
// base layout
"relative inline-flex h-7 w-12 items-center rounded-full p-1",
// transition & cursor
"transition-colors duration-200 ease-in-out",
"cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
// colors - accessible contrast using design tokens
checked
? "bg-accent hover:bg-accent-hover"
: "bg-surface-field border border-border-subtle hover:bg-surface-field/80",
// focus-visible premium ring
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-page",
// hover / focus tokens
"hover:shadow-sm focus-visible:shadow-md",
className,
].join(" ")}
>
<span className="sr-only">{label}</span>
<span
aria-hidden="true"
data-testid="dark-mode-switcher-thumb"
className={[
"inline-block h-5 w-5 transform rounded-full bg-white shadow-sm",
"transition-transform duration-200 ease-in-out",
checked ? "translate-x-5" : "translate-x-0",
"ring-0",
].join(" ")}
/>
{/* Decorative icons - hidden from AT */}
<span
aria-hidden="true"
className={`pointer-events-none absolute left-1.5 text-[10px] leading-none transition-opacity duration-200 ${checked ? "opacity-60 text-white" : "opacity-0"}`}
>
&#9790;
</span>
<span
aria-hidden="true"
className={`pointer-events-none absolute right-1.5 text-[10px] leading-none transition-opacity duration-200 ${checked ? "opacity-0" : "opacity-60 text-text-muted"}`}
>
&#9788;
</span>
</button>
);
}
Loading