`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
476 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|