All files / src/web floating-panel.js

96.61% Statements 57/59
91.66% Branches 22/24
92.3% Functions 12/13
97.87% Lines 46/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91                          112x 112x 112x 112x 112x 112x 112x 112x 8x 1x 1x   112x       9x 7x       79x 76x 76x 76x 76x 76x       39x 36x   36x 36x 36x 36x       112x 6x   4x 3x 3x 3x 3x   112x 9x 6x   112x 224x           77x       7x 7x       7x 7x   7x 7x 7x      
/**
 * A panel that floats over the machine: hidden until opened, dragged by its
 * header, closed by its button or Escape, and kept inside the window as the
 * window changes size. Raises "open" and "close".
 */
export class FloatingPanel extends EventTarget {
    /**
     * @param {object} options
     * @param {HTMLElement} options.panel
     * @param {HTMLElement} options.header the drag handle
     * @param {HTMLElement} options.closeButton
     */
    constructor({ panel, header, closeButton }) {
        super();
        this.panel = panel;
        this.isOpen = false;
        this._position = null;
        this._drag = null;
        this._onResize = () => this._keepInWindow();
        closeButton.addEventListener("click", () => this.close());
        panel.addEventListener("keydown", (e) => {
            if (e.key !== "Escape") return;
            e.stopPropagation();
            this.close();
        });
        this._bindDrag(header);
    }
 
    toggle() {
        if (this.isOpen) this.close();
        else this.open();
    }
 
    open() {
        if (this.isOpen) return;
        this.isOpen = true;
        this.panel.hidden = false;
        window.addEventListener("resize", this._onResize);
        this._keepInWindow();
        this.dispatchEvent(new Event("open"));
    }
 
    close() {
        if (!this.isOpen) return;
        this.isOpen = false;
        // Focus left inside a hidden panel would keep the keyboard from the machine.
        if (this.panel.contains(document.activeElement)) document.activeElement.blur();
        this.panel.hidden = true;
        window.removeEventListener("resize", this._onResize);
        this.dispatchEvent(new Event("close"));
    }
 
    _bindDrag(header) {
        header.addEventListener("pointerdown", (e) => {
            if (e.button !== 0 || e.target.closest("button")) return;
            // The header is a drag handle only while it is styled as one, with the move cursor.
            if (getComputedStyle(header).cursor !== "move") return;
            const { left, top } = this.panel.getBoundingClientRect();
            this._drag = { pointerId: e.pointerId, grabX: e.clientX - left, grabY: e.clientY - top };
            header.setPointerCapture(e.pointerId);
            e.preventDefault();
        });
        header.addEventListener("pointermove", (e) => {
            if (!this._drag || this._drag.pointerId !== e.pointerId) return;
            this._moveTo(e.clientX - this._drag.grabX, e.clientY - this._drag.grabY);
        });
        for (const ending of ["pointerup", "pointercancel"])
            header.addEventListener(ending, (e) => {
                if (this._drag?.pointerId === e.pointerId) this._drag = null;
            });
    }
 
    _keepInWindow() {
        if (this._position) this._moveTo(this._position.left, this._position.top);
    }
 
    _moveTo(left, top) {
        const { width, height } = this.panel.getBoundingClientRect();
        this._position = {
            left: Math.min(Math.max(left, 0), Math.max(0, window.innerWidth - width)),
            top: Math.min(Math.max(top, 0), Math.max(0, window.innerHeight - height)),
        };
        this.panel.style.left = `${this._position.left}px`;
        this.panel.style.top = `${this._position.top}px`;
        // Dragging trades whatever anchored the panel at rest for an explicit position.
        this.panel.style.right = "auto";
        this.panel.style.bottom = "auto";
        this.panel.style.transform = "none";
    }
}