diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 121d623..3635324 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -14,6 +14,7 @@ export default function ComponentsPreview() { const [inverseCheckbox, setInverseCheckbox] = useState(false); const [checkboxGroupValues, setCheckboxGroupValues] = useState([]); const [radioValue, setRadioValue] = useState(""); + const [inverseRadioValue, setInverseRadioValue] = useState(""); return (
@@ -174,12 +175,14 @@ export default function ComponentsPreview() {

- States + Standard Mode

setRadioValue(value)} + mode="standard" options={[ { value: "option1", label: "Option 1" }, { value: "option2", label: "Option 2" }, @@ -190,6 +193,7 @@ export default function ComponentsPreview() { name="interactive-radio" value={radioValue} onChange={({ value }) => setRadioValue(value)} + mode="standard" options={[ { value: "option1", label: "Option 1" }, { value: "option2", label: "Option 2" }, @@ -199,6 +203,47 @@ export default function ComponentsPreview() { +
+
+
+

+ Inverse Mode +

+
+ setInverseRadioValue(value)} + mode="inverse" + options={[ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]} + /> + setInverseRadioValue(value)} + mode="inverse" + options={[ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]} + /> + { 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 = { - 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) => { @@ -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} /> ); }; diff --git a/app/components/RadioButton/RadioButton.types.ts b/app/components/RadioButton/RadioButton.types.ts index 0cfd80d..04639a5 100644 --- a/app/components/RadioButton/RadioButton.types.ts +++ b/app/components/RadioButton/RadioButton.types.ts @@ -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; diff --git a/app/components/RadioButton/RadioButton.view.tsx b/app/components/RadioButton/RadioButton.view.tsx index 0d73dca..8b2e069 100644 --- a/app/components/RadioButton/RadioButton.view.tsx +++ b/app/components/RadioButton/RadioButton.view.tsx @@ -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 (
); }, - play: InverseInteraction.play, +}; + +export const Disabled = { + args: { + checked: false, + mode: "standard", + state: "default", + disabled: true, + label: "Disabled radio button", + }, + render: (args) => , +}; + +export const DisabledChecked = { + args: { + checked: true, + mode: "standard", + state: "default", + disabled: true, + label: "Disabled checked radio button", + }, + render: (args) => , +}; + +// All modes comparison +export const AllModes = () => { + const [standardChecked, setStandardChecked] = React.useState(false); + const [inverseChecked, setInverseChecked] = React.useState(false); + + return ( +
+
+

Standard Mode

+
+ setStandardChecked(checked)} + /> +
+
+ +
+

Inverse Mode

+
+ setInverseChecked(checked)} + /> +
+
+
+ ); +}; + +// All states for standard mode +export const StandardAllStates = () => { + const [unchecked, setUnchecked] = React.useState(false); + const [checked, setChecked] = React.useState(true); + + return ( +
+
+

Standard Mode - Unselected

+
+ setUnchecked(checked)} + /> +
+
+ +
+

Standard Mode - Selected

+
+ setChecked(checked)} + /> +
+
+
+ ); +}; + +// All states for inverse mode +export const InverseAllStates = () => { + const [unchecked, setUnchecked] = React.useState(false); + const [checked, setChecked] = React.useState(true); + + return ( +
+
+

Inverse Mode - Unselected

+
+ setUnchecked(checked)} + /> +
+
+ +
+

Inverse Mode - Selected

+
+ setChecked(checked)} + /> +
+
+
+ ); };