1import { SettingsGroup } from "./SettingsGroup";
2import { ChevronRightIcon } from "lucide-react";
3import { type ReactNode, useState } from "react";
4
5import { cn } from "~/lib/utils";
6import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
7import { useSettingsSearchTarget, useSettingsSearchTargetId } from "./settingsLayout";
8
9/**
10 * A grouped settings section that starts closed. The header carries the title,
11 * a one line summary of what is set inside, and an optional control such as
12 * the section's own switch. A settings search that targets the section opens it.
13 */
14export function FoldedSettingsSection({
15 id,
16 title,
17 summary,
18 control,
19 headerPlacement = "inside",
20 children,
21}: {
22 readonly id: string;
23 readonly title: string;
24 readonly summary?: string | null;
25 readonly control?: ReactNode;
26 readonly headerPlacement?: "inside" | "outside";
27 readonly children: ReactNode;
28}) {
29 const [open, setOpen] = useState(false);
30 const searchTargetId = useSettingsSearchTargetId();
31 const targetRef = useSettingsSearchTarget<HTMLElement>(id);
32 // A search jump lands inside the fold, so open it before the scroll runs.
33 const [openedForTarget, setOpenedForTarget] = useState<string | null>(null);
34 if (searchTargetId === id && openedForTarget !== id) {
35 setOpenedForTarget(id);
36 if (!open) setOpen(true);
37 }
38
39 if (headerPlacement === "outside") {
40 return (
41 <section id={id} ref={targetRef} tabIndex={-1} className="outline-none">
42 <Collapsible open={open} onOpenChange={setOpen}>
43 <div className="space-y-2.5">
44 <div
45 data-settings-scroll-target
46 className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4"
47 >
48 <h2>
49 <CollapsibleTrigger className="flex min-h-7 items-center gap-2 rounded-md text-sm font-normal text-foreground/70 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
50 {title}
51 <ChevronRightIcon
52 aria-hidden
53 className={cn(
54 "size-4 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
55 open && "rotate-90",
56 )}
57 />
58 </CollapsibleTrigger>
59 </h2>
60 {control}
61 </div>
62 <CollapsiblePanel>
63 <SettingsGroup>{children}</SettingsGroup>
64 </CollapsiblePanel>
65 </div>
66 </Collapsible>
67 </section>
68 );
69 }
70
71 return (
72 <section id={id} ref={targetRef} tabIndex={-1} className="outline-none">
73 <Collapsible open={open} onOpenChange={setOpen} render={<SettingsGroup divided={false} />}>
74 <div className="flex items-center gap-4 px-3 sm:px-4">
75 <CollapsibleTrigger className="flex min-h-11 min-w-0 flex-1 items-center gap-2 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring rounded-md">
76 <ChevronRightIcon
77 aria-hidden
78 className={cn(
79 "size-4 shrink-0 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none",
80 open && "rotate-90",
81 )}
82 />
83 <span className="shrink-0 text-sm font-medium">{title}</span>
84 {summary ? (
85 <span className="min-w-0 truncate text-xs text-muted-foreground">{summary}</span>
86 ) : null}
87 </CollapsibleTrigger>
88 {control ? <div className="flex shrink-0 items-center">{control}</div> : null}
89 </div>
90 <CollapsiblePanel>
91 <div className="border-t border-border/50 [&>*+*]:border-t [&>*+*]:border-border/50">
92 {children}
93 </div>
94 </CollapsiblePanel>
95 </Collapsible>
96 </section>
97 );
98}