feat: add accessibility features to UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user