Files
yellowjacket/frontend/src/components/sidebar/app-sidebar.ts
T
logan 89882b4863
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m42s
refactor(ui): give the icons one vocabulary and sweep the call sites
`plus` meant "add to the queue", "add to a playlist", "make a new
playlist" and "you do not own this" -- the first two adjacent in the
same context menu, so two neighbouring items were the same glyph doing
different things. `list` meant the queue (the button that opens it), the
Playlists destination, and adding to the queue in `queue-panel` alone.
Two icons carrying seven meanings is not a vocabulary, and nothing
catches it: a wrong-but-real icon renders perfectly.

`utils/icon-language.ts` is the table, beside `library-status.ts` as the
issue suggested. The rule it is built on is that an icon names the
**noun** it acts on, not the verb: "add to queue" and "add to playlist"
are one verb on two nouns, so the noun is what differs -- which is why
adding to a playlist wears the Playlists destination's own icon, and why
the queue took `bars-staggered` and stopped wearing Playlists'. `plus`
keeps the one meaning it is unambiguous about, making something that is
not there yet, which covers New Playlist and the drop zones.

`bars-staggered` is the only new glyph, vendored through names.txt and
fetch-icons.mjs after confirming it is in Font Awesome **Free** 7.3.1.

Two things this found rather than changed:

- The request toggle's outline/solid pair was already in the app and
  already right -- `explore-album-details`'s "Request this" button has
  used `regular/bookmark` -> `solid/bookmark` since it was written --
  while the badge forty pixels away showed a **plus** for the same
  state. That is `utils/library-status.ts`'s fault one layer down: it
  made the two surfaces agree on what wanting *means* and left them
  disagreeing on what it looks like.
- `explore-artist-details`'s Follow button was `bookmark-check`, which
  is Font Awesome **Pro** and has never been bundled, so it has drawn
  the missing-icon fallback -- a circled question mark -- for every
  followed artist since it was written. `requested-badge.spec.ts` was
  written for exactly this bug on the album button and says so in its
  docstring; this is the same bug one component over, still live,
  because `offline-icons.spec.ts` sweeps `__yjIconMisses` and no spec
  had ever followed an artist.

So the test does what reaching the state cannot. `icon-language.test.ts`
reads every `src/**/*.ts` as raw text and fails on a governed name
written outside the table, and separately asserts every `ICON_*` is a
*bundled* name -- which is what makes a Pro name a failing test rather
than a runtime report from a state something has to reach first. Its
first assertion is that it read any source at all, because a sweep over
an empty glob passes.

`chrome.test.ts` asserted `['check', 'bookmark', 'plus']` and so pinned
the badge's glyphs against the vocabulary they were meant to follow; it
names them from the table now, and keeps the assertion that the three
differ, which is the property the states actually need.

Downloads keeps the solid bookmark on purpose. That is one word twice,
not two words: the badge says the entity is on your list and the nav
item is that list.

Closes #34
2026-08-18 21:18:36 -04:00

476 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 {
ICON_PLAYLIST,
ICON_REQUESTED,
} from '@utils/icon-language';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
interface NavItem {
id: View;
label: string;
icon: string;
}
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;
/** Home, because that is where `index.ts` now navigates on startup
* (H-8). The sidebar does not hear a `navigate` it did not send,
* so this default is what keeps `aria-current` honest on arrival. */
@state()
private activeView: View = 'home';
@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;
private navItems: NavItem[] = [
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
{ id: 'artists', label: 'Artists', icon: 'user-group' },
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
];
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,
);
document.addEventListener(
'navigate',
this.onGlobalNavigate 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,
);
document.removeEventListener(
'navigate',
this.onGlobalNavigate 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.map((item) => {
const classes = [
this.activeView === item.id
? '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=${this.activeView === item.id
? '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']);
/** Keeps the highlighted nav item in sync with navigation that
* originates outside the sidebar itself (e.g. the launch-page
* dispatch in index.ts). */
private onGlobalNavigate = (
e: CustomEvent<{ view?: string }>,
) => {
const view = e.detail.view;
if (view && this.navItems.some((item) => item.id === view)) {
this.activeView = view as View;
}
};
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) {
this.activeView = view;
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
composed: true,
}));
}
}
declare global {
interface HTMLElementTagNameMap {
'app-sidebar': AppSidebar;
}
}