diff options
Diffstat (limited to 'apps/web/src/components/section.tsx')
| -rw-r--r-- | apps/web/src/components/section.tsx | 514 |
1 files changed, 257 insertions, 257 deletions
diff --git a/apps/web/src/components/section.tsx b/apps/web/src/components/section.tsx index e3fc96df..86f2f71f 100644 --- a/apps/web/src/components/section.tsx +++ b/apps/web/src/components/section.tsx @@ -1,257 +1,257 @@ -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; -} +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;
+}
|
