import React, { createContext, useCallback, useContext, useRef } from "react"; import { getRovingTarget, isRtlContext, resolveRadioKey, } from "../../frontend/shared/radiogroup"; /** * Admin exclusive-choice primitive (WAI-ARIA Radio Group pattern). * * The settings screens render "cards" (icon + label + description) for scalar * settings such as layout, shape, tooltip position. Those are *exclusive* * choices, not independent toggles, so `aria-pressed` on a pile of buttons is * the wrong mapping: it announces each card as a standalone toggle button and * gives every card its own tab stop. * * This renders the correct tree instead — `role="radiogroup"` + `role="radio"`, * one tab stop for the whole group (roving tabindex), Arrow keys to move and * select, Home/End to jump, wrapping at the ends, RTL-aware horizontal arrows. * * The keyboard semantics are NOT reimplemented here: `resolveRadioKey` and * `getRovingTarget` are the same pure resolvers the frontend swatch renderer * uses, so the admin cards and the storefront swatches behave identically. * * Zero-or-one selection is supported: pass `value=""` when no option matches * (e.g. the color presets when a custom hex is active). ARIA permits a * radiogroup with no checked radio; the roving tab stop falls to the first * option so the group stays keyboard reachable. * * Free-side component: lives outside src/admin/extension/ and imports nothing * premium, so both the free and the extension admin bundles can consume it. */ interface ChoiceGroupContextValue { value: string; rovingValue: string; isDisabled: (value: string) => boolean; select: (value: string) => void; registerRef: (value: string, element: HTMLButtonElement | null) => void; handleKeyDown: ( value: string, event: React.KeyboardEvent, ) => void; } const ChoiceGroupContext = createContext(null); export interface ChoiceGroupProps { /** Accessible name for the group (WCAG 4.1.2 — a radiogroup must be named). */ label: string; /** Currently selected value, or "" when nothing is selected. */ value: string; /** Option values in DOM order — drives the roving order and Arrow/Home/End. */ optionValues: readonly string[]; onChange: (value: string) => void; /** Disables every option while preserving the rendered choice cards. */ disabled?: boolean; /** Visible options that arrows and Home/End must skip. */ disabledValues?: readonly string[]; /** id of an existing visible description element, wired via aria-describedby. */ describedById?: string; className?: string; children: React.ReactNode; } export function ChoiceGroup({ label, value, optionValues, onChange, disabled = false, disabledValues, describedById, className, children, }: ChoiceGroupProps): React.ReactElement { const refs = useRef(new Map()); const disabledSet = new Set( disabled ? optionValues : (disabledValues ?? []), ); const enabledValues = optionValues.filter( (optionValue) => !disabledSet.has(optionValue), ); const registerRef = useCallback( (optionValue: string, element: HTMLButtonElement | null) => { if (element) { refs.current.set(optionValue, element); } else { refs.current.delete(optionValue); } }, [], ); const handleKeyDown = useCallback( ( optionValue: string, event: React.KeyboardEvent, ): void => { if (disabledSet.has(optionValue)) return; const rtl = isRtlContext(event.currentTarget); const result = resolveRadioKey( event.key, optionValue, enabledValues, rtl, ); if (!result) return; event.preventDefault(); if (result.targetValue !== optionValue) { refs.current.get(result.targetValue)?.focus(); } if (result.selectIntent) { if (!disabledSet.has(result.targetValue)) { onChange(result.targetValue); } } }, [disabledSet, enabledValues, onChange], ); const rovingValue = getRovingTarget(enabledValues, value); const select = useCallback( (optionValue: string) => { if (!disabledSet.has(optionValue)) { onChange(optionValue); } }, [disabledSet, onChange], ); return ( disabledSet.has(optionValue), select, registerRef, handleKeyDown, }} >
{children}
); } export interface ChoiceOptionProps { value: string; className?: string; /** Needed only when the option's visible content is not text (color chips). */ ariaLabel?: string; title?: string; style?: React.CSSProperties; children?: React.ReactNode; } export function ChoiceOption({ value, className, ariaLabel, title, style, children, }: ChoiceOptionProps): React.ReactElement { const context = useContext(ChoiceGroupContext); if (!context) { throw new Error("ChoiceOption must be rendered inside a ChoiceGroup"); } const selected = context.value === value; const disabled = context.isDisabled(value); return ( ); }