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
+45 -22
View File
@@ -53,39 +53,57 @@ export class AppSidebar extends LitElement {
padding: 16px;
}
/* The nav item is a real <button>: it was a bare <li @click>,
which is why tabbing through the whole app reached fourteen
controls and not one of them was navigation (H-5). */
li {
display: block;
}
li button {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
border: none;
border-radius: 5px;
padding: 8px;
cursor: pointer;
background: none;
color: inherit;
font: inherit;
text-align: left;
transition: background-color 0.15s ease;
}
li wa-icon {
li button wa-icon {
font-size: var(--yj-icon-md);
flex-shrink: 0;
width: 20px;
text-align: center;
}
li:hover {
li button:hover {
background-color: var(--yj-bg-elevated, #343a40);
}
li.active {
li button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
}
li button.active {
background-color: var(--yj-bg-overlay, #495057);
}
li p {
li button p {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
li.drag-hover {
li button.drag-hover {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
@@ -94,7 +112,7 @@ export class AppSidebar extends LitElement {
outline-offset: -1px;
}
li p {
li button p {
font-size: var(--yj-text-md);
}
@@ -103,16 +121,16 @@ export class AppSidebar extends LitElement {
padding: 8px;
}
:host(.collapsed) li {
:host(.collapsed) li button {
justify-content: center;
padding: 10px;
}
:host(.collapsed) li p {
:host(.collapsed) li button p {
display: none;
}
:host(.collapsed) li wa-icon {
:host(.collapsed) li button wa-icon {
font-size: var(--yj-icon-md);
}
`];
@@ -199,6 +217,7 @@ export class AppSidebar extends LitElement {
class="resize-handle ${this.isDragging ? 'dragging' : ''}"
@mousedown=${this.handleMouseDown}
></div>
<nav aria-label="Main">
<ul>
${this.navItems.map((item) => {
const classes = [
@@ -213,34 +232,38 @@ export class AppSidebar extends LitElement {
.join(' ');
return html`
<li
class=${classes}
data-testid="nav-${item.id}"
aria-current=${this.activeView === item.id
<li>
<button
type="button"
class=${classes}
data-testid="nav-${item.id}"
aria-current=${this.activeView === item.id
? 'page'
: 'false'}
@click=${() =>
@click=${() =>
this.navigate(item.id)}
@dragover=${(e: DragEvent) =>
@dragover=${(e: DragEvent) =>
this.onNavDragOver(
e,
item.id,
)}
@dragleave=${() =>
@dragleave=${() =>
this.onNavDragLeave(
item.id,
)}
@drop=${(e: DragEvent) =>
@drop=${(e: DragEvent) =>
this.onNavDrop(e)}
>
<wa-icon
name=${item.icon}
></wa-icon>
<p>${item.label}</p>
>
<wa-icon
name=${item.icon}
></wa-icon>
<p>${item.label}</p>
</button>
</li>
`;
})}
</ul>
</nav>
`;
}