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) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:18:47 +11:00
parent 41efb78335
commit 110c62e21e
3 changed files with 456 additions and 0 deletions

View File

@@ -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<typeof ToggleButtonGroup> = {
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<typeof ToggleButtonGroup>;
// ─── Default ─────────────────────────────────────────────────────────────────
/** Binary choice with labels only */
export const Default: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{ value: 'myself', label: 'Myself' },
{ value: 'someone', label: 'Someone else' },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── With Descriptions ──────────────────────────────────────────────────────
/** Options with label + description text */
export const WithDescriptions: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{
value: 'myself',
label: 'Myself',
description: 'I want to plan my own funeral',
},
{
value: 'someone',
label: 'Someone else',
description: 'I am arranging for a family member or friend',
},
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── Pre-selected ───────────────────────────────────────────────────────────
/** With a value already selected */
export const PreSelected: Story = {
render: () => {
const [value, setValue] = useState<string | null>('yes');
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Has the person died?"
options={[
{ value: 'yes', label: 'Yes', description: 'I need to arrange a funeral now' },
{ value: 'no', label: 'No', description: 'I am planning ahead' },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── Error state ─────────────────────────────────────────────────────────────
/** Validation error — no selection made */
export const Error: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{ value: 'myself', label: 'Myself' },
{ value: 'someone', label: 'Someone else' },
]}
value={value}
onChange={setValue}
error
helperText="We need to know who the funeral is for to show you the right options."
fullWidth
/>
</Box>
);
},
};
// ─── Three options ──────────────────────────────────────────────────────────
/** More than two options */
export const ThreeOptions: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 600 }}>
<ToggleButtonGroup
label="Service style preference"
options={[
{ value: 'traditional', label: 'Traditional' },
{ value: 'contemporary', label: 'Contemporary' },
{ value: 'religious', label: 'Religious' },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── Disabled option ────────────────────────────────────────────────────────
/** One option disabled */
export const DisabledOption: Story = {
render: () => {
const [value, setValue] = useState<string | null>('myself');
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Who is this funeral being arranged for?"
options={[
{ value: 'myself', label: 'Myself' },
{ value: 'someone', label: 'Someone else', disabled: true },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};
// ─── Small size ──────────────────────────────────────────────────────────────
/** Small size variant */
export const Small: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 400 }}>
<ToggleButtonGroup
label="Urgency"
options={[
{ value: 'immediate', label: 'Immediate' },
{ value: 'planning', label: 'Planning ahead' },
]}
value={value}
onChange={setValue}
size="small"
fullWidth
/>
</Box>
);
},
};
// ─── With helper text ───────────────────────────────────────────────────────
/** Helper text below the group */
export const WithHelperText: Story = {
render: () => {
const [value, setValue] = useState<string | null>(null);
return (
<Box sx={{ width: 500 }}>
<ToggleButtonGroup
label="Has the person died?"
helperText="This helps us tailor the process to your situation."
options={[
{ value: 'yes', label: 'Yes' },
{ value: 'no', label: 'No' },
]}
value={value}
onChange={setValue}
fullWidth
/>
</Box>
);
},
};

View File

@@ -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<Theme>;
}
// ─── 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
* <ToggleButtonGroup
* label="Who is this funeral being arranged for?"
* options={[
* { value: 'myself', label: 'Myself', description: 'I want to plan my own funeral' },
* { value: 'someone', label: 'Someone else', description: 'I am arranging for a family member or friend' },
* ]}
* value={forWhom}
* onChange={setForWhom}
* />
* ```
*/
export const ToggleButtonGroup = React.forwardRef<HTMLFieldSetElement, ToggleButtonGroupProps>(
(
{
options,
value,
onChange,
label,
helperText,
error = false,
required = false,
fullWidth,
size = 'large',
sx,
...groupProps
},
ref,
) => {
const handleChange = (_event: React.MouseEvent<HTMLElement>, newValue: string | null) => {
// Enforce exclusive selection — don't allow deselect
if (newValue !== null) {
onChange(newValue);
}
};
return (
<FormControl
ref={ref}
component="fieldset"
error={error}
required={required}
fullWidth={fullWidth}
sx={[...(Array.isArray(sx) ? sx : [sx])]}
>
{label && (
<FormLabel
component="legend"
sx={{
color: 'text.primary',
fontWeight: 600,
fontSize: '1rem',
mb: 1,
'&.Mui-focused': { color: 'text.primary' },
'&.Mui-error': { color: 'text.primary' },
}}
>
{label}
</FormLabel>
)}
<MuiToggleButtonGroup
exclusive
value={value}
onChange={handleChange}
fullWidth={fullWidth}
size={size}
aria-label={label}
sx={{
gap: 2,
// Remove MUI's connected-button styling (grouped borders)
'& .MuiToggleButtonGroup-grouped': {
border: '2px solid',
borderColor: 'var(--fa-color-neutral-200)',
borderRadius: (theme: Theme) => `${theme.shape.borderRadius}px !important`,
'&:not(:first-of-type)': {
borderLeft: '2px solid',
borderColor: 'var(--fa-color-neutral-200)',
marginLeft: 0,
},
},
}}
{...groupProps}
>
{options.map((option) => (
<MuiToggleButton
key={option.value}
value={option.value}
disabled={option.disabled}
sx={{
flex: 1,
textTransform: 'none',
flexDirection: 'column',
alignItems: 'center',
gap: 0.5,
py: option.description ? 2 : 1.5,
px: 3,
bgcolor: 'var(--fa-color-neutral-100)',
color: 'text.primary',
transition: (theme: Theme) =>
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,
},
}}
>
<Box
component="span"
sx={{ fontWeight: 600, fontSize: size === 'large' ? '1rem' : '0.875rem' }}
>
{option.label}
</Box>
{option.description && (
<Typography
variant="body2"
color="text.secondary"
component="span"
sx={{ fontWeight: 400, lineHeight: 1.4 }}
>
{option.description}
</Typography>
)}
</MuiToggleButton>
))}
</MuiToggleButtonGroup>
{helperText && <FormHelperText sx={{ mt: 1, mx: 0 }}>{helperText}</FormHelperText>}
</FormControl>
);
},
);
ToggleButtonGroup.displayName = 'ToggleButtonGroup';
export default ToggleButtonGroup;

View File

@@ -0,0 +1,2 @@
export { default } from './ToggleButtonGroup';
export * from './ToggleButtonGroup';