Ctrl K
ComunidadGuías…

src/components/dives/dive-form-section.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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 && (
    <SectionIndexTrigger className="lg:hidden" />
  );

  return (
    // Focusable so a jump from the index can land the keyboard on a card whose
    // heading has no control of its own.
    <Card ref={cardRef} tabIndex={-1} className="relative outline-none">
      <SectionSentinel ref={sentinelRef} />
      {/* 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. */}
      <CardHeader
        className={cn(
          "sticky top-[var(--header-height)] z-10 rounded-t-lg bg-card",
          !shown && "rounded-b-lg",
          // A shadow rather than a border: it takes no height, so the rule appearing
          // neither nudges the fields nor pulls the chevron off the middle.
          shown && stuck && "shadow-[inset_0_-1px_0_hsl(var(--border))]",
        )}
      >
        {props.onOpenChange === undefined ? (
          <div className="flex items-center justify-between gap-2">
            <CardTitle as="h2" className="flex items-center gap-1">
              <DiveSectionIcon group={title} className="mr-1" />
              {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. */}
              <span className="flex h-0 items-center">
                {props.titleAdornment}
              </span>
            </CardTitle>
            {/* 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. */}
            <div className="-my-2 flex items-center gap-2">
              {props.action}
              {sectionsTrigger}
            </div>
          </div>
        ) : (
          <div className="flex items-center gap-2">
            <CardTitle as="h2" className="min-w-0 grow">
              <button
                ref={headingRef}
                type="button"
                aria-expanded={open}
                aria-controls={contentId}
                onClick={() => {
                  // A stuck heading is a way back to the top of its section, which
                  // collapsing would throw away by moving everything below it.
                  if (open && stuck) {
                    if (cardRef.current) scrollToSection(cardRef.current);
                    return;
                  }
                  props.onOpenChange(!open);
                }}
                className="relative flex w-full items-center justify-between gap-2 rounded-sm text-left touch:tap-target focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <span className="flex min-w-0 items-center gap-2">
                  <DiveSectionIcon group={title} />
                  {title}
                </span>
                {/* Stuck, a tap on the heading scrolls rather than collapses, so the
                    chevron says nothing there; on a narrow screen the sections
                    control takes its place rather than widening the heading. */}
                <ChevronDown
                  aria-hidden="true"
                  className={cn(
                    "h-5 w-5 shrink-0 text-muted-foreground transition-transform",
                    !open && "-rotate-90",
                    sectionsTrigger && "max-lg:hidden",
                  )}
                />
              </button>
            </CardTitle>
            {/* 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 && (
              <div className="-my-1 -mr-1 flex items-center lg:hidden">
                {sectionsTrigger}
              </div>
            )}
          </div>
        )}
      </CardHeader>
      {/* 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. */}
      <CardContent
        id={contentId}
        hidden={!open}
        className={cn("space-y-6", CARD_SECTION_CONTENT, empty && "p-0")}
      >
        {open && children}
      </CardContent>
    </Card>
  );
}