Files
yellowjacket/frontend/src/components/sidebar/app-sidebar.ts
T
logan 43d78a731a feat(shell): draw only the destinations the user kept
The navigation reads the resolved map from the backend rather than
holding a copy of the defaults, which would be the copy that shipped in
the binary rather than the one being edited.

Hiding takes away the nav item and nothing else: `navigate` still
resolves a hidden view, which detail views and the launch page depend
on. No special case was needed for the highlight, because #72 moved
that onto `active-view-store` -- the sidebar asks `isActive(id)` per
*rendered* item, so a hidden view lights nothing exactly as a detail
view does.

Downloads is gated at the nav on `downloadStore.available` rather than
in the config, so switching it on in Settings still means what it says
once a client exists, and the tab appears without a restart. `available`
is false until the providers have loaded, which makes the item appear on
a fresh launch rather than appearing and then vanishing.

The tab bar honours the toggles too, and the reason is local rather than
a general rule about phones: "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's subset; this only removes
from it, and "More" is never filtered.

`services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s
pattern, because Settings is now a second reader of the same labels in
the same order.

Two existing sidebar tests had to say which world they describe: eleven
destinations now assumes a configured download client.
2026-08-19 19:33:44 -04:00

459 lines
14 KiB
TypeScript

import { LitElement, html, css } from 'lit';
import { customElement, state, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
import { ActiveViewController } from '@store/controllers/active-view-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
import { VIEW_META } from '../../services/view-meta';
import type { View } from '../../services/view-meta';
const MIN_WIDTH = 56;
const MAX_WIDTH = 400;
const DEFAULT_WIDTH = 200;
const COLLAPSE_WIDTH = 142;
/**
* Below this viewport width the sidebar collapses itself to icons.
* `.collapsed` existed and only a manual drag ever reached it (H-11),
* so a small window kept a 200 px sidebar it could not afford and the
* content pane wore the whole loss.
*/
const AUTO_COLLAPSE_VIEWPORT = 900;
@customElement('app-sidebar')
export class AppSidebar extends LitElement {
static override styles = [designTokens, css`
:host {
display: block;
position: relative;
height: 100%;
background-color: var(--yj-bg-surface, #212529);
min-width: ${MIN_WIDTH}px;
max-width: ${MAX_WIDTH}px;
/* Eleven items need ~406 px and the pane is whatever the
window leaves it — 352 px at 700x480, which clipped Jobs
and Settings behind the player bar with no way to reach
them (H-11). Collapsing to icons does not help: it is a
width mode, and this is the height. */
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.resize-handle {
position: absolute;
top: 0;
right: 0;
width: 4px;
height: 100%;
cursor: col-resize;
background-color: transparent;
transition: background-color 0.15s ease;
z-index: 10;
}
.resize-handle:hover,
.resize-handle.dragging {
background-color: var(--yj-text-tertiary, #6c757d);
}
ul {
list-style-type: none;
margin: 0;
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 button wa-icon {
font-size: var(--yj-icon-md);
flex-shrink: 0;
width: 20px;
text-align: center;
}
li button:hover {
background-color: var(--yj-bg-elevated, #343a40);
}
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 button p {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
li button.drag-hover {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
outline: 1px dashed var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
li button p {
font-size: var(--yj-text-md);
}
/* Icon-only collapsed mode */
:host(.collapsed) ul {
padding: 8px;
}
:host(.collapsed) li button {
justify-content: center;
padding: 10px;
}
:host(.collapsed) li button p {
display: none;
}
:host(.collapsed) li button wa-icon {
font-size: var(--yj-icon-md);
}
`];
/** Delay in ms before a drag-hover triggers navigation. */
private static readonly HOVER_NAV_DELAY = 600;
/**
* Which item is lit, read from the shell rather than tracked here.
*
* This used to be a `@state()` field defaulting to `home` -- the
* landing view -- because "the sidebar does not hear a `navigate`
* it did not send". That default was the only honest moment it
* ever had: a back-navigation dispatches no `navigate`, so the
* highlight stayed on the view the user had just left (#72), and
* the copy of this component that `bottom-nav` mounts inside its
* drawer opened on `home` from whatever page you were standing on.
* The shell publishes the active view now, so there is nothing to
* default and nothing to keep in step.
*/
private activeCtrl = new ActiveViewController(this);
@state()
private isDragging = false;
@state()
private collapsed = false;
/**
* Keep the labels regardless of the viewport, for a host that has
* made room for them -- `bottom-nav`'s drawer, which is the whole
* screen wide on the phone where this would otherwise auto-collapse
* to icons. The auto-collapse is a *width* response to a narrow
* shell, and inside a drawer the shell is not what the sidebar is
* sharing space with.
*/
@property({ type: Boolean, reflect: true })
expanded = false;
/** The width the user chose, restored when the window grows back. */
private userWidth = DEFAULT_WIDTH;
private narrowViewport: MediaQueryList | null =
null;
/** Whether a track drag is in progress somewhere in the app. */
@state()
private trackDragActive = false;
/** The nav item ID being hovered during a drag. */
@state()
private dragHoverView: View | null = null;
private dragHoverTimer: ReturnType<
typeof setTimeout
> | null = null;
/**
* Which destinations the user has kept (#25). The list below is
* still the whole set and its order -- this only filters it, and
* only for drawing: a hidden view is still reachable by `navigate`,
* which is what detail views and the launch page depend on.
*/
private visibilityCtrl = new ViewVisibilityController(this);
private navItems = VIEW_META;
override connectedCallback() {
super.connectedCallback();
this.style.width = `${DEFAULT_WIDTH}px`;
this.narrowViewport = window.matchMedia(
`(max-width: ${AUTO_COLLAPSE_VIEWPORT - 1}px)`,
);
this.narrowViewport.addEventListener(
'change',
this.onViewportChange,
);
this.applyViewportWidth();
document.addEventListener(
'mousemove',
this.handleMouseMove,
);
document.addEventListener(
'mouseup',
this.handleMouseUp,
);
document.addEventListener(
'yj-drag-active',
this.onDragActive as EventListener,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.narrowViewport?.removeEventListener(
'change',
this.onViewportChange,
);
this.narrowViewport = null;
document.removeEventListener(
'mousemove',
this.handleMouseMove,
);
document.removeEventListener(
'mouseup',
this.handleMouseUp,
);
document.removeEventListener(
'yj-drag-active',
this.onDragActive as EventListener,
);
this.clearDragHoverTimer();
}
override updated() {
this.classList.toggle('collapsed', this.collapsed);
}
override render() {
return html`
<div
class="resize-handle ${this.isDragging ? 'dragging' : ''}"
@mousedown=${this.handleMouseDown}
></div>
<nav aria-label="Main">
<ul>
${this.navItems
.filter((item) => this.visibilityCtrl.visible(item.id))
.map((item) => {
const active = this.activeCtrl.isActive(item.id);
const classes = [
active
? 'active'
: '',
this.dragHoverView === item.id
? 'drag-hover'
: '',
]
.filter(Boolean)
.join(' ');
return html`
<li>
<button
type="button"
class=${classes}
data-testid="nav-${item.id}"
aria-current=${active
? 'page'
: 'false'}
@click=${() =>
this.navigate(item.id)}
@dragover=${(e: DragEvent) =>
this.onNavDragOver(
e,
item.id,
)}
@dragleave=${() =>
this.onNavDragLeave(
item.id,
)}
@drop=${(e: DragEvent) =>
this.onNavDrop(e)}
>
<wa-icon
name=${item.icon}
></wa-icon>
<p>${item.label}</p>
</button>
</li>
`;
})}
</ul>
</nav>
`;
}
private handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
this.isDragging = true;
};
private handleMouseMove = (e: MouseEvent) => {
if (!this.isDragging) return;
const rect = this.getBoundingClientRect();
const newWidth = e.clientX - rect.left;
const clampedWidth = Math.min(
Math.max(newWidth, MIN_WIDTH),
MAX_WIDTH,
);
this.style.width = `${clampedWidth}px`;
this.collapsed = clampedWidth < COLLAPSE_WIDTH;
this.userWidth = clampedWidth;
};
private onViewportChange = () => {
this.applyViewportWidth();
};
/**
* Icons below the breakpoint, the user's own width above it. The
* width is inline (set here and by the drag handle), so this cannot
* be a media query in the stylesheet.
*/
private applyViewportWidth() {
const narrow =
!this.expanded &&
(this.narrowViewport?.matches ?? false);
const width = narrow
? MIN_WIDTH
: this.userWidth;
this.style.width = `${width}px`;
this.collapsed = width < COLLAPSE_WIDTH;
}
private handleMouseUp = () => {
this.isDragging = false;
};
// =================================================================
// Drag-hover navigation
// =================================================================
/** Views that accept track drops. */
private static readonly DROP_VIEWS: Set<View> =
new Set(['playlists']);
private onDragActive = (
e: CustomEvent<DragActiveDetail>,
) => {
this.trackDragActive = e.detail.active;
if (!e.detail.active) {
this.clearDragHoverTimer();
this.dragHoverView = null;
}
};
private onNavDragOver = (
e: DragEvent,
view: View,
) => {
if (!this.trackDragActive) return;
if (!AppSidebar.DROP_VIEWS.has(view)) return;
// Prevent default so that `drop` can fire.
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
// Already hovering this item — no-op.
if (this.dragHoverView === view) return;
this.clearDragHoverTimer();
this.dragHoverView = view;
this.dragHoverTimer = setTimeout(() => {
this.dragHoverTimer = null;
if (this.dragHoverView === view) {
this.navigate(view);
}
}, AppSidebar.HOVER_NAV_DELAY);
};
private onNavDragLeave = (view: View) => {
if (this.dragHoverView !== view) return;
this.clearDragHoverTimer();
this.dragHoverView = null;
};
private onNavDrop = (e: DragEvent) => {
// The drop target is the playlist-view, not
// the sidebar itself — just prevent the
// default browser action.
e.preventDefault();
this.clearDragHoverTimer();
this.dragHoverView = null;
};
private clearDragHoverTimer() {
if (this.dragHoverTimer !== null) {
clearTimeout(this.dragHoverTimer);
this.dragHoverTimer = null;
}
}
private navigate(view: View) {
// No optimistic highlight: the shell answers, and it answers
// synchronously in `handleNavigate` before it awaits anything.
// Setting it here as well is the second opinion this fix
// removes -- it is what let a click's highlight survive a
// navigation the shell then handled differently.
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
composed: true,
}));
}
}
declare global {
interface HTMLElementTagNameMap {
'app-sidebar': AppSidebar;
}
}