veza/apps/web/src/components/ui/Sidebar.tsx
senke 66a56409ad feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):

- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
  for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
  (max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
  replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
  AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
  TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:15:58 +01:00

217 lines
5.1 KiB
TypeScript

import * as React from 'react';
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from './button';
import { Card } from './card';
export interface SidebarProps {
/**
* Content to display in the sidebar
*/
children: React.ReactNode;
/**
* Position of the sidebar
* @default 'left'
*/
position?: 'left' | 'right';
/**
* Width of the sidebar when open
* @default 'w-64'
*/
width?: string;
/**
* Whether the sidebar is open
* @default true
*/
open?: boolean;
/**
* Callback when open state changes
*/
onOpenChange?: (open: boolean) => void;
/**
* Whether the sidebar is collapsible
* @default true
*/
collapsible?: boolean;
/**
* Title/header for the sidebar
*/
title?: React.ReactNode;
/**
* Optional icon to display next to the title
*/
icon?: React.ReactNode;
/**
* Additional CSS classes for the container
*/
className?: string;
/**
* Additional CSS classes for the sidebar content
*/
contentClassName?: string;
/**
* Whether to show a backdrop on mobile when open
* @default true
*/
showBackdrop?: boolean;
}
/**
* Sidebar - Reusable sidebar component for filters and content
*
* A generic sidebar component that can be used to display filters,
* additional content, or any sidebar-worthy information. Supports
* collapsible functionality and positioning.
*
* @example
* ```tsx
* <Sidebar title="Filters" position="left" collapsible>
* <div>Filter content here</div>
* </Sidebar>
* ```
*
* @example
* ```tsx
* <Sidebar
* title="Filters"
* icon={<Filter />}
* position="right"
* open={isOpen}
* onOpenChange={setIsOpen}
* width="w-80"
* >
* <FilterContent />
* </Sidebar>
* ```
*/
export function Sidebar({
children,
position = 'left',
width = 'w-64',
open: controlledOpen,
onOpenChange,
collapsible = true,
title,
icon,
className,
contentClassName,
showBackdrop = true,
}: SidebarProps) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(true);
// 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 = position === 'left' ? ChevronLeft : ChevronRight;
const isCollapsed = collapsible && !isOpen;
return (
<>
{/* Mobile Backdrop */}
{showBackdrop && isOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm lg:hidden z-40"
onClick={handleToggle}
aria-hidden="true"
/>
)}
<aside
className={cn(
'flex flex-col transition-all duration-[var(--duration-normal)] ease-in-out',
position === 'left' ? 'border-r' : 'border-l',
'border-white/10 bg-kodo-ink/40 backdrop-blur-md rounded-xl',
isCollapsed ? 'w-0 overflow-hidden' : width,
className,
)}
>
{/* Header */}
{(title || collapsible) && (
<div className="flex items-center justify-between p-4 border-b border-white/10">
{title && (
<div className="flex items-center gap-2 flex-1">
{icon && <div className="text-kodo-steel">{icon}</div>}
<h3 className="text-sm font-semibold text-white">{title}</h3>
</div>
)}
{collapsible && (
<Button
variant="ghost"
size="icon"
onClick={handleToggle}
className="h-8 w-8"
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
>
<ChevronIcon className={cn(
'w-4 h-4 transition-transform',
isOpen && position === 'right' && 'rotate-180',
isOpen && position === 'left' && 'rotate-180',
)} />
</Button>
)}
</div>
)}
{/* Content */}
<div
className={cn(
'flex-1 overflow-y-auto custom-scrollbar',
isCollapsed && 'hidden',
contentClassName,
)}
>
{children}
</div>
</aside>
</>
);
}
/**
* SidebarCard - Sidebar with Card styling
*
* A sidebar variant that wraps content in a Card for consistent styling.
*/
export interface SidebarCardProps extends Omit<SidebarProps, 'children'> {
/**
* Content to display in the sidebar card
*/
children: React.ReactNode;
}
export function SidebarCard({
children,
className,
contentClassName,
...sidebarProps
}: SidebarCardProps) {
return (
<Sidebar
className={cn('p-0', className)}
contentClassName={cn('p-4', contentClassName)}
{...sidebarProps}
>
<Card className="border-0 bg-transparent shadow-none">
{children}
</Card>
</Sidebar>
);
}