import type { ControlSize } from "@charcuterie/tokens" import type { ReactNode } from "react" import { useEffect } from "react" import { FOCUS_RING_CLASS } from "../intentStyles.ts" import { TILE_BOX_CLASS, TILE_HINT_TEXT_CLASS, TILE_PADDING_CLASS, TILE_TEXT_SIZE_CLASS, } from "../tileStyles.ts" import { toClassName } from "../toClassName.ts" import type { RadioItem, RadioItemShape, } from "./RadioGroup.tsx" export type RadioGroupOptionProps = { isChecked: boolean isReadOnly: boolean item: RadioItem itemShape: RadioItemShape onChoose: (value: string) => void /** `RovingFocus.register` — membership of the arrow-key group. */ registerFocus: (value: string) => () => void /** `SinglePicker.register` — membership of the choice. */ registerSelection: (value: string) => () => void size: ControlSize tabIndex: number trackElement: ( value: string, element: HTMLButtonElement | null, ) => void } // Deliberately the `Switch`'s `THUMB_SIZE_CLASS`, value for value — a // radio's ring and a switch's knob are then the same circle at every // density, which is what makes the family read as one set. const RING_SIZE_CLASS: Record = { sm: "size-3", md: "size-4", lg: "size-5", } const DOT_SIZE_CLASS: Record = { sm: "size-1.5", md: "size-2", lg: "size-2.5", } /** * Its own file for the same reason `SegmentedOption` is: **both * registrations are effects**, and an effect cannot run in a loop. * * The ring and the dot are two elements rather than a bordered box * with a background, so the checked dot can be centred by the grid * and sized independently of the ring — a filled `bg-intent-accent` * ring with no inner dot reads as a checkbox, not a radio. * * ### The tile shape is the same element with a border * * `itemShape="tile"` swaps `inline-flex` for `flex` — a grid item * stretches to its track, and an inline box would sit at * max-content inside it — adds the card's padding and border, and * moves the ring to the top of a two-line label. The radio dot is * still there and is still the thing a screen reader reads: a tile * with its border taken away is this row, which is why the two are * one component. */ export const RadioGroupOption = ({ isChecked, isReadOnly, item, itemShape, onChoose, registerFocus, registerSelection, size, tabIndex, trackElement, }: RadioGroupOptionProps): ReactNode => { const { hint, icon, isDisabled = false, label, value, } = item const isTile = itemShape === "tile" useEffect( () => registerSelection(value), [registerSelection, value], ) useEffect(() => { if (isDisabled) { return } return registerFocus(value) }, [isDisabled, registerFocus, value]) return ( // biome-ignore lint/a11y/useSemanticElements: `` owns `checked` *and* owns arrow-key navigation within a `name` group — the same self-owning-control conflict `SegmentedOption` documents, where the browser and `SinglePicker` would each believe they hold the choice and native roving would fight `RovingFocus`. APG's radio-group pattern is built for exactly this. ) }