UNPKG

@browser.style/data-grid

Version:

Dynamic data grid component with sorting, filtering, and pagination support

722 lines (626 loc) 21.9 kB
import { dataFromTable, parseData } from './modules/data.js'; import { renderTable, renderTBody, updateNavigation } from './modules/render.table.js'; import { calculatePages, consoleLog } from './modules/utility.js'; import { attachCustomEventHandlers, attachEventListeners } from './modules/events.js'; import { renderForm, renderSearch } from './modules/render.form.js'; import { printTable } from './modules/print.js'; /** * Data Grid * Wraps a HTML table element and adds functionality for sorting, pagination, searching and selection. * @author Mads Stoumann * @version 1.0.35 * @summary 18-02-2025 * @class * @extends {HTMLElement} */ export default class DataGrid extends HTMLElement { static observedAttributes = ['items', 'itemsperpage', 'page', 'searchterm', 'sortindex', 'sortorder']; constructor() { super(); this.log = (message, color) => consoleLog(message, color, this.settings.debug); this._settings = this.createSettings(); this.state = this.createState(); this.dataInitialized = false; this.lang = this.getAttribute('lang') || 'en'; this.manualTableData = false; this._i18n = { en: { all: "All", densityLarge: "Large density", densityMedium: "Medium density", densitySmall: "Small density", endsWith: "Ends with", equals: "Equals", filter: 'Column filter', first: "First", includes: "Includes", last: "Last", layoutFixed: "Fixed layout", next: "Next", noResult: "No results", of: "of", page: "Page", prev: "Previous", print: "Print", printAll: "Print All", printCurrentPage: "Print Current Page", printOptions: "Print Options", printPreview: "Print Preview", printSearch: "Print Search Results", printSelected: "Print Selected", rowsPerPage: "Rows", search: "Search", selected: "selected", selectAll: "Select all across pages", startsWith: "Starts with", textWrap: "Text wrap", } }; } async connectedCallback() { await this.loadResources(); this.setupElements(); this.settingsWatcher(this.settings); if (this.state.items > 0) { renderTable(this); } attachEventListeners(this); attachCustomEventHandlers(this); // Handle manual table data if no `data` attribute is provided if (this.manualTableData && !this.getAttribute('data')) { if (!this.hasAttribute('itemsperpage')) { this.state.itemsPerPage = this.state.items; } if (this.hasAttribute('page')) { this.state.page = parseInt(this.getAttribute('page'), 10); } renderTable(this); } this.setInitialWidths(); this.dispatchEvent(new CustomEvent('dg:loaded', { bubbles: true, detail: { message: 'DataGrid is ready' } })); } disconnectedCallback() { try { // Clean up overflow observer if (this.overflowListener) { this.overflowListener(); this.overflowListener = null; } // Clean up print preview template if (this.printPreview && this.templateId) { this.printPreview._templates.delete(this.templateId); this.printPreview._templates.delete(`${this.templateId}-settings`); } } catch (error) { this.log(`Error in disconnectedCallback: ${error}`, '#F00'); } } attributeChangedCallback(name, oldValue, newValue) { const render = (oldValue && (oldValue !== newValue)) || false; this.log(`attr: ${name}=${newValue} (${oldValue})`, '#046'); if (name === 'itemsperpage') { this.setItemsPerPage(newValue); if (render) renderTBody(this); } if (name === 'page') { if (parseInt(newValue, 10) !== this.state.page) { this.setPage(parseInt(newValue, 10)); } } if (name === 'searchterm') { if (render) { this.setAttribute('page', 0); renderTBody(this); } } if (name === 'sortindex') { this.state.sortIndex = parseInt(newValue, 10); if (oldValue === newValue) this.setAttribute('sortorder', +!this.state.sortOrder); renderTBody(this); } if (name === 'sortorder') { this.state.sortOrder = parseInt(newValue, 10); if (render) renderTBody(this); } } checkAndSetInitialPage() { try { const page = parseInt(this.getAttribute('page'), 10); if (this.state.pages > 0 && !isNaN(page) && page >= 0 && page < this.state.pages) { this.state.page = page; } } catch (error) { this.log(`Error setting initial page: ${error}`, '#F00'); } } createColgroup() { const colgroup = document.createElement('colgroup'); this.table.prepend(colgroup); return colgroup; } createForm() { const form = document.createElement('form'); form.id = `form${crypto.randomUUID()}`; form.innerHTML = renderForm(this); return form; } createSettings() { return { debug: this.hasAttribute('debug') || false, density: this.getAttribute('density') || 'medium', densityOptions: { small: { label: 'Small', icon: 'densitySmall', class: '--density-sm', i18n: 'densitySmall' }, medium: { label: 'Medium', icon: 'densityMedium', class: '--density-md', i18n: 'densityMedium' }, large: { label: 'Large', icon: 'densityLarge', class: '--density-lg', i18n: 'densityLarge' }, }, expandable: !this.hasAttribute('noexpand'), externalNavigation: this.hasAttribute('external-navigation') || false, filter: !this.hasAttribute('nofilter'), isTouch: 'ontouchstart' in window || navigator.maxTouchPoints > 0, layout: this.getAttribute('layout'), navigation: !this.hasAttribute('nonav'), pagesize: this.getAttribute('pagesize')?.split(',') || [5, 10, 25, 50, 100], pagination: !this.hasAttribute('nopage'), printable: !this.hasAttribute('noprint'), rows: !this.hasAttribute('norows'), searchable: !this.hasAttribute('nosearch'), selectable: this.hasAttribute('selectable') || false, sortable: !this.hasAttribute('nosortable'), stickyCols: this.parseStickyCols(this.getAttribute('stickycols')) || [], tableClasses: this.getAttribute('tableclasses')?.split(' ') || ['ui-table', '--th-light', '--hover-all'], textwrap: this.getAttribute('textwrap'), wrapperClasses: this.getAttribute('wrapperclasses')?.split(',') || ['ui-table-wrapper'], } } createState() { return { cellIndex: 0, cols: 0, items: 0, /* total amount of items */ itemsPerPage: parseInt(this.getAttribute('itemsperpage'), 10) || 10, page: 0, pages: 0, pageItems: 0, /* actual amount of items on the current page */ printOptions: 'all', rowIndex: 0, searchItems: 0, /* amount of items after search */ searchPages: 0, /* total pages in the current search result */ selected: new Set(), sortIndex: -1, sortOrder: 0, tbody: [], thead: [], } } createTable() { const table = document.createElement('table'); this.wrapper.appendChild(table); return table; } dispatch(name, detail) { try { this.log(`event: ${name}`, '#A0A', this.settings.debug); this.dispatchEvent(new CustomEvent(name, { detail })); } catch (error) { this.log(`Error in dispatch: ${error}`, '#F00'); } }; async fetchResource(url) { if (!url) return null; if (!this.isValidUrl(url)) return null; try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } catch (error) { this.log(`Error fetching resource: ${error.message}`); return null; } } isValidUrl(str) { // First, try to parse the string as JSON. If it succeeds, return false (as it is valid JSON, not a URL). try { JSON.parse(str); return false; } catch { // If JSON parsing fails, proceed to check if it is a valid URL. } // Now try to validate the string as a URL. try { const url = new URL(str, window.location.origin); return ['http:', 'https:', 'ftp:'].includes(url.protocol); } catch { return false; } } async loadResources() { try { const dataAttr = this.getAttribute('data'); const i18nAttr = this.getAttribute('i18n'); const schemaUrl = this.getAttribute('schema'); let dataPromise = Promise.resolve(null); // Handle `data` attribute as either a URL or JSON if (dataAttr) { if (this.isValidUrl(dataAttr)) { dataPromise = this.fetchResource(dataAttr); } else { try { dataPromise = Promise.resolve(JSON.parse(dataAttr)); } catch (jsonError) { this.log(`Invalid JSON in data attribute: ${jsonError.message}`, '#F00'); } } } // Handle `i18n` attribute as either a URL or JSON let i18nPromise; if (i18nAttr) { if (this.isValidUrl(i18nAttr)) { // Fetch if it's a URL i18nPromise = this.fetchResource(i18nAttr); } else { // Try parsing as JSON if it's not a URL try { const parsedI18n = JSON.parse(i18nAttr); i18nPromise = Promise.resolve(parsedI18n); } catch (jsonError) { this.log(`Invalid JSON in i18n attribute: ${jsonError.message}`, '#F00'); i18nPromise = Promise.resolve(null); } } } else { i18nPromise = Promise.resolve(null); } // Load all resources in parallel const [data, i18n, schema] = await Promise.all([ dataPromise, i18nPromise, schemaUrl ? this.fetchResource(schemaUrl) : Promise.resolve(null), ]); // Apply loaded data to component state this.state = { ...this.state, ...(data ? parseData(data, this) : {}) }; this.schema = schema || {}; this.i18n = { ...this._i18n, ...i18n }; this.checkAndSetInitialPage(); } catch (error) { this.log(`Error loading resources: ${error}`, '#F00'); } } navigatePage(page = null, direction = null) { try { let newPage = this.state.page; const searchtermExists = this.form?.elements.searchterm?.value?.length > 0; if (direction === 'next') { newPage = Math.min(this.state.page + 1, this.state.pages - 1); } else if (direction === 'prev') { newPage = Math.max(this.state.page - 1, 0); } if (page !== null) { newPage = Math.max(0, Math.min(page, this.state.pages - 1)); } if (this.settings.externalNavigation && !searchtermExists) { this.dispatch('dg:requestpagechange', { page: newPage, direction }); } else { this.setPage(newPage); } } catch (error) { this.log(`Error navigating to page: ${error}`, '#F00'); } } parseStickyCols(stickycolsAttr) { return stickycolsAttr ? stickycolsAttr.split(',').map(col => parseInt(col.trim(), 10)).filter(Number.isInteger) : []; } print(directPrint = false) { printTable(this, directPrint); } renderIcon(paths) { return `<svg viewBox="0 0 24 24" class="ui-icon">${paths.split(',').map(path => `<path d="${path}"></path>`).join('')}</svg>`; } resizeColumn(index, value) { try { const col = this.colgroup.children[index]; const width = (col.offsetWidth / this.table.offsetWidth) * 100; col.style.width = `${width + value}%`; } catch (error) { this.log(`Error resizing column: ${error}`, '#F00'); } } selectRows = (rows, toggle = true, force = false, shiftKey = false) => { try { if (shiftKey) { this.toggleSelection(toggle); } // Regular selection logic Array.from(rows).forEach(row => { const shouldSelect = force ? toggle : row.hasAttribute('aria-selected') ? !toggle : toggle; row.toggleAttribute('aria-selected', shouldSelect); const selected = row.hasAttribute('aria-selected'); const input = row.querySelector(`input[data-toggle-row]`); if (input) input.checked = selected; const key = row.dataset.keys; if (selected) this.state.selected.add(key); else this.state.selected.delete(key); }); this.form.elements.selected.value = this.state.selected.size; this.dispatch('dg:selection', this.state.selected); if (this.toggle) { this.toggle.indeterminate = this.state.selected.size > 0 && this.state.selected.size < this.state.pageItems; } } catch (error) { this.log(`Error selecting rows: ${error}`, '#F00'); } }; setActive = () => { try { const { rowIndex, cellIndex } = this.state; const targetCell = this.table.rows[rowIndex]?.cells[cellIndex]; if (this.active === targetCell && this.active.isContentEditable) return; if (this.active) { this.active.setAttribute('tabindex', '-1'); } this.active = targetCell; if (this.active) { this.active.setAttribute('tabindex', '0'); this.active.focus(); } } catch (error) { this.log(`Error setting active cell: ${error}`, '#F00'); } }; setInitialWidths() { try { if (!this.table || !this.colgroup) return; const calculateWidths = () => { const tableWidth = this.table.offsetWidth; Array.from(this.colgroup.children).forEach((col, index) => { if (this.settings?.colWidths && this.settings.colWidths[index] !== undefined) { col.style.width = `${this.settings.colWidths[index]}%`; } else { const cell = this.table.tHead?.rows[0]?.cells[index] || this.table.tBodies[0].rows[0].cells[index]; if (cell) { const colWidthPercentage = ((cell.offsetWidth / tableWidth) * 100).toFixed(2); col.style.width = `${colWidthPercentage}%`; } } }); }; requestAnimationFrame(calculateWidths); } catch (error) { this.log(`Error setting initial column widths: ${error}`, '#F00'); } } setItemsPerPage(itemsPerPage) { try { const newItemsPerPage = parseInt(itemsPerPage, 10) || 10; if (newItemsPerPage === this.state.itemsPerPage) return; this.state.itemsPerPage = newItemsPerPage; this.state.pages = calculatePages(this.state.items, this.state.itemsPerPage); if (this.form?.elements.itemsperpage) { this.form.elements.itemsperpage.value = newItemsPerPage; } if (parseInt(this.getAttribute('itemsperpage'), 10) !== this.state.itemsPerPage) { this.setAttribute('itemsperpage', this.state.itemsPerPage); } this.setPage(0); this.dispatch('dg:itemsperpage', this.state); } catch (error) { this.log(`Error setting items per page: ${error}`, '#F00'); } } setTotalItems(totalItems) { if (this.settings.externalNavigation && Number.isInteger(totalItems) && totalItems >= 0) { this.state.items = totalItems; this.state.pages = calculatePages(this.state.items, this.state.itemsPerPage); this.state.pageItems = Math.min(this.state.itemsPerPage, totalItems - this.state.page * this.state.itemsPerPage); this.form.elements.pages.value = this.state.pages; this.form.elements.total.value = this.state.items; } } setPage(page, forceRender = false) { try { const newPage = Math.max(0, Math.min(page, this.state.pages - 1)); if (newPage === this.state.page) return; this.state.page = newPage; const currentPage = parseInt(this.getAttribute('page'), 10); if (currentPage !== this.state.page) { this.setAttribute('page', this.state.page); } const searchtermExists = this.form?.elements.searchterm?.value?.length > 0; if (!this.settings.externalNavigation || searchtermExists || forceRender) { renderTBody(this); } if (this.settings.externalNavigation && !forceRender) { updateNavigation(this); } } catch (error) { this.log(`Error setting page: ${error}`, '#F00'); } } setupElements() { this.wrapper = document.createElement('div'); this.appendChild(this.wrapper); this.table = this.querySelector('table'); if (this.table) { this.manualTableData = true; this.state = Object.assign(this.state, dataFromTable(this.table, this.state.itemsPerPage, this.settings.selectable)); } else { this.table = this.createTable(); } if (!this.table.tHead) this.table.appendChild(document.createElement('thead')); if (!this.table.tBodies.length) this.table.appendChild(document.createElement('tbody')); this.colgroup = this.table.querySelector('colgroup') || this.createColgroup(); this.form = this.createForm(); this.appendChild(this.form); this.insertAdjacentHTML('afterbegin', renderSearch(this)); } setupOverflowListener(wrapper, table, callback) { let callbackPending = false; // Debounce flag to avoid duplicate calls const checkOverflow = () => { const isOverflowing = wrapper.scrollHeight > wrapper.clientHeight || wrapper.scrollWidth > wrapper.clientWidth; // Trigger the callback only if not pending if (!callbackPending && typeof callback === 'function') { callbackPending = true; requestAnimationFrame(() => { callback(isOverflowing); setTimeout(() => callbackPending = false, 50); // Reset pending flag after short delay }); } }; // Observe size changes on wrapper (overflow detection) const wrapperObserver = new ResizeObserver(checkOverflow); wrapperObserver.observe(wrapper); const tableObserver = new ResizeObserver(checkOverflow); tableObserver.observe(table); return () => { wrapperObserver.disconnect(); tableObserver.disconnect(); }; } setStickyCols(isOverflowing) { if (isOverflowing) { this.wrapper.classList.add('--overflowing'); let offset = 0; this.settings.stickyCols.forEach((index, i) => { const cell = this.table.tHead.rows[0].cells[index]; if (!cell) return; const cellWidth = offset + cell.offsetWidth; this.table.style.setProperty(`--c${index}`, `${offset}px`); this.table.classList.add(`--c${index}`); offset = cellWidth; }); } else { this.wrapper.classList.remove('--overflowing'); } } /** * Watches for changes in the settings and updates the UI elements accordingly. */ settingsWatcher() { try { if (!this.form || !this.table) { this.log('Form or Table element is not yet initialized.'); return; } this.settings.tableClasses.forEach(cls => this.table.classList.toggle(cls, true)); this.settings.wrapperClasses.forEach(cls => this.wrapper.classList.toggle(cls, true)); /* density */ this.form.elements.density.hidden = this.settings.density === 'none'; if (this.settings.density) { this.form.elements.density_option.value = this.settings.density; this.form.elements.density.value = this.settings.density; this.form.elements.density.dispatchEvent(new Event('change')); } /* search, print, column filter */ this.form.elements.filter.hidden = !this.settings.filter; this.form.elements.preview.hidden = !this.settings.printable; this.form.elements.print.hidden = !this.settings.printable; this.form.elements.searchmethod.hidden = !this.settings.searchable; this.form.elements.searchterm.hidden = !this.settings.searchable; /* navigation */ this.form.elements.pagination.hidden = !this.settings.pagination; this.form.elements.rows.hidden = !this.settings.rows; /* selectable */ this.form.elements.selection.hidden = !this.settings.selectable; /* sorting */ this.table.classList.toggle('--nosortable', !this.settings.sortable); /* text- and layout options */ const isLayoutFixed = this.settings.layout === 'fixed'; const isNoWrap = this.settings.textwrap === 'nowrap'; this.form.elements.layoutfixed.checked = isLayoutFixed; this.form.elements.textwrap.checked = !isNoWrap; this.table.classList.toggle('--fixed', isLayoutFixed); this.table.classList.toggle('--no-wrap', isNoWrap); /* sticky cols */ const toggleEventListener = (condition, addListener) => { if (condition && !this.overflowListener) { this.overflowListener = addListener(); } else if (!condition && this.overflowListener) { this.overflowListener(); this.overflowListener = null; } }; toggleEventListener( this.settings.stickyCols && this.settings.stickyCols.length > 0, () => this.setupOverflowListener(this.wrapper, this.table, this.setStickyCols.bind(this)) ); if (!this.settings.stickyCols || this.settings.stickyCols.length === 0) { this.wrapper.classList.remove('--overflowing'); for (let index = 0; index < this.state.cols; index++) { this.table.classList.remove(`--c${index}`); this.table.style.removeProperty(`--c${index}`); } } } catch (error) { this.log(`Error in settingsWatcher: ${error.message}`); } } /** * Toggles selection for all rows in the current tbody based on the specified toggle parameter. * This method is only called if Shift is held down during a select-all action. * * @param {boolean} select - Determines if all rows should be selected (true) or deselected (false). */ toggleSelection(select = true) { const keyFields = this.state.thead.filter(col => col.key).map(col => col.field); this.state.tbody.forEach(row => { const compositeKey = keyFields.map(field => row[field]).join(','); if (select) { this.state.selected.add(compositeKey); } else { this.state.selected.delete(compositeKey); } }); this.form.elements.selected.value = this.state.selected.size; } /* =================== Getters and Setters =================== */ /** * @param {string | any[]} newData */ set data(newData) { if (Array.isArray(newData) || (newData && typeof newData === 'object')) { if (!this.dataInitialized) { // First time: parse all data including thead this.state = { ...this.state, ...parseData(newData, this) }; renderTable(this); this.setInitialWidths(); this.dataInitialized = true; } else { // Subsequent times: only update tbody const parsed = parseData(newData, this); this.state.tbody = parsed.tbody; renderTBody(this); } } else { this.log(`Invalid data format: ${newData}`, '#F00'); } } get i18n() { return this._i18n; } set i18n(value) { if (typeof value === 'object' && value !== null) { this._i18n = value; } else { this.log('i18n should be a valid object. Defaulting to an empty object.'); this._i18n = {}; } } set settings(value) { this._settings = { ...this._settings, ...value }; this.settingsWatcher(); } get settings() { return this._settings || {}; } } customElements.define("data-grid", DataGrid);