Files
siftlode/frontend/src/components/ui/BackToTop.tsx
T
peter 5ebb4cb506 fix(a11y): chips, grips, the panel tab and Back to top keep the focus somewhere real
- F5: a chip's × pressed from the keyboard hands the focus to the next chip (else the previous one,
  else what follows) before it unmounts — in DismissibleChip, so all five chip rows get it
  (`focusNeighbour` in lib/useFocusKeeper).
- F4: a drag grip names what it lifts (`common.order.dragNamed`: panel groups, personal views);
  the two generic "Drag to reorder" keys are gone.
- F6: the side panel's edge tab is out of the Tab order while the card covers it, names its panel,
  and a keyboard pin moves the focus into the card.
- F7: Back to top is no Tab stop while hidden, and a keyboard press leaves the focus on the page.
2026-09-27 15:01:22 +02:00

75 lines
4.2 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// A floating "back to top" button that fades in once the current page's scroll container is
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
// <main class="overflow-y-auto"> — the normal pages share App's, the channel page renders its own
// — so this follows whichever one is mounted. Portaled to <body> so its fixed position is
// viewport-relative regardless of transformed ancestors.
export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
const { t } = useTranslation();
const [show, setShow] = useState(false);
const scrollerRef = useRef<HTMLElement | null>(null);
// Listen on the document in the CAPTURE phase and read the scroller off the event, rather than
// looking <main> up when this binds. Scroll events don't bubble, but they do capture — and the
// lookup was a race the lazy pages lost: on the FIRST visit to the channel page its chunk is
// still loading, so the Suspense fallback is on screen, there is no <main> to find, and the
// effect gave up for good (its dep never changes again). The button then stayed dead for that
// whole visit and came back only once the chunk was cached. Nothing to find, nothing to race.
useEffect(() => {
const onScroll = (e: Event) => {
const el = e.target as HTMLElement | null;
if (!el || el.tagName !== "MAIN") return;
scrollerRef.current = el;
setShow(el.scrollTop > threshold);
};
document.addEventListener("scroll", onScroll, { passive: true, capture: true });
return () => document.removeEventListener("scroll", onScroll, { capture: true });
}, [threshold]);
// On navigation, re-read where the new page sits: usually the top (so hide), but some pages
// restore their scroll position. Missing <main> here is now harmless — a page that hasn't
// rendered hasn't scrolled either, and the listener above picks it up the moment it does.
useEffect(() => {
const el = document.querySelector("main");
scrollerRef.current = el;
// eslint-disable-next-line react-hooks/set-state-in-effect -- reading the DOM is the point — <main> is swapped by the page switch above us, and only after that commit can its scrollTop be read at all. Nothing derivable in render knows where the new page is scrolled to.
setShow(!!el && el.scrollTop > threshold);
}, [dep, threshold]);
// A keyboard press hides the button it was on: the focus goes to the page it scrolled (its <main>
// is a focus rest, not a Tab stop), not left on an invisible control (E28 review F7).
const toTop = (e: React.MouseEvent) => {
const main = scrollerRef.current ?? document.querySelector("main");
main?.scrollTo({ top: 0, behavior: "smooth" });
if (e.detail === 0) main?.focus({ preventScroll: true });
};
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
// Shape (docs/DESIGN.md A2): round because it floats over the content, 3rem (48) on mouse and touch —
// a lone corner button at a toolbar rung gets lost.
return createPortal(
<Pressable
align="center"
onClick={toTop}
// Hidden, it only goes transparent (so it can fade): out of the Tab order as well.
tabIndex={show ? undefined : -1}
aria-hidden={show ? undefined : true}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center size-12 ${SURFACE.floating} glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
<ArrowUp className="size-5" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</Pressable>,
document.body,
);
}