96 lines
2.8 KiB
TypeScript
96 lines
2.8 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|