/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; import NileElement from '../internal/nile-element'; import { styles } from './nile-minimap.css'; export type NileMinimapOrientation = 'horizontal' | 'vertical' | 'auto'; export type NileMinimapMode = 'segments' | 'track'; export type NileMinimapAxis = 'horizontal' | 'vertical'; export type NileMinimapSource = 'drag' | 'click' | 'wheel' | 'keyboard'; /** Formats the `aria-valuetext` in segments mode. */ export type NileMinimapValueTextFormatter = ( first: number, last: number, total: number ) => string; interface AxisMap { len: 'clientWidth' | 'clientHeight'; size: 'scrollWidth' | 'scrollHeight'; pos: 'scrollLeft' | 'scrollTop'; border: 'clientLeft' | 'clientTop'; rectStart: 'left' | 'top'; rectExt: 'width' | 'height'; client: 'clientX' | 'clientY'; scrollKey: 'left' | 'top'; } const AXES: Record = { horizontal: { len: 'clientWidth', size: 'scrollWidth', pos: 'scrollLeft', border: 'clientLeft', rectStart: 'left', rectExt: 'width', client: 'clientX', scrollKey: 'left', }, vertical: { len: 'clientHeight', size: 'scrollHeight', pos: 'scrollTop', border: 'clientTop', rectStart: 'top', rectExt: 'height', client: 'clientY', scrollKey: 'top', }, }; const MIN_THUMB = 14; const MIN_SEG = 3; const MAX_ITEMS = 48; interface SegGeo { start: number; len: number; } const defaultValueText: NileMinimapValueTextFormatter = ( first, last, total ) => { if (first === last) return `Item ${first} of ${total}`; return `Items ${first}–${last} of ${total}`; }; /** * A small overview of any scrolling container. It mirrors the container's * scroll position and lets people drag, click, wheel or key to another part of * it. One axis at a time: horizontal for boards and wide tables, vertical for * long documents and lists. It adds to the native scrollbar; it never replaces * it. * * NxtGen theme only: styles use `--ng-*` tokens with no legacy `--nile-*` * fallbacks, so the page must load the NxtGen theme (e.g. `nxtgen.css`). * * @tag nile-minimap * @summary Scroll overview that attaches to a scroll container by id and lets people navigate it. * * @event nile-navigate - Fired before the minimap scrolls the container. Cancelable: `preventDefault()` skips the scroll. Detail: { position: number, ratio: number, source: 'drag' | 'click' | 'wheel' | 'keyboard', orientation: 'horizontal' | 'vertical' } * @event nile-overflow-change - Fired once on first measure, then whenever overflow on the active axis starts or stops. Detail: { overflowing: boolean, orientation: 'horizontal' | 'vertical' } * * @csspart base - The container surface. * @csspart track - The scaled area; drawn as a rail in track mode. * @csspart segment - One block per item matched by `itemSelector`. * @csspart viewport - The focusable, draggable thumb (`role="scrollbar"`). * * @cssproperty --nile-minimap-width - Width of the minimap (112px). Swaps with height in vertical mode. * @cssproperty --nile-minimap-height - Height of the minimap (40px). Swaps with width in vertical mode. */ @customElement('nile-minimap') export class NileMinimap extends NileElement { static styles: CSSResultGroup = styles; /** Id of the scroll container, resolved in the minimap's root node. The only way to attach. */ @property({ reflect: true }) for = ''; /** Which axis the minimap represents. `auto` picks the overflowing axis (horizontal first). */ @property({ reflect: true }) orientation: NileMinimapOrientation = 'auto'; /** `segments` draws one block per item; `track` draws a single rail. */ @property({ reflect: true }) mode: NileMinimapMode = 'segments'; /** Selector queried on the container to find the segment items. */ @property() itemSelector = ':scope > *'; /** Hides the host while nothing overflows. */ @property({ type: Boolean, reflect: true }) hideWhenFits = true; /** Smooth scroll for click and keyboard. Always off under reduced motion. */ @property({ type: Boolean }) smooth = true; /** Still mirrors scrolling, but can't be used. */ @property({ type: Boolean, reflect: true }) disabled = false; /** Accessible name on the viewport. */ @property() label = 'Scroll position'; /** * Keeps the minimap hidden until the container scrolls, then fades it out * after `hideDelay` ms without scrolling. It stays visible while it is * hovered, focused or being dragged. Hidden, it keeps its space and ignores * the pointer. */ @property({ type: Boolean, reflect: true }) autoHide = false; /** With `autoHide`, milliseconds without scrolling before the minimap fades out. */ @property({ type: Number }) hideDelay = 1200; /** Builds the `aria-valuetext` in segments mode. */ @property({ attribute: false }) formatValueText: NileMinimapValueTextFormatter = defaultValueText; @query('.base') private base!: HTMLDivElement; @query('.track') private trackEl!: HTMLDivElement; @query('.segments') private segsEl!: HTMLDivElement; @query('.viewport') private viewport!: HTMLDivElement; @state() private resolvedDir: NileMinimapAxis = 'horizontal'; private target: HTMLElement | null = null; private axis: AxisMap = AXES.horizontal; private geo: SegGeo[] = []; private observed = new Set(); private overflowing: boolean | null = null; private segOn = false; private rtl = false; private raf = 0; private full = false; private pending: number | null = null; private pendingAt = 0; private retried = false; private retryRaf = 0; // Live geometry cache used by the pointer/keyboard handlers. private contentSize = 0; private clientSize = 0; private maxScroll = 0; private trackLen = 0; private vpLen = 0; private ro: ResizeObserver | null = null; private mo: MutationObserver | null = null; private onScroll = () => { this.schedule(false); this.reveal(); }; // autoHide state: the minimap stays visible while any of these hold. private hideTimer = 0; private hovered = false; private focused = false; private onPointerEnter = () => { this.hovered = true; }; private onPointerLeave = () => { this.hovered = false; this.queueHide(); }; private onFocusIn = () => { this.focused = true; this.reveal(); }; private onFocusOut = () => { this.focused = false; this.queueHide(); }; // Drag state. private drag: { pointer: number; start: number } | null = null; private dragRaf = 0; private lastMove: PointerEvent | null = null; private reduceMotion = matchMedia('(prefers-reduced-motion: reduce)'); connectedCallback(): void { super.connectedCallback(); this.addEventListener('pointerenter', this.onPointerEnter); this.addEventListener('pointerleave', this.onPointerLeave); this.addEventListener('focusin', this.onFocusIn); this.addEventListener('focusout', this.onFocusOut); if (this.hasUpdated) { this.attach(); } } disconnectedCallback(): void { this.detach(); this.removeEventListener('pointerenter', this.onPointerEnter); this.removeEventListener('pointerleave', this.onPointerLeave); this.removeEventListener('focusin', this.onFocusIn); this.removeEventListener('focusout', this.onFocusOut); clearTimeout(this.hideTimer); this.hideTimer = 0; super.disconnectedCallback(); } firstUpdated(): void { this.bindInteractions(); this.attach(); } updated(changed: PropertyValues): void { if (changed.has('autoHide')) { clearTimeout(this.hideTimer); this.hideTimer = 0; this.toggleAttribute('data-idle', this.autoHide && !this.held); } if (changed.has('for')) { this.attach(); return; } if ( changed.has('orientation') || changed.has('mode') || changed.has('itemSelector') || changed.has('hideWhenFits') || changed.has('disabled') || changed.has('label') ) { this.schedule(true); } } render(): TemplateResult { return html`
`; } // --- autoHide ------------------------------------------------------------- private get held(): boolean { return this.hovered || this.focused || this.drag !== null; } /** Shows an auto-hidden minimap and restarts the countdown to hiding it. */ private reveal(): void { if (!this.autoHide) return; this.removeAttribute('data-idle'); this.queueHide(); } private queueHide(): void { clearTimeout(this.hideTimer); this.hideTimer = 0; if (!this.autoHide) return; this.hideTimer = window.setTimeout(() => { this.hideTimer = 0; if (this.autoHide && !this.held) this.setAttribute('data-idle', ''); }, Math.max(0, this.hideDelay)); } // --- attach / detach ------------------------------------------------------ private resolveTarget(): HTMLElement | null { if (!this.for) return null; const root = this.getRootNode() as Document | ShadowRoot; if (root instanceof Document) { return root.getElementById(this.for); } const shadow = root as ShadowRoot; if (typeof shadow.getElementById === 'function') { return shadow.getElementById(this.for) as HTMLElement | null; } return shadow.querySelector(`#${CSS.escape(this.for)}`); } private attach(): void { this.detach(); const el = this.resolveTarget(); this.toggleAttribute('data-unresolved', !el); if (!el) { if (!this.retried && this.for) { this.retried = true; this.retryRaf = requestAnimationFrame(() => { this.retryRaf = 0; this.attach(); }); } else if (this.for) { this.retried = false; // eslint-disable-next-line no-console console.warn( `nile-minimap: no element with id "${this.for}" found in the minimap's root node.` ); } return; } this.retried = false; this.target = el; this.ro = new ResizeObserver(() => this.schedule(true)); this.mo = new MutationObserver(() => this.schedule(true)); this.ro.observe(el); this.ro.observe(this); this.mo.observe(el, { childList: true, subtree: true }); el.addEventListener('scroll', this.onScroll, { passive: true }); // Defer the first measure out of the Lit update lifecycle: attach() runs // synchronously from firstUpdated()/updated(), and layout() sets @state // (resolvedDir) and calls requestUpdate(), which otherwise triggers the // dev-mode "scheduled an update after an update completed" warning. this.schedule(true); } private detach(): void { if (this.raf) { cancelAnimationFrame(this.raf); this.raf = 0; } if (this.retryRaf) { cancelAnimationFrame(this.retryRaf); this.retryRaf = 0; } if (this.dragRaf) { cancelAnimationFrame(this.dragRaf); this.dragRaf = 0; } this.ro?.disconnect(); this.mo?.disconnect(); this.ro = null; this.mo = null; this.observed.clear(); if (this.target) { this.target.removeEventListener('scroll', this.onScroll); } this.target = null; this.drag = null; this.full = false; } // --- scheduling ----------------------------------------------------------- private schedule(full: boolean): void { if (full) this.full = true; if (this.raf) return; this.raf = requestAnimationFrame(() => { this.raf = 0; // The held element may have been re-rendered (e.g. *ngIf): re-resolve. if (this.target && !this.target.isConnected) { this.attach(); return; } const needsLayout = this.full; this.full = false; if (needsLayout) this.layout(); else this.refresh(); }); } // --- measurement ---------------------------------------------------------- private layout(): void { const t = this.target; if (!t) return; let dir: NileMinimapAxis; if (this.orientation === 'auto') { const vOver = t.scrollHeight - t.clientHeight > 1; const hOver = t.scrollWidth - t.clientWidth > 1; dir = !hOver && vOver ? 'vertical' : 'horizontal'; } else { dir = this.orientation; } this.resolvedDir = dir; this.axis = AXES[dir]; this.rtl = dir === 'horizontal' && getComputedStyle(t).direction === 'rtl'; const a = this.axis; const el = t as unknown as Record; const size = el[a.size]; const client = el[a.len]; const over = size - client > 1; if (over !== this.overflowing) { this.overflowing = over; this.emit( 'nile-overflow-change', { overflowing: over, orientation: dir }, true, true, false ); } // hideWhenFits toggles an internal attribute; never the consumer's [hidden]. if (this.hideWhenFits && !over) { this.setAttribute('data-fit', ''); } else { this.removeAttribute('data-fit'); } if (dir === 'vertical') this.setAttribute('data-vertical', ''); else this.removeAttribute('data-vertical'); const items = this.mode === 'segments' ? Array.from(t.querySelectorAll(this.itemSelector)) : []; // Keep the per-item ResizeObserver set in sync without triggering loops. for (const old of Array.from(this.observed)) { if (!items.includes(old as HTMLElement)) { this.ro?.unobserve(old); this.observed.delete(old); } } for (const it of items) { if (!this.observed.has(it)) { this.ro?.observe(it); this.observed.add(it); } } const tr = t.getBoundingClientRect() as unknown as Record; const scrollPos = el[a.pos]; this.geo = items.map(it => { const r = it.getBoundingClientRect() as unknown as Record; return { start: r[a.rectStart] - tr[a.rectStart] - el[a.border] + scrollPos, len: r[a.rectExt], }; }); const trackLen = (this.trackEl as unknown as Record)[a.len]; const scale = size ? trackLen / size : 0; const dense = this.geo.length > MAX_ITEMS || this.geo.some(g => g.len * scale < MIN_SEG); this.segOn = this.mode === 'segments' && this.geo.length > 0 && !dense; this.renderSegments(scale); this.refresh(); } private renderSegments(scale: number): void { const segs = this.segsEl; if (!segs) return; if (!this.segOn) { segs.textContent = ''; this.requestUpdate(); return; } while (segs.children.length < this.geo.length) { const d = document.createElement('div'); d.className = 'segment'; d.setAttribute('part', 'segment'); segs.appendChild(d); } while (segs.children.length > this.geo.length) { segs.lastChild?.remove(); } this.geo.forEach((g, i) => { const s = (segs.children[i] as HTMLElement).style; s.setProperty('--s', `${(g.start * scale).toFixed(2)}px`); s.setProperty('--l', `${(g.len * scale).toFixed(2)}px`); }); this.requestUpdate(); } private refresh(): void { const t = this.target; if (!t) return; const a = this.axis; const el = t as unknown as Record; const size = el[a.size]; const client = el[a.len]; const maxScroll = Math.max(0, size - client); const trackLen = (this.trackEl as unknown as Record)[a.len]; let vpLen = size ? Math.max(MIN_THUMB, (trackLen * client) / size) : trackLen; vpLen = Math.min(vpLen, trackLen); const pos = Math.abs(el[a.pos]); const ratio = maxScroll ? Math.min(1, Math.max(0, pos / maxScroll)) : 0; const vpStart = ratio * (trackLen - vpLen); this.contentSize = size; this.clientSize = client; this.maxScroll = maxScroll; this.trackLen = trackLen; this.vpLen = vpLen; const vp = this.viewport; if (vp) { vp.style.setProperty('--vs', `${vpStart.toFixed(2)}px`); vp.style.setProperty('--vl', `${vpLen.toFixed(2)}px`); vp.setAttribute('aria-orientation', this.resolvedDir); vp.setAttribute('aria-label', this.label); vp.setAttribute('aria-controls', this.for); vp.setAttribute('aria-valuenow', String(Math.round(ratio * 100))); if (this.disabled) vp.setAttribute('aria-disabled', 'true'); else vp.removeAttribute('aria-disabled'); let valueText = ''; if (this.segOn) { const visible: number[] = []; this.geo.forEach((g, i) => { if (g.start + g.len > pos + 1 && g.start < pos + client - 1) { visible.push(i + 1); } }); if (visible.length) { valueText = this.formatValueText( visible[0], visible[visible.length - 1], this.geo.length ); } } if (valueText) vp.setAttribute('aria-valuetext', valueText); else vp.removeAttribute('aria-valuetext'); } if (this.pending !== null && Math.abs(pos - this.pending) < 2) { this.pending = null; } } // --- navigation ----------------------------------------------------------- private go(rawPos: number, source: NileMinimapSource, smooth: boolean): void { const t = this.target; if (!t) return; const a = this.axis; const el = t as unknown as Record; const max = el[a.size] - el[a.len]; const pos = Math.max(0, Math.min(max, rawPos)); const ev = this.emit( 'nile-navigate', { position: Math.round(pos), ratio: max ? Number((pos / max).toFixed(3)) : 0, source, orientation: this.resolvedDir, }, true, true, true ); if (ev.defaultPrevented) return; const sm = smooth && this.smooth && !this.reduceMotion.matches; this.pending = sm ? pos : null; this.pendingAt = performance.now(); const signed = this.rtl ? -pos : pos; t.scrollTo({ [a.scrollKey]: signed, behavior: sm ? 'smooth' : 'auto' }); } private cur(): number { if (!this.target) return 0; if (this.pending !== null && performance.now() - this.pendingAt < 600) { return this.pending; } const el = this.target as unknown as Record; return Math.abs(el[this.axis.pos]); } private step(dir: number): number { const cur = this.cur(); if (this.segOn && this.geo.length) { const inset = this.geo[0].start; const stops = this.geo.map(g => Math.max(0, g.start - inset)); if (dir > 0) { const next = stops.find(s => s > cur + 2); return next === undefined ? Infinity : next; } const prev = [...stops].reverse().find(s => s < cur - 2); return prev === undefined ? 0 : prev; } return cur + dir * Math.max(40, this.clientSize * 0.1); } // --- interactions --------------------------------------------------------- private bindInteractions(): void { const vp = this.viewport; const track = this.trackEl; const { base } = this; vp.addEventListener('pointerdown', e => { if (this.disabled) return; e.preventDefault(); e.stopPropagation(); try { vp.setPointerCapture(e.pointerId); } catch { // Synthetic or already-released pointers can't be captured; drag still works. } const el = this.target as unknown as Record; this.drag = { pointer: e[this.axis.client], start: this.target ? Math.abs(el[this.axis.pos]) : 0, }; this.base.classList.add('base--drag'); vp.focus({ preventScroll: true }); }); vp.addEventListener('pointermove', e => { if (!this.drag) return; this.lastMove = e; if (this.dragRaf) return; this.dragRaf = requestAnimationFrame(() => { this.dragRaf = 0; applyDrag(); }); }); const applyDrag = () => { if (!this.drag || !this.lastMove) return; const room = this.trackLen - this.vpLen; if (room <= 0) return; const delta = this.lastMove[this.axis.client] - this.drag.pointer; this.go( this.drag.start + (delta * this.maxScroll) / room, 'drag', false ); }; const end = () => { if (!this.drag) return; // Flush a move still waiting for its frame so the release position isn't lost. if (this.dragRaf) { cancelAnimationFrame(this.dragRaf); this.dragRaf = 0; applyDrag(); } this.lastMove = null; this.drag = null; this.base.classList.remove('base--drag'); this.queueHide(); }; vp.addEventListener('pointerup', end); vp.addEventListener('pointercancel', end); vp.addEventListener('lostpointercapture', end); track.addEventListener('pointerdown', e => { if (e.target === vp || this.disabled || !this.target) return; const r = track.getBoundingClientRect() as unknown as Record< string, number >; const p = e[this.axis.client] - r[this.axis.rectStart]; this.go( (p / this.trackLen) * this.contentSize - this.clientSize / 2, 'click', true ); }); base.addEventListener( 'wheel', e => { if (this.disabled || !this.overflowing || !this.target) return; let d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; if (e.deltaMode === 1) d *= 16; else if (e.deltaMode === 2) d *= this.clientSize; const el = this.target as unknown as Record; const cur = Math.abs(el[this.axis.pos]); if ((d < 0 && cur <= 0) || (d > 0 && cur >= this.maxScroll - 1)) return; e.preventDefault(); this.pending = null; this.go(cur + d, 'wheel', false); }, { passive: false } ); vp.addEventListener('keydown', e => { if (this.disabled || !this.target) return; let n: number | null = null; switch (e.key) { case 'ArrowRight': case 'ArrowDown': n = this.step(1); break; case 'ArrowLeft': case 'ArrowUp': n = this.step(-1); break; case 'PageDown': n = this.cur() + this.clientSize; break; case 'PageUp': n = this.cur() - this.clientSize; break; case 'Home': n = 0; break; case 'End': n = this.maxScroll; break; default: n = null; } if (n !== null) { e.preventDefault(); this.go(n, 'keyboard', true); } }); } } export default NileMinimap; declare global { interface HTMLElementTagNameMap { 'nile-minimap': NileMinimap; } }