From 274671fdc68c39027ab1b0de845b2cf0e45e0532 Mon Sep 17 00:00:00 2001 From: Richie Date: Wed, 25 Mar 2026 21:20:58 +1100 Subject: [PATCH] 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) --- .../AddOnOption/AddOnOption.stories.tsx | 30 +++++++ .../molecules/AddOnOption/AddOnOption.tsx | 89 +++++++++++++------ .../ServiceOption/ServiceOption.stories.tsx | 35 ++++++++ .../molecules/ServiceOption/ServiceOption.tsx | 57 ++++++++++-- 4 files changed, 177 insertions(+), 34 deletions(-) diff --git a/src/components/molecules/AddOnOption/AddOnOption.stories.tsx b/src/components/molecules/AddOnOption/AddOnOption.stories.tsx index 7e3f998..4183714 100644 --- a/src/components/molecules/AddOnOption/AddOnOption.stories.tsx +++ b/src/components/molecules/AddOnOption/AddOnOption.stories.tsx @@ -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 ( + + setChecks({ ...checks, a: v })} + /> + setChecks({ ...checks, b: v })} + /> + + ); + }, +}; + // ─── Edge Cases ───────────────────────────────────────────────────────────── /** Edge cases — long text, high prices, missing fields */ diff --git a/src/components/molecules/AddOnOption/AddOnOption.tsx b/src/components/molecules/AddOnOption/AddOnOption.tsx index 995274c..ffbbc07 100644 --- a/src/components/molecules/AddOnOption/AddOnOption.tsx +++ b/src/components/molecules/AddOnOption/AddOnOption.tsx @@ -4,6 +4,7 @@ import type { SxProps, Theme } from '@mui/material/styles'; import { Card } from '../../atoms/Card'; import { Typography } from '../../atoms/Typography'; import { Switch } from '../../atoms/Switch'; +import { Link } from '../../atoms/Link'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -13,7 +14,7 @@ export interface AddOnOptionProps { name: string; /** Description text explaining the add-on */ description?: string; - /** Price in dollars — shown after the heading */ + /** Price in dollars — shown below the heading */ price?: number; /** Whether this add-on is currently enabled */ checked?: boolean; @@ -21,6 +22,8 @@ export interface AddOnOptionProps { onChange?: (checked: boolean) => void; /** Whether this add-on is disabled/unavailable */ disabled?: boolean; + /** Max visible lines for description before "View more" toggle. Omit for no limit. */ + maxDescriptionLines?: number; /** MUI sx prop for style overrides */ sx?: SxProps; } @@ -51,8 +54,19 @@ export interface AddOnOptionProps { * ``` */ export const AddOnOption = React.forwardRef( - ({ 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 [expanded, setExpanded] = React.useState(false); + const [isClamped, setIsClamped] = React.useState(false); + const descRef = React.useRef(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 = () => { if (!disabled && onChange) { @@ -85,33 +99,23 @@ export const AddOnOption = React.forwardRef( ...(Array.isArray(sx) ? sx : [sx]), ]} > - {/* Heading row: name + optional price + switch */} + {/* Top row: name + switch pinned top-right */} - - - {name} - - {price != null && ( - - ${price.toLocaleString('en-AU')} - - )} - + + {name} + ( /> - {/* Description */} - {description && ( + {/* Price — own row below heading */} + {price != null && ( - {description} + ${price.toLocaleString('en-AU')} )} + + {/* Description with optional line clamping */} + {description && ( + <> + + {description} + + {maxDescriptionLines && isClamped && ( + { + e.stopPropagation(); + setExpanded((prev) => !prev); + }} + sx={{ mt: 0.5, fontSize: 'inherit' }} + > + {expanded ? 'View less' : 'View more'} + + )} + + )} ); }, diff --git a/src/components/molecules/ServiceOption/ServiceOption.stories.tsx b/src/components/molecules/ServiceOption/ServiceOption.stories.tsx index 23a3a52..8d9f6c5 100644 --- a/src/components/molecules/ServiceOption/ServiceOption.stories.tsx +++ b/src/components/molecules/ServiceOption/ServiceOption.stories.tsx @@ -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 ( + + setSelected('complete')} + /> + setSelected('cremation')} + /> + + ); + }, +}; + // ─── Edge Cases ───────────────────────────────────────────────────────────── /** Edge cases: long text, no description, high price */ diff --git a/src/components/molecules/ServiceOption/ServiceOption.tsx b/src/components/molecules/ServiceOption/ServiceOption.tsx index bf93bfe..bb206d4 100644 --- a/src/components/molecules/ServiceOption/ServiceOption.tsx +++ b/src/components/molecules/ServiceOption/ServiceOption.tsx @@ -3,6 +3,7 @@ import Box from '@mui/material/Box'; import type { SxProps, Theme } from '@mui/material/styles'; import { Card } from '../../atoms/Card'; import { Typography } from '../../atoms/Typography'; +import { Link } from '../../atoms/Link'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -20,6 +21,8 @@ export interface ServiceOptionProps { disabled?: boolean; /** Click handler — toggles selection */ onClick?: () => void; + /** Max visible lines for description before "View more" toggle. Omit for no limit. */ + maxDescriptionLines?: number; /** MUI sx prop for style overrides */ sx?: SxProps; } @@ -52,7 +55,19 @@ export interface ServiceOptionProps { * ``` */ export const ServiceOption = React.forwardRef( - ({ 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(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 ( - {/* Description */} + {/* Description with optional line clamping */} {description && ( - - {description} - + <> + + {description} + + {maxDescriptionLines && isClamped && ( + { + e.stopPropagation(); + setExpanded((prev) => !prev); + }} + sx={{ mt: 0.5, fontSize: 'inherit' }} + > + {expanded ? 'View less' : 'View more'} + + )} + )} );