From 110c62e21ec70bcc71ccf0063130deb05fbd4502 Mon Sep 17 00:00:00 2001 From: Richie Date: Sun, 29 Mar 2026 14:18:47 +1100 Subject: [PATCH] Add ToggleButtonGroup atom for button_select fields MUI ToggleButtonGroup wrapper with FA brand tokens. Exclusive single-select with fieldset/legend a11y, external label, helper/error text. Selected state uses brand.50 bg + brand border. Supports optional description text per option. Used in wizard for binary choices (Myself/Someone else, Yes/No). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ToggleButtonGroup.stories.tsx | 224 +++++++++++++++++ .../ToggleButtonGroup/ToggleButtonGroup.tsx | 230 ++++++++++++++++++ .../atoms/ToggleButtonGroup/index.ts | 2 + 3 files changed, 456 insertions(+) create mode 100644 src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.stories.tsx create mode 100644 src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx create mode 100644 src/components/atoms/ToggleButtonGroup/index.ts diff --git a/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.stories.tsx b/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.stories.tsx new file mode 100644 index 0000000..72f000f --- /dev/null +++ b/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.stories.tsx @@ -0,0 +1,224 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ToggleButtonGroup } from './ToggleButtonGroup'; +import Box from '@mui/material/Box'; + +const meta: Meta = { + title: 'Atoms/ToggleButtonGroup', + component: ToggleButtonGroup, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + value: { control: 'text' }, + error: { control: 'boolean' }, + required: { control: 'boolean' }, + fullWidth: { control: 'boolean' }, + size: { + control: 'select', + options: ['small', 'medium', 'large'], + table: { defaultValue: { summary: 'large' } }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── Default ───────────────────────────────────────────────────────────────── + +/** Binary choice with labels only */ +export const Default: Story = { + render: () => { + const [value, setValue] = useState(null); + return ( + + + + ); + }, +}; + +// ─── With Descriptions ────────────────────────────────────────────────────── + +/** Options with label + description text */ +export const WithDescriptions: Story = { + render: () => { + const [value, setValue] = useState(null); + return ( + + + + ); + }, +}; + +// ─── Pre-selected ─────────────────────────────────────────────────────────── + +/** With a value already selected */ +export const PreSelected: Story = { + render: () => { + const [value, setValue] = useState('yes'); + return ( + + + + ); + }, +}; + +// ─── Error state ───────────────────────────────────────────────────────────── + +/** Validation error — no selection made */ +export const Error: Story = { + render: () => { + const [value, setValue] = useState(null); + return ( + + + + ); + }, +}; + +// ─── Three options ────────────────────────────────────────────────────────── + +/** More than two options */ +export const ThreeOptions: Story = { + render: () => { + const [value, setValue] = useState(null); + return ( + + + + ); + }, +}; + +// ─── Disabled option ──────────────────────────────────────────────────────── + +/** One option disabled */ +export const DisabledOption: Story = { + render: () => { + const [value, setValue] = useState('myself'); + return ( + + + + ); + }, +}; + +// ─── Small size ────────────────────────────────────────────────────────────── + +/** Small size variant */ +export const Small: Story = { + render: () => { + const [value, setValue] = useState(null); + return ( + + + + ); + }, +}; + +// ─── With helper text ─────────────────────────────────────────────────────── + +/** Helper text below the group */ +export const WithHelperText: Story = { + render: () => { + const [value, setValue] = useState(null); + return ( + + + + ); + }, +}; diff --git a/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx b/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx new file mode 100644 index 0000000..e2a38e2 --- /dev/null +++ b/src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import MuiToggleButton from '@mui/material/ToggleButton'; +import MuiToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import type { ToggleButtonGroupProps as MuiToggleButtonGroupProps } from '@mui/material/ToggleButtonGroup'; +import FormControl from '@mui/material/FormControl'; +import FormLabel from '@mui/material/FormLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import Box from '@mui/material/Box'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { Typography } from '../Typography'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** A single option in the toggle button group */ +export interface ToggleOption { + /** Unique value for this option */ + value: string; + /** Display label */ + label: string; + /** Optional description shown below the label */ + description?: string; + /** Whether this option is disabled */ + disabled?: boolean; +} + +/** Props for the FA ToggleButtonGroup component */ +export interface ToggleButtonGroupProps extends Omit< + MuiToggleButtonGroupProps, + 'onChange' | 'children' +> { + /** Available options to choose from */ + options: ToggleOption[]; + /** Currently selected value (single-select) */ + value: string | null; + /** Callback fired when the selection changes */ + onChange: (value: string | null) => void; + /** Fieldset legend / visible label above the group */ + label?: string; + /** Helper text below the group */ + helperText?: string; + /** Error state — shows error styling and uses helperText as error message */ + error?: boolean; + /** Whether a selection is required */ + required?: boolean; + /** MUI sx prop for the root FormControl */ + sx?: SxProps; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +/** + * Exclusive toggle button group for the FA design system. + * + * Renders a set of toggle buttons in a horizontal row (stacks on narrow + * viewports). Used for binary or small-set choices in the arrangement + * wizard (e.g. "Myself / Someone else", "Yes / No"). + * + * Wraps MUI ToggleButtonGroup in a FormControl with fieldset semantics, + * external label, helper/error text, and FA brand styling. + * + * Keyboard: Arrow keys cycle options, Space/Enter selects. Tab moves + * between groups. + * + * Usage: + * ```tsx + * + * ``` + */ +export const ToggleButtonGroup = React.forwardRef( + ( + { + options, + value, + onChange, + label, + helperText, + error = false, + required = false, + fullWidth, + size = 'large', + sx, + ...groupProps + }, + ref, + ) => { + const handleChange = (_event: React.MouseEvent, newValue: string | null) => { + // Enforce exclusive selection — don't allow deselect + if (newValue !== null) { + onChange(newValue); + } + }; + + return ( + + {label && ( + + {label} + + )} + + `${theme.shape.borderRadius}px !important`, + '&:not(:first-of-type)': { + borderLeft: '2px solid', + borderColor: 'var(--fa-color-neutral-200)', + marginLeft: 0, + }, + }, + }} + {...groupProps} + > + {options.map((option) => ( + + theme.transitions.create(['background-color', 'border-color', 'box-shadow'], { + duration: theme.transitions.duration.short, + }), + + '&:hover': { + bgcolor: 'var(--fa-color-neutral-200)', + }, + + // Selected state — brand styling + '&.Mui-selected': { + bgcolor: 'var(--fa-color-brand-50)', + borderColor: 'primary.main', + color: 'text.primary', + '&:hover': { + bgcolor: 'var(--fa-color-brand-100)', + }, + }, + + // Error border + ...(error && { + borderColor: 'error.main', + '&.Mui-selected': { + borderColor: 'primary.main', + }, + }), + + // Focus ring + '&:focus-visible': { + outline: '2px solid var(--fa-color-interactive-focus)', + outlineOffset: '2px', + }, + + // Disabled + '&.Mui-disabled': { + opacity: 0.4, + }, + }} + > + + {option.label} + + {option.description && ( + + {option.description} + + )} + + ))} + + + {helperText && {helperText}} + + ); + }, +); + +ToggleButtonGroup.displayName = 'ToggleButtonGroup'; +export default ToggleButtonGroup; diff --git a/src/components/atoms/ToggleButtonGroup/index.ts b/src/components/atoms/ToggleButtonGroup/index.ts new file mode 100644 index 0000000..f8533d6 --- /dev/null +++ b/src/components/atoms/ToggleButtonGroup/index.ts @@ -0,0 +1,2 @@ +export { default } from './ToggleButtonGroup'; +export * from './ToggleButtonGroup';