diff --git a/__tests__/dark-mode-switcher-empty.test.tsx b/__tests__/dark-mode-switcher-empty.test.tsx new file mode 100644 index 0000000..05dd5ea --- /dev/null +++ b/__tests__/dark-mode-switcher-empty.test.tsx @@ -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(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("renders empty placeholder when isDarkMode is undefined", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("does NOT render switch when empty", () => { + render(); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + }); + + it("does not render empty state when isDarkMode is false (valid light mode)", () => { + render(); + 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(); + expect(screen.queryByTestId("dark-mode-switcher-empty-state")).not.toBeInTheDocument(); + }); + + it("does not render empty when loading (loading takes precedence)", () => { + render(); + 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(); + expect(screen.getByRole("region", { name: "No theme preferences" })).toBeInTheDocument(); + }); + + it("shows title No theme preferences available", () => { + render(); + expect(screen.getByText("No theme preferences available")).toBeInTheDocument(); + }); + + it("shows descriptive copy about theme data is empty", () => { + render(); + expect(screen.getByText(/Theme data is empty/)).toBeInTheDocument(); + }); + + it("shows illustrative copy about default light theme", () => { + render(); + expect(screen.getByText(/default light theme/)).toBeInTheDocument(); + }); + + it("shows Waiting for theme data badge", () => { + render(); + expect(screen.getByText("Waiting for theme data")).toBeInTheDocument(); + }); + + it("has decorative icon hidden from AT (aria-hidden)", () => { + const { container } = render(); + const hidden = container.querySelector("[aria-hidden=\"true\"]"); + expect(hidden).toBeInTheDocument(); + }); + + it("uses design tokens: border-border-strong bg-surface-card rounded-xl", () => { + render(); + 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(); + 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(); + 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(); + expect(screen.getByTestId("dark-mode-switcher-empty-state")).toBeInTheDocument(); + }); + + it("accepts custom className", () => { + render(); + expect(screen.getByTestId("dark-mode-switcher-empty-state").className).toContain("mt-4"); + }); + + it("has correct test id and roles", () => { + render(); + 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(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + }); + + it("renders switch for dark mode with correct aria", () => { + render(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true"); + }); + }); +}); diff --git a/app/components/DarkModeSwitcher.tsx b/app/components/DarkModeSwitcher.tsx new file mode 100644 index 0000000..65dfc26 --- /dev/null +++ b/app/components/DarkModeSwitcher.tsx @@ -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 ( +
+ +

+ No theme preferences available +

+

+ 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. +

+ +
+ ); +} + +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 ; + } + + // Loading state + if (loading) { + return ( + + + Loading theme... + + ); + } + + const label = ariaLabel ?? (checked ? "Switch to light mode" : "Switch to dark mode"); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isDisabled) return; + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + onToggle?.(); + } + }; + + const handleClick = () => { + if (isDisabled) return; + onToggle?.(); + }; + + return ( + + ); +}