From 5fd1d364da35699fb236174b3a595d0335a1c3b7 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 27 Sep 2026 10:00:15 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20header=20stops=20cropping=20itself?= =?UTF-8?q?=20and=20the=20search=20stops=20losing=20its=20place=20?= =?UTF-8?q?=E2=80=94=20a=20review=20of=20the=20header,=20search=20bar=20an?= =?UTF-8?q?d=20presence=20row=20turned=20up=20tabs=20cut=20off=20at=20both?= =?UTF-8?q?=20ends,=20avatars=20that=20could=20only=20be=20all=20there=20o?= =?UTF-8?q?r=20gone,=20and=20a=20search=20that=20forgot=20what=20it=20was?= =?UTF-8?q?=20showing.=20Between=20860px=20and=20about=201000px,=20and=20a?= =?UTF-8?q?gain=20just=20past=201150px=20where=20the=20username=20and=20Pl?= =?UTF-8?q?aylists=20links=20arrive,=20presence=20plus=20tabs=20ran=20wide?= =?UTF-8?q?r=20than=20the=20header's=20centre=20column;=20the=20slot=20cou?= =?UTF-8?q?ldn't=20shrink,=20so=20the=20column=20clipped=20it=20on=20both?= =?UTF-8?q?=20sides,=20and=20logged=20in=20FOLLOWED=20went=20missing=20wit?= =?UTF-8?q?h=20one=20person=20online.=20The=20container=20queries=20meant?= =?UTF-8?q?=20to=20shed=20presence=20under=20460px=20and=20tabs=20under=20?= =?UTF-8?q?280px=20never=20fired,=20overridden=20by=20later=20rules=20of?= =?UTF-8?q?=20the=20same=20weight,=20and=20no=20fixed=20width=20would=20ha?= =?UTF-8?q?ve=20been=20right=20anyway:=20the=20tabs=20run=20from=20314px?= =?UTF-8?q?=20(nyt,=20English)=20to=20485px=20(melange,=20French).=20The?= =?UTF-8?q?=20slot=20can=20now=20shrink,=20and=20the=20presence=20row=20si?= =?UTF-8?q?zes=20itself=20through=20a=20new=20useRoomBeside,=20which=20mea?= =?UTF-8?q?sures=20the=20room=20the=20tabs=20leave=20it=20in=20the=20heade?= =?UTF-8?q?r=20and=20in=20the=20row=20under=20it:=20as=20many=20avatars=20?= =?UTF-8?q?as=20fit,=20at=20most=20six=20seats,=20the=20rest=20folded=20in?= =?UTF-8?q?to=20a=20"+N"=20counter=20(square=20in=20brutalist),=20and=20ne?= =?UTF-8?q?ver=20fewer=20than=20an=20avatar=20and=20the=20counter=20?= =?UTF-8?q?=E2=80=94=20below=20that=20the=20tabs=20scroll=20instead.=20So?= =?UTF-8?q?=20presence=20shows=20at=20every=20width,=20phones=20included,?= =?UTF-8?q?=20where=20a=20512px=20rule=20used=20to=20hide=20it.=20When=20t?= =?UTF-8?q?he=20header's=20tabs=20do=20scroll,=20they=20get=20the=20app's?= =?UTF-8?q?=20right-edge=20fade=20in=20the=20header's=20own=20colour,=20on?= =?UTF-8?q?ly=20while=20they=20overflow,=20and=20the=20active=20tab=20is?= =?UTF-8?q?=20scrolled=20clear=20of=20it.=20The=20avatars=20fan=20out=20on?= =?UTF-8?q?=20hover=20only=20where=20there's=20a=20pointer,=20so=20a=20tap?= =?UTF-8?q?=20doesn't=20leave=20them=20spread,=20and=20the=20label-less=20?= =?UTF-8?q?"+"=20button=20squares=20up=20with=20the=20icons=20beside=20it?= =?UTF-8?q?=20on=20small=20screens.=20Search:=20the=20=E2=9C=95=20on=20res?= =?UTF-8?q?ults=20opened=20directly=20ran=20navigate(-1)=20out=20of=20the?= =?UTF-8?q?=20app=20and=20now=20goes=20home;=20after=20several=20searches?= =?UTF-8?q?=20it=20stepped=20back=20one=20query=20at=20a=20time=20and=20no?= =?UTF-8?q?w=20returns=20to=20where=20the=20search=20began=20in=20one=20st?= =?UTF-8?q?ep,=20carried=20in=20the=20entry's=20state,=20which=20useTabPar?= =?UTF-8?q?am=20now=20keeps=20across=20result=20tabs;=20those=20tabs=20(/s?= =?UTF-8?q?earch/~/users)=20weren't=20recognised=20as=20the=20results=20pa?= =?UTF-8?q?ge=20at=20all.=20On=20results=20the=20bar=20stays=20open=20hold?= =?UTF-8?q?ing=20the=20current=20query,=20including=20after=20a=20new=20se?= =?UTF-8?q?arch=20or=20Back,=20where=20it=20used=20to=20collapse=20and=20e?= =?UTF-8?q?mpty.=20The=20input=20no=20longer=20grabs=20focus=20on=20a=20re?= =?UTF-8?q?sults=20page,=20so=20a=20phone's=20keyboard=20doesn't=20cover?= =?UTF-8?q?=20the=20results,=20and=20submitting=20puts=20it=20away.=20Esca?= =?UTF-8?q?pe=20closes=20an=20opened=20search=20and=20returns=20focus=20to?= =?UTF-8?q?=20its=20button,=20or=20on=20results=20reverts=20an=20edited=20?= =?UTF-8?q?query,=20overriding=20the=20search=20input's=20own=20clear-on-E?= =?UTF-8?q?scape.=20The=20two=20search=20bars=20share=20one=20draft,=20so?= =?UTF-8?q?=20crossing=20the=20breakpoint=20keeps=20it.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/App.css | 81 +++--- src/components/AppHeader.tsx | 101 +++++-- src/components/PresenceRow.tsx | 52 +++- src/components/SearchBar.tsx | 99 +++---- src/components/TabBar.tsx | 45 ++- src/hooks/useRoomBeside.ts | 73 +++++ src/hooks/useTabParam.ts | 11 +- src/locales/en.js | 2 +- src/locales/en.po | 503 +++++++++++++++++---------------- src/locales/fr.js | 2 +- src/locales/fr.po | 503 +++++++++++++++++---------------- src/pages/Index.tsx | 4 +- src/themes/brutalist.css | 1 + 13 files changed, 849 insertions(+), 628 deletions(-) create mode 100644 src/hooks/useRoomBeside.ts 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} - +