75 lines
2.8 KiB
TypeScript
75 lines
2.8 KiB
TypeScript
|
|
import { useId } from "react";
|
||
|
|
|
||
|
|
export interface Choice<T extends string> {
|
||
|
|
value: T;
|
||
|
|
label: string;
|
||
|
|
note?: string; // small extra text, e.g. "14d" or "No key"
|
||
|
|
noteTone?: "muted" | "subtle" | "warning";
|
||
|
|
disabled?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A row of single-choice chips — used for providers, forecast views, map layers.
|
||
|
|
*
|
||
|
|
* Each chip is a real radio input, visually hidden; the label is styled from
|
||
|
|
* the input's state with Tailwind's `has-*` variants. Arrow keys move between
|
||
|
|
* chips for free because they're a native radio group.
|
||
|
|
*/
|
||
|
|
export function ChoiceChips<T extends string>({ legend, choices, selected, onChange, size = "md" }: {
|
||
|
|
legend: string;
|
||
|
|
choices: Choice<T>[];
|
||
|
|
selected: T;
|
||
|
|
onChange: (value: T) => void;
|
||
|
|
size?: "sm" | "md";
|
||
|
|
}) {
|
||
|
|
const name = useId();
|
||
|
|
|
||
|
|
return (
|
||
|
|
// min-w-0: fieldsets default to min-content width, which would widen the page on phones
|
||
|
|
<fieldset className="m-0 flex min-w-0 gap-2 overflow-x-auto border-0 p-0 pb-1 [scrollbar-width:none]">
|
||
|
|
<legend className="sr-only">{legend}</legend>
|
||
|
|
|
||
|
|
{choices.map((choice) => (
|
||
|
|
<label
|
||
|
|
key={choice.value}
|
||
|
|
className={[
|
||
|
|
"relative flex flex-none cursor-pointer items-center gap-2 border border-gold-dim bg-panel/60 font-pixel text-xs text-stone transition-colors duration-150",
|
||
|
|
size === "md" ? "min-h-[38px] px-3.5" : "min-h-8 px-2.5",
|
||
|
|
"hover:not-has-disabled:not-has-checked:border-gold/70 hover:not-has-disabled:not-has-checked:text-ink",
|
||
|
|
// Selected: gold border and text, plus a small diamond in front
|
||
|
|
"has-checked:border-gold has-checked:bg-gold/12 has-checked:text-gold-bright",
|
||
|
|
"has-checked:before:size-1.5 has-checked:before:rotate-45 has-checked:before:bg-gold-bright has-checked:before:content-['']",
|
||
|
|
// Keyboard focus lands on the hidden input, so show it on the label
|
||
|
|
"has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-teal",
|
||
|
|
"has-disabled:cursor-not-allowed has-disabled:opacity-50",
|
||
|
|
].join(" ")}
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
type="radio"
|
||
|
|
name={name}
|
||
|
|
className="sr-only"
|
||
|
|
value={choice.value}
|
||
|
|
checked={choice.value === selected}
|
||
|
|
disabled={choice.disabled}
|
||
|
|
onChange={() => onChange(choice.value)}
|
||
|
|
/>
|
||
|
|
{choice.label}
|
||
|
|
{choice.note && (
|
||
|
|
<span
|
||
|
|
className={
|
||
|
|
choice.noteTone === "warning"
|
||
|
|
? "text-heart"
|
||
|
|
: choice.noteTone === "subtle"
|
||
|
|
? "text-[0.65rem] text-stone/80"
|
||
|
|
: "text-teal/90"
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{choice.note}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
</fieldset>
|
||
|
|
);
|
||
|
|
}
|