planmymeal/app/components/RadioGroup.tsx

96 lines
2.8 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
"use client";
import { useRef, type KeyboardEvent, type ReactNode } from "react";
export interface RadioOption<T extends string | number> {
value: T;
label: ReactNode;
activeClass?: string;
}
interface RadioGroupProps<T extends string | number> {
name: string;
options: readonly RadioOption<T>[];
value: T;
onChange: (value: T) => void;
size?: "xs" | "sm";
fullWidth?: boolean;
}
/**
* Accessible segmented control implementing WAI-ARIA `radiogroup` roving tabIndex
* and arrow/Home/End keyboard navigation (1 Tab stop per group instead of N).
*/
export function RadioGroup<T extends string | number>({
name,
options,
value,
onChange,
size = "sm",
fullWidth = false,
}: RadioGroupProps<T>) {
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const currentIndex = Math.max(0, options.findIndex((o) => o.value === value));
let nextIndex = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
nextIndex = (currentIndex + 1) % options.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
nextIndex = (currentIndex - 1 + options.length) % options.length;
} else if (e.key === "Home") {
nextIndex = 0;
} else if (e.key === "End") {
nextIndex = options.length - 1;
}
if (nextIndex !== -1) {
e.preventDefault();
onChange(options[nextIndex].value);
buttonsRef.current[nextIndex]?.focus();
}
}
const pad =
size === "xs"
? fullWidth
? "min-h-8 flex-1 min-w-0 px-1.5 py-1 text-xs"
: "min-h-8 px-2.5 py-1 text-xs"
: fullWidth
? "min-h-9 flex-1 min-w-0 px-2 py-1.5 text-sm"
: "min-h-9 min-w-9 px-3 py-1.5 text-sm";
return (
<div
role="radiogroup"
aria-label={name}
onKeyDown={handleKeyDown}
className={`${fullWidth ? "flex w-full" : "inline-flex shrink-0"} max-w-full rounded-lg border border-line-strong bg-raised p-0.5`}
>
{options.map((option, i) => {
const checked = option.value === value;
const activeStyle = option.activeClass ?? "bg-surface text-ink border border-selected-line/60 shadow-2xs";
return (
<button
key={String(option.value)}
ref={(el) => {
buttonsRef.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
onClick={() => onChange(option.value)}
className={`trim-cap inline-flex items-center justify-center rounded-md font-medium tabular-nums transition-colors ${pad} ${
checked ? activeStyle : "border border-transparent text-ink-muted hover:text-ink"
}`}
>
{option.label}
</button>
);
})}
</div>
);
}