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