veza/apps/web/src/components/ui/collapsible.tsx
senke 3a86f853ab feat(ui): avatar polish, smooth accordion, modal animation consistency
Avatar improvements:
- Image loading skeleton with animate-pulse overlay
- Fade-in transition on image load (opacity 0→1, 200ms)
- Error fallback shows initials when image fails
- Click animation: active:scale-95
- Badge overlay prop: count/dot/color at top-right corner

Accordion/Collapsible smooth animation:
- Replaced max-h-[5000px] hack with CSS grid-template-rows trick
- grid-rows-[0fr] → grid-rows-[1fr] for content-aware smooth collapse
- 200ms ease-out transition on both Collapsible and AccordionContent

Modal animation consistency (5 modals migrated):
- CreatePlaylistModal → base Modal (focus trap, AnimatePresence, portal)
- AutoMetadataDetectionModal → base Modal
- ReviewProductModal → base Modal
- ChangeUsernameModal → base Modal
- TagSuggestionsModal → base Modal
- Skipped: SharePostModal (multi-view pattern, would lose layout flexibility)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:46:46 +01:00

198 lines
4.7 KiB
TypeScript

import * as React from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card, CardHeader, CardTitle, CardContent } from './card';
export interface CollapsibleProps {
/**
* Content to display when collapsed (header/trigger)
*/
trigger: React.ReactNode;
/**
* Content to display when expanded
*/
children: React.ReactNode;
/**
* Whether the collapsible is open by default
* @default false
*/
defaultOpen?: boolean;
/**
* Controlled open state
*/
open?: boolean;
/**
* Callback when open state changes
*/
onOpenChange?: (open: boolean) => void;
/**
* Additional CSS classes for the container
*/
className?: string;
/**
* Additional CSS classes for the trigger button
*/
triggerClassName?: string;
/**
* Additional CSS classes for the content
*/
contentClassName?: string;
/**
* Whether to show the chevron icon
* @default true
*/
showChevron?: boolean;
}
/**
* Collapsible - Reusable collapsible component
*
* A component that can be expanded/collapsed to show/hide content.
* Supports both controlled and uncontrolled modes.
*
* @example
* ```tsx
* <Collapsible trigger={<h3>Click to expand</h3>}>
* <p>This content is hidden by default</p>
* </Collapsible>
* ```
*
* @example
* ```tsx
* <Collapsible
* trigger={<h3>Activity Feed</h3>}
* defaultOpen={false}
* onOpenChange={(open) => console.log('State:', open)}
* >
* <div>Activity content here</div>
* </Collapsible>
* ```
*/
export function Collapsible({
trigger,
children,
defaultOpen = false,
open: controlledOpen,
onOpenChange,
className,
triggerClassName,
contentClassName,
showChevron = true,
}: CollapsibleProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
// Use controlled state if provided, otherwise use uncontrolled
const isOpen = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen;
const handleToggle = () => {
const newOpen = !isOpen;
if (controlledOpen === undefined) {
setUncontrolledOpen(newOpen);
}
onOpenChange?.(newOpen);
};
const ChevronIcon = isOpen ? ChevronUp : ChevronDown;
return (
<div className={cn('w-full', className)}>
<button
type="button"
onClick={handleToggle}
className={cn(
'w-full flex items-center justify-between',
'transition-colors hover:bg-white/5 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
triggerClassName,
)}
aria-expanded={isOpen}
aria-controls="collapsible-content"
>
<div className="flex-1 text-left">{trigger}</div>
{showChevron && (
<ChevronIcon className="w-4 h-4 text-muted-foreground transition-transform duration-[var(--duration-fast)]" />
)}
</button>
<div
id="collapsible-content"
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
aria-hidden={!isOpen}
>
<div className="overflow-hidden">
<div className={cn('pt-4', contentClassName)}>{children}</div>
</div>
</div>
</div>
);
}
/**
* CollapsibleCard - Collapsible component with Card styling
*
* A collapsible component that wraps content in a Card for consistent styling.
*
* @example
* ```tsx
* <CollapsibleCard
* title="Activity Feed"
* defaultOpen={false}
* >
* <div>Activity content</div>
* </CollapsibleCard>
* ```
*/
export interface CollapsibleCardProps extends Omit<CollapsibleProps, 'trigger'> {
/**
* Title/header text for the collapsible card
*/
title: React.ReactNode;
/**
* Optional icon to display next to the title
*/
icon?: React.ReactNode;
}
export function CollapsibleCard({
title,
icon,
children,
className,
triggerClassName,
contentClassName,
...collapsibleProps
}: CollapsibleCardProps) {
return (
<Card className={cn('overflow-hidden', className)}>
<CardHeader className="pb-2">
<Collapsible
trigger={
<CardTitle className="flex items-center gap-2">
{icon}
{title}
</CardTitle>
}
triggerClassName={cn('p-0 hover:bg-transparent', triggerClassName)}
contentClassName={cn('pt-0', contentClassName)}
{...collapsibleProps}
>
<CardContent className={cn('pt-0', contentClassName)}>
{children}
</CardContent>
</Collapsible>
</CardHeader>
</Card>
);
}