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>
198 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|