From 04783d3f62ab3cf19a3110fe66e73fc0b5e9eaa5 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:57:51 -0600 Subject: [PATCH] Radio button and group component with storybook and testing --- app/components/Checkbox.js | 4 +- app/components/RadioButton.js | 148 +++++++ app/components/RadioGroup.js | 65 +++ app/forms/page.js | 103 +++-- stories/RadioButton.stories.js | 93 ++++ stories/RadioGroup.stories.js | 133 ++++++ .../unit/RadioButton.a11y.test.jsx | 231 ++++++++++ .../unit/RadioGroup.a11y.test.jsx | 317 +++++++++++++ .../RadioButton.integration.test.jsx | 367 +++++++++++++++ .../RadioGroup.integration.test.jsx | 419 ++++++++++++++++++ .../RadioButton.interactions.test.js | 126 ++++++ tests/storybook/RadioButton.storybook.test.js | 177 ++++++++ .../storybook/RadioGroup.interactions.test.js | 184 ++++++++ tests/storybook/RadioGroup.storybook.test.js | 253 +++++++++++ tests/unit/RadioButton.test.jsx | 236 ++++++++++ tests/unit/RadioGroup.test.jsx | 240 ++++++++++ 16 files changed, 3053 insertions(+), 43 deletions(-) create mode 100644 app/components/RadioButton.js create mode 100644 app/components/RadioGroup.js create mode 100644 stories/RadioButton.stories.js create mode 100644 stories/RadioGroup.stories.js create mode 100644 tests/accessibility/unit/RadioButton.a11y.test.jsx create mode 100644 tests/accessibility/unit/RadioGroup.a11y.test.jsx create mode 100644 tests/integration/RadioButton.integration.test.jsx create mode 100644 tests/integration/RadioGroup.integration.test.jsx create mode 100644 tests/storybook/RadioButton.interactions.test.js create mode 100644 tests/storybook/RadioButton.storybook.test.js create mode 100644 tests/storybook/RadioGroup.interactions.test.js create mode 100644 tests/storybook/RadioGroup.storybook.test.js create mode 100644 tests/unit/RadioButton.test.jsx create mode 100644 tests/unit/RadioGroup.test.jsx diff --git a/app/components/Checkbox.js b/app/components/Checkbox.js index b5ad828..70a7672 100644 --- a/app/components/Checkbox.js +++ b/app/components/Checkbox.js @@ -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; diff --git a/app/components/RadioButton.js b/app/components/RadioButton.js new file mode 100644 index 0000000..0bc51bc --- /dev/null +++ b/app/components/RadioButton.js @@ -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 ( +