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
+47 -2
View File
@@ -14,6 +14,7 @@ export default function ComponentsPreview() {
const [inverseCheckbox, setInverseCheckbox] = useState(false); const [inverseCheckbox, setInverseCheckbox] = useState(false);
const [checkboxGroupValues, setCheckboxGroupValues] = useState<string[]>([]); const [checkboxGroupValues, setCheckboxGroupValues] = useState<string[]>([]);
const [radioValue, setRadioValue] = useState(""); const [radioValue, setRadioValue] = useState("");
const [inverseRadioValue, setInverseRadioValue] = useState("");
return ( return (
<div className="min-h-screen bg-[var(--color-surface-default-primary)] p-[var(--spacing-scale-032)]"> <div className="min-h-screen bg-[var(--color-surface-default-primary)] p-[var(--spacing-scale-032)]">
@@ -174,12 +175,14 @@ export default function ComponentsPreview() {
<div className="space-y-[var(--spacing-scale-016)]"> <div className="space-y-[var(--spacing-scale-016)]">
<div> <div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]"> <h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
States Standard Mode
</h3> </h3>
<div className="space-y-[var(--spacing-scale-016)]"> <div className="space-y-[var(--spacing-scale-016)]">
<RadioGroup <RadioGroup
name="default-radio" name="default-radio"
value="" value={radioValue}
onChange={({ value }) => setRadioValue(value)}
mode="standard"
options={[ options={[
{ value: "option1", label: "Option 1" }, { value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" }, { value: "option2", label: "Option 2" },
@@ -190,6 +193,7 @@ export default function ComponentsPreview() {
name="interactive-radio" name="interactive-radio"
value={radioValue} value={radioValue}
onChange={({ value }) => setRadioValue(value)} onChange={({ value }) => setRadioValue(value)}
mode="standard"
options={[ options={[
{ value: "option1", label: "Option 1" }, { value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" }, { value: "option2", label: "Option 2" },
@@ -199,6 +203,47 @@ export default function ComponentsPreview() {
<RadioGroup <RadioGroup
name="disabled-radio" name="disabled-radio"
value="" value=""
mode="standard"
disabled
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
</div>
</div>
<div>
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-012)]">
Inverse Mode
</h3>
<div className="space-y-[var(--spacing-scale-016)]">
<RadioGroup
name="inverse-default-radio"
value={inverseRadioValue}
onChange={({ value }) => setInverseRadioValue(value)}
mode="inverse"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
<RadioGroup
name="inverse-interactive-radio"
value={inverseRadioValue}
onChange={({ value }) => setInverseRadioValue(value)}
mode="inverse"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
<RadioGroup
name="inverse-disabled-radio"
value=""
mode="inverse"
disabled disabled
options={[ options={[
{ value: "option1", label: "Option 1" }, { value: "option1", label: "Option 1" },
@@ -7,7 +7,7 @@ import type { RadioButtonProps } from "./RadioButton.types";
const RadioButtonContainer = ({ const RadioButtonContainer = ({
checked = false, checked = false,
mode = "standard", mode = "standard",
state = "default", state = "default", // This state prop is now only for static display in Storybook/Preview
disabled = false, disabled = false,
label, label,
onChange, onChange,
@@ -19,52 +19,90 @@ const RadioButtonContainer = ({
...props ...props
}: RadioButtonProps) => { }: RadioButtonProps) => {
const isInverse = mode === "inverse"; const isInverse = mode === "inverse";
const isStandard = mode === "standard";
// Base tokens (using same design tokens as Checkbox) // Base box styles per Figma - 24px size, circular
const colorContent = isInverse const baseBox = `
? "var(--color-content-inverse-primary)" flex
: "var(--color-content-default-primary)"; 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 // Get box styles based on mode and checked status per Figma designs
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`; 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)]";
const stateStyles: Record<string, string> = { // When focused and checked, border should be invert tertiary (#2d2d2d) per Figma
default: "", const focusBorder = checked
hover: "", ? "focus:border-[var(--color-content-invert-tertiary,#2d2d2d)]"
focus: "", : "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`;
}
// 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: const combinedBoxStyles = getBoxStyles();
// - 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)";
// Dot color for selected state // Dot color per Figma
const dotColor = checked // Selected state: light cream/yellow (#fefcc9)
? isInverse // Selected hover state: darker yellow/brown (#333000 or rgba(51, 48, 0, 1))
? "var(--color-content-inverse-primary)" const getDotColor = (): string => {
: "var(--color-border-default-brand-primary)" if (!checked) return "transparent";
: "transparent";
const labelColor = colorContent;
const combinedBoxStyles = `${baseBox} ${stateStyles[state]}`; if (isStandard) {
// Use CSS to handle hover state - default is light cream, hover is darker
return "var(--color-content-default-brand-primary, #fefcc9)";
}
// Force visible outline for standard / default / unchecked // Inverse mode: black dot
const defaultOutlineClass = isInverse return "var(--color-content-default-primary, #000000)";
? "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 const dotColor = getDotColor();
// 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 // Label color
const conditionalFocusClass = const labelColor = isInverse
"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)]"; ? "var(--color-content-inverse-primary)"
: "var(--color-content-default-primary)";
// Generate unique ID for accessibility if not provided // Generate unique ID for accessibility if not provided
const generatedId = useId(); const generatedId = useId();
@@ -72,11 +110,13 @@ const RadioButtonContainer = ({
const handleToggle = useCallback( const handleToggle = useCallback(
(_e: React.MouseEvent | React.KeyboardEvent) => { (_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 }); onChange({ checked: true, value });
} }
}, },
[disabled, onChange, checked, value], [disabled, onChange, value],
); );
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
@@ -91,7 +131,7 @@ const RadioButtonContainer = ({
radioId={radioId} radioId={radioId}
checked={checked} checked={checked}
mode={mode} mode={mode}
state={state} state={state} // Passed for static display in Storybook/Preview
disabled={disabled} disabled={disabled}
label={label} label={label}
name={name} name={name}
@@ -99,15 +139,10 @@ const RadioButtonContainer = ({
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
className={className} className={className}
combinedBoxStyles={combinedBoxStyles} combinedBoxStyles={combinedBoxStyles}
defaultOutlineClass={defaultOutlineClass}
conditionalHoverOutlineClass={conditionalHoverOutlineClass}
conditionalFocusClass={conditionalFocusClass}
backgroundWhenChecked={backgroundWhenChecked}
dotColor={dotColor} dotColor={dotColor}
labelColor={labelColor} labelColor={labelColor}
onToggle={handleToggle} onToggle={handleToggle}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
{...props}
/> />
); );
}; };
@@ -24,10 +24,6 @@ export interface RadioButtonViewProps {
ariaLabel?: string; ariaLabel?: string;
className: string; className: string;
combinedBoxStyles: string; combinedBoxStyles: string;
defaultOutlineClass: string;
conditionalHoverOutlineClass: string;
conditionalFocusClass: string;
backgroundWhenChecked: string;
dotColor: string; dotColor: string;
labelColor: string; labelColor: string;
onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void; onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void;
+12 -16
View File
@@ -3,6 +3,7 @@ import type { RadioButtonViewProps } from "./RadioButton.types";
export function RadioButtonView({ export function RadioButtonView({
radioId, radioId,
checked, checked,
mode,
disabled, disabled,
label, label,
name, name,
@@ -10,15 +11,10 @@ export function RadioButtonView({
ariaLabel, ariaLabel,
className, className,
combinedBoxStyles, combinedBoxStyles,
defaultOutlineClass,
conditionalHoverOutlineClass,
conditionalFocusClass,
backgroundWhenChecked,
dotColor, dotColor,
labelColor, labelColor,
onToggle, onToggle,
onKeyDown, onKeyDown,
...props
}: RadioButtonViewProps) { }: RadioButtonViewProps) {
return ( return (
<label <label
@@ -30,25 +26,25 @@ export function RadioButtonView({
> >
<span <span
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`} className={`group ${combinedBoxStyles} ${disabled ? "" : "cursor-pointer"}`}
style={{ tabIndex={disabled ? -1 : 0}
backgroundColor: backgroundWhenChecked,
}}
tabIndex={0}
role="radio" role="radio"
aria-checked={checked} aria-checked={checked}
{...(disabled && { "aria-disabled": true })} {...(disabled && { "aria-disabled": true })}
{...(ariaLabel && { "aria-label": ariaLabel })} {...(ariaLabel && { "aria-label": ariaLabel })}
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })} {...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
id={radioId} id={radioId}
{...props}
> >
{/* Radio dot */} {/* Radio dot - 16px size per Figma */}
{/* Selected hover state: darker dot color (#333000) per Figma */}
<div <div
className="w-[16px] h-[16px] rounded-full transition-all duration-200" className={`w-[16px] h-[16px] rounded-full transition-all duration-200 ${
style={{ checked && mode === "standard"
backgroundColor: dotColor, ? "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> </span>
{label && ( {label && (
+155 -104
View File
@@ -1,96 +1,53 @@
import React from "react"; import React from "react";
import RadioButton from "../app/components/RadioButton"; import RadioButton from "../app/components/RadioButton";
import { expect } from "@storybook/test";
import { userEvent, within } from "@storybook/test";
// Interaction functions for Storybook play functions export default {
const DefaultInteraction = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const radioButton = canvas.getByRole("radio");
await expect(radioButton).toHaveAttribute("aria-checked", "false");
await userEvent.click(radioButton);
await expect(radioButton).toHaveAttribute("aria-checked", "true");
await userEvent.click(radioButton);
await expect(radioButton).toHaveAttribute("aria-checked", "true");
},
};
const CheckedInteraction = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const radioButton = canvas.getByRole("radio");
await expect(radioButton).toHaveAttribute("aria-checked", "true");
await userEvent.click(radioButton);
await expect(radioButton).toHaveAttribute("aria-checked", "true");
},
};
const StandardInteraction = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const radioButtons = canvas.getAllByRole("radio");
await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
await expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
await userEvent.click(radioButtons[0]);
await expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
},
};
const InverseInteraction = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const radioButtons = canvas.getAllByRole("radio");
await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
await expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
await userEvent.click(radioButtons[0]);
await expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
},
};
const meta = {
title: "Forms/RadioButton", title: "Forms/RadioButton",
component: RadioButton, component: RadioButton,
parameters: { parameters: {
layout: "centered", layout: "centered",
backgrounds: { backgrounds: {
default: "dark", default: "dark",
values: [{ name: "dark", value: "black" }], values: [
{ name: "light", value: "#ffffff" },
{ name: "dark", value: "#000000" },
],
}, },
}, },
tags: ["autodocs"],
argTypes: { argTypes: {
checked: { control: "boolean" }, checked: {
control: "boolean",
description: "Whether the radio button is checked",
},
mode: { mode: {
control: { type: "select" }, control: "select",
options: ["standard", "inverse"], options: ["standard", "inverse"],
description: "Visual mode of the radio button",
}, },
state: { state: {
control: { type: "select" }, control: "select",
options: ["default", "hover", "focus"], options: ["default", "hover", "focus"],
description: "Interaction state for static display",
},
disabled: {
control: "boolean",
description: "Whether the radio button is disabled",
},
label: {
control: "text",
description: "Label text for the radio button",
}, },
label: { control: "text" },
},
args: {
checked: false,
mode: "standard",
state: "default",
label: "Radio Button Label",
}, },
}; };
export default meta;
export const Default = { export const Default = {
args: { args: {
checked: false, checked: false,
mode: "standard", mode: "standard",
state: "default", state: "default",
disabled: false,
label: "Default radio button", label: "Default radio button",
}, },
play: DefaultInteraction.play,
render: (args) => { render: (args) => {
const [checked, setChecked] = React.useState(args.checked); const [checked, setChecked] = React.useState(args.checked);
return ( return (
@@ -108,9 +65,9 @@ export const Checked = {
checked: true, checked: true,
mode: "standard", mode: "standard",
state: "default", state: "default",
disabled: false,
label: "Checked radio button", label: "Checked radio button",
}, },
play: CheckedInteraction.play,
render: (args) => { render: (args) => {
const [checked, setChecked] = React.useState(args.checked); const [checked, setChecked] = React.useState(args.checked);
return ( return (
@@ -125,7 +82,7 @@ export const Checked = {
export const Standard = { export const Standard = {
render: () => { render: () => {
const [selectedValue, setSelectedValue] = React.useState("checked"); const [checked, setChecked] = React.useState(false);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -133,36 +90,21 @@ export const Standard = {
<h3 className="text-white font-medium">Standard Mode</h3> <h3 className="text-white font-medium">Standard Mode</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<RadioButton <RadioButton
label="Unchecked" label="Standard Radio Button"
checked={selectedValue === "unchecked"} checked={checked}
name="standard-example"
value="unchecked"
mode="standard" mode="standard"
onChange={({ checked }) => { onChange={({ checked: newChecked }) => setChecked(newChecked)}
if (checked) setSelectedValue("unchecked");
}}
/>
<RadioButton
label="Checked"
checked={selectedValue === "checked"}
name="standard-example"
value="checked"
mode="standard"
onChange={({ checked }) => {
if (checked) setSelectedValue("checked");
}}
/> />
</div> </div>
</div> </div>
</div> </div>
); );
}, },
play: StandardInteraction.play,
}; };
export const Inverse = { export const Inverse = {
render: () => { render: () => {
const [selectedValue, setSelectedValue] = React.useState("checked"); const [checked, setChecked] = React.useState(false);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -170,29 +112,138 @@ export const Inverse = {
<h3 className="text-white font-medium">Inverse Mode</h3> <h3 className="text-white font-medium">Inverse Mode</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<RadioButton <RadioButton
label="Unchecked" label="Inverse Radio Button"
checked={selectedValue === "unchecked"} checked={checked}
name="inverse-example"
value="unchecked"
mode="inverse" mode="inverse"
onChange={({ checked }) => { onChange={({ checked: newChecked }) => setChecked(newChecked)}
if (checked) setSelectedValue("unchecked");
}}
/>
<RadioButton
label="Checked"
checked={selectedValue === "checked"}
name="inverse-example"
value="checked"
mode="inverse"
onChange={({ checked }) => {
if (checked) setSelectedValue("checked");
}}
/> />
</div> </div>
</div> </div>
</div> </div>
); );
}, },
play: InverseInteraction.play, };
export const Disabled = {
args: {
checked: false,
mode: "standard",
state: "default",
disabled: true,
label: "Disabled radio button",
},
render: (args) => <RadioButton {...args} />,
};
export const DisabledChecked = {
args: {
checked: true,
mode: "standard",
state: "default",
disabled: true,
label: "Disabled checked radio button",
},
render: (args) => <RadioButton {...args} />,
};
// All modes comparison
export const AllModes = () => {
const [standardChecked, setStandardChecked] = React.useState(false);
const [inverseChecked, setInverseChecked] = React.useState(false);
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 text-white">Standard Mode</h3>
<div className="space-y-4">
<RadioButton
label="Standard Radio Button"
checked={standardChecked}
mode="standard"
onChange={({ checked }) => setStandardChecked(checked)}
/>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4 text-white">Inverse Mode</h3>
<div className="space-y-4">
<RadioButton
label="Inverse Radio Button"
checked={inverseChecked}
mode="inverse"
onChange={({ checked }) => setInverseChecked(checked)}
/>
</div>
</div>
</div>
);
};
// All states for standard mode
export const StandardAllStates = () => {
const [unchecked, setUnchecked] = React.useState(false);
const [checked, setChecked] = React.useState(true);
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 text-white">Standard Mode - Unselected</h3>
<div className="space-y-4">
<RadioButton
label="Unselected (default, hover, focus)"
checked={unchecked}
mode="standard"
onChange={({ checked }) => setUnchecked(checked)}
/>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4 text-white">Standard Mode - Selected</h3>
<div className="space-y-4">
<RadioButton
label="Selected (default, hover, focus)"
checked={checked}
mode="standard"
onChange={({ checked }) => setChecked(checked)}
/>
</div>
</div>
</div>
);
};
// All states for inverse mode
export const InverseAllStates = () => {
const [unchecked, setUnchecked] = React.useState(false);
const [checked, setChecked] = React.useState(true);
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 text-white">Inverse Mode - Unselected</h3>
<div className="space-y-4">
<RadioButton
label="Unselected (default, hover, focus)"
checked={unchecked}
mode="inverse"
onChange={({ checked }) => setUnchecked(checked)}
/>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4 text-white">Inverse Mode - Selected</h3>
<div className="space-y-4">
<RadioButton
label="Selected (default, hover, focus)"
checked={checked}
mode="inverse"
onChange={({ checked }) => setChecked(checked)}
/>
</div>
</div>
</div>
);
}; };