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:
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Which primary view the app is showing.
|
||||
*
|
||||
* The shell has always known this -- `handleNavigate()` sets
|
||||
* `#main-content`'s `data-active-view` on every path, `_isBack`
|
||||
* included -- and never told anyone. The nav components learned it
|
||||
* from the `navigate` CustomEvent instead, which only the *outbound*
|
||||
* path dispatches: the `popstate` listener calls `handleNavigate()`
|
||||
* directly. So both navs kept highlighting the view you had just left
|
||||
* (#72).
|
||||
*
|
||||
* The fix cannot be a re-dispatch of `navigate`. `index.ts` is itself a
|
||||
* document listener for it, so emitting one from inside
|
||||
* `handleNavigate` is an infinite loop -- and the two statements are
|
||||
* different anyway: `navigate` means *please go to X*, and 28 call
|
||||
* sites across 18 files say it. This says *the active view is now X*,
|
||||
* which only the shell is in a position to say and only once per
|
||||
* navigation.
|
||||
*
|
||||
* Three things about it are load-bearing.
|
||||
*
|
||||
* **It is a store rather than an event**, because a component that
|
||||
* mounts *after* a navigation still has to know. `bottom-nav`'s "More"
|
||||
* drawer creates its `<app-sidebar>` on open, and that copy had heard
|
||||
* no `navigate` at all: standing on Albums, the drawer highlighted
|
||||
* Home -- its `activeView` default, which existed to match the landing
|
||||
* view and matched nothing else ever after. An event has no answer for
|
||||
* a listener that was not there; a value does.
|
||||
*
|
||||
* **A detail view is not a view here.** Opening one leaves the primary
|
||||
* view it was opened from lit, which is what #72 asks for and what
|
||||
* `app-sidebar` used to do by accident -- it guarded on
|
||||
* `navItems.some(...)`, so a name matching no item left its highlight
|
||||
* alone. `bottom-nav` had no such guard and so lit nothing on a detail
|
||||
* view. Neither was correct; the sidebar was stale-but-lucky, and
|
||||
* stating the rule once is what makes the two agree.
|
||||
*
|
||||
* **Whether a view is primary is the shell's fact, not this store's.**
|
||||
* `VIEW_TAGS` in `index.ts` is the list, and a copy of it here is a
|
||||
* second list to forget -- so the caller passes the answer it already
|
||||
* has rather than this file re-deriving it.
|
||||
*/
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
class ActiveViewStore {
|
||||
/** Empty until the shell's first navigation, which happens at
|
||||
* startup from `GetDefaultPage()`. Nothing is highlighted for that
|
||||
* moment, which is honest: the alternative is a written-down
|
||||
* default that is right only when the default page agrees with it. */
|
||||
private activeView = '';
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
/** The active primary view, e.g. `albums`. */
|
||||
get(): string {
|
||||
return this.activeView;
|
||||
}
|
||||
|
||||
isActive(view: string): boolean {
|
||||
return this.activeView !== '' && this.activeView === view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the shell on every navigation, `popstate` included.
|
||||
*
|
||||
* `isPrimary` is `view in VIEW_TAGS` at the call site: a detail
|
||||
* view reports itself and deliberately changes nothing, so the view
|
||||
* it was opened from stays lit until the user picks another one.
|
||||
*/
|
||||
setView(view: string, isPrimary: boolean): void {
|
||||
if (!isPrimary) return;
|
||||
if (view === this.activeView) return;
|
||||
|
||||
this.activeView = view;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe(fn: Subscriber): () => void {
|
||||
this.subscribers.add(fn);
|
||||
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.subscribers.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
|
||||
export const activeViewStore = new ActiveViewStore();
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from 'lit';
|
||||
import { activeViewStore } from '../active-view-store';
|
||||
|
||||
/**
|
||||
* ActiveViewController connects a Lit component to the
|
||||
* ActiveViewStore.
|
||||
*
|
||||
* Usage in a component:
|
||||
*
|
||||
* private activeCtrl = new ActiveViewController(this);
|
||||
*
|
||||
* render() {
|
||||
* const lit = this.activeCtrl.isActive('albums');
|
||||
* }
|
||||
*
|
||||
* It reads through to the store rather than copying the value into a
|
||||
* `@state()` field, which is the point of #72: two components holding
|
||||
* their own idea of the active view is what let them disagree with the
|
||||
* shell and with each other.
|
||||
*/
|
||||
export class ActiveViewController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// LIFECYCLE HOOKS
|
||||
// ===============================================================
|
||||
|
||||
hostConnected(): void {
|
||||
this.unsubscribe = activeViewStore.subscribe(() => {
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// DATA ACCESS
|
||||
// ===============================================================
|
||||
|
||||
/** The active primary view, e.g. `albums`. */
|
||||
get current(): string {
|
||||
return activeViewStore.get();
|
||||
}
|
||||
|
||||
isActive(view: string): boolean {
|
||||
return activeViewStore.isActive(view);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export type { ThemeState, BackgroundShade } from './theme-store';
|
||||
export { ThemeController } from './controllers/theme-controller';
|
||||
export { searchStore } from './search-store';
|
||||
export { SearchController } from './controllers/search-controller';
|
||||
export { activeViewStore } from './active-view-store';
|
||||
export { ActiveViewController } from './controllers/active-view-controller';
|
||||
export { shortcutsStore } from './shortcuts-store';
|
||||
export type { ShortcutsState } from './shortcuts-store';
|
||||
export { ShortcutsController } from './controllers/shortcuts-controller';
|
||||
|
||||
Reference in New Issue
Block a user