Drop the phone's top bar; search becomes a button and a modal #167

Merged
logan merged 5 commits from feat/57-drop-the-android-top-bar into main 2026-08-21 00:18:33 +00:00
Showing only changes of commit 47bd9ef211 - Show all commits
@@ -162,6 +162,15 @@ export class PageHeader extends LitElement {
@state()
private collapsed: ReadonlySet<string> = new Set();
/**
* Whether the count has been given up. Derived, like `collapsed`.
*
* It is the last thing to yield and the only thing here that is
* neither an identity nor an action — see `measureFit`.
*/
@state()
private countCollapsed = false;
@state()
private menuOpen = false;
@@ -531,12 +540,6 @@ export class PageHeader extends LitElement {
if (!header) return;
if (this.actions.length === 0) {
this.commitCollapsed(new Set());
return;
}
const buttons = new Map<string, HTMLElement>();
for (const el of this.renderRoot.querySelectorAll<HTMLElement>(
@@ -549,6 +552,7 @@ export class PageHeader extends LitElement {
const more = this.moreButton;
const title = this.renderRoot.querySelector('h1');
const count = this.renderRoot.querySelector<HTMLElement>('.count');
/**
* Nothing is clipped — which is not the same as the header not
@@ -570,6 +574,8 @@ export class PageHeader extends LitElement {
if (more) more.hidden = true;
if (count) count.hidden = false;
const collapsed = new Set<string>();
if (!fits()) {
@@ -586,7 +592,42 @@ export class PageHeader extends LitElement {
}
}
this.commitCollapsed(collapsed);
this.commitCollapsed(collapsed, this.collapseCount(count, fits));
}
/**
* The last thing to give way, after every action is in the menu and
* the title has already run out.
*
* There are four things competing for this row and three of them
* cannot go. The **title** yields first and is allowed to ellipsis
* away entirely at 320px, because the navigation also says which
* page you are on. The **sort** control and the **actions** are
* each the only place they are said, so an action collapses into
* the menu rather than disappearing and the sort control stays.
* That leaves the **count**, which is the one purely informational
* item on the row — an empty page says so in its empty state, and a
* full one is being looked at.
*
* It became reachable rather than theoretical with #57: below 600px
* the header also carries the phone's search button, and on
* Playlists at 320px that is 43px more than the row has. Measured
* there: title 0, count 50, sort 143, search 40, "More actions" 38,
* five 12px gaps and 32px of gutters — 363 in 320, with the More
* button ending 27px past the edge. Something has to go, and this
* is the only candidate that is not an action.
*
* @returns whether the count was given up.
*/
private collapseCount(
count: HTMLElement | null,
fits: () => boolean,
): boolean {
if (count === null || fits()) return false;
count.hidden = true;
return true;
}
/** Lowest priority first; ties broken from the right. */
@@ -601,7 +642,9 @@ export class PageHeader extends LitElement {
.map(({ action }) => action);
}
private commitCollapsed(next: Set<string>): void {
private commitCollapsed(next: Set<string>, countHidden: boolean): void {
this.countCollapsed = countHidden;
const same =
next.size === this.collapsed.size &&
[...next].every((id) => this.collapsed.has(id));
@@ -769,7 +812,16 @@ export class PageHeader extends LitElement {
const noun = this.count === 1 ? this.countNoun : plural;
return html`<span class="count" data-testid="page-count"
// Rendered whether or not it fits, and hidden with an
// attribute -- the same shape the action buttons use, and for
// the same reason: `measureFit` starts every pass from
// all-visible, so it needs a node to un-hide. Returning
// `nothing` here would take the count away for the rest of the
// session the first time a 320px window appeared.
return html`<span
class="count"
data-testid="page-count"
?hidden=${this.countCollapsed}
>${this.count.toLocaleString()} ${noun}</span
>`;
}