OpenCut/apps/web/src/components/section.tsx

258 lines
5.6 KiB
TypeScript

import { createContext, useContext, useEffect, useState } from "react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
const sectionExpandedCache = new Map<string, boolean>();
const mountedSectionKeys = new Set<string>();
interface SectionContext {
isOpen: boolean;
toggle: () => void;
collapsible: boolean;
}
const SectionCtx = createContext<SectionContext | null>(null);
function useSectionContext() {
return useContext(SectionCtx);
}
interface SectionProps {
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
sectionKey?: string;
className?: string;
showTopBorder?: boolean;
showBottomBorder?: boolean;
}
export function Section({
children,
collapsible = false,
defaultOpen = true,
sectionKey,
className,
showTopBorder = false,
showBottomBorder = true,
}: SectionProps) {
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
useEffect(() => {
if (!sectionKey) return;
if (
process.env.NODE_ENV !== "production" &&
mountedSectionKeys.has(sectionKey)
) {
console.error(
`[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`,
);
}
mountedSectionKeys.add(sectionKey);
return () => {
mountedSectionKeys.delete(sectionKey);
};
}, [sectionKey]);
const toggle = () => {
const next = !isOpen;
setIsOpen(next);
if (sectionKey) sectionExpandedCache.set(sectionKey, next);
};
return (
<SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
<div
className={cn(
"flex flex-col",
showTopBorder && "border-t first:border-t-0",
showBottomBorder && "border-b",
className,
)}
>
{children}
</div>
</SectionCtx.Provider>
);
}
interface SectionHeaderProps {
children?: React.ReactNode;
trailing?: React.ReactNode;
leading?: React.ReactNode;
actions?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function SectionHeader({
children,
trailing,
leading,
actions,
onClick,
className,
}: SectionHeaderProps) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const isInteractive = isCollapsible || !!onClick;
const handleClick = isCollapsible ? ctx?.toggle : onClick;
const chevronIcon = (
<HugeiconsIcon
icon={ArrowDownIcon}
className={cn(
"size-4 shrink-0 transition-transform duration-200 ease-out",
isOpen
? "rotate-0 text-foreground"
: "-rotate-90 text-muted-foreground",
)}
/>
);
const trailingArea =
trailing || isCollapsible ? (
<div className="flex items-center">
{trailing}
{isCollapsible && (
<Button
variant="ghost"
size="icon"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={handleClick}
>
{chevronIcon}
</Button>
)}
</div>
) : null;
const innerContent = isInteractive ? (
<button
type="button"
className="min-w-0 flex-1 flex items-center gap-2 h-full cursor-pointer text-left"
onClick={handleClick}
>
{leading}
<div className="min-w-0 flex-1 flex items-center">{children}</div>
</button>
) : (
<>
{leading}
<div className="min-w-0 flex-1 flex items-center">{children}</div>
</>
);
return (
<div
className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}
>
{innerContent}
{trailingArea}
{actions}
</div>
);
}
export function SectionTitle({
children,
className,
onClick,
}: {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const titleClass = cn(
"text-sm font-medium",
isCollapsible && isOpen ? "text-foreground" : "text-muted-foreground",
className,
);
if (onClick) {
return (
<button
type="button"
className={cn("cursor-pointer", titleClass)}
onClick={onClick}
>
{children}
</button>
);
}
return <span className={titleClass}>{children}</span>;
}
export function SectionFields({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
);
}
export function SectionField({
label,
beforeLabel,
children,
className,
}: {
label: string;
beforeLabel?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex h-4 items-center gap-1.5">
{beforeLabel}
<Label>{label}</Label>
</div>
{children}
</div>
);
}
export function SectionContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const ctx = useSectionContext();
const isCollapsible = ctx?.collapsible ?? false;
const isOpen = ctx?.isOpen ?? true;
const content = <div className={cn("p-4 pt-0", className)}>{children}</div>;
if (isCollapsible) {
return (
<div
className={cn(
"grid transition-[grid-template-rows] duration-100 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">{content}</div>
</div>
);
}
return content;
}