v3: the header stops cropping itself and the search stops losing its place — a review of the header, search bar and presence row turned up tabs cut off at both ends, avatars that could only be all there or gone, and a search that forgot what it was showing. Between 860px and about 1000px, and again just past 1150px where the username and Playlists links arrive, presence plus tabs ran wider than the header's centre column; the slot couldn't shrink, so the column clipped it on both sides, and logged in FOLLOWED went missing with one person online. The container queries meant to shed presence under 460px and tabs under 280px never fired, overridden by later rules of the same weight, and no fixed width would have been right anyway: the tabs run from 314px (nyt, English) to 485px (melange, French). The slot can now shrink, and the presence row sizes itself through a new useRoomBeside, which measures the room the tabs leave it in the header and in the row under it: as many avatars as fit, at most six seats, the rest folded into a "+N" counter (square in brutalist), and never fewer than an avatar and the counter — below that the tabs scroll instead. So presence shows at every width, phones included, where a 512px rule used to hide it. When the header's tabs do scroll, they get the app's right-edge fade in the header's own colour, only while they overflow, and the active tab is scrolled clear of it. The avatars fan out on hover only where there's a pointer, so a tap doesn't leave them spread, and the label-less "+" button squares up with the icons beside it on small screens. Search: the ✕ on results opened directly ran navigate(-1) out of the app and now goes home; after several searches it stepped back one query at a time and now returns to where the search began in one step, carried in the entry's state, which useTabParam now keeps across result tabs; those tabs (/search/~/users) weren't recognised as the results page at all. On results the bar stays open holding the current query, including after a new search or Back, where it used to collapse and empty. The input no longer grabs focus on a results page, so a phone's keyboard doesn't cover the results, and submitting puts it away. Escape closes an opened search and returns focus to its button, or on results reverts an edited query, overriding the search input's own clear-on-Escape. The two search bars share one draft, so crossing the breakpoint keeps it.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 10:00:15 +00:00
parent bfce3186f8
commit 5fd1d364da
13 changed files with 849 additions and 628 deletions

View File

@@ -1,5 +1,5 @@
import { lazy, type ReactNode, Suspense, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts";
@@ -14,6 +14,19 @@ const DumpCreateModal = lazy(() =>
import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal }))
);
/** Carried in a search entry's history state: the history index of the page
* the search started from, so leaving search returns there in one step
* however many searches (or result tabs) came in between. */
interface SearchEntryState {
searchExit?: number;
}
/** React Router's index of the current history entry — 0 for the first page
* of the session, which has nothing in the app to go back to. */
function historyIndex(): number {
return (window.history.state as { idx?: number } | null)?.idx ?? 0;
}
export function AppHeader(
{ centerSlot, disableNew, initialDumpUrl }: {
centerSlot?: ReactNode;
@@ -25,22 +38,72 @@ export function AppHeader(
const { wsStatus, wsErrorMessage } = useWS();
const navigate = useNavigate();
const location = useLocation();
const isSearchPage = location.pathname === "/search";
const [searchExpanded, setSearchExpanded] = useState(isSearchPage);
const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl);
const [searchParams] = useSearchParams();
// The results page and each of its tabs (/search/~/users…).
const isSearchPage = location.pathname === "/search" ||
location.pathname.startsWith("/search/");
const query = isSearchPage ? searchParams.get("q") ?? "" : "";
function handleSearchToggle() {
if (searchExpanded) {
if (isSearchPage) {
navigate(-1);
} else {
setSearchExpanded(false);
}
// On the results page the search stays open, holding the query it shows.
const [searchOpen, setSearchOpen] = useState(false);
const searchExpanded = isSearchPage || searchOpen;
const [draft, setDraft] = useState(query);
const [prevQuery, setPrevQuery] = useState(query);
if (prevQuery !== query) {
setPrevQuery(query);
setDraft(query);
}
const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl);
const searchToggleRef = useRef<HTMLButtonElement>(null);
function submitSearch(q: string) {
const here = location.state as SearchEntryState | null;
// A search from elsewhere remembers that page; one from the results page
// passes on what it was given, or — for results reached by a link —
// the page before them. Results that opened the session have nowhere to
// return to, so a new search just replaces them.
const searchExit = !isSearchPage
? historyIndex()
: here?.searchExit ?? (historyIndex() > 0 ? historyIndex() - 1 : null);
const to = `/search?q=${encodeURIComponent(q)}`;
if (searchExit === null) navigate(to, { replace: true });
else navigate(to, { state: { searchExit } satisfies SearchEntryState });
}
function closeSearch() {
if (isSearchPage) {
const exit = (location.state as SearchEntryState | null)?.searchExit;
if (exit !== undefined) navigate(exit - historyIndex());
else if (historyIndex() > 0) navigate(-1);
else navigate("/");
} else {
setSearchExpanded(true);
setSearchOpen(false);
setDraft("");
searchToggleRef.current?.focus();
}
}
// Escape leaves an opened search, but only reverts an edited query on the
// results page — leaving that page is the ✕'s job.
function escapeSearch() {
if (isSearchPage) setDraft(query);
else closeSearch();
}
function handleSearchToggle() {
if (searchExpanded) closeSearch();
else setSearchOpen(true);
}
const searchBarProps = {
expanded: searchExpanded,
value: draft,
onValueChange: setDraft,
onSubmit: submitSearch,
onCancel: escapeSearch,
};
return (
<>
<header className="app-header app-header--has-center">
@@ -53,17 +116,16 @@ export function AppHeader(
<div className="app-header-center">
{centerSlot}
<SearchBar
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
<SearchBar {...searchBarProps} />
</div>
<nav className="app-header-nav">
<button
ref={searchToggleRef}
type="button"
className="nav-search-btn"
aria-label={searchExpanded ? t`Cancel search` : t`Search`}
aria-expanded={searchExpanded}
onClick={handleSearchToggle}
>
{searchExpanded ? "✕" : "🔍"}
@@ -127,10 +189,7 @@ export function AppHeader(
>
<div className="header-search-reveal-clip">
<div className="header-search-below">
<SearchBar
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
<SearchBar {...searchBarProps} />
</div>
</div>
</div>