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>
217 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|