fix(shell): publish the active view, so both navs follow the back path

The nav components learned where the user was from the `navigate`
CustomEvent, which only the outbound path dispatches: `popstate` calls
`handleNavigate()` directly. So a back-navigation left both of them
highlighting the view just left — desktop included, at any width, on
any back across two primary views. Opening a detail view was the same
cause wearing a different symptom: `app-sidebar` guarded on its own
item list and kept its highlight, `bottom-nav` did not and lit nothing.

It cannot be fixed by re-dispatching `navigate` — `index.ts` is that
event's document listener, so that is an infinite loop, and "please go
to X" is not the statement being made. `activeViewStore` is the shell
saying "the active view is now X", once per navigation, `popstate`
included; both navs read it through a controller and hold no
`activeView` of their own.

A store rather than an event because a component that mounts *after* a
navigation still has to know: `bottom-nav`'s drawer builds its
`app-sidebar` on open, and that copy had heard nothing at all, so the
drawer opened on Home from any page in the app.

Closes #72
This commit is contained in:
2026-08-19 17:00:39 -04:00
parent c84a9069ef
commit f18691560d
9 changed files with 355 additions and 65 deletions
@@ -7,6 +7,7 @@ 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';
type View = 'home' | 'albums' | 'tracks' | 'playlists';
@@ -114,8 +115,19 @@ export class BottomNav extends LitElement {
}
`];
@state()
private activeView = 'home';
/**
* 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);
/**
* Whether the drawer has been asked for.
@@ -167,12 +179,9 @@ export class BottomNav extends LitElement {
nameDialog(this.drawer);
}
private onGlobalNavigate = (e: Event) => {
const detail = (e as CustomEvent<{ view?: string }>).detail;
if (detail?.view) this.activeView = detail.view;
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;
};
@@ -206,9 +215,11 @@ export class BottomNav extends LitElement {
<li>
<button
type="button"
class=${this.activeView === tab.id ? 'active' : ''}
class=${this.activeCtrl.isActive(tab.id)
? 'active'
: ''}
data-testid="tab-${tab.id}"
aria-current=${this.activeView === tab.id
aria-current=${this.activeCtrl.isActive(tab.id)
? 'page'
: 'false'}
@click=${() => this.navigate(tab.id)}
+23 -29
View File
@@ -4,6 +4,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
import { ActiveViewController } from '@store/controllers/active-view-controller';
import {
ICON_PLAYLIST,
ICON_AUTOTAG,
@@ -159,11 +160,20 @@ export class AppSidebar extends LitElement {
/** Delay in ms before a drag-hover triggers navigation. */
private static readonly HOVER_NAV_DELAY = 600;
/** Home, because that is where `index.ts` now navigates on startup
* (H-8). The sidebar does not hear a `navigate` it did not send,
* so this default is what keeps `aria-current` honest on arrival. */
@state()
private activeView: View = 'home';
/**
* Which item is lit, read from the shell rather than tracked here.
*
* This used to be a `@state()` field defaulting to `home` -- the
* landing view -- because "the sidebar does not hear a `navigate`
* it did not send". That default was the only honest moment it
* ever had: a back-navigation dispatches no `navigate`, so the
* highlight stayed on the view the user had just left (#72), and
* the copy of this component that `bottom-nav` mounts inside its
* drawer opened on `home` from whatever page you were standing on.
* The shell publishes the active view now, so there is nothing to
* default and nothing to keep in step.
*/
private activeCtrl = new ActiveViewController(this);
@state()
private isDragging = false;
@@ -237,10 +247,6 @@ export class AppSidebar extends LitElement {
'yj-drag-active',
this.onDragActive as EventListener,
);
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override disconnectedCallback() {
@@ -262,10 +268,6 @@ export class AppSidebar extends LitElement {
'yj-drag-active',
this.onDragActive as EventListener,
);
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
this.clearDragHoverTimer();
}
@@ -282,8 +284,9 @@ export class AppSidebar extends LitElement {
<nav aria-label="Main">
<ul>
${this.navItems.map((item) => {
const active = this.activeCtrl.isActive(item.id);
const classes = [
this.activeView === item.id
active
? 'active'
: '',
this.dragHoverView === item.id
@@ -299,7 +302,7 @@ export class AppSidebar extends LitElement {
type="button"
class=${classes}
data-testid="nav-${item.id}"
aria-current=${this.activeView === item.id
aria-current=${active
? 'page'
: 'false'}
@click=${() =>
@@ -382,19 +385,6 @@ export class AppSidebar extends LitElement {
private static readonly DROP_VIEWS: Set<View> =
new Set(['playlists']);
/** Keeps the highlighted nav item in sync with navigation that
* originates outside the sidebar itself (e.g. the launch-page
* dispatch in index.ts). */
private onGlobalNavigate = (
e: CustomEvent<{ view?: string }>,
) => {
const view = e.detail.view;
if (view && this.navItems.some((item) => item.id === view)) {
this.activeView = view as View;
}
};
private onDragActive = (
e: CustomEvent<DragActiveDetail>,
) => {
@@ -460,7 +450,11 @@ export class AppSidebar extends LitElement {
}
private navigate(view: View) {
this.activeView = view;
// No optimistic highlight: the shell answers, and it answers
// synchronously in `handleNavigate` before it awaits anything.
// Setting it here as well is the second opinion this fix
// removes -- it is what let a click's highlight survive a
// navigation the shell then handled differently.
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,