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:
73
src/hooks/useRoomBeside.ts
Normal file
73
src/hooks/useRoomBeside.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* How much width an element has to itself in a row it shares with something
|
||||
* that takes precedence — in the feed's header, how much the tabs leave the
|
||||
* presence avatars. `within` (an ancestor selector) is what bounds the row,
|
||||
* `beside` (found inside it) is what comes first, measured at its content's
|
||||
* natural width because how wide the tabs run depends on the theme's type and
|
||||
* on the locale. The row's gap between the two is taken off too.
|
||||
*
|
||||
* Takes the element itself (from a callback ref) rather than a ref object,
|
||||
* so the measuring starts whenever it mounts. Its own width doesn't enter
|
||||
* into it, so it can size itself to the answer without feeding back into the
|
||||
* next one. Null until measured.
|
||||
*/
|
||||
export function useRoomBeside(
|
||||
el: HTMLElement | null,
|
||||
{ within, beside }: { within?: string; beside?: string },
|
||||
): number | null {
|
||||
const [room, setRoom] = useState<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = within ? el?.closest<HTMLElement>(within) : null;
|
||||
if (!el || !container || !beside) return;
|
||||
const row = el.parentElement;
|
||||
|
||||
function measure() {
|
||||
const kept = container!.querySelector<HTMLElement>(beside!);
|
||||
const cs = getComputedStyle(container!);
|
||||
const width = container!.clientWidth - parseFloat(cs.paddingLeft) -
|
||||
parseFloat(cs.paddingRight);
|
||||
const gap = row ? parseFloat(getComputedStyle(row).columnGap) || 0 : 0;
|
||||
setRoom(Math.floor(width - gap - (kept ? contentWidth(kept) : 0)));
|
||||
}
|
||||
|
||||
// The container for its width; the tabs themselves, since they change
|
||||
// width without their box changing (a web font arriving, a tab added at
|
||||
// login) — re-bound when they come and go.
|
||||
const resize = new ResizeObserver(measure);
|
||||
const mutation = new MutationObserver(observe);
|
||||
function observe() {
|
||||
resize.disconnect();
|
||||
mutation.disconnect();
|
||||
resize.observe(container!);
|
||||
const kept = container!.querySelector<HTMLElement>(beside!);
|
||||
if (kept) {
|
||||
for (const child of kept.children) resize.observe(child);
|
||||
mutation.observe(kept, { childList: true });
|
||||
}
|
||||
measure();
|
||||
}
|
||||
observe();
|
||||
return () => {
|
||||
resize.disconnect();
|
||||
mutation.disconnect();
|
||||
};
|
||||
}, [el, within, beside]);
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
/** How wide an element's content runs, from its first child to its last plus
|
||||
* its padding — neither its box, which a flex row may squeeze (the tabs then
|
||||
* scroll) or stretch (filling the row under the header), nor its
|
||||
* scrollWidth, which is at least the box. */
|
||||
function contentWidth(el: HTMLElement): number {
|
||||
const first = el.firstElementChild, last = el.lastElementChild;
|
||||
if (!first || !last) return 0;
|
||||
const cs = getComputedStyle(el);
|
||||
return last.getBoundingClientRect().right -
|
||||
first.getBoundingClientRect().left + parseFloat(cs.paddingLeft) +
|
||||
parseFloat(cs.paddingRight);
|
||||
}
|
||||
@@ -37,10 +37,15 @@ export function useTabParam<T extends string>(
|
||||
const setTab = useCallback(
|
||||
(t: T) => {
|
||||
const target = t === defaultTab && base ? base : `${base}${SEP}${t}`;
|
||||
// Preserve any unrelated query string (e.g. the search page's `?q=`).
|
||||
navigate(`${target}${location.search}`, { replace: true });
|
||||
// Preserve any unrelated query string (e.g. the search page's `?q=`),
|
||||
// and the entry's state along with it — this is the same entry, and the
|
||||
// header's search reads where to go back to from there.
|
||||
navigate(`${target}${location.search}`, {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
});
|
||||
},
|
||||
[navigate, base, defaultTab, location.search],
|
||||
[navigate, base, defaultTab, location.search, location.state],
|
||||
);
|
||||
|
||||
return [tab, setTab];
|
||||
|
||||
Reference in New Issue
Block a user