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:
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
230
src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx
Normal file
230
src/components/atoms/ToggleButtonGroup/ToggleButtonGroup.tsx
Normal 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;
|
||||
2
src/components/atoms/ToggleButtonGroup/index.ts
Normal file
2
src/components/atoms/ToggleButtonGroup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './ToggleButtonGroup';
|
||||
export * from './ToggleButtonGroup';
|
||||
Reference in New Issue
Block a user