"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 && } ); }