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; } /* A host that has made room owns the box, not just the labels (#71). The bottom-nav sheet is the width of the screen and provides the one scroll container it needs; left to itself the sidebar is a 200px column with a second scroller inside it, which is what a nested scroll region feels like under a thumb -- part of the surface moves and part of it does not. */ :host([expanded]) { max-width: none; height: auto; overflow: visible; } /* And the width is not draggable there. It is a mouse affordance (mousedown, col-resize) sitting on the right edge of a touch surface, where the compatibility mouse events a tap synthesises can start a resize nobody asked for. */ :host([expanded]) .resize-handle { display: none; } .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 `; })} `; } 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() { // A host that made room decides how much: `bottom-nav`'s sheet // is the whole screen wide, and the inline width below -- which // beats any rule the host could write -- would draw the old // 200px side drawer inside it. if (this.expanded) { this.style.width = '100%'; this.collapsed = false; return; } const narrow = 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 = new Set(['playlists']); private onDragActive = ( e: CustomEvent, ) => { 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; } }