Ctrl K
ComunidadGuías…

src/components/dives/dive-neighbor-nav.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { divesAPI, DiveNeighbor, DiveNeighbors } from "@/lib/api/dives";
import { formatDiveDateTime } from "@/lib/date-time";
import { cn } from "@/lib/utils";
import { withReturnTo } from "@/lib/return-to";

export interface DiveNeighborNavProps {
  diveUuid: string;
  /**
   * Bumped by the page when something has changed which dives are adjacent
   * *without* changing which dive is on screen — today, a merge this dive
   * survived, which soft-deletes the one it absorbed.
   *
   * The uuid cannot carry that: it is the same string before and after, so the
   * effect below would never re-run and this pager would keep a live arrow
   * pointing at a dive that no longer resolves — one click, a failed `getDive`,
   * and the diver is bounced to `/dives` with a load error about a dive they
   * did not ask for.
   */
  reloadToken?: number;
  // The buttons' look, from the surface they sit on - the dive hero's top row.
  className?: string;
}

// What the button's tooltip and accessible name say about where it goes. The
// neighbour's own date, not "older"/"newer": a diver skimming a trip knows the
// dive they want by its day, and `#212` alone is ambiguous in a log with
// duplicate numbers - which the numbering summary exists precisely because
// logs have.
function neighborLabel(
  label: "Previous dive" | "Next dive",
  neighbor: DiveNeighbor,
): string {
  const date = formatDiveDateTime(neighbor.start_time, {
    year: "numeric",
    month: "short",
    day: "numeric",
  });
  return `${label}: #${neighbor.dive_number}, ${date}`;
}

/**
 * The dive detail page's pager: one step back and one step forward through the
 * log, sitting opposite the "Back to dives" link in the page header.
 *
 * Chronological, not list-order - "Previous" is the earlier dive and "Next" the
 * later one, so a trip reads front to back however the log page happens to be
 * sorted. See `DiveNeighbors` for why that is the opposite of `getDives`'
 * ordering.
 *
 * The two labels are fixed words and never the neighbour's date. This is a
 * control a diver clicks repeatedly, and a label that emptied and refilled with
 * each step's fetch would resize the button under their cursor between two
 * clicks. The date rides the accessible name and the tooltip instead, where it
 * costs no layout.
 *
 * An end of the log leaves its button in place but dead rather than dropping it,
 * so the pair doesn't shift sideways as a diver steps onto the oldest dive - and
 * so the end of the log is visible instead of merely being where clicking stops.
 */
export function DiveNeighborNav({
  diveUuid,
  reloadToken = 0,
  className,
}: DiveNeighborNavProps) {
  // Keyed by the uuid they were fetched for. Navigating between two dives keeps
  // this component mounted with a new `diveUuid`, and a plain `neighbors` state
  // would spend that render pointing the buttons at the previous dive's
  // neighbours - a click landing somewhere the diver didn't aim.
  const [loaded, setLoaded] = useState<{
    diveUuid: string;
    neighbors: DiveNeighbors;
  } | null>(null);

  useEffect(() => {
    let cancelled = false;

    const load = async () => {
      try {
        const neighbors = await divesAPI.getDiveNeighbors(diveUuid);
        if (!cancelled) setLoaded({ diveUuid, neighbors });
      } catch (error) {
        // Silent, and both buttons stay dead. This is a shortcut to the rest of
        // the log, not part of the dive the diver came to read, and a toast over
        // the top of a page that loaded fine would say otherwise.
        console.error("Failed to load neighbouring dives:", error);
      }
    };

    load();

    return () => {
      cancelled = true;
    };
  }, [diveUuid, reloadToken]);

  const neighbors = loaded?.diveUuid === diveUuid ? loaded.neighbors : null;
  // "Not yet known" rather than "not there". Both render the same dead button, but
  // a screen reader following a step would otherwise hear the end of the log every
  // time - the button's name and `aria-disabled` flip on each one - with no way to
  // tell that from actually having reached the oldest dive.
  const isPending = loaded?.diveUuid !== diveUuid;

  // A landmark, so the pair is announced as one named thing rather than as two
  // loose links in the middle of the header.
  return (
    <nav
      aria-label="Adjacent dives"
      className="flex shrink-0 items-center gap-2"
    >
      <NavLink
        direction="previous"
        neighbor={neighbors?.previous ?? null}
        isPending={isPending}
        className={className}
      />
      <NavLink
        direction="next"
        neighbor={neighbors?.next ?? null}
        isPending={isPending}
        className={className}
      />
    </nav>
  );
}

interface NavLinkProps {
  direction: "previous" | "next";
  neighbor: DiveNeighbor | null;
  // Whether `neighbor` being null means "still loading" rather than "end of log".
  isPending: boolean;
  className?: string;
}

/**
 * One step. Always the same `<a>`, whether or not it currently leads anywhere.
 *
 * That is the whole reason this doesn't use `next/link` and drive the unavailable
 * state with `disabled`, which is the obvious build. `<Link>` and a bare `<a>` are
 * different element types to React, so alternating between them - which every step
 * does, since arriving at a dive nulls the neighbours until the next fetch lands -
 * replaces the DOM node. The browser drops focus when the focused node goes, so a
 * keyboard diver pressing Enter on "Next" landed on the next dive with focus on
 * `<body>` and had to tab all the way back for every single step. `disabled` on a
 * focused button costs the same thing.
 *
 * One node that keeps its `tabIndex` and swaps its `href` survives all of it, at
 * the price of re-doing the two things `<Link>` was doing: pushing the route on a
 * plain click, and staying out of the way of a modified one so cmd-click still
 * opens a dive in a new tab.
 *
 * It only started paying for itself once the route tree stopped destroying this
 * node from above. Until `dives/(detail)/layout.tsx` hoisted the fetch out of
 * the dynamic segment, the App Router re-mounted the whole page on a
 * `/dives/[id]` param change and focus landed on `<body>` however careful this
 * component was - see "`dives/(detail)/layout.tsx` owns the dive fetch, so a
 * step keeps the page mounted" in `DECISIONS.md`. Both halves are load-bearing:
 * the hoist keeps the component mounted, and this keeps the node inside it.
 */
function NavLink({ direction, neighbor, isPending, className }: NavLinkProps) {
  const router = useRouter();
  // A step sideways keeps the way back the dive on screen was opened with.
  const from = useSearchParams()?.get("from") ?? null;
  const isPrevious = direction === "previous";
  const Chevron = isPrevious ? ChevronLeft : ChevronRight;
  const fallbackLabel = isPrevious ? "Previous dive" : "Next dive";

  const href = neighbor
    ? withReturnTo(`/dives/${neighbor.uuid}`, from)
    : undefined;
  const label = neighbor
    ? neighborLabel(fallbackLabel, neighbor)
    : fallbackLabel;

  return (
    <a
      className={cn(
        buttonVariants({ variant: "ghost", size: "sm" }),
        className,
        // Matches `Button`'s own `disabled:` styling, since `aria-disabled` is
        // what stands in for `disabled` here.
        !href && "pointer-events-none opacity-50",
      )}
      href={href}
      // Focusable even with nothing to point at - the point of the exercise.
      tabIndex={0}
      // An `<a>` with no `href` is `generic` to the accessibility tree, not a
      // link, and a generic node carries no accessible name - so without this
      // the unavailable button would stop announcing itself entirely rather
      // than announcing itself as unavailable.
      role="link"
      aria-disabled={!href}
      aria-busy={isPending}
      // The word on screen is the first word of this, which is what keeps the
      // visible label part of the accessible one.
      aria-label={label}
      title={label}
      onClick={(event) => {
        if (!href) {
          event.preventDefault();
          return;
        }
        // Leave cmd/ctrl/shift/alt-clicks to the browser, so "open in a new
        // tab" still works on what is, after all, a link to a page.
        if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
          return;
        }
        event.preventDefault();
        router.push(href);
      }}
    >
      {/* The chevron alone on a phone, where the words would push the
            top row past the screen's edge; the name is `aria-label`'s at
            every width. */}
      {isPrevious && <Chevron className="h-4 w-4 sm:mr-1" />}
      <span className="max-sm:hidden">{isPrevious ? "Previous" : "Next"}</span>
      {!isPrevious && <Chevron className="h-4 w-4 sm:ml-1" />}
    </a>
  );
}