Files
yellowjacket/frontend/src/components/bottom-nav/bottom-nav.ts
T
logan 57fbbdf0d2
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
feat(ui): a shell a phone can be held in
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column,
`bottom-nav` becomes the primary navigation, and the shell fits the
viewport instead of scrolling sideways out of it.

600 rather than the sidebar's own 900, because 900 is a laptop and the
answer there is a narrower sidebar, which is still a sidebar. Under 600
there is no room for one at all: 360px of viewport over a 200px nav is
not a layout.

**The tab bar is four destinations and a way to everything else.**
Three to five is where touch targets stop being thumb-sized -- eleven
over 360px is 32px each -- so the four are the ones plan 016's subset
says a phone is for, and "More" opens the *existing* `app-sidebar` in a
drawer rather than listing the destinations a second time. Two lists is
two places to add the next view to.

That reuse has a cost this found the hard way: a shared component
brings its `data-testid`s with it, so rendering the drawer's sidebar
unconditionally put a second `nav-home` (and ten siblings) in the DOM
and **failed 30 existing specs** with "resolved to 2 elements" -- on a
desktop viewport, where this element is `display: none` and the drawer
can never open. It renders only while the drawer is open, and the
component test asserts the absence, because the failure is invisible
from inside the component and lands in files nobody touched.

**What made the shell overflow was minimums, not padding.** Measured at
360px: the body was 652px wide, because a `min-width` in a flex row is
a hard floor and a grid item's implicit minimum is its content. So
`min-width: 0` on the boxes between the viewport and the content, and
each component stands its own non-essential parts down in its *own*
stylesheet -- search-bar's 200px floor, job-indicator's label (the
visible one; the live region that announces it is untouched),
audio-player's seek bar and volume. A media query inside a shadow root
is answered by the viewport, so this is the component saying what it
drops rather than the shell reaching in.

Volume goes because the hardware keys own it on a phone, which is the
same reason mediacontrols' Android handler implements no volume
callback. Seeking goes because 4px is not a thumb target; it belongs to
the full-screen now-playing view, which is the next phase.

An existing spec therefore asserts the opposite of what it did:
layout-overflow's 320px case used to require that the 464px behind
`overflow: hidden` could be *scrolled to*, which was the remedy
available while the shell had one layout. It reflows now -- 320px in a
320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
2026-08-16 23:19:26 -04:00

254 lines
8.6 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
type View = 'home' | 'albums' | 'tracks' | 'playlists';
interface Tab {
id: View;
label: string;
icon: string;
}
/**
* The phone's primary navigation: a bottom tab bar, shown only below
* the phone breakpoint (index.css owns that; this element is
* `display: none` above it).
*
* **Four destinations and a way to everything else.** A tab bar is
* three to five items before the targets stop being thumb-sized —
* 360 px over eleven sidebar entries is 32 px each — so the four here
* are the ones plan 016's subset says a phone is *for*, and "More"
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
* a reuse rather than a second nav: two lists of destinations is two
* places to add the next view to, and the sidebar already carries the
* drag-to-navigate behaviour, the active state and the labels.
*
* It emits the same bubbling, composed `navigate` event the sidebar
* does, so `index.ts` needs no knowledge of it, and it listens for that
* event globally for the same reason the sidebar does: a navigation it
* did not send (a card click, a detail view, the drawer) still has to
* move the highlight.
*/
@customElement('bottom-nav')
export class BottomNav extends LitElement {
static override styles = [designTokens, css`
:host {
display: block;
background-color: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border, #495057);
/* The home indicator on a gesture-navigation phone sits
under the last few pixels of the viewport, so the bar
pads itself out of the way where the browser reports
one and by nothing where it does not. */
padding-bottom: env(safe-area-inset-bottom, 0);
}
nav ul {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
margin: 0;
padding: 0;
list-style: none;
}
button {
width: 100%;
/* 48px is the smallest target this should ever be; the
label sits under the icon rather than beside it, which
is what keeps five of them legible at 360px. */
min-height: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px 0;
background: none;
border: none;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-family: inherit;
font-size: var(--yj-font-size-xs, 0.7rem);
}
button wa-icon {
font-size: 1.15rem;
}
button.active {
color: var(--yj-accent, #ffd43b);
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
.label {
/* A tab label is an aid, not the name: the button's own
accessible name comes from its text, and truncating it
visually does not change that. */
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
wa-drawer::part(body) {
padding: 0;
}
app-sidebar {
/* The sidebar sizes itself inline and collapses to icons
below 900px, which is every phone. In the drawer there
is room for the labels, so it is told not to. */
height: 100%;
}
`];
@state()
private activeView = 'home';
/**
* Whether the drawer has been asked for.
*
* The sidebar inside it is rendered only while this is true, and
* that is not an optimisation. `app-sidebar` carries a
* `data-testid` per destination, so a second copy standing by in
* the DOM makes every `nav-*` testid ambiguous **for the whole
* app** -- 30 existing specs failed with "strict mode violation:
* resolved to 2 elements" on a desktop viewport where this element
* is not even visible. A duplicate of a shared component is a
* duplicate of its handles.
*/
@state()
private drawerOpen = false;
@query('wa-drawer')
private drawer?: WaDrawer;
private static readonly TABS: Tab[] = [
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'playlists', label: 'Playlists', icon: 'list' },
];
override connectedCallback() {
super.connectedCallback();
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override updated() {
// Web Awesome renders its heading into its own shadow root and
// never points aria-labelledby at it, so the drawer would
// otherwise be announced unnamed -- the same fix, and the same
// reason, as every wa-dialog in the app. A drawer's shadow root
// has the same shape, so the helper needs no change.
nameDialog(this.drawer);
}
private onGlobalNavigate = (e: Event) => {
const detail = (e as CustomEvent<{ view?: string }>).detail;
if (detail?.view) this.activeView = detail.view;
// A navigation from inside the drawer is the drawer's job done.
this.drawerOpen = false;
};
private openDrawer = () => {
this.drawerOpen = true;
};
/**
* Web Awesome closes itself on Escape and on a click outside, and
* tells us afterwards rather than asking -- so the flag follows the
* element, or the next `open` would be a no-op against a drawer
* that thinks it is already open.
*/
private onDrawerHide = () => {
this.drawerOpen = false;
};
private navigate(view: View) {
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
composed: true,
}));
}
override render() {
return html`
<nav aria-label="Primary">
<ul>
${BottomNav.TABS.map((tab) => html`
<li>
<button
type="button"
class=${this.activeView === tab.id ? 'active' : ''}
data-testid="tab-${tab.id}"
aria-current=${this.activeView === tab.id
? 'page'
: 'false'}
@click=${() => this.navigate(tab.id)}
>
<wa-icon name=${tab.icon}></wa-icon>
<span class="label">${tab.label}</span>
</button>
</li>
`)}
<li>
<button
type="button"
data-testid="tab-more"
aria-haspopup="dialog"
@click=${this.openDrawer}
>
<wa-icon name="bars"></wa-icon>
<span class="label">More</span>
</button>
</li>
</ul>
</nav>
<wa-drawer
placement="start"
label="All views"
data-testid="nav-drawer"
?open=${this.drawerOpen}
@wa-after-hide=${this.onDrawerHide}
>
${this.drawerOpen
? html`<app-sidebar expanded></app-sidebar>`
: nothing}
</wa-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'bottom-nav': BottomNav;
}
}