Files
kandev/apps/web/components/app-sidebar/app-sidebar-section.tsx
T
2026-06-16 20:19:13 +01:00

162 lines
5.3 KiB
TypeScript

"use client";
import { IconChevronRight } from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { cn } from "@/lib/utils";
type AppSidebarSectionProps = {
id: string;
label: string;
collapsed: boolean;
icon: TablerIcon;
children: React.ReactNode;
/** Optional control rendered between the label and the collapse chevron. */
headerAction?: React.ReactNode;
/** By default header actions render only while the section accordion is open.
* "always" keeps them visible while the accordion is closed, but has no
* effect when the sidebar itself is in collapsed/rail mode. */
headerActionVisibility?: "expanded" | "always";
/** Fills remaining sidebar height when expanded. Parent must be a flex column. */
grow?: boolean;
/** Initial expansion when the persisted section map does not yet contain this id. */
defaultExpanded?: boolean;
};
type SectionHeaderProps = {
label: string;
expanded: boolean;
headerAction?: React.ReactNode;
headerActionVisibility: "expanded" | "always";
onToggle: () => void;
};
function SectionHeader({
label,
expanded,
headerAction,
headerActionVisibility,
onToggle,
}: SectionHeaderProps) {
const showHeaderAction = !!headerAction && (expanded || headerActionVisibility === "always");
return (
<div className="group/section flex items-center px-2 h-7 shrink-0">
<button
type="button"
onClick={onToggle}
className="flex min-w-0 flex-1 items-center text-left cursor-pointer text-foreground/70 hover:text-foreground transition-colors"
aria-expanded={expanded}
>
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{label}</span>
</button>
{showHeaderAction && <div className="shrink-0 mr-1 flex items-center">{headerAction}</div>}
<button
type="button"
onClick={onToggle}
tabIndex={-1}
aria-hidden="true"
className="shrink-0 flex h-5 w-5 items-center justify-center text-muted-foreground/60 hover:text-foreground/70 cursor-pointer transition-colors"
>
<IconChevronRight
className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-90")}
/>
</button>
</div>
);
}
export function AppSidebarSection({
id,
label,
collapsed,
icon: Icon,
children,
headerAction,
headerActionVisibility = "expanded",
grow,
defaultExpanded = false,
}: AppSidebarSectionProps) {
const expanded = useAppStore((s) => s.appSidebar.sectionExpanded[id] ?? defaultExpanded);
const toggleSection = useAppStore((s) => s.toggleAppSidebarSection);
const setCollapsed = useAppStore((s) => s.setAppSidebarCollapsed);
const railButton = (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-9 w-9 mx-auto items-center justify-center rounded-md text-foreground/70 hover:bg-muted/60 cursor-pointer"
onClick={() => {
setCollapsed(false);
if (!expanded) toggleSection(id, defaultExpanded);
}}
aria-label={label}
>
<Icon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
if (collapsed && !grow) return railButton;
const handleToggle = () => toggleSection(id, defaultExpanded);
// The grow section (Tasks) absorbs remaining vertical space and scrolls
// internally, so it stays flex-driven rather than animating to content
// height like the fixed-size sections below.
//
// In rail mode its children stay mounted but CSS-hidden: this section holds
// the (potentially huge) task list, and unmounting it made collapsing the
// sidebar jank — React tore down every task row in the same frame the close
// animation started. Hiding is O(1) on close and makes reopening instant.
// The children div keeps the same position in the tree across the toggle so
// React preserves it instead of remounting.
if (grow) {
return (
<div className={cn(!collapsed && expanded && "flex-1 min-h-0 flex flex-col")}>
{collapsed ? (
railButton
) : (
<SectionHeader
label={label}
expanded={expanded}
headerAction={headerAction}
headerActionVisibility={headerActionVisibility}
onToggle={handleToggle}
/>
)}
{expanded && (
<div
className={cn(
"flex flex-col gap-0.5",
collapsed ? "hidden" : "flex-1 min-h-0 sidebar-fade-in",
)}
>
{children}
</div>
)}
</div>
);
}
return (
<Collapsible open={expanded}>
<SectionHeader
label={label}
expanded={expanded}
headerAction={headerAction}
headerActionVisibility={headerActionVisibility}
onToggle={handleToggle}
/>
<CollapsibleContent className="sidebar-section-content">
<div className="flex flex-col gap-0.5">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}