Radio button and group component with storybook and testing
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user