"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 (
);
}
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 ``, 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. `` and a bare `` 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
* `` 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 `` 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 `` 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 (
` 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 && }
{isPrevious ? "Previous" : "Next"}
{!isPrevious && }
);
}