"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. */}
); }