init
This commit is contained in:
commit
c19b910c36
80 changed files with 16134 additions and 0 deletions
95
app/components/RadioGroup.tsx
Normal file
95
app/components/RadioGroup.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue