All files / src/components/ui radio.tsx

100% Statements 20/20
100% Branches 27/27
100% Functions 4/4
100% Lines 20/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195                                                                    1x                                                 13x 13x   13x 12x                   13x                                       1x                                                       1x                               43x 43x       43x 43x 43x     43x                     3x 3x                       43x 4x     39x                             1x        
/**
 * Radio / RadioGroup — one choice out of a printed list.
 *
 * Real `<input type="radio">`s sharing one `name`, so the browser gives the
 * roving tab stop and arrow-key cycling for free (a hand-rolled version is
 * where that behaviour always goes missing). The group is a `radiogroup` and
 * takes its accessible name from the enclosing `Field`'s `Label`, so a
 * `Field` + `Label` + `RadioGroup` is wired without the call site saying so.
 *
 * Round, because it is a round thing: full radius here is the one exception
 * the Job Book's 2/3/4px radii allow.
 */
 
import { cva, type VariantProps } from "class-variance-authority";
import {
	createContext,
	forwardRef,
	type InputHTMLAttributes,
	type ReactNode,
	useContext,
	useId,
	useMemo,
} from "react";
import { cn } from "../../lib/utils";
import { useFieldControl } from "./field";
 
type RadioGroupContextValue = {
	name: string;
	value: string | undefined;
	onValueChange: ((value: string) => void) | undefined;
	disabled: boolean;
	invalid: boolean;
};
 
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
 
interface RadioGroupProps {
	/** Shared `name` for the inputs. Generated when omitted. */
	name?: string;
	value?: string;
	onValueChange?: (value: string) => void;
	disabled?: boolean;
	/** Marks the group invalid. A `Field` with an `ErrorText` sets this too. */
	invalid?: boolean;
	orientation?: "vertical" | "horizontal";
	className?: string;
	children: ReactNode;
}
 
function RadioGroup({
	name,
	value,
	onValueChange,
	disabled = false,
	invalid,
	orientation = "vertical",
	className,
	children,
}: RadioGroupProps) {
	const generatedName = useId();
	const field = useFieldControl({ invalid });
 
	const context = useMemo<RadioGroupContextValue>(
		() => ({
			name: name ?? generatedName,
			value,
			onValueChange,
			disabled,
			invalid: field.invalid,
		}),
		[name, generatedName, value, onValueChange, disabled, field.invalid],
	);
 
	return (
		<RadioGroupContext.Provider value={context}>
			<div
				role="radiogroup"
				aria-labelledby={field.labelId}
				aria-describedby={field["aria-describedby"]}
				aria-invalid={field["aria-invalid"]}
				aria-required={field.required ? true : undefined}
				className={cn(
					"flex gap-2",
					orientation === "vertical" ? "flex-col" : "flex-row flex-wrap",
					className,
				)}
			>
				{children}
			</div>
		</RadioGroupContext.Provider>
	);
}
 
const radioVariants = cva(
	"peer size-4 shrink-0 cursor-pointer appearance-none rounded-full border bg-background-elevated transition-colors checked:border-primary-500 checked:bg-primary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
	{
		variants: {
			invalid: {
				true: "border-error",
				// The portal's fields resolve their stroke through --border-strong,
				// 3:1 against both grounds (WCAG 1.4.11, UI component boundary).
				// --border-strong is 1.67:1 light / 1.91:1 dark and is NOT a legal
				// field stroke; it was a placeholder while the Earthy override
				// still shadowed the package tokens.
				false: "border-input",
			},
		},
		defaultVariants: {
			invalid: false,
		},
	},
);
 
interface RadioProps
	extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "size">,
		VariantProps<typeof radioVariants> {
	value: string;
	/** Label printed to the right; makes the whole row the hit target. */
	children?: ReactNode;
}
 
const Radio = forwardRef<HTMLInputElement, RadioProps>(
	(
		{
			className,
			invalid,
			value,
			children,
			name,
			checked,
			disabled,
			onChange,
			id,
			...props
		},
		ref,
	) => {
		const group = useContext(RadioGroupContext);
		const generatedId = useId();
		// The label wraps the input AND points at it: nesting alone is enough
		// for a browser, but the explicit pairing is what a screen reader and a
		// static a11y check both read without guessing.
		const inputId = id ?? generatedId;
		const isDisabled = disabled || group?.disabled;
		const isInvalid = invalid ?? group?.invalid ?? false;
 
		const dot = (
			<span className="relative inline-flex items-center justify-center">
				<input
					type="radio"
					id={inputId}
					ref={ref}
					value={value}
					name={name ?? group?.name}
					checked={checked ?? (group ? group.value === value : undefined)}
					disabled={isDisabled}
					aria-invalid={isInvalid ? true : undefined}
					onChange={(event) => {
						onChange?.(event);
						group?.onValueChange?.(value);
					}}
					className={cn(radioVariants({ invalid: isInvalid }), className)}
					{...props}
				/>
				<span
					aria-hidden="true"
					className="pointer-events-none absolute size-1.5 rounded-full bg-foreground-inverse opacity-0 peer-checked:opacity-100"
				/>
			</span>
		);
 
		if (children === undefined) {
			return dot;
		}
 
		return (
			<label
				htmlFor={inputId}
				className={cn(
					"inline-flex items-center gap-2 text-sm text-foreground-default",
					isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
				)}
			>
				{dot}
				<span>{children}</span>
			</label>
		);
	},
);
 
Radio.displayName = "Radio";
 
export type { RadioGroupProps, RadioProps };
export { Radio, RadioGroup, radioVariants };