The navigation reads the resolved map from the backend rather than holding a copy of the defaults, which would be the copy that shipped in the binary rather than the one being edited. Hiding takes away the nav item and nothing else: `navigate` still resolves a hidden view, which detail views and the launch page depend on. No special case was needed for the highlight, because #72 moved that onto `active-view-store` -- the sidebar asks `isActive(id)` per *rendered* item, so a hidden view lights nothing exactly as a detail view does. Downloads is gated at the nav on `downloadStore.available` rather than in the config, so switching it on in Settings still means what it says once a client exists, and the tab appears without a restart. `available` is false until the providers have loaded, which makes the item appear on a fresh launch rather than appearing and then vanishing. The tab bar honours the toggles too, and the reason is local rather than a general rule about phones: "More" opens the *same* `<app-sidebar>`, which filters, so an unfiltered bar would contradict its own drawer one tap away. Which four tabs is still plan 016's subset; this only removes from it, and "More" is never filtered. `services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s pattern, because Settings is now a second reader of the same labels in the same order. Two existing sidebar tests had to say which world they describe: eleven destinations now assumes a configured download client.
285 lines
10 KiB
TypeScript
285 lines
10 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';
|
|
import { ActiveViewController } from '@store/controllers/active-view-controller';
|
|
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
|
|
|
|
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%;
|
|
}
|
|
`];
|
|
|
|
/**
|
|
* Which tab is lit, read from the shell rather than tracked here.
|
|
*
|
|
* This was a `@state()` field set from the `navigate` event, which
|
|
* only the outbound path dispatches -- so backing out of a detail
|
|
* view left the highlight wherever it had been (#72). It had no
|
|
* equivalent of `app-sidebar`'s `navItems.some(...)` guard either,
|
|
* so a detail view set it to a name matching no tab and *nothing*
|
|
* was lit; that asymmetry is why one nav looked broken and the
|
|
* other looked fine. The store answers both: a detail view leaves
|
|
* the tab it was opened from lit, in both components.
|
|
*/
|
|
private activeCtrl = new ActiveViewController(this);
|
|
|
|
/**
|
|
* The tab bar honours the sidebar's toggles (#25), and the reason is
|
|
* inside this component rather than a general rule about phones.
|
|
* `PHONE_COLUMN_IDS` is the precedent for "what a phone shows is a
|
|
* different question", and it would apply here too -- except that
|
|
* "More" opens the *same* `<app-sidebar>`, which filters. An
|
|
* unfiltered bar would therefore contradict its own drawer, one tap
|
|
* apart, and a destination the user switched off is off wherever it
|
|
* is offered.
|
|
*
|
|
* Which four tabs remains plan 016's committed subset; this only
|
|
* removes from it. Hiding all four leaves "More", which is always
|
|
* present and reaches everything.
|
|
*/
|
|
private visibilityCtrl = new ViewVisibilityController(this);
|
|
|
|
/**
|
|
* 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 = () => {
|
|
// A navigation from inside the drawer is the drawer's job done.
|
|
// The highlight is not this listener's business any more.
|
|
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
|
|
.filter((tab) => this.visibilityCtrl.visible(tab.id))
|
|
.map((tab) => html`
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class=${this.activeCtrl.isActive(tab.id)
|
|
? 'active'
|
|
: ''}
|
|
data-testid="tab-${tab.id}"
|
|
aria-current=${this.activeCtrl.isActive(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;
|
|
}
|
|
}
|