feat(frontend): give a cached view a lifecycle and a keyboard owner

`index.ts` caches primary views and hides them with a class so
scrollTop survives navigation. Nothing else was told: `disconnectedCallback`
never fires for one, so everything written to clean up there never
cleans up. The worst case was not a leak — pressing `s` on Settings
skipped two albums out of the Autotag queue, and `a` on that same live
handler rewrites tags on disk.

- `utils/view-lifecycle.ts` is the missing half: `viewActivated` /
  `viewDeactivated`, with `listenWhileActive`, `intervalWhileActive`
  and `whileActive` torn down on the way out, and an off-screen view
  that does not render. `registerViewAware` gives a shared reactive
  controller the same treatment, because a controller cannot know
  whether its host is a cached view — `ContextMenuController` bound
  three document listeners in `hostConnected`, which for a cached host
  is "forever".
- `services/shortcut-scope.ts` publishes the ambient scope. Resolving
  scope from focus alone was not enough: this app is driven with the
  mouse, focus sits on `<body>`, and a focus-only rule would have made
  the panel keys work only after a click landed inside the panel.
- Global bindings yield to a focused control that owns the key —
  button, select, slider, checkbox, menu, grid row, or anything inside
  an open dialog — so the unmodified single-key bindings stop stealing
  Space and the arrows.
- `utils/roving-grid.ts` gives a card grid one tab stop moved with the
  arrows, since a card per tab stop makes a library-length tab
  sequence.
This commit is contained in:
2026-08-12 01:18:34 -04:00
parent 69ad558a44
commit 7acb197daf
7 changed files with 659 additions and 27 deletions
+30 -2
View File
@@ -5,6 +5,8 @@ import type {
} from 'lit';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import { registerViewAware } from './view-lifecycle';
/**
* Host interface for components using the ContextMenuController.
* The host must provide access to the popup elements (typically
@@ -96,7 +98,31 @@ export class ContextMenuController
// LIFECYCLE
// =================================================================
/** Whether the document listeners are currently installed. */
private listening = false;
hostConnected(): void {
// On a cached view, connection is not the right signal: it never
// un-happens, so these listeners would stay on the document for
// the life of the session and close a menu belonging to a page
// the user left. A lifecycle host drives attach/detach instead.
const managed = registerViewAware(this.host, {
onHostActivate: () => this.attach(),
onHostDeactivate: () => this.detach(),
});
if (!managed) this.attach();
}
hostDisconnected(): void {
this.detach();
this.clearSubmenuCloseTimer();
}
private attach(): void {
if (this.listening) return;
this.listening = true;
document.addEventListener(
'click',
this.closeHandler,
@@ -111,7 +137,10 @@ export class ContextMenuController
);
}
hostDisconnected(): void {
private detach(): void {
if (!this.listening) return;
this.listening = false;
document.removeEventListener(
'click',
this.closeHandler,
@@ -124,7 +153,6 @@ export class ContextMenuController
'mousedown',
this.mousedownCloseHandler,
);
this.clearSubmenuCloseTimer();
}
// =================================================================