Source: data-grid.js

/**
 * Data Grid Web component
 *
 * Credits for inspiration
 * @link https://github.com/riverside/zino-grid
 */

import BaseElement from "./core/base-element.js";
import addSelectOption from "./utils/addSelectOption.js";
import appendParamsToUrl from "./utils/appendParamsToUrl.js";
import camelize from "./utils/camelize.js";
import convertArray from "./utils/convertArray.js";
import elementOffset from "./utils/elementOffset.js";
import interpolate from "./utils/interpolate.js";
import getTextWidth from "./utils/getTextWidth.js";
import randstr from "./utils/randstr.js";
import debounce from "./utils/debounce.js";
import {
    dispatch,
    find,
    findAll,
    hasClass,
    removeAttribute,
    getAttribute,
    setAttribute,
    addClass,
    toggleClass,
    on,
    ce,
} from "./utils/shortcuts.js";

/**
 * Column definition
 * @typedef Column
 * @property {String} field - the key in the data
 * @property {String} title - the title to display in the header (defaults to "field" if not set)
 * @property {Number} [width] - the width of the column (auto otherwise)
 * @property {String} [class] - class to set on the column (target body or header with th.class or td.class)
 * @property {String} [attr] - don't render the column and set a matching attribute on the row with the value of the field
 * @property {Boolean} [hidden] - hide the column
 * @property {Boolean} [noSort] - allow disabling sort for a given column
 * @property {String | Function} [format] - custom data formatting
 * @property {String} [defaultFormatValue] - default value to use for formatting
 * @property {String} [transform] - custom value transformation
 * @property {Boolean} [editable] - replace with input (EditableColumn module)
 * @property {String} [editableType] - type of input (EditableColumn module)
 * @property {Number} [responsive] - the higher the value, the sooner it will be hidden, disable with 0 (ResponsiveGrid module)
 * @property {Boolean} [responsiveHidden] - hidden through responsive module (ResponsiveGrid module)
 * @property {String} [filterType] - defines a filter field type ("text" or "select" - defaults to "text")
 * @property {Array} [filterList] - defines a custom array to populate a filter select field in the format of [{value: "", text: ""},...]. When defined, it overrides the default behaviour where the filter select elements are populated by the unique values from the corresponding column records.
 * @property {Object} [firstFilterOption] - defines an object for the first option element of the filter select field. defaults to {value: "", text: ""}
 */

/**
 * Row action
 * @typedef Action
 * @property {String} title - the title of the button
 * @property {String} name - the name of the action
 * @property {String} class - the class for the button
 * @property {String} url - link for the action
 * @property {String} html - custom button data
 * @property {Boolean} [confirm] - needs confirmation
 * @property {Boolean} default - is the default row action
 */

// Import definitions without importing the actual file
/** @typedef {import('./plugins/autosize-column').default} AutosizeColumn */
/** @typedef {import('./plugins/column-resizer').default} ColumnResizer */
/** @typedef {import('./plugins/context-menu').default} ContextMenu */
/** @typedef {import('./plugins/draggable-headers').default} DraggableHeaders */
/** @typedef {import('./plugins/editable-column').default} EditableColumn */
/** @typedef {import('./plugins/fixed-height').default} FixedHeight */
/** @typedef {import('./plugins/responsive-grid').default} ResponsiveGrid */
/** @typedef {import('./plugins/row-actions').default} RowActions */
/** @typedef {import('./plugins/selectable-rows').default} SelectableRows */
/** @typedef {import('./plugins/touch-support').default} TouchSupport */
/** @typedef {import('./plugins/spinner-support').default} SpinnerSupport */
/** @typedef {import('./plugins/save-state').default} SaveState */

/**
 * These plugins are all optional
 * @typedef {Object} Plugins
 * @property {ColumnResizer} [ColumnResizer] resize handlers in the headers
 * @property {ContextMenu} [ContextMenu] menu to show/hide columns
 * @property {DraggableHeaders} [DraggableHeaders] draggable headers columns
 * @property {EditableColumn} [EditableColumn] draggable headers columns
 * @property {TouchSupport} [TouchSupport] touch swipe
 * @property {SelectableRows} [SelectableRows] create a column with checkboxes to select rows
 * @property {FixedHeight} [FixedHeight] allows having fixed height tables
 * @property {AutosizeColumn} [AutosizeColumn] compute ideal width based on column content
 * @property {ResponsiveGrid} [ResponsiveGrid] hide/show column on the fly
 * @property {RowActions} [RowActions] add action on rows
 * @property {SpinnerSupport} [SpinnerSupport] inserts a spinning icon element to indicate grid loading.
 * @property {SaveState} [SaveState] stores grid filter, sort, and paging.
 */

/**
 * Parameters to pass along or receive from the server
 * @typedef ServerParams
 * @property {String} serverParams.start
 * @property {String} serverParams.length
 * @property {String} serverParams.search
 * @property {String} serverParams.sort
 * @property {String} serverParams.sortDir
 * @property {String} serverParams.dataKey
 * @property {String} serverParams.metaKey
 * @property {String} serverParams.metaTotalKey
 * @property {String} serverParams.metaFilteredKey
 * @property {String} serverParams.optionsKey
 * @property {String} serverParams.paramsKey
 */

/**
 * Available data grid options, plugins included
 * @typedef Options
 * @property {?String} id Custom id for the grid
 * @property {?String} url An URL with data to display in JSON format
 * @property {Boolean} debug Log actions in DevTools console
 * @property {Boolean} filter Allows a filtering functionality
 * @property {Boolean} sort Allows a sort by column functionality
 * @property {String} defaultSort Default sort field if sorting is enabled
 * @property {Boolean} server Is a server side powered grid
 * @property {ServerParams} serverParams Describe keys passed to the server backend
 * @property {String} dir Dir
 * @property {Array} perPageValues Available per page options
 * @property {Boolean} hidePerPage Hides the page size select element
 * @property {Column[]} columns Available columns
 * @property {Number} defaultPage Starting page
 * @property {Number} perPage Number of records displayed per page (page size)
 * @property {Boolean} expand  Allow cell content to spawn over multiple lines
 * @property {Action[]} actions Row actions (RowActions module)
 * @property {Boolean} collapseActions Group actions (RowActions module)
 * @property {Boolean} resizable Make columns resizable (ColumnResizer module)
 * @property {Boolean} selectable Allow selecting rows with a checkbox (SelectableRows module)
 * @property {Boolean} selectVisibleOnly Select all only selects visible rows (SelectableRows module)
 * @property {Boolean} autosize Compute column sizes based on given data (Autosize module)
 * @property {Boolean} autoheight Adjust height so that it matches table size (FixedHeight module)
 * @property {Boolean} autohidePager auto-hides the pager when number of records falls below the selected page size
 * @property {Boolean} menu Right click menu on column headers (ContextMenu module)
 * @property {Boolean} reorder Allows a column reordering functionality (DraggableHeaders module)
 * @property {Boolean} responsive Change display mode on small screens (ResponsiveGrid module)
 * @property {Boolean} responsiveToggle Show toggle column (ResponsiveGrid module)
 * @property {Boolean} filterOnEnter Toggles the ability to filter column data by pressing the Enter or Return key
 * @property {String} spinnerClass Sets a space-delimited string of css classes for a spinner (use spinner-border css class for bootstrap 5 spinner)
 * @property {Number} filterKeypressDelay Sets a keypress delay time in milliseconds before triggering filter operation.
 * @property {Boolean} saveState Enable/disable save state plugin (SaveState module)
 */

/**
 * Available labels that can be translated
 * @typedef Labels
 * @property {String} itemsPerPage
 * @property {String} gotoPage
 * @property {String} gotoFirstPage
 * @property {String} gotoPrevPage
 * @property {String} gotoNextPage
 * @property {String} gotoLastPage
 * @property {String} of
 * @property {String} items
 * @property {String} resizeColumn
 * @property {String} noData
 * @property {String} areYouSure
 * @property {String} networkError
 */

/**
 * List of registered plugins
 * @type {Plugins}
 */
let plugins = {};

/**
 * @type {Labels}
 */
let labels = {
    itemsPerPage: "Items per page",
    gotoPage: "Go to page",
    gotoFirstPage: "Go to first page",
    gotoPrevPage: "Go to previous page",
    gotoNextPage: "Go to next page",
    gotoLastPage: "Go to last page",
    of: "of",
    items: "items",
    resizeColumn: "Resize column",
    noData: "No data",
    areYouSure: "Are you sure?",
    networkError: "Network response error",
};

/**
 * Column definition will update some props on the html element
 * @param {HTMLElement} el
 * @param {Column} column
 */
function applyColumnDefinition(el, column) {
    if (column.width) {
        setAttribute(el, "width", column.width);
    }
    if (column.class) {
        addClass(el, column.class);
    }
    if (column.hidden) {
        setAttribute(el, "hidden", "");
        if (column.responsiveHidden) {
            addClass(el, "dg-responsive-hidden");
        }
    }
}

/**
 */
class DataGrid extends BaseElement {
    _filterSelector = "[id^=dg-filter]";
    _excludedKeys = [
        37,
        39,
        38,
        40,
        45,
        36,
        35,
        33,
        34,
        27,
        20,
        16,
        17,
        91,
        92,
        18,
        93,
        144,
        231,
        "ArrowLeft",
        "ArrowRight",
        "ArrowUp",
        "ArrowDown",
        "Insert",
        "Home",
        "End",
        "PageUp",
        "PageDown",
        "Escape",
        "CapsLock",
        "Shift",
        "Control",
        "Meta",
        "Alt",
        "ContextMenu",
        "NumLock",
        "Unidentified",
    ];

    _ready() {
        setAttribute(this, "id", this.options.id ?? randstr("el-"), true);

        /**
         * The grid displays that data
         * @type {Array}
         */
        this.data = [];
        /**
         * We store the original data in this
         * @type {Array}
         */
        this.originalData; // declared uninitialized to allow data preloading before fetch.

        // Make the IDE happy
        /**
         * @type {Options}
         */
        this.options = this.options || this.defaultOptions;

        // Init values
        this.fireEvents = false;
        this.page = this.options.defaultPage || 1;
        this.pages = 0;
        this.meta; // declared uninitialized to allow data preloading before fetch.
        /**
         * @type {Plugins}
         */
        this.plugins = {};
        // Init plugins
        for (const [pluginName, pluginClass] of Object.entries(plugins)) {
            // @ts-ignore until we can set typeof import ...
            this.plugins[pluginName] = new pluginClass(this);
        }

        // Expose options as observed attributes in the dom
        // Do it when fireEvents is disabled to avoid firing change callbacks
        for (const attr of DataGrid.observedAttributes) {
            if (attr.indexOf("data-") === 0) {
                setAttribute(this, attr, this.options[camelize(attr.slice(5))]);
            }
        }

        // Inserts spinner
        if (this.options.spinnerClass && this.plugins.SpinnerSupport) {
            this.plugins.SpinnerSupport.add();
        }
    }

    static template() {
        return `
<table role="grid" >
    <thead role="rowgroup">
        <tr role="row" aria-rowindex="1" class="dg-head-columns"><th><!-- keep for getTextWidth --></th></tr>
        <tr role="row" aria-rowindex="2" class="dg-head-filters"></tr>
    </thead>
    <tbody role="rowgroup" data-empty="${labels.noData}"></tbody>
    <tfoot role="rowgroup" hidden>
        <tr role="row" aria-rowindex="1">
            <td role="gridcell">
            <div class="dg-footer">
                <div class="dg-page-nav">
                  <select class="dg-select-per-page" aria-label="${labels.itemsPerPage}"></select>
                </div>
                <div class="dg-pagination">
                  <button type="button" class="dg-btn-first dg-rotate" title="${labels.gotoFirstPage}" aria-label="${labels.gotoFirstPage}" disabled>
                    <i class="dg-skip-icon"></i>
                  </button>
                  <button type="button" class="dg-btn-prev dg-rotate" title="${labels.gotoPrevPage}" aria-label="${labels.gotoPrevPage}" disabled>
                    <i class="dg-nav-icon"></i>
                  </button>
                  <input type="number" class="dg-input-page" min="1" step="1" value="1" aria-label="${labels.gotoPage}">
                  <button type="button" class="dg-btn-next" title="${labels.gotoNextPage}" aria-label="${labels.gotoNextPage}" disabled>
                    <i class="dg-nav-icon"></i>
                  </button>
                  <button type="button" class="dg-btn-last" title="${labels.gotoLastPage}" aria-label="${labels.gotoLastPage}" disabled>
                    <i class="dg-skip-icon"></i>
                  </button>
                </div>
                <div class="dg-meta">
                  <span class="dg-low">0</span> - <span class="dg-high">0</span> ${labels.of} <span class="dg-total">0</span> ${labels.items}
                </div>
            </div>
            </td>
        </tr>
    </tfoot>
    <ul class="dg-menu" hidden></ul>
</table>
`;
    }

    /**
     * @returns {Labels}
     */
    get labels() {
        return labels;
    }

    /**
     * @returns {Labels}
     */
    static getLabels() {
        return labels;
    }

    /**
     * @param {Object} v
     */
    static setLabels(v) {
        labels = Object.assign(labels, v);
    }

    /**
     * @returns {Column}
     */
    get defaultColumn() {
        return {
            field: "",
            title: "",
            width: 0,
            class: "",
            attr: "",
            hidden: false,
            editable: false,
            noSort: false,
            responsive: 1,
            responsiveHidden: false,
            format: "",
            transform: "",
            filterType: "text",
            firstFilterOption: { value: "", text: "" },
        };
    }

    /**
     * @returns {Options}
     */
    get defaultOptions() {
        return {
            id: null,
            url: null,
            perPage: 10,
            debug: false,
            filter: false,
            menu: false,
            sort: false,
            server: false,
            serverParams: {
                start: "start",
                length: "length",
                search: "search",
                sort: "sort",
                sortDir: "sortDir",
                dataKey: "data",
                metaKey: "meta",
                metaTotalKey: "total",
                metaFilteredKey: "filtered",
                optionsKey: "options",
                paramsKey: "params",
            },
            defaultSort: "",
            reorder: false,
            dir: "ltr",
            perPageValues: [10, 25, 50, 100, 250],
            hidePerPage: false,
            columns: [],
            actions: [],
            collapseActions: false,
            selectable: false,
            selectVisibleOnly: true,
            defaultPage: 1,
            resizable: false,
            autosize: true,
            expand: false,
            autoheight: true,
            autohidePager: false,
            responsive: false,
            responsiveToggle: true,
            filterOnEnter: true,
            filterKeypressDelay: 500,
            spinnerClass: "",
            saveState: false,
        };
    }

    /**
     * @param {Plugins} list
     */
    static registerPlugins(list) {
        plugins = list;
    }

    /**
     * @param {String} plugin
     */
    static unregisterPlugins(plugin = null) {
        if (plugin === null) {
            plugins = {};
        } else {
            delete plugins[plugin];
        }
    }

    /**
     * @returns {Plugins}
     */
    static registeredPlugins() {
        return plugins;
    }

    /**
     * @param {Object|Array} columns
     * @returns {Column[]}
     */
    convertColumns(columns) {
        const cols = [];
        // Convert key:value objects to actual columns
        if (typeof columns === "object" && !Array.isArray(columns)) {
            for (const key of Object.keys(columns)) {
                const col = Object.assign({}, this.defaultColumn);
                col.title = columns[key];
                col.field = key;
                cols.push(col);
            }
        } else {
            for (const item of columns) {
                let col = Object.assign({}, this.defaultColumn);
                if (typeof item === "string") {
                    col.title = item;
                    col.field = item;
                } else if (typeof item === "object") {
                    col = Object.assign(col, item);
                    if (!col.field) {
                        console.error("Invalid column definition", item);
                    }
                    if (!col.title) {
                        col.title = col.field;
                    }
                } else {
                    console.error("Column definition must be a string or an object");
                }
                cols.push(col);
            }
        }
        return cols;
    }

    /**
     * @link https://gist.github.com/WebReflection/ec9f6687842aa385477c4afca625bbf4#reflected-dom-attributes
     * @returns {Array}
     */
    static get observedAttributes() {
        return [
            "page",
            "data-filter",
            "data-sort",
            "data-debug",
            "data-reorder",
            "data-menu",
            "data-selectable",
            "data-url",
            "data-per-page",
            "data-responsive",
        ];
    }

    get transformAttributes() {
        return {
            columns: (v) => this.convertColumns(convertArray(v)),
            actions: (v) => convertArray(v),
            defaultPage: (v) => Number.parseInt(v),
            perPage: (v) => Number.parseInt(v),
        };
    }

    get page() {
        return Number.parseInt(this.getAttribute("page"));
    }

    set page(val) {
        setAttribute(this, "page", this.constrainPageValue(val));
    }

    urlChanged() {
        this.loadData().then(() => {
            this.configureUi();
        });
    }

    constrainPageValue(v) {
        let pv = v;
        if (this.pages < pv) {
            pv = this.pages;
        }
        if (pv < 1 || !pv) {
            pv = 1;
        }
        return pv;
    }

    fixPage() {
        this.pages = this.totalPages();
        this.page = this.constrainPageValue(this.page);

        // Show current page in input
        setAttribute(this.inputPage, "max", this.pages);
        this.inputPage.value = `${this.page}`;
        this.inputPage.disabled = this.pages < 2;
    }

    pageChanged() {
        this.reload();
    }

    responsiveChanged() {
        if (!this.plugins.ResponsiveGrid) {
            return;
        }
        if (this.options.responsive) {
            this.plugins.ResponsiveGrid.observe();
        } else {
            this.plugins.ResponsiveGrid.unobserve();
        }
    }

    menuChanged() {
        this.renderHeader();
    }

    /**
     * This is the callback for the select control
     */
    changePerPage() {
        this.options.perPage = Number.parseInt(this.selectPerPage.options[this.selectPerPage.selectedIndex].value);
        this.perPageChanged();
    }

    /**
     * This is the actual event triggered on attribute change
     */
    perPageChanged() {
        // Refresh UI
        if (
            this.options.perPage !== Number.parseInt(this.selectPerPage.options[this.selectPerPage.selectedIndex].value)
        ) {
            this.perPageValuesChanged();
        }
        // Make sure current page is still valid
        let updatePage = this.page;
        while (updatePage > 1 && this.page * this.options.perPage > this.totalRecords()) {
            updatePage--;
        }
        if (updatePage !== this.page) {
            // Triggers pageChanged, which will trigger reload
            this.page = updatePage;
        } else {
            // Simply reload current page
            this.reload(() => {
                // Preserve distance between top of page and select control if no fixed height
                if (!this.plugins.FixedHeight || !this.plugins.FixedHeight.hasFixedHeight) {
                    this.selectPerPage.scrollIntoView();
                }
            });
        }
    }

    dirChanged() {
        setAttribute(this, "dir", this.options.dir);
    }

    defaultSortChanged() {
        this.sortChanged();
    }

    /**
     * Populate the select dropdown according to options
     */
    perPageValuesChanged() {
        if (!this.selectPerPage) {
            return;
        }
        while (this.selectPerPage.lastChild) {
            this.selectPerPage.removeChild(this.selectPerPage.lastChild);
        }
        for (const v of this.options.perPageValues) {
            addSelectOption(this.selectPerPage, v, v, v === this.options.perPage);
        }
    }

    _connected() {
        /**
         * @type {HTMLTableElement}
         */
        this.table = this.querySelector("table");
        /**
         * @type {HTMLInputElement}
         */
        this.btnFirst = this.querySelector(".dg-btn-first");
        /**
         * @type {HTMLInputElement}
         */
        this.btnPrev = this.querySelector(".dg-btn-prev");
        /**
         * @type {HTMLInputElement}
         */
        this.btnNext = this.querySelector(".dg-btn-next");
        /**
         * @type {HTMLInputElement}
         */
        this.btnLast = this.querySelector(".dg-btn-last");
        /**
         * @type {HTMLSelectElement}
         */
        this.selectPerPage = this.querySelector(".dg-select-per-page");
        /**
         * @type {HTMLInputElement}
         */
        this.inputPage = this.querySelector(".dg-input-page");

        this.getFirst = this.getFirst.bind(this);
        this.getPrev = this.getPrev.bind(this);
        this.getNext = this.getNext.bind(this);
        this.getLast = this.getLast.bind(this);
        this.changePerPage = this.changePerPage.bind(this);
        this.gotoPage = this.gotoPage.bind(this);

        this.btnFirst.addEventListener("click", this.getFirst);
        this.btnPrev.addEventListener("click", this.getPrev);
        this.btnNext.addEventListener("click", this.getNext);
        this.btnLast.addEventListener("click", this.getLast);
        this.selectPerPage.addEventListener("change", this.changePerPage);
        this.selectPerPage.toggleAttribute("hidden", this.options.hidePerPage);
        this.inputPage.addEventListener("input", this.gotoPage);

        for (const plugin of Object.values(this.plugins)) {
            plugin.connected();
        }

        // Display even if we don't have data
        this.dirChanged();
        this.perPageValuesChanged();

        // @ts-ignore
        this.loadData().finally(() => {
            this.configureUi();

            this.sortChanged();
            this.classList.add("dg-initialized"); //acts as a flag to prevent unnecessary server calls down the chain.

            this.filterChanged();
            this.reorderChanged();

            this.dirChanged();
            this.perPageValuesChanged();
            this.pageChanged();

            this.fireEvents = true; // We can now fire attributeChangedCallback events

            this.log("initialized");
        });
    }

    _disconnected() {
        this.btnFirst.removeEventListener("click", this.getFirst);
        this.btnPrev.removeEventListener("click", this.getPrev);
        this.btnNext.removeEventListener("click", this.getNext);
        this.btnLast.removeEventListener("click", this.getLast);
        this.selectPerPage.removeEventListener("change", this.changePerPage);
        this.inputPage.removeEventListener("input", this.gotoPage);

        for (const plugin of Object.values(this.plugins)) {
            plugin.disconnected();
        }
    }

    /**
     * @param {string} field
     * @returns {Column}
     */
    getCol(field) {
        let found = null;

        for (const col of this.options.columns) {
            if (col.field === field) {
                found = col;
            }
        }
        return found;
    }

    getColProp(field, prop) {
        const c = this.getCol(field);
        return c ? c[prop] : null;
    }

    setColProp(field, prop, val) {
        const c = this.getCol(field);
        if (c) {
            c[prop] = val;
        }
    }

    visibleColumns() {
        return this.options.columns.filter((col) => {
            return !col.hidden;
        });
    }

    hiddenColumns() {
        return this.options.columns.filter((col) => {
            return col.hidden === true;
        });
    }

    showColumn(field, render = true) {
        this.setColProp(field, "hidden", false);

        // We need to render the whole table otherwise layout fixed won't do its job
        if (render) this.renderTable();

        dispatch(this, "columnVisibility", {
            col: field,
            visibility: "visible",
        });
    }

    hideColumn(field, render = true) {
        this.setColProp(field, "hidden", true);

        // We need to render the whole table otherwise layout fixed won't do its job
        if (render) this.renderTable();

        dispatch(this, "columnVisibility", {
            col: field,
            visibility: "hidden",
        });
    }

    /**
     * Returns the starting index of actual data
     * @returns {Number}
     */
    startColIndex() {
        let start = 1;
        if (this.options.selectable && this.plugins.SelectableRows) {
            start++;
        }
        if (this.options.responsive && this.plugins.ResponsiveGrid && this.plugins.ResponsiveGrid.hasHiddenColumns()) {
            start++;
        }
        return start;
    }

    /**
     * @returns {Boolean}
     */
    isSticky() {
        return this.hasAttribute("sticky");
    }

    /**
     * @param {Boolean} visibleOnly
     * @returns {Number}
     */
    columnsLength(visibleOnly = false) {
        let len = 0;
        // One column per (visible) column
        for (const col of this.options.columns) {
            if (visibleOnly && col.hidden) {
                continue;
            }
            if (!col.attr) {
                len++;
            }
        }
        // Add one col for selectable checkbox at the beginning
        if (this.options.selectable && this.plugins.SelectableRows) {
            len++;
        }
        // Add one col for actions at the end
        if (this.options.actions.length && this.plugins.RowActions) {
            len++;
        }
        // Add one col for the responsive toggle
        if (this.options.responsive && this.plugins.ResponsiveGrid && this.plugins.ResponsiveGrid.hasHiddenColumns()) {
            len++;
        }
        return len;
    }

    /**
     * Global configuration and renderTable
     * This should be called after your data has been loaded
     */
    configureUi() {
        setAttribute(this.querySelector("table"), "aria-rowcount", this.data.length);

        this.table.style.visibility = "hidden";
        this.renderTable();
        if (this.options.responsive && this.plugins.ResponsiveGrid) {
            // Let the observer make the table visible
        } else {
            this.table.style.visibility = "visible";
        }

        // Store row height for later usage
        if (!this.rowHeight) {
            const tr = find(this, "tbody tr") || find(this, "table tr");
            if (tr) {
                this.rowHeight = tr.offsetHeight;
            }
        }
    }

    filterChanged() {
        const row = this.querySelector("thead tr.dg-head-filters");
        if (this.options.filter) {
            removeAttribute(row, "hidden");
        } else {
            this.clearFilters();
            setAttribute(row, "hidden", "");
        }
    }

    reorderChanged() {
        const headers = findAll(this, "thead tr.dg-head-columns th");
        for (const th of headers) {
            if (th.classList.contains("dg-selectable") || th.classList.contains("dg-actions")) {
                continue;
            }
            if (this.options.reorder && this.plugins.DraggableHeaders) {
                th.draggable = true;
            } else {
                th.removeAttribute("draggable");
            }
        }
    }

    sortChanged() {
        this.log("toggle sort");

        const headers = findAll(this, "thead tr.dg-head-columns th");
        for (const th of headers) {
            const fieldName = th.getAttribute("field");
            if (
                th.classList.contains("dg-not-sortable") ||
                (!this.fireEvents && fieldName === this.options.defaultSort)
            ) {
                return;
            }
            if (this.options.sort && !this.getColProp(fieldName, "noSort")) {
                setAttribute(th, "aria-sort", "none");
            } else {
                removeAttribute(th, "aria-sort");
            }
        }
    }

    selectableChanged() {
        this.renderTable();
    }

    addRow(row) {
        if (!Array.isArray(this.originalData)) {
            return;
        }
        this.log("Add row");
        this.originalData.push(row);
        this.data = this.originalData.slice();
        this.sortData();
    }

    /**
     * @param {any} value Value to remove. Defaults to last row.
     * @param {String} key The key of the item to remove. Defaults to first column
     */
    removeRow(value = null, key = null) {
        if (!Array.isArray(this.originalData)) {
            return;
        }

        let v = value;
        let k = key;
        if (k === null) {
            k = this.options.columns[0].field;
        }
        if (v === null) {
            v = this.originalData[this.originalData.length - 1][k];
        }
        this.log(`Removing ${k}:${v}`);
        for (let i = 0; i < this.originalData.length; i++) {
            if (this.originalData[i][k] === v) {
                this.originalData.splice(i, 1);
                break;
            }
        }
        this.data = this.originalData.slice();
        this.sortData();
    }

    /**
     * @param {String} key Return a specific key (eg: id) instead of the whole row
     * @returns {Array}
     */
    getSelection(key = null) {
        if (!this.plugins.SelectableRows) {
            return [];
        }
        return this.plugins.SelectableRows.getSelection(key);
    }

    getData() {
        return this.originalData;
    }

    clearData() {
        // Already empty
        if (this.data.length === 0) {
            return;
        }
        this.data = this.originalData = [];
        this.renderBody();
    }

    /**
     * Preloads the data intended to bypass the initial fetch operation, allowing for faster intial page load time.
     * Subsequent grid actions after initialization will operate as normal.
     * @param {Object} data - an object with meta ({total, filtered, start}) and data (array of objects) properties.
     */
    preload(data) {
        const metaKey = this.options.serverParams.metaKey;
        const dataKey = this.options.serverParams.dataKey;
        if (data?.[metaKey]) {
            this.meta = data[metaKey];
        }
        if (data?.[dataKey]) {
            this.data = this.originalData = data[dataKey];
        }
    }

    refresh(cb = null) {
        this.data = this.originalData = [];
        return this.reload(cb);
    }

    reload(cb = null) {
        this.log("reload");

        // If the data was cleared, we need to render again
        const needRender = !this.originalData?.length;
        this.fixPage();
        // @ts-ignore
        this.loadData().finally(() => {
            // If we load data from the server, we redraw the table body
            // Otherwise, we just need to paginate
            this.options.server || needRender ? this.renderBody() : this.paginate();
            if (cb) {
                cb();
            }
        });
    }

    /**
     * @returns {Promise}
     */
    loadData() {
        const flagEmpty = () => !this.data.length && this.classList.add("dg-empty");
        const tbody = this.querySelector("tbody");

        // We already have some data
        if (this.meta || this.originalData || this.classList.contains("dg-initialized")) {
            // We don't use server side data
            if (!this.options.server || (this.options.server && !this.fireEvents)) {
                this.log("skip loadData");
                flagEmpty();
                return new Promise((resolve) => {
                    resolve();
                });
            }
        }
        this.log("loadData");
        this.loading = true;
        this.classList.add("dg-loading");
        this.classList.remove("dg-empty", "dg-network-error");
        return (
            this.fetchData()
                .then((response) => {
                    // We can get a straight array or an object
                    if (Array.isArray(response)) {
                        this.data = response;
                    } else {
                        // Object must contain data key
                        if (!response[this.options.serverParams.dataKey]) {
                            console.error(
                                "Invalid response, it should contain a data key with an array or be a plain array",
                                response,
                            );
                            this.options.url = null;
                            return;
                        }

                        // We may have a config object
                        this.options = Object.assign(
                            this.options,
                            response[this.options.serverParams.optionsKey] ?? {},
                        );
                        // It should return meta data (see metaFilteredKey)
                        this.meta = response[this.options.serverParams.metaKey] ?? {};
                        this.data = response[this.options.serverParams.dataKey];
                    }
                    this.originalData = this.data.slice();
                    this.fixPage();

                    // Make sure we have a proper set of columns
                    if (this.options.columns.length === 0 && this.originalData.length) {
                        this.options.columns = this.convertColumns(Object.keys(this.originalData[0]));
                    } else {
                        this.options.columns = this.convertColumns(this.options.columns);
                    }
                })
                .catch((err) => {
                    this.log(err);
                    if (err.message) {
                        tbody.setAttribute("data-empty", err.message.replace(/^\s+|\r\n|\n|\r$/g, ""));
                    }
                    this.classList.add("dg-empty", "dg-network-error");
                })
                // @ts-ignore
                .finally(() => {
                    flagEmpty();
                    if (
                        !this.classList.contains("dg-network-error") &&
                        tbody.getAttribute("data-empty") !== this.labels.noData
                    ) {
                        tbody.setAttribute("data-empty", this.labels.noData);
                    }
                    this.classList.remove("dg-loading");
                    this.loading = false;
                })
        );
    }

    getFirst() {
        if (this.loading) {
            return;
        }
        this.page = 1;
    }

    getLast() {
        if (this.loading) {
            return;
        }
        this.page = this.pages;
    }

    getPrev() {
        if (this.loading) {
            return;
        }
        this.page = this.page - 1;
    }

    getNext() {
        if (this.loading) {
            return;
        }
        this.page = this.page + 1;
    }

    gotoPage(event) {
        if (event.type === "keypress") {
            const key = event.keyCode || event.key;
            if (key === 13 || key === "Enter") {
                event.preventDefault();
            } else {
                return;
            }
        }
        this.page = Number.parseInt(this.inputPage.value);
    }

    getSort() {
        const col = this.querySelector("thead tr.dg-head-columns th[aria-sort$='scending']");
        if (col) {
            return col.getAttribute("field");
        }
        return this.options.defaultSort;
    }

    getSortDir() {
        const col = this.querySelector("thead tr.dg-head-columns th[aria-sort$='scending']");
        if (col) {
            return col.getAttribute("aria-sort") || "";
        }
        return "";
    }

    getFilters() {
        const filters = [];
        const inputs = findAll(this, this._filterSelector);
        for (const input of inputs) {
            filters[input.dataset.name] = input.value;
        }
        return filters;
    }

    clearFilters() {
        const inputs = findAll(this, this._filterSelector);
        for (const input of inputs) {
            input.value = "";
        }
        this.filterData();
    }

    filterData() {
        this.log("filter data");

        this.page = 1;

        if (this.options.server) {
            this.reload();
        } else {
            this.data = this.originalData?.slice() ?? [];

            // Look for rows matching the filters
            const inputs = findAll(this, this._filterSelector);
            for (const input of inputs) {
                const value = input.value;
                if (value) {
                    const name = input.dataset.name;
                    this.data = this.data.filter((item) => {
                        const str = `${item[name]}`;
                        return str.toLowerCase().indexOf(value.toLowerCase()) !== -1;
                    });
                }
            }
            this.pageChanged();

            const col = this.querySelector("thead tr.dg-head-columns th[aria-sort$='scending']");
            if (this.options.sort && col) {
                this.sortData();
            } else {
                this.renderBody();
            }
        }
    }

    /**
     * Data will be sorted then rendered using renderBody
     * @param {Element} baseCol The column that was clicked or null to use current sort
     */
    sortData(baseCol = null) {
        this.log("sort data");

        let col = baseCol;

        // Early exit
        if (col && this.getColProp(col.getAttribute("field"), "noSort")) {
            this.log("sorting prevented because column is not sortable");
            return;
        }
        if (this.plugins.ColumnResizer?.isResizing) {
            this.log("sorting prevented because resizing");
            return;
        }
        if (this.loading) {
            this.log("sorting prevented because loading");
            return;
        }

        // We clicked on a column, update sort state
        if (col !== null) {
            // Remove active sort if any
            const haveClasses = (c) => ["dg-selectable", "dg-actions", "dg-responsive-toggle"].includes(c);

            const headers = findAll(this, "thead tr:first-child th");
            for (const th of headers) {
                // @ts-ignore
                if ([...th.classList].some(haveClasses)) {
                    continue;
                }
                if (th !== col) {
                    th.setAttribute("aria-sort", "none");
                }
            }

            // Set tristate col
            if (!col.hasAttribute("aria-sort") || col.getAttribute("aria-sort") === "none") {
                col.setAttribute("aria-sort", "ascending");
            } else if (col.getAttribute("aria-sort") === "ascending") {
                col.setAttribute("aria-sort", "descending");
            } else if (col.getAttribute("aria-sort") === "descending") {
                col.setAttribute("aria-sort", "none");
            }
        } else {
            // Or fetch current sort
            col = this.querySelector("thead tr.dg-head-columns th[aria-sort$='scending']");
        }

        if (this.options.server) {
            // Reload data with updated sort
            this.loadData().finally(() => {
                this.renderBody();
            });
        } else {
            const sort = col ? col.getAttribute("aria-sort") : "none";
            if (sort === "none") {
                const stack = [];

                // Restore order while keeping filters
                this.originalData?.some((itemA) => {
                    this.data.some((itemB) => {
                        if (JSON.stringify(itemA) === JSON.stringify(itemB)) {
                            stack.push(itemB);
                            return true;
                        }
                        return false;
                    });
                    return stack.length === this.data.length;
                });

                this.data = stack;
            } else {
                const field = col.getAttribute("field");
                this.data.sort((a, b) => {
                    if (!Number.isNaN(a[field]) && !Number.isNaN(b[field])) {
                        return sort === "ascending" ? a[field] - b[field] : b[field] - a[field];
                    }
                    const valA = sort === "ascending" ? a[field].toUpperCase() : b[field].toUpperCase();
                    const valB = sort === "ascending" ? b[field].toUpperCase() : a[field].toUpperCase();

                    switch (true) {
                        case valA > valB:
                            return 1;
                        case valA < valB:
                            return -1;
                        case valA === valB:
                            return 0;
                    }
                });
            }
            this.renderBody();
        }
    }

    _sort(columnName, sortDir) {
        const col = this.querySelector(`.dg-head-columns th[field=${columnName}]`);
        const dir = sortDir === "ascending" ? "none" : sortDir === "descending" ? "ascending" : "descending";
        col?.setAttribute("aria-sort", dir);
        this.sortData(col);
    }

    sortAsc = (columnName) => this._sort(columnName, "ascending");
    sortDesc = (columnName) => this._sort(columnName, "descending");
    sortNone = (columnName) => this._sort(columnName, "none");

    fetchData() {
        if (!this.options.url) {
            return new Promise((resolve, reject) => reject("No url set"));
        }

        let base = window.location.href;
        // Fix trailing slash if no extension is present
        if (!base.split("/").pop().includes(".")) {
            base += base.endsWith("/") ? "" : "/";
        }
        const url = new URL(this.options.url, base);
        let params = {
            r: Date.now(),
        };
        if (this.options.server) {
            // 0 based
            params[this.options.serverParams.start] = this.page - 1;
            params[this.options.serverParams.length] = this.options.perPage;
            if (this.options.filter) params[this.options.serverParams.search] = this.getFilters();
            params[this.options.serverParams.sort] = this.getSort() || "";
            params[this.options.serverParams.sortDir] = this.getSortDir();

            // extra params ?
            if (this.meta?.[this.options.serverParams.paramsKey]) {
                params = Object.assign(params, this.meta[this.options.serverParams.paramsKey]);
            }
        }

        appendParamsToUrl(url, params);

        return fetch(url).then((response) => {
            if (!response.ok) {
                throw new Error(response.statusText || labels.networkError);
            }
            return response.json();
        });
    }

    renderTable() {
        this.log("render table");

        if (this.options.menu && this.plugins.ContextMenu) {
            this.plugins.ContextMenu.createMenu();
        }

        let sortedColumn;

        this.renderHeader();
        if (this.options.defaultSort) {
            // We can have a default sort even with sort disabled
            sortedColumn = this.querySelector(`thead tr.dg-head-columns th[field="${this.options.defaultSort}"]`);
        }

        if (sortedColumn) {
            this.sortData(sortedColumn);
        } else {
            this.renderBody();
        }

        this.renderFooter();
    }

    /**
     * Create table header
     * - One row for the column headers
     * - One row for the filters
     */
    renderHeader() {
        this.log("render header");

        const thead = this.querySelector("thead");
        this.createColumnHeaders(thead);
        this.createColumnFilters(thead);

        if (this.options.resizable && this.plugins.ColumnResizer) {
            this.plugins.ColumnResizer.renderResizer(labels.resizeColumn);
        }

        dispatch(this, "headerRendered");
    }

    renderFooter() {
        this.log("render footer");

        const tfoot = this.querySelector("tfoot");
        const td = tfoot.querySelector("td");
        tfoot.removeAttribute("hidden");
        setAttribute(td, "colspan", this.columnsLength(true));
        tfoot.style.display = "";
    }

    /**
     * Create the column headers based on column definitions and set options
     * @param {HTMLTableSectionElement} thead
     */
    createColumnHeaders(thead) {
        // @link https://stackoverflow.com/questions/21064101/understanding-offsetwidth-clientwidth-scrollwidth-and-height-respectively
        const availableWidth = this.clientWidth;
        const colMaxWidth = Math.round((availableWidth / this.columnsLength(true)) * 2);

        let idx = 0;
        let tr;

        // Create row
        tr = ce("tr");
        this.headerRow = tr;
        tr.setAttribute("role", "row");
        tr.setAttribute("aria-rowindex", "1");
        tr.setAttribute("class", "dg-head-columns");

        // We need a real th from the dom to compute the size
        let sampleTh = thead.querySelector("tr.dg-head-columns th");
        if (!sampleTh) {
            sampleTh = ce("th");
            thead.querySelector("tr").appendChild(sampleTh);
        }

        if (this.options.selectable && this.plugins.SelectableRows) {
            this.plugins.SelectableRows.createHeaderCol(tr);
        }
        if (this.options.responsive && this.plugins.ResponsiveGrid && this.plugins.ResponsiveGrid.hasHiddenColumns()) {
            this.plugins.ResponsiveGrid.createHeaderCol(tr);
        }

        // Create columns
        idx = 0;
        let totalWidth = 0;

        for (const column of this.options.columns) {
            if (column.attr) {
                continue;
            }
            const colIdx = idx + this.startColIndex();
            const th = ce("th");
            th.setAttribute("scope", "col");
            th.setAttribute("role", "columnheader button");
            th.setAttribute("aria-colindex", `${colIdx}`);
            th.setAttribute("id", randstr("dg-col-"));
            if (this.options.sort) {
                th.setAttribute("aria-sort", "none");
            }
            th.setAttribute("field", column.field);
            if (this.plugins.ResponsiveGrid && this.options.responsive) {
                setAttribute(th, "data-responsive", column.responsive || "");
            }
            // Make sure the header fits (+ add some room for sort icon if necessary)
            const computedWidth = getTextWidth(column.title, sampleTh, true) + 20;
            th.dataset.minWidth = `${computedWidth}`;
            applyColumnDefinition(th, column);
            th.tabIndex = 0;
            th.textContent = column.title;

            let w = 0;
            // Autosize small based on first/last row ?
            // Take into account minWidth of the header and max available size based on col numbers
            if (this.options.autosize && this.plugins.AutosizeColumn) {
                const colAvailableWidth = Math.min(availableWidth - totalWidth, colMaxWidth);
                w = this.plugins.AutosizeColumn.computeSize(
                    th,
                    column,
                    Number.parseInt(th.dataset.minWidth),
                    colAvailableWidth,
                );
            } else {
                w = Math.max(Number.parseInt(th.dataset.minWidth), Number.parseInt(th.getAttribute("width")));
            }

            setAttribute(th, "width", w);
            if (column.hidden) {
                th.setAttribute("hidden", "");
            } else {
                totalWidth += w;
            }

            // Reorder columns with drag/drop
            if (this.options.reorder && this.plugins.DraggableHeaders) {
                this.plugins.DraggableHeaders.makeHeaderDraggable(th);
            }

            tr.appendChild(th);
            idx++;
        }

        // There is too much available width, and we want to avoid fixed layout to split remaining amount
        if (totalWidth < availableWidth) {
            const visibleCols = findAll(tr, "th:not([hidden],.dg-not-resizable)");
            if (visibleCols.length) {
                const lastCol = visibleCols[visibleCols.length - 1];
                removeAttribute(lastCol, "width");
            }
        }

        // Actions
        if (this.options.actions.length && this.plugins.RowActions) {
            this.plugins.RowActions.makeActionHeader(tr);
        }

        thead.replaceChild(tr, thead.querySelector("tr.dg-head-columns"));

        // Once columns are inserted, we have an actual dom to query
        if (thead.offsetWidth > availableWidth) {
            this.log(`adjust width to fix size, ${thead.offsetWidth} > ${availableWidth}`);
            const scrollbarWidth = this.offsetWidth - this.clientWidth;
            let diff = thead.offsetWidth - availableWidth - scrollbarWidth;
            if (this.options.responsive && this.plugins.ResponsiveGrid) {
                diff += scrollbarWidth;
            }
            // Remove diff for columns that can afford it
            const thWithWidth = findAll(tr, "th[width]");

            for (const th of thWithWidth) {
                if (hasClass(th, "dg-not-resizable")) {
                    continue;
                }
                if (diff <= 0) {
                    continue;
                }
                const actualWidth = Number.parseInt(th.getAttribute("width"));
                const minWidth = th.dataset.minWidth ? Number.parseInt(th.dataset.minWidth) : 0;
                if (actualWidth > minWidth) {
                    let newWidth = actualWidth - diff;
                    if (newWidth < minWidth) {
                        newWidth = minWidth;
                    }
                    diff -= actualWidth - newWidth;
                    setAttribute(th, "width", newWidth);
                }
            }
        }

        // Context menu
        if (this.options.menu && this.plugins.ContextMenu) {
            this.plugins.ContextMenu.attachContextMenu();
        }

        // Sort col on click
        const rowsWithSort = findAll(tr, "[aria-sort]");
        for (const sortableRow of rowsWithSort) {
            sortableRow.addEventListener("click", () => this.sortData(sortableRow));
        }

        setAttribute(this.querySelector("table"), "aria-colcount", this.columnsLength(true));
    }

    createColumnFilters(thead) {
        let idx = 0;
        let tr;

        // Create row for filters
        tr = ce("tr");
        this.filterRow = tr;
        tr.setAttribute("role", "row");
        tr.setAttribute("aria-rowindex", "2");
        tr.setAttribute("class", "dg-head-filters");
        if (!this.options.filter) {
            tr.setAttribute("hidden", "");
        }

        if (this.options.selectable && this.plugins.SelectableRows) {
            this.plugins.SelectableRows.createFilterCol(tr);
        }
        if (this.options.responsive && this.plugins.ResponsiveGrid && this.plugins.ResponsiveGrid.hasHiddenColumns()) {
            this.plugins.ResponsiveGrid.createFilterCol(tr);
        }

        for (const column of this.options.columns) {
            if (column.attr) {
                continue;
            }
            const colIdx = idx + this.startColIndex();
            const relatedTh = thead.querySelector(`tr.dg-head-columns th[aria-colindex="${colIdx}"]`);
            if (!relatedTh) {
                console.warn("Related th not found", colIdx);
                continue;
            }
            const th = ce("th");
            th.setAttribute("aria-colindex", `${colIdx}`);

            const filter = this.createFilterElement(column, relatedTh);
            if (!this.options.filter) {
                th.tabIndex = 0;
            } else {
                filter.tabIndex = 0;
            }

            if (column.hidden) {
                th.setAttribute("hidden", "");
            }

            th.appendChild(filter);
            tr.appendChild(th);
            idx++;
        }

        // Actions
        if (this.options.actions.length && this.plugins.RowActions) {
            this.plugins.RowActions.makeActionFilter(tr);
        }

        thead.replaceChild(tr, thead.querySelector("tr.dg-head-filters"));

        if (typeof this.options.filterKeypressDelay !== "number" || this.options.filterOnEnter)
            this.options.filterKeypressDelay = 0;

        // Filter content by field events
        const filteredRows = findAll(tr, this._filterSelector);
        for (const el of filteredRows) {
            const eventName = /select/i.test(el.tagName) ? "change" : "keyup";
            const eventHandler = debounce((e) => {
                const key = e.keyCode || e.key;
                const isKeyPressFilter = !this.options.filterOnEnter && !this._excludedKeys.some((k) => k === key);
                if (key === 13 || key === "Enter" || isKeyPressFilter || e.type === "change") {
                    this.filterData.call(this);
                }
            }, this.options.filterKeypressDelay);
            el.addEventListener(eventName, eventHandler);
        }
    }

    createFilterElement(column, relatedTh) {
        const isSelect = column.filterType === "select";
        const filter = isSelect ? ce("select") : ce("input");
        if (isSelect) {
            if (!Array.isArray(column.filterList)) {
                // Gets unique values from column records
                const uniqueValues = [...new Set((this.data ?? []).map((e) => e[column.field]))]
                    .filter((v) => v)
                    .sort();
                column.filterList = [column.firstFilterOption || this.defaultColumn.firstFilterOption].concat(
                    uniqueValues.map((e) => ({ value: e, text: e })),
                );
            }

            for (const e of column.filterList) {
                const opt = ce("option");
                opt.value = e.value;
                opt.text = e.text;

                if (filter instanceof HTMLSelectElement) {
                    filter.add(opt);
                }
            }
        } else {
            //@ts-ignore
            filter.type = "text";
            filter.inputMode = "search";
            filter.autocomplete = "off";
            filter.spellcheck = false;
        }
        // Allows binding filter to this column
        filter.dataset.name = column.field;
        filter.id = randstr("dg-filter-");
        // Don't use aria-label as it triggers autocomplete
        filter.setAttribute("aria-labelledby", relatedTh.getAttribute("id"));
        return filter;
    }

    /**
     * Render the data as rows in tbody
     * It will call paginate() at the end
     */
    renderBody() {
        this.log("render body");
        let tr;
        let td;
        let idx;
        const tbody = ce("tbody");

        this.data.forEach((item, i) => {
            tr = ce("tr");
            setAttribute(tr, "role", "row");
            setAttribute(tr, "hidden", "");
            setAttribute(tr, "aria-rowindex", i + 1);
            tr.tabIndex = 0;

            if (this.options.selectable && this.plugins.SelectableRows) {
                this.plugins.SelectableRows.createDataCol(tr);
            }
            if (
                this.options.responsive &&
                this.plugins.ResponsiveGrid &&
                this.plugins.ResponsiveGrid.hasHiddenColumns()
            ) {
                this.plugins.ResponsiveGrid.createDataCol(tr);
            }

            // Expandable
            if (this.options.expand) {
                tr.classList.add("dg-expandable");

                on(tr, "click", (ev) => {
                    if (this.plugins.ResponsiveGrid) {
                        this.plugins.ResponsiveGrid.blockObserver();
                    }
                    toggleClass(ev.currentTarget, "dg-expanded");
                    if (this.plugins.ResponsiveGrid) {
                        this.plugins.ResponsiveGrid.unblockObserver();
                    }
                });
            }

            idx = 0;

            for (const column of this.options.columns) {
                if (!column) {
                    console.error("Empty column found!", this.options.columns);
                }
                // It should be applied as an attr of the row
                if (column.attr) {
                    if (item[column.field]) {
                        // Special case if we try to write over the class attr
                        if (column.attr === "class") {
                            addClass(tr, item[column.field]);
                        } else {
                            tr.setAttribute(column.attr, item[column.field]);
                        }
                    }
                    return;
                }
                td = ce("td");
                td.setAttribute("role", "gridcell");
                td.setAttribute("aria-colindex", `${idx}${this.startColIndex()}`);
                applyColumnDefinition(td, column);
                // This is required for pure css responsive layout
                td.setAttribute("data-name", column.title);
                td.tabIndex = -1;

                // Inline editing ...
                if (column.editable && this.plugins.EditableColumn) {
                    addClass(td, "dg-editable-col");
                    this.plugins.EditableColumn.makeEditableInput(td, column, item, i);
                } else {
                    // ... or formatting
                    const v = item[column.field] ?? "";
                    let tv;
                    // TODO: make this modular
                    switch (column.transform) {
                        case "uppercase":
                            tv = v.toUpperCase();
                            break;
                        case "lowercase":
                            tv = v.toLowerCase();
                            break;
                        default:
                            tv = v;
                            break;
                    }
                    if (column.format) {
                        // Only use formatting with values or if defaultFormatValue is set
                        if (column.defaultFormatValue !== undefined && (tv === "" || tv === null)) {
                            tv = `${column.defaultFormatValue}`;
                        }
                        if (typeof column.format === "string" && tv) {
                            td.innerHTML = interpolate(
                                // @ts-ignore
                                column.format,
                                Object.assign(
                                    {
                                        _v: v,
                                        _tv: tv,
                                    },
                                    item,
                                ),
                            );
                        } else if (column.format instanceof Function) {
                            const val = column.format.call(this, { column, rowData: item, cellData: tv, td, tr });
                            td.innerHTML = val || tv || v;
                        }
                    } else {
                        td.textContent = tv;
                    }
                }
                tr.appendChild(td);
                idx++;
            }

            // Actions
            if (this.options.actions.length && this.plugins.RowActions) {
                this.plugins.RowActions.makeActionRow(tr, item);
            }

            tbody.appendChild(tr);
        });

        tbody.setAttribute("role", "rowgroup");

        // Keep data empty message
        const prev = this.querySelector("tbody");
        tbody.setAttribute("data-empty", prev.getAttribute("data-empty"));
        this.querySelector("table").replaceChild(tbody, prev);

        if (this.plugins.FixedHeight) {
            this.plugins.FixedHeight.createFakeRow();
        }

        this.paginate();

        if (this.plugins.SelectableRows) {
            this.plugins.SelectableRows.shouldSelectAll(tbody);
        }

        this.data.length && this.classList.remove("dg-empty");

        dispatch(this, "bodyRendered");
    }

    paginate() {
        this.log("paginate");

        const total = this.totalRecords();
        const p = this.page || 1;

        let index;
        let high = p * this.options.perPage;
        let low = high - this.options.perPage + 1;
        const tbody = this.querySelector("tbody");
        const tfoot = this.querySelector("tfoot");

        if (high > total) {
            high = total;
        }
        if (!total) {
            low = 0;
        }

        // Display all rows within the set indexes
        // For server side paginated grids, we display everything
        // since the server is taking care of actual pagination
        const bodyRows = findAll(tbody, "tr");
        for (const tr of bodyRows) {
            if (this.options.server) {
                removeAttribute(tr, "hidden");
                continue;
            }
            index = Number(getAttribute(tr, "aria-rowindex"));
            if (index > high || index < low) {
                setAttribute(tr, "hidden", "");
            } else {
                removeAttribute(tr, "hidden");
            }
        }

        if (this.options.selectable && this.plugins.SelectableRows) {
            this.plugins.SelectableRows.clearCheckboxes(tbody);
        }

        // Store default height and update styles if needed
        if (this.plugins.FixedHeight) {
            this.plugins.FixedHeight.updateFakeRow();
        }

        // Enable/disable buttons if shown
        if (this.btnFirst) {
            this.btnFirst.disabled = this.page <= 1;
            this.btnPrev.disabled = this.page <= 1;
            this.btnNext.disabled = this.page >= this.pages;
            this.btnLast.disabled = this.page >= this.pages;
        }
        tfoot.querySelector(".dg-low").textContent = low.toString();
        tfoot.querySelector(".dg-high").textContent = high.toString();
        tfoot.querySelector(".dg-total").textContent = `${this.totalRecords()}`;
        tfoot.toggleAttribute("hidden", this.options.autohidePager && this.options.perPage > this.totalRecords());
    }

    /**
     * @returns {number}
     */
    totalPages() {
        return Math.ceil(this.totalRecords() / this.options.perPage);
    }

    /**
     * @returns {number}
     */
    totalRecords() {
        if (this.options.server) {
            return this.meta?.[this.options.serverParams.metaFilteredKey] || 0;
        }
        return this.data.length;
    }
}

export default DataGrid;