feat: add accessibility features to UI

This commit is contained in:
gosangam
2026-04-18 12:25:36 +05:30
parent 5235f479b3
commit c865b59088
30 changed files with 557 additions and 233 deletions
+43
View File
@@ -0,0 +1,43 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
import { useSyncExternalStore } from "react";
const emptySubscribe = () => () => {};
function useMounted() {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
}
export function Logo({
className,
height = 48,
}: {
className?: string;
height?: number;
}) {
const { resolvedTheme } = useTheme();
const mounted = useMounted();
// Aspect ratio from the original images: 480x200 = 2.4:1
const width = Math.round(height * 2.4);
// Before mount, show the dark logo (works on both themes as a fallback)
const isDark = mounted ? resolvedTheme === "dark" : false;
return (
<Image
src={isDark ? "/logo_light.png" : "/logo_dark.png"}
alt="Prabhupāda Says"
width={width}
height={height}
className={className}
priority
/>
);
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { ThemeToggle } from "@/components/theme-toggle";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils";
export function PageHeader({
@@ -16,7 +16,7 @@ export function PageHeader({
<header
className={cn(
"border-b border-border bg-card px-6",
sticky ? "py-6 sticky top-0 z-20" : "py-8",
sticky ? "py-4 sticky top-0 z-20" : "py-4",
className,
)}
>
@@ -28,7 +28,7 @@ export function PageHeader({
>
<div className="flex-1 min-w-0">{children}</div>
<div className="shrink-0 pt-1">
<ThemeToggle />
<ReadingSettings />
</div>
</div>
</header>
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTheme } from "next-themes";
import { Settings, Sun, Moon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useSyncExternalStore } from "react";
const emptySubscribe = () => () => {};
function useMounted() {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
}
type ReadingPrefs = {
serifFont: boolean;
fontSize: string;
justified: boolean;
};
const DEFAULT_PREFS: ReadingPrefs = {
serifFont: false,
fontSize: "base",
justified: false,
};
const FONT_SIZES = [
{ value: "xs", label: "XS" },
{ value: "sm", label: "S" },
{ value: "base", label: "M" },
{ value: "lg", label: "L" },
{ value: "xl", label: "XL" },
{ value: "2xl", label: "XXL" },
] as const;
const STORAGE_KEY = "spsays_reading_prefs";
function loadPrefs(): ReadingPrefs {
if (typeof window === "undefined") return DEFAULT_PREFS;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_PREFS;
const parsed = JSON.parse(raw);
return {
serifFont:
typeof parsed.serifFont === "boolean"
? parsed.serifFont
: DEFAULT_PREFS.serifFont,
fontSize: FONT_SIZES.some((f) => f.value === parsed.fontSize)
? parsed.fontSize
: DEFAULT_PREFS.fontSize,
justified:
typeof parsed.justified === "boolean"
? parsed.justified
: DEFAULT_PREFS.justified,
};
} catch {
return DEFAULT_PREFS;
}
}
function savePrefs(prefs: ReadingPrefs) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// localStorage might be unavailable
}
}
function applyClasses(prefs: ReadingPrefs) {
const root = document.documentElement;
// Serif
root.classList.toggle("reading-serif", prefs.serifFont);
// Font size — remove all, add current
for (const fs of FONT_SIZES) {
root.classList.remove(`reading-fs-${fs.value}`);
}
root.classList.add(`reading-fs-${prefs.fontSize}`);
// Justified
root.classList.toggle("reading-justified", prefs.justified);
}
function Toggle({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex items-center justify-between w-full py-2 cursor-pointer"
>
<span className="text-sm text-foreground">{label}</span>
<div
className={cn(
"w-10 h-5 rounded-full transition-colors relative shrink-0 ml-3",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<div
className={cn(
"absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm",
checked ? "translate-x-5" : "translate-x-0.5",
)}
/>
</div>
</button>
);
}
export function ReadingSettings() {
const [open, setOpen] = useState(false);
const [prefs, setPrefs] = useState<ReadingPrefs>(() => loadPrefs());
const panelRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const { theme, setTheme } = useTheme();
const mounted = useMounted();
// Apply classes on mount (prefs already loaded via lazy initializer)
useEffect(() => {
applyClasses(prefs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Click outside to close
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open]);
const updatePrefs = useCallback((update: Partial<ReadingPrefs>) => {
setPrefs((prev) => {
const next = { ...prev, ...update };
savePrefs(next);
applyClasses(next);
return next;
});
}, []);
const fontSizeIndex = FONT_SIZES.findIndex((f) => f.value === prefs.fontSize);
const currentFontSizeLabel = FONT_SIZES[fontSizeIndex]?.label ?? "M";
if (!mounted) {
return (
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
<Settings className="size-4" />
</Button>
);
}
return (
<div className="relative">
<Button
ref={buttonRef as React.Ref<HTMLButtonElement>}
variant="ghost"
size="icon-sm"
aria-label="Reading settings"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Settings className="size-4" />
</Button>
{open && (
<div
ref={panelRef}
className="absolute right-0 top-full mt-2 w-72 rounded-lg border border-border bg-card shadow-lg z-50 p-4"
>
{/* Theme selector */}
<div className="mb-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Theme
</span>
<div className="flex gap-1 mt-2">
{(
[
{ value: "light", icon: Sun, label: "Light" },
{ value: "dark", icon: Moon, label: "Dark" },
{ value: "system", icon: Monitor, label: "System" },
] as const
).map(({ value, icon: Icon, label }) => (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
theme === value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-3.5" />
{label}
</button>
))}
</div>
</div>
<div className="border-t border-border pt-3 space-y-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Reading
</span>
{/* Serif font toggle */}
<Toggle
checked={prefs.serifFont}
onChange={(v) => updatePrefs({ serifFont: v })}
label="Serif Font"
/>
{/* Justify text toggle */}
<Toggle
checked={prefs.justified}
onChange={(v) => updatePrefs({ justified: v })}
label="Justify Text"
/>
{/* Font size slider */}
<div className="pt-1 pb-1">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-foreground">Font Size</span>
<span className="text-xs font-medium text-muted-foreground bg-muted px-2 py-0.5 rounded">
{currentFontSizeLabel}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">A</span>
<input
type="range"
min={0}
max={FONT_SIZES.length - 1}
step={1}
value={fontSizeIndex}
onChange={(e) => {
const idx = parseInt(e.target.value, 10);
updatePrefs({ fontSize: FONT_SIZES[idx].value });
}}
className="flex-1 h-1.5 accent-primary cursor-pointer"
/>
<span className="text-base text-muted-foreground font-medium">
A
</span>
</div>
</div>
</div>
</div>
)}
</div>
);
}
-57
View File
@@ -1,57 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSyncExternalStore } from "react";
const emptySubscribe = () => () => {};
function useMounted() {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
}
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const mounted = useMounted();
// Avoid hydration mismatch — on the server and during hydration, render a placeholder
if (!mounted) {
return (
<Button variant="ghost" size="icon-sm" aria-label="Toggle theme">
<Sun className="size-4" />
</Button>
);
}
const isDark = resolvedTheme === "dark";
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
/>
}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</TooltipTrigger>
<TooltipContent side="bottom">
{isDark ? "Light mode" : "Dark mode"}
</TooltipContent>
</Tooltip>
);
}
+7 -7
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import * as React from "react";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -9,12 +9,12 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
"h-10 w-full min-w-0 rounded-lg border border-input bg-card px-3 py-2 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-base file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-card dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };