Add expandable descriptions + fix AddOnOption layout

AddOnOption:
- Move price to its own row below heading (was squeezed between name and switch)
- Switch pins to top-right with alignItems: flex-start
- Long headings now wrap cleanly without layout pressure

Both AddOnOption + ServiceOption:
- Add optional maxDescriptionLines prop for CSS line-clamp truncation
- "View more" / "View less" toggle appears only when text is actually truncated
- Omit prop for no limit (default, backwards compatible)
- stopPropagation on toggle so clicking it doesn't trigger card selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 21:20:58 +11:00
parent c56aed3f74
commit 274671fdc6
4 changed files with 177 additions and 34 deletions

View File

@@ -182,6 +182,36 @@ export const Disabled: Story = {
}, },
}; };
// ─── With Line Limit ────────────────────────────────────────────────────────
/** Clamped descriptions with "View more" toggle */
export const WithLineLimit: Story = {
render: function Render() {
const [checks, setChecks] = React.useState({ a: false, b: true });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<AddOnOption
name="Premium memorial video"
description="Our most comprehensive video tribute including on-site filmed interviews with family members, professional editing with music selection, up to 20 minutes of curated content, plus a USB copy and online streaming access for 12 months."
price={2500}
maxDescriptionLines={2}
checked={checks.a}
onChange={(v) => setChecks({ ...checks, a: v })}
/>
<AddOnOption
name="Floral arrangements"
description="Casket spray and two standing arrangements."
price={280}
maxDescriptionLines={2}
checked={checks.b}
onChange={(v) => setChecks({ ...checks, b: v })}
/>
</Box>
);
},
};
// ─── Edge Cases ───────────────────────────────────────────────────────────── // ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Edge cases — long text, high prices, missing fields */ /** Edge cases — long text, high prices, missing fields */

View File

@@ -4,6 +4,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { Card } from '../../atoms/Card'; import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Switch } from '../../atoms/Switch'; import { Switch } from '../../atoms/Switch';
import { Link } from '../../atoms/Link';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -13,7 +14,7 @@ export interface AddOnOptionProps {
name: string; name: string;
/** Description text explaining the add-on */ /** Description text explaining the add-on */
description?: string; description?: string;
/** Price in dollars — shown after the heading */ /** Price in dollars — shown below the heading */
price?: number; price?: number;
/** Whether this add-on is currently enabled */ /** Whether this add-on is currently enabled */
checked?: boolean; checked?: boolean;
@@ -21,6 +22,8 @@ export interface AddOnOptionProps {
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
/** Whether this add-on is disabled/unavailable */ /** Whether this add-on is disabled/unavailable */
disabled?: boolean; disabled?: boolean;
/** Max visible lines for description before "View more" toggle. Omit for no limit. */
maxDescriptionLines?: number;
/** MUI sx prop for style overrides */ /** MUI sx prop for style overrides */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -51,8 +54,19 @@ export interface AddOnOptionProps {
* ``` * ```
*/ */
export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>( export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>(
({ name, description, price, checked = false, onChange, disabled = false, sx }, ref) => { ({ name, description, price, checked = false, onChange, disabled = false, maxDescriptionLines, sx }, ref) => {
const switchId = React.useId(); const switchId = React.useId();
const [expanded, setExpanded] = React.useState(false);
const [isClamped, setIsClamped] = React.useState(false);
const descRef = React.useRef<HTMLElement>(null);
// Detect whether the description is actually truncated
React.useEffect(() => {
const el = descRef.current;
if (el && maxDescriptionLines) {
setIsClamped(el.scrollHeight > el.clientHeight + 1);
}
}, [description, maxDescriptionLines]);
const handleToggle = () => { const handleToggle = () => {
if (!disabled && onChange) { if (!disabled && onChange) {
@@ -85,33 +99,23 @@ export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>(
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
> >
{/* Heading row: name + optional price + switch */} {/* Top row: name + switch pinned top-right */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'flex-start',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 2, gap: 2,
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flex: 1, minWidth: 0 }}>
<Typography <Typography
variant="h6" variant="h6"
component="span" component="span"
id={`${switchId}-label`} id={`${switchId}-label`}
sx={{ flex: 1, minWidth: 0 }}
> >
{name} {name}
</Typography> </Typography>
{price != null && (
<Typography
variant="body2"
color="text.secondary"
sx={{ whiteSpace: 'nowrap' }}
>
${price.toLocaleString('en-AU')}
</Typography>
)}
</Box>
<Switch <Switch
checked={checked} checked={checked}
@@ -123,15 +127,50 @@ export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>(
/> />
</Box> </Box>
{/* Description */} {/* Price — own row below heading */}
{description && ( {price != null && (
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ mt: 0.5 }} sx={{ mt: 0.5 }}
>
${price.toLocaleString('en-AU')}
</Typography>
)}
{/* Description with optional line clamping */}
{description && (
<>
<Typography
ref={descRef}
variant="body2"
color="text.secondary"
sx={{
mt: 0.5,
...(maxDescriptionLines && !expanded && {
display: '-webkit-box',
WebkitLineClamp: maxDescriptionLines,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}),
}}
> >
{description} {description}
</Typography> </Typography>
{maxDescriptionLines && isClamped && (
<Link
component="button"
variant="body2"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setExpanded((prev) => !prev);
}}
sx={{ mt: 0.5, fontSize: 'inherit' }}
>
{expanded ? 'View less' : 'View more'}
</Link>
)}
</>
)} )}
</Card> </Card>
); );

View File

@@ -202,6 +202,41 @@ export const Disabled: Story = {
), ),
}; };
// ─── With Line Limit ────────────────────────────────────────────────────────
/** Clamped descriptions with "View more" toggle */
export const WithLineLimit: Story = {
name: 'With Line Limit',
render: () => {
const [selected, setSelected] = useState('');
return (
<Box
role="radiogroup"
aria-label="Service type"
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<ServiceOption
name="Complete Funeral Package"
description="This comprehensive package covers everything from initial collection through to the memorial service. Includes hearse, funeral director coordination, venue hire, catering for up to 50 guests, memorial booklets, and a dedicated family liaison throughout the process."
price={8500}
maxDescriptionLines={2}
selected={selected === 'complete'}
onClick={() => setSelected('complete')}
/>
<ServiceOption
name="Cremation"
description="Chapel service followed by cremation."
price={2800}
maxDescriptionLines={2}
selected={selected === 'cremation'}
onClick={() => setSelected('cremation')}
/>
</Box>
);
},
};
// ─── Edge Cases ───────────────────────────────────────────────────────────── // ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Edge cases: long text, no description, high price */ /** Edge cases: long text, no description, high price */

View File

@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { Card } from '../../atoms/Card'; import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Link } from '../../atoms/Link';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -20,6 +21,8 @@ export interface ServiceOptionProps {
disabled?: boolean; disabled?: boolean;
/** Click handler — toggles selection */ /** Click handler — toggles selection */
onClick?: () => void; onClick?: () => void;
/** Max visible lines for description before "View more" toggle. Omit for no limit. */
maxDescriptionLines?: number;
/** MUI sx prop for style overrides */ /** MUI sx prop for style overrides */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -52,7 +55,19 @@ export interface ServiceOptionProps {
* ``` * ```
*/ */
export const ServiceOption = React.forwardRef<HTMLDivElement, ServiceOptionProps>( export const ServiceOption = React.forwardRef<HTMLDivElement, ServiceOptionProps>(
({ name, description, price, selected = false, disabled = false, onClick, sx }, ref) => { ({ name, description, price, selected = false, disabled = false, onClick, maxDescriptionLines, sx }, ref) => {
const [expanded, setExpanded] = React.useState(false);
const [isClamped, setIsClamped] = React.useState(false);
const descRef = React.useRef<HTMLElement>(null);
// Detect whether the description is actually truncated
React.useEffect(() => {
const el = descRef.current;
if (el && maxDescriptionLines) {
setIsClamped(el.scrollHeight > el.clientHeight + 1);
}
}, [description, maxDescriptionLines]);
return ( return (
<Card <Card
ref={ref} ref={ref}
@@ -99,15 +114,39 @@ export const ServiceOption = React.forwardRef<HTMLDivElement, ServiceOptionProps
)} )}
</Box> </Box>
{/* Description */} {/* Description with optional line clamping */}
{description && ( {description && (
<>
<Typography <Typography
ref={descRef}
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ mt: 0.5 }} sx={{
mt: 0.5,
...(maxDescriptionLines && !expanded && {
display: '-webkit-box',
WebkitLineClamp: maxDescriptionLines,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}),
}}
> >
{description} {description}
</Typography> </Typography>
{maxDescriptionLines && isClamped && (
<Link
component="button"
variant="body2"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setExpanded((prev) => !prev);
}}
sx={{ mt: 0.5, fontSize: 'inherit' }}
>
{expanded ? 'View less' : 'View more'}
</Link>
)}
</>
)} )}
</Card> </Card>
); );