"use client";
import { useId, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import {
CARD_SECTION_CONTENT,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DIVE_SECTION_ICONS,
DiveSectionIcon,
} from "@/components/dives/dive-section-icon";
import {
SectionIndexTrigger,
SectionSentinel,
scrollToSection,
useSection,
useSectionIndexShown,
} from "@/components/ui/section-index";
import type { DiveFormFieldGroup } from "@/lib/dive-form-fields";
import { cn } from "@/lib/utils";
type DiveFormSectionProps = {
// The group's name is the heading, and names the glyph beside it.
title: DiveFormFieldGroup;
// Nothing in the content is visible, so it drops its padding and the card is all
// heading. Its children stay mounted - a status region among them, say.
empty?: boolean;
children: ReactNode;
} & (
| {
open: boolean;
onOpenChange: (open: boolean) => void;
action?: never;
titleAdornment?: never;
}
| {
// Takes the chevron's place, and the section doesn't collapse.
action: ReactNode;
// Sits right after the title - an info popover, say.
titleAdornment?: ReactNode;
open?: never;
onOpenChange?: never;
}
);
// One of the dive form's field groups, a card of its own. The heading sticks under the
// site header while its card scrolls past, and the card being its containing block is
// what hands the spot to the next heading: this one leaves with its card's bottom edge.
//
// A collapsed section unmounts its fields, as a hidden field is not rendered - their
// values stay in form state (`shouldUnregister: false`) and are submitted all the same.
export function DiveFormSection(props: DiveFormSectionProps) {
const { title, empty = false, children } = props;
const open = props.onOpenChange === undefined || props.open;
const contentId = useId();
const { cardRef, headingRef, sentinelRef, stuck } = useSection({
key: title,
label: title,
icon: DIVE_SECTION_ICONS[title],
onExpand: () => props.onOpenChange?.(true),
});
const indexShown = useSectionIndexShown();
const shown = open && !empty;
// Where the index is not beside the form, the stuck heading carries it: the
// reader is a long way from the top, which is where the page's own controls are.
const sectionsTrigger = shown && stuck && indexShown && (
);
return (
// Focusable so a jump from the index can land the keyboard on a card whose
// heading has no control of its own.
{/* Squared off at the foot while content shows, so content scrolling under the
stuck heading cannot show through its corners. A card that is all heading
never sticks and takes no rule. */}
{props.onOpenChange === undefined ? (
{title}
{/* No height of its own: a touch screen's 44px floor would otherwise
stretch the title's line, and the box beyond its icon is invisible. */}
{props.titleAdornment}
{/* Overhangs the title's line into the heading's padding, so the heading
is as tall as every other section's - on a touch screen nearly so,
where the button's floor is 44px. */}
{props.action}
{sectionsTrigger}
) : (
{/* As tall as the chevron's line once its margins are counted, so the
heading does not move when the control appears. Hidden as a whole
where the control is, since an empty flex item still costs the row
its gap and would nudge the chevron as the heading sticks. */}
{sectionsTrigger && (
{sectionsTrigger}
)}
)}
{/* The heading's even padding is `--card-pad`, which a phone halves; the rest of
the first field's gap is made up here, so it sits as far below the heading as
the fields sit from each other. */}
{open && children}
);
}