Update radio button component

This commit is contained in:
adilallo
2026-02-04 13:54:08 -07:00
parent 87a1e1d2a8
commit 3f35e581b7
5 changed files with 296 additions and 173 deletions
@@ -7,7 +7,7 @@ import type { RadioButtonProps } from "./RadioButton.types";
const RadioButtonContainer = ({
checked = false,
mode = "standard",
state = "default",
state = "default", // This state prop is now only for static display in Storybook/Preview
disabled = false,
label,
onChange,
@@ -19,52 +19,90 @@ const RadioButtonContainer = ({
...props
}: RadioButtonProps) => {
const isInverse = mode === "inverse";
const isStandard = mode === "standard";
// Base tokens (using same design tokens as Checkbox)
const colorContent = isInverse
? "var(--color-content-inverse-primary)"
: "var(--color-content-default-primary)";
// Base box styles per Figma - 24px size, circular
const baseBox = `
flex
items-center
justify-center
shrink-0
w-[24px]
h-[24px]
rounded-full
transition-all
duration-200
ease-in-out
p-[4px]
`.trim().replace(/\s+/g, " ");
// Visual container depending on state
const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`;
// Get box styles based on mode and checked status per Figma designs
const getBoxStyles = (): string => {
// Standard mode styles
if (isStandard) {
// Default state: tertiary border (or brand primary when checked), with hover and focus states via CSS
// Hover changes border to brand primary color
// Focus shows shadow (double ring: 2px white inner, 4px dark outer)
// When checked, border is brand primary (but changes to invert tertiary on focus)
const defaultBorder = checked
? "border-[var(--color-border-default-brand-primary,#fdfaa8)]"
: "border-[var(--color-border-default-tertiary,#464646)]";
// When focused and checked, border should be invert tertiary (#2d2d2d) per Figma
const focusBorder = checked
? "focus:border-[var(--color-content-invert-tertiary,#2d2d2d)]"
: "focus:border-[var(--color-border-default-tertiary,#464646)]";
return `${baseBox} bg-[var(--color-surface-default-primary)] border border-solid ${defaultBorder} hover:border-[var(--color-border-default-brand-primary,#fdfaa8)] ${focusBorder} focus:shadow-[0px_0px_0px_2px_var(--color-border-invert-primary,white),0px_0px_0px_4px_var(--color-border-default-primary,#141414)] focus:outline-none`;
}
const stateStyles: Record<string, string> = {
default: "",
hover: "",
focus: "",
// Inverse mode styles
if (isInverse) {
// Default state: white border (or brand primary when checked), transparent background
// Hover changes border to inverse brand primary color (#6c6701) for both selected and unselected
// Focus shows shadow (double ring: 2px dark inner, 4px white outer)
// When checked, border is brand primary (but changes to white on focus)
const defaultBorder = checked
? "border-[var(--color-border-default-brand-primary,#fdfaa8)]"
: "border-[var(--color-border-invert-primary,white)]";
// Hover border: inverse brand primary for both selected and unselected per Figma
const hoverBorder = "hover:border-[var(--color-border-invert-brand-primary,#6c6701)]";
// Focus border: when focused and checked, border should be white per Figma
const focusBorder = checked
? "focus:border-[var(--color-border-invert-primary,white)]"
: "focus:border-[var(--color-border-invert-primary,white)]";
return `${baseBox} bg-transparent border border-solid ${defaultBorder} ${hoverBorder} ${focusBorder} focus:shadow-[0px_0px_0px_2px_var(--color-border-default-primary,#141414),0px_0px_0px_4px_var(--color-border-invert-primary,white)] focus:outline-none`;
}
return baseBox;
};
// Background behavior:
// - Standard: background does not change on check; only dot appears
// - Inverse: transparent background, dot appears on check
const backgroundWhenChecked = isInverse
? "var(--color-surface-default-transparent)"
: "var(--color-surface-default-primary)";
const combinedBoxStyles = getBoxStyles();
// Dot color for selected state
const dotColor = checked
? isInverse
? "var(--color-content-inverse-primary)"
: "var(--color-border-default-brand-primary)"
: "transparent";
const labelColor = colorContent;
// Dot color per Figma
// Selected state: light cream/yellow (#fefcc9)
// Selected hover state: darker yellow/brown (#333000 or rgba(51, 48, 0, 1))
const getDotColor = (): string => {
if (!checked) return "transparent";
if (isStandard) {
// Use CSS to handle hover state - default is light cream, hover is darker
return "var(--color-content-default-brand-primary, #fefcc9)";
}
// Inverse mode: black dot
return "var(--color-content-default-primary, #000000)";
};
const combinedBoxStyles = `${baseBox} ${stateStyles[state]}`;
const dotColor = getDotColor();
// Force visible outline for standard / default / unchecked
const defaultOutlineClass = isInverse
? "outline outline-1 outline-[var(--color-border-inverse-primary)]"
: "outline outline-1 outline-[var(--color-border-default-tertiary)]";
// Apply brand outline only on actual :hover
// Standard mode uses default brand primary, inverse mode uses inverse brand primary
const conditionalHoverOutlineClass = isInverse
? "hover:outline hover:outline-1 hover:outline-[var(--color-border-inverse-brand-primary)]"
: "hover:outline hover:outline-1 hover:outline-[var(--color-border-default-brand-primary)]";
// Focus state for standard/unchecked with brand primary color and specific blur/spread
const conditionalFocusClass =
"focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]";
// Label color
const labelColor = isInverse
? "var(--color-content-inverse-primary)"
: "var(--color-content-default-primary)";
// Generate unique ID for accessibility if not provided
const generatedId = useId();
@@ -72,11 +110,13 @@ const RadioButtonContainer = ({
const handleToggle = useCallback(
(_e: React.MouseEvent | React.KeyboardEvent) => {
if (!disabled && onChange && !checked) {
if (!disabled && onChange) {
// Always call onChange when clicked, even if already checked
// The parent (RadioGroup) will handle the logic
onChange({ checked: true, value });
}
},
[disabled, onChange, checked, value],
[disabled, onChange, value],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
@@ -91,7 +131,7 @@ const RadioButtonContainer = ({
radioId={radioId}
checked={checked}
mode={mode}
state={state}
state={state} // Passed for static display in Storybook/Preview
disabled={disabled}
label={label}
name={name}
@@ -99,15 +139,10 @@ const RadioButtonContainer = ({
ariaLabel={ariaLabel}
className={className}
combinedBoxStyles={combinedBoxStyles}
defaultOutlineClass={defaultOutlineClass}
conditionalHoverOutlineClass={conditionalHoverOutlineClass}
conditionalFocusClass={conditionalFocusClass}
backgroundWhenChecked={backgroundWhenChecked}
dotColor={dotColor}
labelColor={labelColor}
onToggle={handleToggle}
onKeyDown={handleKeyDown}
{...props}
/>
);
};
@@ -24,10 +24,6 @@ export interface RadioButtonViewProps {
ariaLabel?: string;
className: string;
combinedBoxStyles: string;
defaultOutlineClass: string;
conditionalHoverOutlineClass: string;
conditionalFocusClass: string;
backgroundWhenChecked: string;
dotColor: string;
labelColor: string;
onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void;
+12 -16
View File
@@ -3,6 +3,7 @@ import type { RadioButtonViewProps } from "./RadioButton.types";
export function RadioButtonView({
radioId,
checked,
mode,
disabled,
label,
name,
@@ -10,15 +11,10 @@ export function RadioButtonView({
ariaLabel,
className,
combinedBoxStyles,
defaultOutlineClass,
conditionalHoverOutlineClass,
conditionalFocusClass,
backgroundWhenChecked,
dotColor,
labelColor,
onToggle,
onKeyDown,
...props
}: RadioButtonViewProps) {
return (
<label
@@ -30,25 +26,25 @@ export function RadioButtonView({
>
<span
onKeyDown={onKeyDown}
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
style={{
backgroundColor: backgroundWhenChecked,
}}
tabIndex={0}
className={`group ${combinedBoxStyles} ${disabled ? "" : "cursor-pointer"}`}
tabIndex={disabled ? -1 : 0}
role="radio"
aria-checked={checked}
{...(disabled && { "aria-disabled": true })}
{...(ariaLabel && { "aria-label": ariaLabel })}
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
id={radioId}
{...props}
>
{/* Radio dot */}
{/* Radio dot - 16px size per Figma */}
{/* Selected hover state: darker dot color (#333000) per Figma */}
<div
className="w-[16px] h-[16px] rounded-full transition-all duration-200"
style={{
backgroundColor: dotColor,
}}
className={`w-[16px] h-[16px] rounded-full transition-all duration-200 ${
checked && mode === "standard"
? "bg-[var(--color-content-default-brand-primary,#fefcc9)] group-hover:!bg-[#333000]"
: checked && mode === "inverse"
? "bg-[var(--color-content-default-primary,#000000)]"
: "bg-transparent"
}`}
/>
</span>
{label && (