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:
@@ -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 — long text, high prices, missing fields */
|
||||
|
||||
@@ -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<Theme>;
|
||||
}
|
||||
@@ -51,8 +54,19 @@ export interface 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 [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 = () => {
|
||||
if (!disabled && onChange) {
|
||||
@@ -85,33 +99,23 @@ export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>(
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Heading row: name + optional price + switch */}
|
||||
{/* Top row: name + switch pinned top-right */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="span"
|
||||
id={`${switchId}-label`}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
{price != null && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
${price.toLocaleString('en-AU')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Switch
|
||||
checked={checked}
|
||||
@@ -123,15 +127,50 @@ export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>(
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
{/* Price — own row below heading */}
|
||||
{price != null && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
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}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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: long text, no description, high price */
|
||||
|
||||
@@ -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<Theme>;
|
||||
}
|
||||
@@ -52,7 +55,19 @@ export interface 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 (
|
||||
<Card
|
||||
ref={ref}
|
||||
@@ -99,15 +114,39 @@ export const ServiceOption = React.forwardRef<HTMLDivElement, ServiceOptionProps
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
{/* Description with optional line clamping */}
|
||||
{description && (
|
||||
<>
|
||||
<Typography
|
||||
ref={descRef}
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
...(maxDescriptionLines && !expanded && {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: maxDescriptionLines,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user