Add collapsible-card-header component to consolidate and improve folding

This commit is contained in:
2026-01-16 13:26:30 +00:00
parent 6f6e5d9696
commit e1aaa24186
3 changed files with 219 additions and 118 deletions

View File

@@ -10,12 +10,13 @@
import React from 'react'; import React from 'react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Card, CardContent } from './ui/card';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { FormTimeInput } from './ui/form-time-input'; import { FormTimeInput } from './ui/form-time-input';
import { FormNumericInput } from './ui/form-numeric-input'; import { FormNumericInput } from './ui/form-numeric-input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Plus, Copy, Trash2, ArrowDownAZ, ChevronDown, ChevronUp, TrendingUp, TrendingDown } from 'lucide-react'; import CollapsibleCardHeader from './ui/collapsible-card-header';
import { Plus, Copy, Trash2, ArrowDownAZ, TrendingUp, TrendingDown } from 'lucide-react';
import type { DayGroup } from '../constants/defaults'; import type { DayGroup } from '../constants/defaults';
interface DayScheduleProps { interface DayScheduleProps {
@@ -46,6 +47,23 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
// Track collapsed state for each day (by day ID) // Track collapsed state for each day (by day ID)
const [collapsedDays, setCollapsedDays] = React.useState<Set<string>>(new Set()); const [collapsedDays, setCollapsedDays] = React.useState<Set<string>>(new Set());
// Load and persist collapsed days state
React.useEffect(() => {
const savedCollapsed = localStorage.getItem('dayScheduleCollapsedDays_v1');
if (savedCollapsed) {
try {
const collapsedArray = JSON.parse(savedCollapsed);
setCollapsedDays(new Set(collapsedArray));
} catch (e) {
console.warn('Failed to load collapsed days state:', e);
}
}
}, []);
const saveCollapsedDays = (newCollapsedDays: Set<string>) => {
localStorage.setItem('dayScheduleCollapsedDays_v1', JSON.stringify(Array.from(newCollapsedDays)));
};
const toggleDayCollapse = (dayId: string) => { const toggleDayCollapse = (dayId: string) => {
setCollapsedDays(prev => { setCollapsedDays(prev => {
const newSet = new Set(prev); const newSet = new Set(prev);
@@ -54,6 +72,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
} else { } else {
newSet.add(dayId); newSet.add(dayId);
} }
saveCollapsedDays(newSet);
return newSet; return newSet;
}); });
}; };
@@ -89,26 +108,37 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
return ( return (
<Card key={day.id}> <Card key={day.id}>
<CardHeader className="pb-3"> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={day.isTemplate ? t('regularPlan') : t('alternativePlan')}
<div className="flex items-center gap-2 flex-wrap"> isCollapsed={collapsedDays.has(day.id)}
onToggle={() => toggleDayCollapse(day.id)}
toggleLabel={collapsedDays.has(day.id) ? t('expandDay') : t('collapseDay')}
rightSection={
<>
{canAddDay && (
<Button <Button
type="button" onClick={() => onAddDay(day.id)}
size="sm" size="sm"
variant="ghost" variant="outline"
className="h-6 w-6 p-0" title={t('cloneDay')}
onClick={() => toggleDayCollapse(day.id)}
title={collapsedDays.has(day.id) ? t('expandDay') : t('collapseDay')}
> >
{collapsedDays.has(day.id) ? ( <Copy className="h-4 w-4" />
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button> </Button>
<CardTitle className="text-lg"> )}
{day.isTemplate ? t('regularPlan') : t('alternativePlan')} {!day.isTemplate && (
</CardTitle> <Button
onClick={() => onRemoveDay(day.id)}
size="sm"
variant="outline"
className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground"
title={t('removeDay')}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</>
}
>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{t('day')} {dayIndex + 1} {t('day')} {dayIndex + 1}
</Badge> </Badge>
@@ -170,32 +200,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
{day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg {day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg
</Badge> </Badge>
)} )}
</div> </CollapsibleCardHeader>
<div className="flex gap-2">
{canAddDay && (
<Button
onClick={() => onAddDay(day.id)}
size="sm"
variant="outline"
title={t('cloneDay')}
>
<Copy className="h-4 w-4" />
</Button>
)}
{!day.isTemplate && (
<Button
onClick={() => onRemoveDay(day.id)}
size="sm"
variant="outline"
className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground"
title={t('removeDay')}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
{!collapsedDays.has(day.id) && ( {!collapsedDays.has(day.id) && (
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* Dose table header */} {/* Dose table header */}

View File

@@ -10,15 +10,16 @@
*/ */
import React from 'react'; import React from 'react';
import { FormNumericInput } from './ui/form-numeric-input'; import { Card, CardContent } from './ui/card';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Button } from './ui/button';
import { Switch } from './ui/switch';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Switch } from './ui/switch';
import { Separator } from './ui/separator'; import { Separator } from './ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { ChevronDown, ChevronUp, Info } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { FormNumericInput } from './ui/form-numeric-input';
import CollapsibleCardHeader from './ui/collapsible-card-header';
import { Info } from 'lucide-react';
import { getDefaultState } from '../constants/defaults'; import { getDefaultState } from '../constants/defaults';
/** /**
@@ -124,6 +125,46 @@ const Settings = ({
const [isPharmacokineticExpanded, setIsPharmacokineticExpanded] = React.useState(true); const [isPharmacokineticExpanded, setIsPharmacokineticExpanded] = React.useState(true);
const [isAdvancedExpanded, setIsAdvancedExpanded] = React.useState(false); const [isAdvancedExpanded, setIsAdvancedExpanded] = React.useState(false);
// Load and persist settings card expansion states
React.useEffect(() => {
const savedStates = localStorage.getItem('settingsCardStates_v1');
if (savedStates) {
try {
const states = JSON.parse(savedStates);
if (states.diagram !== undefined) setIsDiagramExpanded(states.diagram);
if (states.simulation !== undefined) setIsSimulationExpanded(states.simulation);
if (states.pharmacokinetic !== undefined) setIsPharmacokineticExpanded(states.pharmacokinetic);
if (states.advanced !== undefined) setIsAdvancedExpanded(states.advanced);
} catch (e) {
console.warn('Failed to load settings card states:', e);
}
}
}, []);
const updateDiagramExpanded = (value: boolean) => {
setIsDiagramExpanded(value);
saveCardStates({ diagram: value, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded });
};
const updateSimulationExpanded = (value: boolean) => {
setIsSimulationExpanded(value);
saveCardStates({ diagram: isDiagramExpanded, simulation: value, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded });
};
const updatePharmacokineticExpanded = (value: boolean) => {
setIsPharmacokineticExpanded(value);
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: value, advanced: isAdvancedExpanded });
};
const updateAdvancedExpanded = (value: boolean) => {
setIsAdvancedExpanded(value);
saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: value });
};
const saveCardStates = (states: any) => {
localStorage.setItem('settingsCardStates_v1', JSON.stringify(states));
};
// Create defaults object for translation interpolation // Create defaults object for translation interpolation
const defaultsForT = getDefaultsForTranslation(pkParams, therapeuticRange, uiSettings); const defaultsForT = getDefaultsForTranslation(pkParams, therapeuticRange, uiSettings);
@@ -158,12 +199,11 @@ const Settings = ({
<div className="space-y-4"> <div className="space-y-4">
{/* Diagram Settings Card */} {/* Diagram Settings Card */}
<Card> <Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsDiagramExpanded(!isDiagramExpanded)}> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={t('diagramSettings')}
<CardTitle className="text-lg">{t('diagramSettings')}</CardTitle> isCollapsed={!isDiagramExpanded}
{isDiagramExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} onToggle={() => updateDiagramExpanded(!isDiagramExpanded)}
</div> />
</CardHeader>
{isDiagramExpanded && ( {isDiagramExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-3"> <div className="space-y-3">
@@ -431,12 +471,11 @@ const Settings = ({
{/* Simulation Settings Card */} {/* Simulation Settings Card */}
<Card> <Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsSimulationExpanded(!isSimulationExpanded)}> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={t('simulationSettings')}
<CardTitle className="text-lg">{t('simulationSettings')}</CardTitle> isCollapsed={!isSimulationExpanded}
{isSimulationExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} onToggle={() => updateSimulationExpanded(!isSimulationExpanded)}
</div> />
</CardHeader>
{isSimulationExpanded && ( {isSimulationExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -507,12 +546,11 @@ const Settings = ({
{/* Pharmacokinetic Settings Card */} {/* Pharmacokinetic Settings Card */}
<Card> <Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsPharmacokineticExpanded(!isPharmacokineticExpanded)}> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={t('pharmacokineticsSettings')}
<CardTitle className="text-lg">{t('pharmacokineticsSettings')}</CardTitle> isCollapsed={!isPharmacokineticExpanded}
{isPharmacokineticExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} onToggle={() => updatePharmacokineticExpanded(!isPharmacokineticExpanded)}
</div> />
</CardHeader>
{isPharmacokineticExpanded && ( {isPharmacokineticExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<h3 className="text-lg font-semibold">{t('dAmphetamineParameters')}</h3> <h3 className="text-lg font-semibold">{t('dAmphetamineParameters')}</h3>
@@ -627,12 +665,11 @@ const Settings = ({
{/* Advanced Settings Card */} {/* Advanced Settings Card */}
<Card> <Card>
<CardHeader className="cursor-pointer pb-3" onClick={() => setIsAdvancedExpanded(!isAdvancedExpanded)}> <CollapsibleCardHeader
<div className="flex items-center justify-between"> title={t('advancedSettings')}
<CardTitle className="text-lg">{t('advancedSettings')}</CardTitle> isCollapsed={!isAdvancedExpanded}
{isAdvancedExpanded ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />} onToggle={() => updateAdvancedExpanded(!isAdvancedExpanded)}
</div> />
</CardHeader>
{isAdvancedExpanded && ( {isAdvancedExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 text-sm"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 text-sm">

View File

@@ -0,0 +1,59 @@
/**
* CollapsibleCardHeader
*
* Shared header row with a title + chevron toggle, optional children after title/chevron,
* and an optional right section for action buttons.
*/
import React from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { CardHeader, CardTitle } from './card';
import { cn } from '../../lib/utils';
interface CollapsibleCardHeaderProps {
title: React.ReactNode;
isCollapsed: boolean;
onToggle: () => void;
children?: React.ReactNode;
rightSection?: React.ReactNode;
className?: string;
titleClassName?: string;
toggleLabel?: string;
}
const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
title,
isCollapsed,
onToggle,
children,
rightSection,
className,
titleClassName,
toggleLabel
}) => {
const accessibilityProps = toggleLabel ? { title: toggleLabel, 'aria-label': toggleLabel } : {};
return (
<CardHeader className={cn('pb-3', className)}>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap flex-1">
<button
type="button"
onClick={onToggle}
className="inline-flex items-center gap-2 rounded-md px-1 py-1 -ml-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:bg-muted/60 cursor-pointer"
aria-expanded={!isCollapsed}
{...accessibilityProps}
>
<CardTitle className={cn('text-lg', titleClassName)}>
{title}
</CardTitle>
{isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />}
</button>
{children}
</div>
{rightSection && <div className="flex items-center gap-2">{rightSection}</div>}
</div>
</CardHeader>
);
};
export default CollapsibleCardHeader;