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:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The ambient shortcut scope: which panel's bindings apply when nothing
|
||||
* inside a panel has focus.
|
||||
*
|
||||
* `resolveScope` in the shortcut service walks up from the focused
|
||||
* element looking for `data-shortcut-scope`, which is the right answer
|
||||
* when something is focused — but this app is mostly driven with the
|
||||
* mouse, so the usual state is that focus sits on `<body>` and the walk
|
||||
* finds nothing. Without a fallback the panel bindings would only work
|
||||
* after a click landed inside the panel, which is not the behaviour the
|
||||
* Autotag page has today and not one worth regressing to.
|
||||
*
|
||||
* A claim is held by the *active view* (see `utils/view-lifecycle.ts`),
|
||||
* so it is released the moment the view leaves the screen — which is
|
||||
* what stops an off-screen view's keys from firing.
|
||||
*/
|
||||
|
||||
/** Claims, innermost last. A stack rather than a single value so that
|
||||
* releasing an outer claim out of order cannot resurrect it. */
|
||||
const claims: Array<{ scope: string }> = [];
|
||||
|
||||
/**
|
||||
* Claim `scope` as the ambient panel scope. Returns the release.
|
||||
*/
|
||||
export function claimShortcutScope(scope: string): () => void {
|
||||
const claim = { scope };
|
||||
|
||||
claims.push(claim);
|
||||
|
||||
return () => {
|
||||
const at = claims.indexOf(claim);
|
||||
|
||||
if (at >= 0) claims.splice(at, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** The innermost claimed scope, or null. */
|
||||
export function ambientShortcutScope(): string | null {
|
||||
return claims.length > 0 ? claims[claims.length - 1]!.scope : null;
|
||||
}
|
||||
|
||||
/** Drop every claim. Test-only escape hatch. */
|
||||
export function resetShortcutScopes(): void {
|
||||
claims.length = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user