Radio button and group component with storybook and testing

This commit is contained in:
adilallo
2025-10-09 14:57:51 -06:00
parent 0b9e918fd0
commit 04783d3f62
16 changed files with 3053 additions and 43 deletions
+2 -2
View File
@@ -69,9 +69,9 @@ const Checkbox = memo(
const conditionalHoverOutlineClass =
"hover:outline hover:outline-1 hover:outline-[var(--color-border-default-brand-primary)]";
// Focus state for standard/unchecked with utility info color and specific blur/spread
// 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-border-default-utility-info)]";
"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)]";
const handleToggle = (e) => {
if (disabled) return;
+148
View File
@@ -0,0 +1,148 @@
"use client";
import React, { memo, useCallback } from "react";
const RadioButton = ({
checked = false,
mode = "standard",
state = "default",
disabled = false,
label,
onChange,
id,
name,
value,
ariaLabel,
className = "",
...props
}) => {
const isInverse = mode === "inverse";
// Base tokens (using same design tokens as Checkbox)
const colorSurface = isInverse
? "var(--color-surface-inverse-primary)"
: "var(--color-surface-default-primary)";
const colorContent = isInverse
? "var(--color-content-inverse-primary)"
: "var(--color-content-default-primary)";
const colorBrand = isInverse
? "var(--color-content-inverse-brand-primary)"
: "var(--color-content-default-brand-primary)";
// 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`;
const stateStyles = {
default: "",
hover: "",
focus: "",
};
// 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)";
// Dot color for selected state
const dotColor = checked
? isInverse
? "var(--color-content-inverse-primary)"
: "var(--color-border-default-brand-primary)"
: "transparent";
const labelColor = colorContent;
const combinedBoxStyles = `${baseBox} ${stateStyles[state]}`;
// 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)]";
// Generate unique ID for accessibility if not provided
const radioId = id || `radio-${Math.random().toString(36).substr(2, 9)}`;
const handleToggle = useCallback(
(e) => {
if (!disabled && onChange && !checked) {
onChange({ checked: true, value });
}
},
[disabled, onChange, checked, value]
);
return (
<label
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
disabled ? "opacity-60 cursor-not-allowed" : ""
} ${className}`}
onMouseDown={(e) => e.preventDefault()}
onClick={handleToggle}
>
<span
onKeyDown={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleToggle(e);
}
}}
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
style={{
backgroundColor: backgroundWhenChecked,
}}
tabIndex={0}
role="radio"
aria-checked={checked ? "true" : "false"}
{...(disabled && { "aria-disabled": "true" })}
{...(ariaLabel && { "aria-label": ariaLabel })}
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
id={radioId}
>
{/* Radio dot */}
<div
className="w-[16px] h-[16px] rounded-full transition-all duration-200"
style={{
backgroundColor: dotColor,
}}
/>
</span>
{label && (
<span
id={`${radioId}-label`}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
{label}
</span>
)}
{/* Hidden input for form submission */}
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => {}}
disabled={disabled}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
{...props}
/>
</label>
);
};
RadioButton.displayName = "RadioButton";
export default memo(RadioButton);
+65
View File
@@ -0,0 +1,65 @@
"use client";
import React, { memo, useCallback } from "react";
import RadioButton from "./RadioButton";
const RadioGroup = ({
name,
value,
onChange,
mode = "standard",
state = "default",
disabled = false,
options = [],
className = "",
...props
}) => {
// Generate unique ID for accessibility if not provided
const groupId =
name || `radio-group-${Math.random().toString(36).substr(2, 9)}`;
const handleChange = useCallback(
(optionValue) => {
if (!disabled && onChange) {
onChange({ value: optionValue });
}
},
[disabled, onChange]
);
return (
<div
className={`space-y-[8px] ${className}`}
role="radiogroup"
aria-label={props["aria-label"]}
{...props}
>
{options.map((option, index) => {
const isSelected = value === option.value;
return (
<RadioButton
key={option.value}
checked={isSelected}
mode={mode}
state={state}
disabled={disabled}
label={option.label}
name={groupId}
value={option.value}
ariaLabel={option.ariaLabel}
onChange={({ checked }) => {
if (checked) {
handleChange(option.value);
}
}}
/>
);
})}
</div>
);
};
RadioGroup.displayName = "RadioGroup";
export default memo(RadioGroup);
+62 -41
View File
@@ -2,28 +2,22 @@
import React, { useState } from "react";
import Checkbox from "../components/Checkbox";
import RadioButton from "../components/RadioButton";
import RadioGroup from "../components/RadioGroup";
export default function FormsPlayground() {
const [standardChecked, setStandardChecked] = useState(false);
const [inverseChecked, setInverseChecked] = useState(true);
const variations = [
{ title: "Standard / Default", mode: "standard", state: "default" },
{ title: "Standard / Hover", mode: "standard", state: "hover" },
{ title: "Standard / Focus", mode: "standard", state: "focus" },
{ title: "Inverse / Default", mode: "inverse", state: "default" },
{ title: "Inverse / Hover", mode: "inverse", state: "hover" },
{ title: "Inverse / Focus", mode: "inverse", state: "focus" },
];
const [radioValue, setRadioValue] = useState("option1");
const [standardRadioValue, setStandardRadioValue] = useState("option1");
const [inverseRadioValue, setInverseRadioValue] = useState("option2");
return (
<div className="p-[24px] space-y-[24px]">
<h1 className="font-bricolage text-[24px]">
Forms Playground Checkbox
</h1>
<h1 className="font-bricolage text-[24px]">Forms Playground</h1>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Interactive examples</h2>
<h2 className="font-space text-[18px]">Checkbox Examples</h2>
<div className="flex flex-col gap-[12px] max-w-[520px]">
<Checkbox
label="Standard (controlled)"
@@ -43,34 +37,61 @@ export default function FormsPlayground() {
</section>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Static states</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[16px]">
{variations.map((v) => (
<div
key={`${v.mode}-${v.state}`}
className="border border-[color:var(--border-color-default-tertiary)] rounded-[8px] p-[12px]"
>
<div className="text-[12px] mb-[8px] opacity-70">{v.title}</div>
<div>
<div className="flex items-center gap-[12px]">
<Checkbox
checked={false}
mode={v.mode}
state={v.state}
label="Unchecked"
onChange={() => {}}
/>
<Checkbox
checked
mode={v.mode}
state={v.state}
label="Checked"
onChange={() => {}}
/>
</div>
</div>
</div>
))}
<h2 className="font-space text-[18px]">Radio Button Examples</h2>
<div className="flex flex-col gap-[12px] max-w-[520px]">
<RadioButton
label="Standard (controlled)"
checked={radioValue === "option1"}
mode="standard"
state="default"
value="option1"
onChange={({ checked }) => checked && setRadioValue("option1")}
/>
<RadioButton
label="Inverse (controlled)"
checked={radioValue === "option2"}
mode="inverse"
state="default"
value="option2"
onChange={({ checked }) => checked && setRadioValue("option2")}
/>
</div>
</section>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Radio Group</h2>
<div className="max-w-[520px] space-y-[16px]">
<div>
<h3 className="font-space text-[14px] mb-[8px]">Standard Mode</h3>
<RadioGroup
name="standard-radio"
value={standardRadioValue}
mode="standard"
state="default"
onChange={({ value }) => setStandardRadioValue(value)}
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
</div>
<div>
<h3 className="font-space text-[14px] mb-[8px]">Inverse Mode</h3>
<RadioGroup
name="inverse-radio"
value={inverseRadioValue}
mode="inverse"
state="default"
onChange={({ value }) => setInverseRadioValue(value)}
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
</div>
</div>
</section>
</div>