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
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:
@@ -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>
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useRoomBeside } from "../hooks/useRoomBeside.ts";
|
||||
|
||||
export function PresenceRow() {
|
||||
// Past this many, the row ends in a "+N" counter rather than growing, so a
|
||||
// busy evening doesn't push the feed tabs out of the header.
|
||||
const MAX_SEATS = 6;
|
||||
// However tight the row, it keeps an avatar and the counter — past that, the
|
||||
// tabs beside it scroll instead.
|
||||
const MIN_SEATS = 2;
|
||||
// Each seat (avatar or counter) is 32px wide and overlaps the one before it
|
||||
// by 10px (.index-presence-avatar's negative margin), so adds 22px.
|
||||
const SEAT = 32;
|
||||
const SEAT_STEP = 22;
|
||||
|
||||
/**
|
||||
* Who's online, as a row of overlapping avatars. Given `fitWithin` and
|
||||
* `fitBeside`, it gives up seats to what's beside it — as many avatars as the
|
||||
* room left by the tabs allows, the rest folded into the counter.
|
||||
*/
|
||||
export function PresenceRow(
|
||||
{ fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string },
|
||||
) {
|
||||
const { onlineUsers } = useWS();
|
||||
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
||||
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });
|
||||
|
||||
if (onlineUsers.length === 0) return null;
|
||||
|
||||
const seats = room === null ? MAX_SEATS : Math.min(
|
||||
MAX_SEATS,
|
||||
Math.max(MIN_SEATS, 1 + Math.floor((room - SEAT) / SEAT_STEP)),
|
||||
);
|
||||
// The counter takes the last seat, so the row never runs past `seats`.
|
||||
const shown = onlineUsers.length > seats
|
||||
? onlineUsers.slice(0, seats - 1)
|
||||
: onlineUsers;
|
||||
const rest = onlineUsers.slice(shown.length);
|
||||
const restNames = rest.map((u) => u.username).join(", ");
|
||||
|
||||
return (
|
||||
<div className="index-presence">
|
||||
{onlineUsers.map((u) => (
|
||||
<div className="index-presence" ref={setEl}>
|
||||
{shown.map((u) => (
|
||||
<Link
|
||||
key={u.userId}
|
||||
to={`/users/${u.username}`}
|
||||
@@ -20,11 +54,21 @@ export function PresenceRow() {
|
||||
userId={u.userId}
|
||||
username={u.username}
|
||||
hasAvatar={u.hasAvatar}
|
||||
size={32}
|
||||
size={SEAT}
|
||||
version={u.avatarVersion}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<span
|
||||
className="index-presence-avatar index-presence-more"
|
||||
title={restNames}
|
||||
aria-label={t`${rest.length} more online: ${restNames}`}
|
||||
role="img"
|
||||
>
|
||||
+{rest.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +1,64 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
interface SearchBarProps {
|
||||
collapsible?: boolean;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (v: boolean) => void;
|
||||
expanded: boolean;
|
||||
value: string;
|
||||
onValueChange: (v: string) => void;
|
||||
onSubmit: (q: string) => void;
|
||||
/** Escape pressed in the input. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's search form. The header renders two of these (one in its
|
||||
* centre column, one in the row that slides open under it on small
|
||||
* viewports) over one shared state, so the draft survives crossing the
|
||||
* breakpoint.
|
||||
*/
|
||||
export function SearchBar(
|
||||
{ collapsible = false, expanded: expandedProp, onExpandedChange }:
|
||||
SearchBarProps,
|
||||
{ expanded, value, onValueChange, onSubmit, onCancel }: SearchBarProps,
|
||||
) {
|
||||
const isControlled = expandedProp !== undefined;
|
||||
const [expandedState, setExpandedState] = useState(!collapsible);
|
||||
const expanded = isControlled ? expandedProp! : expandedState;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [value, setValue] = useState(
|
||||
() => new URLSearchParams(location.search).get("q") ?? "",
|
||||
);
|
||||
|
||||
// Collapsing always discards the draft, however it was collapsed (the ✕ in
|
||||
// the header toggles `expanded` from outside).
|
||||
const [prevExpanded, setPrevExpanded] = useState(expanded);
|
||||
if (prevExpanded !== expanded) {
|
||||
setPrevExpanded(expanded);
|
||||
if (!expanded) setValue("");
|
||||
}
|
||||
|
||||
function setExpanded(v: boolean) {
|
||||
if (!isControlled) setExpandedState(v);
|
||||
onExpandedChange?.(v);
|
||||
}
|
||||
const wasExpanded = useRef(expanded);
|
||||
|
||||
useEffect(() => {
|
||||
if ((collapsible || isControlled) && expanded) {
|
||||
// No scroll-into-view: the below-header row is still clipped while it
|
||||
// slides open, and scrolling the clip would offset its content.
|
||||
// Only an opening takes focus: a page that mounts with search already
|
||||
// open (the results page) mustn't raise a phone's keyboard over its
|
||||
// results. No scroll-into-view either: the below-header row is still
|
||||
// clipped while it slides open, and scrolling the clip would offset its
|
||||
// content. Of the two bars, the hidden one just ignores the call.
|
||||
if (expanded && !wasExpanded.current) {
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
} else if (!expanded && inputRef.current === document.activeElement) {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}, [expanded, collapsible, isControlled]);
|
||||
|
||||
function handleIconClick() {
|
||||
if (!collapsible && !isControlled) return;
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
setValue("");
|
||||
} else {
|
||||
setExpanded(true);
|
||||
}
|
||||
}
|
||||
wasExpanded.current = expanded;
|
||||
}, [expanded]);
|
||||
|
||||
function handleSubmit(e: React.SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const q = value.trim();
|
||||
if (!q) return;
|
||||
navigate(`/search?q=${encodeURIComponent(q)}`);
|
||||
if (collapsible || isControlled) {
|
||||
setExpanded(false);
|
||||
setValue("");
|
||||
}
|
||||
// Submitting shows results — put a phone's keyboard away so they can be
|
||||
// seen.
|
||||
inputRef.current?.blur();
|
||||
onSubmit(q);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape" && (collapsible || isControlled)) {
|
||||
setExpanded(false);
|
||||
setValue("");
|
||||
if (e.key === "Escape") {
|
||||
// A search input clears itself on Escape, which would undo a revert.
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className={`search-bar${
|
||||
collapsible || isControlled ? " search-bar--collapsible" : ""
|
||||
}${expanded ? " search-bar--expanded" : ""}`}
|
||||
className={`search-bar search-bar--collapsible${
|
||||
expanded ? " search-bar--expanded" : ""
|
||||
}`}
|
||||
onSubmit={handleSubmit}
|
||||
role="search"
|
||||
>
|
||||
@@ -85,18 +68,16 @@ export function SearchBar(
|
||||
className="search-bar-input"
|
||||
placeholder={t`Search dumps, users, playlists…`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={t`Search`}
|
||||
tabIndex={expanded ? 0 : -1}
|
||||
/>
|
||||
<button
|
||||
type={expanded ? "submit" : "button"}
|
||||
type="submit"
|
||||
className="search-bar-btn"
|
||||
aria-label={expanded ? t`Submit search` : t`Open search`}
|
||||
onClick={!expanded && (collapsible || isControlled)
|
||||
? handleIconClick
|
||||
: undefined}
|
||||
aria-label={t`Submit search`}
|
||||
tabIndex={expanded ? 0 : -1}
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
interface Tab<T extends string> {
|
||||
key: T;
|
||||
@@ -20,9 +20,48 @@ export function TabBar<T extends string>(
|
||||
{ tabs, activeTab, onChange, className, innerClassName, leading }:
|
||||
TabBarProps<T>,
|
||||
) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
// Flags a row whose tabs don't all fit, for places that fade the edge only
|
||||
// then (the app header) rather than always.
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
if (!row) return;
|
||||
const measure = () => setOverflowing(row.scrollWidth > row.clientWidth);
|
||||
const resize = new ResizeObserver(measure);
|
||||
resize.observe(row);
|
||||
for (const child of row.children) resize.observe(child);
|
||||
measure();
|
||||
return () => resize.disconnect();
|
||||
}, [tabs.length]);
|
||||
|
||||
// When they don't all fit, keep the active tab in view, clear of the fade
|
||||
// over the row's right edge — scrolling the row alone, never the page.
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
|
||||
if (!row || !active || !overflowing) return;
|
||||
// The fade is 2rem wide (.feed-sort-scroller::after).
|
||||
const fade =
|
||||
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
|
||||
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
|
||||
if (b.left < r.left) row.scrollLeft -= r.left - b.left;
|
||||
else if (b.right > r.right - fade) {
|
||||
row.scrollLeft += b.right - r.right + fade;
|
||||
}
|
||||
}, [activeTab, overflowing]);
|
||||
|
||||
return (
|
||||
<div className={`feed-sort-scroller${className ? ` ${className}` : ""}`}>
|
||||
<div className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}>
|
||||
<div
|
||||
className={`feed-sort-scroller${
|
||||
overflowing ? " feed-sort-scroller--overflowing" : ""
|
||||
}${className ? ` ${className}` : ""}`}
|
||||
>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
|
||||
>
|
||||
{leading}
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user