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:
logan committed 2026-08-12 01:18:34 -04:00
1 parent 69ad558a44
commit 7acb197daf
7 files changed
+659 -27

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
/**
* A roving tab stop for a grid of cards.
*
* Every card in the albums, artists and genres grids is
* `role="button" tabindex="0"`, which is reachable but unusable: the tab
* sequence is as long as the library, so getting past the grid means
* pressing Tab a thousand times and getting *into* it lands on card one
* with no way to move but Tab. The convention for a grid is one tab
* stop that the arrow keys move — which is what this is.
*
* The column count is measured from the rendered cards rather than
* computed from the layout config, because all three grids are
* virtualized with a centring `justify` and the arithmetic would be a
* second description of a layout the DOM already knows.
*/
import type { ReactiveController, ReactiveControllerHost } from 'lit';
export interface RovingGridHost extends ReactiveControllerHost {
shadowRoot: ShadowRoot | null;
}
export interface RovingGridOptions {
/** CSS selector matching one card, inside the host's shadow root. */
cardSelector: string;
/** How many cards there are right now. */
count: () => number;
/** Bring an index into view before focusing it — the grids are
* virtualized, so a card outside the window does not exist yet. */
scrollToIndex?: (index: number) => void;
/** Called when the user activates the focused card. */
activate?: (index: number) => void;
}
export class RovingGridController implements ReactiveController {
private host: RovingGridHost;
private opts: RovingGridOptions;
/** The card holding the tab stop. */
private focusedIndex = 0;
constructor(host: RovingGridHost, opts: RovingGridOptions) {
this.host = host;
this.opts = opts;
host.addController(this);
}
hostConnected(): void {}
/** `tabindex` for the card at `index`. */
tabIndexFor(index: number): number {
return index === this.focusedIndex ? 0 : -1;
}
/** Remember where the user is, so tabbing back returns there. */
noteFocus(index: number): void {
if (index === this.focusedIndex) return;
this.focusedIndex = index;
this.host.requestUpdate();
}
/** Keydown handler for the scroll container. */
handleKeydown = (e: KeyboardEvent): void => {
const last = this.opts.count() - 1;
if (last < 0) return;
const columns = this.measureColumns();
let next = this.focusedIndex;
switch (e.key) {
case 'ArrowRight':
next = Math.min(this.focusedIndex + 1, last);
break;
case 'ArrowLeft':
next = Math.max(this.focusedIndex - 1, 0);
break;
case 'ArrowDown':
next = Math.min(this.focusedIndex + columns, last);
break;
case 'ArrowUp':
next = Math.max(this.focusedIndex - columns, 0);
break;
case 'Home':
next = 0;
break;
case 'End':
next = last;
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
this.focus(next);
};
/** Move the tab stop, scrolling and focusing the card. */
focus(index: number): void {
this.focusedIndex = index;
this.opts.scrollToIndex?.(index);
this.host.requestUpdate();
void this.host.updateComplete.then(() => {
const cards = this.cards();
cards
.find((card) => Number(card.dataset['index']) === index)
?.focus();
});
}
private cards(): HTMLElement[] {
return [
...(this.host.shadowRoot?.querySelectorAll<HTMLElement>(
this.opts.cardSelector,
) ?? []),
];
}
/** Cards sharing a top offset are one row. */
private measureColumns(): number {
const cards = this.cards();
if (cards.length === 0) return 1;
const top = cards[0]!.offsetTop;
const inRow = cards.filter((card) => card.offsetTop === top).length;
return Math.max(1, inRow);
}
}