diff options
Diffstat (limited to 'apps/web/src/components/editor/panels/properties/section.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/properties/section.tsx | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx new file mode 100644 index 00000000..0b45065f --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/section.tsx @@ -0,0 +1,161 @@ +import { createContext, useContext, useState } from "react"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowDownIcon } from "@hugeicons/core-free-icons"; + +const sectionExpandedCache = new Map<string, boolean>(); + +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; + hasBorderTop?: boolean; + hasBorderBottom?: boolean; +} + +export function Section({ + children, + collapsible = false, + defaultOpen = true, + sectionKey, + className, + hasBorderTop = true, + hasBorderBottom = true, +}: SectionProps) { + const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; + const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); + + 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", + hasBorderTop && "border-t", + hasBorderBottom && "last:border-b", + className, + )} + > + {children} + </div> + </SectionCtx.Provider> + ); +} + +interface SectionHeaderProps { + title: string; + children?: React.ReactNode; + onClick?: () => void; + className?: string; +} + +export function SectionHeader({ + title, + children, + 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 content = ( + <> + <span + className={cn( + "text-sm font-medium", + isOpen ? "text-foreground" : "text-muted-foreground", + )} + > + {title} + </span> + <div className="flex items-center gap-1"> + {children} + {isCollapsible && ( + <HugeiconsIcon + icon={ArrowDownIcon} + className={cn( + "size-3 shrink-0 transition-transform duration-200 ease-out", + isOpen + ? "rotate-0 text-foreground" + : "-rotate-90 text-muted-foreground", + )} + /> + )} + </div> + </> + ); + + const baseClassName = cn( + "flex w-full items-center justify-between h-11 px-3.5", + className, + ); + + if (isInteractive) { + return ( + <button + type="button" + className={cn(baseClassName, "cursor-pointer text-left")} + onClick={(event) => { + handleClick?.(); + event.currentTarget.blur(); + }} + > + {content} + </button> + ); + } + + return <div className={baseClassName}>{content}</div>; +} + +export function SectionContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + const ctx = useSectionContext(); + const isCollapsible = ctx?.collapsible ?? false; + const isOpen = ctx?.isOpen ?? true; + + 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"> + <div className={cn("p-4 pt-0", className)}>{children}</div> + </div> + </div> + ); + } + + return <div className={cn("p-4 pt-0", className)}>{children}</div>; +} |
