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:
1 parent
69ad558a44
commit
7acb197daf
7 files changed
+659
-27
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user