`plus` meant "add to the queue", "add to a playlist", "make a new playlist" and "you do not own this" -- the first two adjacent in the same context menu, so two neighbouring items were the same glyph doing different things. `list` meant the queue (the button that opens it), the Playlists destination, and adding to the queue in `queue-panel` alone. Two icons carrying seven meanings is not a vocabulary, and nothing catches it: a wrong-but-real icon renders perfectly. `utils/icon-language.ts` is the table, beside `library-status.ts` as the issue suggested. The rule it is built on is that an icon names the **noun** it acts on, not the verb: "add to queue" and "add to playlist" are one verb on two nouns, so the noun is what differs -- which is why adding to a playlist wears the Playlists destination's own icon, and why the queue took `bars-staggered` and stopped wearing Playlists'. `plus` keeps the one meaning it is unambiguous about, making something that is not there yet, which covers New Playlist and the drop zones. `bars-staggered` is the only new glyph, vendored through names.txt and fetch-icons.mjs after confirming it is in Font Awesome **Free** 7.3.1. Two things this found rather than changed: - The request toggle's outline/solid pair was already in the app and already right -- `explore-album-details`'s "Request this" button has used `regular/bookmark` -> `solid/bookmark` since it was written -- while the badge forty pixels away showed a **plus** for the same state. That is `utils/library-status.ts`'s fault one layer down: it made the two surfaces agree on what wanting *means* and left them disagreeing on what it looks like. - `explore-artist-details`'s Follow button was `bookmark-check`, which is Font Awesome **Pro** and has never been bundled, so it has drawn the missing-icon fallback -- a circled question mark -- for every followed artist since it was written. `requested-badge.spec.ts` was written for exactly this bug on the album button and says so in its docstring; this is the same bug one component over, still live, because `offline-icons.spec.ts` sweeps `__yjIconMisses` and no spec had ever followed an artist. So the test does what reaching the state cannot. `icon-language.test.ts` reads every `src/**/*.ts` as raw text and fails on a governed name written outside the table, and separately asserts every `ICON_*` is a *bundled* name -- which is what makes a Pro name a failing test rather than a runtime report from a state something has to reach first. Its first assertion is that it read any source at all, because a sweep over an empty glob passes. `chrome.test.ts` asserted `['check', 'bookmark', 'plus']` and so pinned the badge's glyphs against the vocabulary they were meant to follow; it names them from the table now, and keeps the assertion that the three differ, which is the property the states actually need. Downloads keeps the solid bookmark on purpose. That is one word twice, not two words: the badge says the entity is on your list and the nav item is that list. Closes #34
255 lines
8.7 KiB
TypeScript
255 lines
8.7 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';
|
|
import { ICON_PLAYLIST } from '@utils/icon-language';
|
|
|
|
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: ICON_PLAYLIST },
|
|
];
|
|
|
|
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;
|
|
}
|
|
}
|