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:
@@ -11,6 +11,7 @@
|
||||
* - Action dispatch to player/queue/nav stores
|
||||
*/
|
||||
import { shortcutsStore } from '@store/shortcuts-store';
|
||||
import { ambientShortcutScope } from './shortcut-scope';
|
||||
import { playerStore } from '@store/player-store';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import * as Player from '@go/player/Player';
|
||||
@@ -131,6 +132,128 @@ type ShortcutScope =
|
||||
| `panel:${string}`
|
||||
| 'global';
|
||||
|
||||
/**
|
||||
* Elements that own particular keys themselves.
|
||||
*
|
||||
* The global bindings are unmodified single keys (Space, arrows, letters)
|
||||
* — see Decision 1 in plan 007 — so without this a focused button cannot
|
||||
* be activated with Space and a `<select>` cannot be arrowed through,
|
||||
* because the service `preventDefault()`s every match. Only text inputs
|
||||
* were exempt, which is finding H-6.
|
||||
*/
|
||||
const ACTIVATION_KEYS = new Set(['Space', 'Enter']);
|
||||
const ARROW_KEYS = new Set(['Up', 'Down', 'Left', 'Right', 'Home', 'End']);
|
||||
const LIST_KEYS = new Set([...ARROW_KEYS, ...ACTIVATION_KEYS]);
|
||||
const SLIDER_KEYS = new Set([...ARROW_KEYS, 'PageUp', 'PageDown']);
|
||||
|
||||
/** Roles/tags that consume a key, and which keys they consume. */
|
||||
function keysOwnedBy(el: Element): ReadonlySet<string> | null {
|
||||
const tag = el.tagName.toUpperCase();
|
||||
const role = el.getAttribute('role')?.toLowerCase() ?? '';
|
||||
|
||||
if (tag === 'SELECT' || role === 'listbox' || role === 'combobox') {
|
||||
return LIST_KEYS;
|
||||
}
|
||||
|
||||
if (role === 'menu' || role === 'menuitem' || role === 'menubar') {
|
||||
return LIST_KEYS;
|
||||
}
|
||||
|
||||
// A grid row or a listbox option moves with the arrow keys, which is
|
||||
// what makes a track list navigable without a mouse.
|
||||
if (
|
||||
role === 'row' ||
|
||||
role === 'gridcell' ||
|
||||
role === 'grid' ||
|
||||
role === 'option' ||
|
||||
role === 'treeitem'
|
||||
) {
|
||||
return ARROW_KEYS;
|
||||
}
|
||||
|
||||
if (tag === 'INPUT') {
|
||||
const type = (el as HTMLInputElement).type.toLowerCase();
|
||||
|
||||
if (type === 'range') return SLIDER_KEYS;
|
||||
if (type === 'radio') return LIST_KEYS;
|
||||
if (type === 'checkbox') return ACTIVATION_KEYS;
|
||||
}
|
||||
|
||||
if (role === 'slider' || tag === 'WA-SLIDER') return SLIDER_KEYS;
|
||||
|
||||
if (
|
||||
role === 'checkbox' ||
|
||||
role === 'switch' ||
|
||||
role === 'radio' ||
|
||||
tag === 'WA-CHECKBOX' ||
|
||||
tag === 'WA-SWITCH'
|
||||
) {
|
||||
return ACTIVATION_KEYS;
|
||||
}
|
||||
|
||||
if (
|
||||
tag === 'BUTTON' ||
|
||||
tag === 'SUMMARY' ||
|
||||
tag === 'WA-BUTTON' ||
|
||||
role === 'button' ||
|
||||
(tag === 'A' && el.hasAttribute('href'))
|
||||
) {
|
||||
return ACTIVATION_KEYS;
|
||||
}
|
||||
|
||||
if (tag === 'WA-SELECT') return LIST_KEYS;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether an open dialog contains the focused element. A dialog owns
|
||||
* the whole keyboard while it is up. */
|
||||
function insideOpenDialog(el: Element | null): boolean {
|
||||
let walker: Element | null = el;
|
||||
|
||||
while (walker) {
|
||||
const role = walker.getAttribute?.('role')?.toLowerCase();
|
||||
const tag = walker.tagName.toUpperCase();
|
||||
|
||||
if (
|
||||
role === 'dialog' ||
|
||||
role === 'alertdialog' ||
|
||||
((tag === 'DIALOG' || tag === 'WA-DIALOG' || tag === 'WA-DRAWER') &&
|
||||
walker.hasAttribute('open'))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parent =
|
||||
walker.parentElement ??
|
||||
((walker.getRootNode() as ShadowRoot).host ?? null);
|
||||
|
||||
if (parent === walker) break;
|
||||
|
||||
walker = parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the focused control, rather than the app, should get this key.
|
||||
*/
|
||||
function focusedControlOwnsKey(
|
||||
el: Element | null,
|
||||
keyStr: string,
|
||||
): boolean {
|
||||
if (!el) return false;
|
||||
|
||||
// Modified combinations (Ctrl+F, Ctrl+A) are the app's; only the
|
||||
// unmodified keys are ever contested.
|
||||
if (keyStr.includes('+')) return false;
|
||||
|
||||
if (insideOpenDialog(el)) return true;
|
||||
|
||||
return keysOwnedBy(el)?.has(keyStr) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current shortcut scope based on the focused element.
|
||||
*
|
||||
@@ -159,7 +282,13 @@ function resolveScope(deepEl: Element | null): ShortcutScope {
|
||||
walker = parent ?? null;
|
||||
}
|
||||
|
||||
return 'global';
|
||||
// Nothing focused inside a panel. Fall back to the panel the active
|
||||
// view claimed, if any: this app is driven with the mouse, so focus
|
||||
// usually sits on <body> and a focus-only rule would make panel
|
||||
// bindings work only after a click landed inside the panel.
|
||||
const ambient = ambientShortcutScope();
|
||||
|
||||
return ambient ? (`panel:${ambient}` as ShortcutScope) : 'global';
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
@@ -288,6 +417,23 @@ async function dispatch(action: string): Promise<void> {
|
||||
);
|
||||
break;
|
||||
|
||||
// Panel-specific: autotag review. The view listens for these
|
||||
// while it is the view on screen, and for nothing while it is
|
||||
// not — which is the whole of finding H-1.
|
||||
case 'autotag.apply':
|
||||
case 'autotag.skip':
|
||||
case 'autotag.leave':
|
||||
case 'autotag.paste':
|
||||
case 'autotag.search':
|
||||
case 'autotag.next':
|
||||
case 'autotag.previous':
|
||||
document.dispatchEvent(
|
||||
new CustomEvent(
|
||||
`shortcut:autotag-${action.slice('autotag.'.length)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown action — silently ignore.
|
||||
break;
|
||||
@@ -335,6 +481,10 @@ class KeyboardShortcutService {
|
||||
|
||||
if (!action) return;
|
||||
|
||||
// A focused control that owns this key keeps it: Space activates
|
||||
// the button you tabbed to, arrows move the select you opened.
|
||||
if (focusedControlOwnsKey(deepEl, keyStr)) return;
|
||||
|
||||
// Found a match — prevent default and dispatch.
|
||||
e.preventDefault();
|
||||
void dispatch(action);
|
||||
|
||||
@@ -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