diff --git a/src/App.css b/src/App.css index 0c1cc20f..b2766d62 100644 --- a/src/App.css +++ b/src/App.css @@ -2655,7 +2655,6 @@ body.has-player .chat-fab { min-width: 0; overflow-x: clip; overflow-y: visible; - container-type: inline-size; } @media (min-width: 860px) { @@ -2725,24 +2724,13 @@ body.has-player .chat-fab { display: none; } -/* As the center column shrinks (viewport narrow, search collapsed), - shed content in order: presence first, then tabs (still in .index-below-header) */ -@container (max-width: 460px) { - .index-presence { - display: none; - } -} - -@container (max-width: 280px) { - .feed-sort { - display: none; - } -} - .header-center-slot { display: flex; align-items: center; gap: 0.75rem; + /* Lets the tabs scroll as a last resort, rather than the centred slot + overflowing the column and being clipped at both ends. */ + min-width: 0; } .app-header-brand:hover { @@ -2884,7 +2872,8 @@ body.has-player .chat-fab { justify-content: center; } -/* Ghost search button — always visible except when the center search bar is shown */ +/* Ghost search button — opens the search (in the center column, or the row + under the header on small viewports) and turns into its ✕ */ .nav-search-btn { display: flex; align-items: center; @@ -3404,17 +3393,39 @@ body.has-player .chat-fab { margin-left: 0; } -.index-presence:hover .index-presence-avatar { - margin-left: 4px; +/* Hover only where there's a pointer to hover with — a tap would otherwise + leave the row fanned out and the tapped avatar raised. */ +@media (hover: hover) { + .index-presence:hover .index-presence-avatar { + margin-left: 4px; + } + + .index-presence:hover .index-presence-avatar:first-child { + margin-left: 0; + } + + .index-presence-avatar:hover { + transform: translateY(-3px) scale(1.12); + z-index: 2; + } } -.index-presence:hover .index-presence-avatar:first-child { - margin-left: 0; -} - -.index-presence-avatar:hover { - transform: translateY(-3px) scale(1.12); - z-index: 2; +/* The "+N" that ends a crowded row, sized and ringed like the avatars. */ +.index-presence-more { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-2xs); + font-weight: 700; + font-variant-numeric: tabular-nums; + cursor: default; + box-sizing: border-box; + border: var(--border-width) solid var(--color-border-subtle); } .index-status { @@ -3465,8 +3476,14 @@ body.has-player .chat-fab { pointer-events: none; } -/* In the app header the tabs never overflow — suppress the gradient there */ +/* In the app header the tabs only scroll as a last resort, and a permanent + fade would dim the last tab whenever they fit — so it shows only while they + overflow, in the header's own colour. */ .app-header .feed-sort-scroller::after { + background: linear-gradient(to right, transparent, var(--header-bg)); +} + +.app-header .feed-sort-scroller:not(.feed-sort-scroller--overflowing)::after { display: none; } @@ -3992,12 +4009,6 @@ body.has-player .chat-fab { align-self: center; } -@media (max-width: 512px) { - .index-below-header .index-presence { - display: none; - } -} - @media (max-width: 512px) { .app-header-brand-name { display: none; @@ -4006,6 +4017,12 @@ body.has-player .chat-fab { .btn-new-label { display: none; } + + /* Down to its "+", the button squares up with the icons beside it. */ + .app-header-nav .btn-primary { + width: 2.25rem; + padding: 0; + } } /* ── Playlist card image thumbnail ── */ diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index d7a3eb2b..c4a168c0 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -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(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 ( <>
@@ -53,17 +116,16 @@ export function AppHeader(
{centerSlot} - +