veza/apps/web/src/components/ui/collapsible.tsx
senke cc4d641926 ui: add focus states for keyboard navigation (Action 8.2.1.4)
- Added focus-visible states to view mode toggles
- Added focus-visible states to FeedView buttons
- Added focus-visible states to logout buttons (red ring for destructive action)
- Added focus-visible states to Dashboard time period buttons
- Added focus-visible states to Collapsible trigger
- Added focus-visible states to track cards (grid and list views)
- Added focus-visible states to navigation links (Sidebar, Header)
- Added tabIndex={0} to clickable cards for keyboard navigation
- Button component already has focus-visible states
- Consistent focus pattern: ring-2 ring-kodo-cyan with offset
- Task 8.2.1.4 complete
2026-01-16 00:38:37 +01:00

197 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-kodo-cyan focus-visible:ring-offset-2 focus-visible:ring-offset-kodo-void',
triggerClassName,
)}
aria-expanded={isOpen}
aria-controls="collapsible-content"
>
<div className="flex-1 text-left">{trigger}</div>
{showChevron && (
<ChevronIcon className="w-4 h-4 text-kodo-secondary transition-transform duration-200" />
)}
</button>
<div
id="collapsible-content"
className={cn(
'overflow-hidden transition-all duration-300 ease-in-out',
isOpen ? 'max-h-[5000px] opacity-100' : 'max-h-0 opacity-0',
contentClassName,
)}
aria-hidden={!isOpen}
>
<div className={cn('pt-4', contentClassName)}>{children}</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>
);
}