Source: plugins/responsive-grid.js

import BasePlugin from "../core/base-plugin.js";
import debounce from "../utils/debounce.js";
import {
    addClass,
    ce,
    find,
    findAll,
    hasClass,
    insertAfter,
    removeAttribute,
    removeClass,
    setAttribute,
} from "../utils/shortcuts.js";

const RESPONSIVE_CLASS = "dg-responsive";

let obsTo;

/**
 * @param {Array<HTMLElement>} list
 * @returns {Array<HTMLElement>}
 */
function sortByPriority(list) {
    return list.sort((a, b) => {
        const v1 = Number.parseInt(a.dataset.responsive) || 1;
        const v2 = Number.parseInt(b.dataset.responsive) || 1;
        return v2 - v1;
    });
}

/**
 * @type {ResizeObserverCallback}
 */
//@ts-ignore
const callback = debounce((entries) => {
    for (const entry of entries) {
        /**
         * @type {import("../data-grid").default}
         */
        // @ts-ignore
        const grid = entry.target;
        const table = grid.table;
        if (grid.plugins.ResponsiveGrid.observerBlocked) {
            return;
        }
        // check inlineSize (width) and not blockSize (height)
        const contentBoxSize = Array.isArray(entry.contentBoxSize) ? entry.contentBoxSize[0] : entry.contentBoxSize;
        const size = Number.parseInt(contentBoxSize.inlineSize);
        const tableWidth = table.offsetWidth;
        const realTableWidth = findAll(grid.headerRow, "th").reduce((result, th) => {
            return result + th.offsetWidth;
        }, 0);
        const diff = (realTableWidth || tableWidth) - size - 1;
        const minWidth = 50;
        const prevAction = grid.plugins.ResponsiveGrid.prevAction;
        // We have an array with the columns to show/hide are in order, most important first
        const headerCols = sortByPriority(
            findAll(grid.headerRow, "th[field]")
                .reverse() // Order takes precedence if no priority is set
                .filter((col) => {
                    // Leave out unresponsive columns
                    return col.dataset.responsive !== "0";
                }),
        );
        let changed = false;

        grid.log(`table is ${tableWidth}/${realTableWidth} and available size is ${size}. Diff: ${diff}`);

        // The table is too big when diff has a high value, otherwise it will be like -1 or -2
        if (diff > 0) {
            if (prevAction === "show") {
                return;
            }
            grid.plugins.ResponsiveGrid.prevAction = "hide";
            let remaining = diff;
            let cols = headerCols.filter((col) => {
                return !col.hasAttribute("hidden") && col.hasAttribute("data-responsive");
            });
            if (cols.length === 0) {
                cols = headerCols.filter((col) => {
                    return !col.hasAttribute("hidden");
                });
                // Always keep one column
                if (cols.length === 1) {
                    return;
                }
            }

            for (const col of cols) {
                if (remaining < 0) {
                    continue;
                }

                const colWidth = col.offsetWidth;
                const field = col.getAttribute("field");
                if (!field) {
                    continue;
                }
                col.dataset.baseWidth = `${col.offsetWidth}`;

                grid.hideColumn(field, false);
                grid.setColProp(field, "responsiveHidden", true);
                changed = true;

                remaining -= colWidth;
                remaining = Math.round(remaining);
            }
        } else {
            if (prevAction === "hide") {
                return;
            }
            grid.plugins.ResponsiveGrid.prevAction = "show";

            const requiredWidth =
                headerCols
                    .filter((col) => {
                        return !col.hasAttribute("hidden");
                    })
                    .reduce((result, col) => {
                        const width = col.dataset.minWidth ? Number.parseInt(col.dataset.minWidth) : col.offsetWidth;
                        return result + width;
                    }, 0) + minWidth; // Add an offset so that inserting column is smoother

            // Compute available width to insert columns
            let remaining = size - requiredWidth;
            // Do we have any hidden column that we can restore ?
            const filteredHeaderCols = headerCols
                .slice()
                .reverse() // Reverse the array to restore the columns in the proper order
                .filter((col) => {
                    return col.hasAttribute("hidden");
                });

            for (const col of filteredHeaderCols) {
                if (remaining < minWidth) {
                    continue;
                }
                const colWidth = Number.parseInt(col.dataset.minWidth);

                // We need to have enough space to restore it
                if (colWidth > remaining) {
                    remaining = -1; // break loop to keep restoring in order
                    continue;
                }

                const field = col.getAttribute("field");
                if (!field) {
                    continue;
                }

                grid.showColumn(field, false);
                grid.setColProp(field, "responsiveHidden", false);
                changed = true;

                remaining -= colWidth;
                remaining = Math.round(remaining);
            }
        }

        // Check footer
        const footer = find(grid.table, "tfoot");
        const realFooterWidth = findAll(grid.table, ".dg-footer > div").reduce((result, div) => {
            return result + div.offsetWidth;
        }, 0);
        const availableFooterWidth = footer.offsetWidth - realFooterWidth;
        if (realFooterWidth > size) {
            addClass(footer, "dg-footer-compact");
        } else if (availableFooterWidth > 250) {
            removeClass(footer, "dg-footer-compact");
        }
        if (changed) {
            grid.renderTable();
        }
        // Prevent resize loop
        setTimeout(() => {
            grid.plugins.ResponsiveGrid.prevAction = null;
        }, 1000);
        grid.table.style.visibility = "visible";
    }
}, 100);
const resizeObserver = new ResizeObserver(callback);

/**
 * Responsive data grid
 */
class ResponsiveGrid extends BasePlugin {
    constructor(grid) {
        super(grid);

        this.observerBlocked = false;
        this.prevAction = null;
    }

    connected() {
        if (this.grid.options.responsive) {
            this.observe();
        }
    }

    disconnected() {
        this.unobserve();
    }

    observe() {
        if (!this.grid.options.responsive) {
            return;
        }
        resizeObserver.observe(this.grid);
        this.grid.style.display = "block"; // Otherwise resize doesn't happen
        this.grid.style.overflowX = "hidden"; // Prevent scrollbars from appearing
    }

    unobserve() {
        resizeObserver.unobserve(this.grid);
        this.grid.style.display = "unset";
        this.grid.style.overflowX = "unset";
    }

    blockObserver() {
        this.observerBlocked = true;
        if (obsTo) {
            clearTimeout(obsTo);
        }
    }

    unblockObserver() {
        obsTo = setTimeout(() => {
            this.observerBlocked = false;
        }, 200); // more than debounce
    }

    /**
     * @returns {Boolean}
     */
    hasHiddenColumns() {
        let flag = false;

        for (const col of this.grid.options.columns) {
            if (col.responsiveHidden) {
                flag = true;
            }
        }
        return flag;
    }

    colIndex() {
        return this.grid.startColIndex() - 1;
    }

    /**
     * @param {HTMLTableRowElement} tr
     */
    createHeaderCol(tr) {
        if (!this.grid.options.responsiveToggle) {
            return;
        }
        const th = ce("th", tr);
        setAttribute(th, "scope", "col");
        setAttribute(th, "role", "columnheader button");
        setAttribute(th, "aria-colindex", this.colIndex());
        setAttribute(th, "width", "40");
        th.classList.add(...[`${RESPONSIVE_CLASS}-toggle`, "dg-not-resizable", "dg-not-sortable"]);
        th.tabIndex = 0;
    }

    /**
     * @param {HTMLTableRowElement} tr
     */
    createFilterCol(tr) {
        if (!this.grid.options.responsiveToggle) {
            return;
        }
        const th = ce("th", tr);
        setAttribute(th, "role", "columnheader button");
        setAttribute(th, "aria-colindex", this.colIndex());
        th.classList.add(`${RESPONSIVE_CLASS}-toggle`);
        th.tabIndex = 0;
    }

    /**
     * @param {HTMLTableRowElement} tr
     */
    createDataCol(tr) {
        if (!this.grid.options.responsiveToggle) {
            return;
        }
        // Create col
        const td = document.createElement("td");
        setAttribute(td, "role", "gridcell button");
        setAttribute(td, "aria-colindex", this.colIndex());
        td.classList.add(`${RESPONSIVE_CLASS}-toggle`);

        // Create icon
        td.innerHTML = `<div class='dg-clickable-cell'><svg class='${RESPONSIVE_CLASS}-open' viewbox="0 0 24 24" height="24" width="24">
  <line x1="7" y1="12" x2="17" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
  <line y1="7" x1="12" y2="17" x2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg class='${RESPONSIVE_CLASS}-close' viewbox="0 0 24 24" height="24" width="24" style="display:none">
  <line x1="7" y1="12" x2="17" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg></div>`;
        tr.appendChild(td);

        td.addEventListener("click", this);
        td.addEventListener("mousedown", this);
    }

    computeLabelWidth() {
        let idealWidth = 0;
        let consideredCol = 0;
        while (idealWidth < 120) {
            consideredCol++;
            const hCol = find(this.grid, `.dg-head-columns th[aria-colindex="${consideredCol}"]`);
            if (hCol) {
                idealWidth += hCol.offsetWidth;
            } else {
                break;
            }
        }
        return idealWidth;
    }

    /**
     * @param {Event} ev
     */
    onmousedown(ev) {
        // Avoid selection through double click
        ev.preventDefault();
    }

    /**
     * @param {Event} ev
     */
    onclick(ev) {
        // Prevent expandable
        ev.stopPropagation();

        // target is the element that triggered the event (e.g., the user clicked on)
        // currentTarget is the element that the event listener is attached to.

        /**
         * @type {HTMLTableRowElement}
         */
        //@ts-ignore
        const td = ev.currentTarget;
        const tr = td.parentElement;
        const open = find(td, `.${RESPONSIVE_CLASS}-open`);
        const close = find(td, `.${RESPONSIVE_CLASS}-close`);

        this.blockObserver();

        const isExpanded = hasClass(tr, `${RESPONSIVE_CLASS}-expanded`);
        if (isExpanded) {
            removeClass(tr, `${RESPONSIVE_CLASS}-expanded`);
            open.style.display = "unset";
            close.style.display = "none";

            // Move back rows and cleanup row
            const childRow = tr.nextElementSibling;
            const hiddenCols = findAll(childRow, `.${RESPONSIVE_CLASS}-hidden`);

            for (const col of hiddenCols) {
                // We don't really need to care where we insert them since we are going to redraw anyway
                tr.appendChild(col);
                setAttribute(col, "hidden");
            }

            childRow.parentElement.removeChild(childRow);
        } else {
            addClass(tr, `${RESPONSIVE_CLASS}-expanded`);
            open.style.display = "none";
            close.style.display = "unset";

            // Create a child row and move rows into it
            const childRow = ce("tr");
            insertAfter(childRow, tr);
            addClass(childRow, `${RESPONSIVE_CLASS}-child-row`);

            const childRowTd = ce("td", childRow);
            setAttribute(childRowTd, "colspan", this.grid.columnsLength(true));

            const childTable = ce("table", childRowTd);
            addClass(childTable, `${RESPONSIVE_CLASS}-table`);

            const hiddenCols = findAll(tr, `.${RESPONSIVE_CLASS}-hidden`);
            const idealWidth = this.computeLabelWidth();

            for (const col of hiddenCols) {
                const childTableRow = ce("tr", childTable);

                // Add label
                const label = col.dataset.name;
                const labelCol = ce("th", childTableRow);
                // It looks much better when aligned with an actual col
                labelCol.style.width = `${idealWidth}px`;
                labelCol.innerHTML = label;

                // Add actual row
                childTableRow.appendChild(col);
                removeAttribute(col, "hidden");
            }
        }

        this.unblockObserver();
    }
}

export default ResponsiveGrid;