@browser.style/data-grid
Version:
Dynamic data grid component with sorting, filtering, and pagination support
296 lines (247 loc) • 11.4 kB
JavaScript
import { icons } from './icons.js';
import { calculatePages, t } from './utility.js';
export function applySorting(context, data) {
const { sortIndex, sortOrder } = context.state;
const { locale } = context.settings;
if (sortIndex > -1) {
const { type = 'string' } = context.state.thead[sortIndex] || {};
data.sort((a, b) => {
const A = a[context.state.thead[sortIndex].field] ?? '';
const B = b[context.state.thead[sortIndex].field] ?? '';
switch (type) {
case 'number':
return Number(A) - Number(B);
case 'date':
return new Date(A) - new Date(B);
case 'boolean':
return (A === B) ? 0 : A ? 1 : -1;
case 'currency':
const numA = parseFloat(A.replace(/[^0-9.-]+/g, ""));
const numB = parseFloat(B.replace(/[^0-9.-]+/g, ""));
return numA - numB;
case 'percentage':
const percentA = parseFloat(A.replace('%', ''));
const percentB = parseFloat(B.replace('%', ''));
return percentA - percentB;
default:
// Default to string sorting
return String(A).localeCompare(String(B), locale, { sensitivity: 'variant' });
}
});
// Reverse order if sortOrder is descending
if (sortOrder === 1) data.reverse();
}
return data;
}
export function filterData(context, data) {
const { thead } = context.state;
const hiddenIndices = thead.reduce((acc, cell, index) => cell.hidden ? [...acc, index] : acc, []);
const searchterm = context.getAttribute('searchterm')?.toLowerCase()?.trim();
const method = context.getAttribute('searchmethod') || 'includes';
const searchMethods = {
start: (cell, term) => cell.startsWith(term),
end: (cell, term) => cell.endsWith(term),
equals: (cell, term) => cell === term,
includes: (cell, term) => cell.includes(term),
};
const searchMethod = searchMethods[method] || searchMethods.includes;
if (!searchterm) return data;
return data.filter(row =>
Object.values(row).some((cell, index) => {
if (hiddenIndices.includes(index) || cell == null || typeof cell === 'object') return false;
return searchMethod(cell.toString().toLowerCase().trim(), searchterm);
})
);
}
function paginate(context, data) {
if (context.settings.externalNavigation) return data;
const { page, itemsPerPage } = context.state;
const startIndex = page * itemsPerPage;
return data.slice(startIndex, startIndex + itemsPerPage);
}
export function renderColGroup(colgroup, cols) {
try {
colgroup.innerHTML = new Array(cols).fill('<col>').join('');
} catch (error) {
console.error(`Error in colGroup: ${error}`);
}
}
export function renderColumnFilter(context) {
if (!context.form?.elements.columnfilter) return;
const columnfilter = context.form.elements.columnfilter;
columnfilter.innerHTML = `
${context.state.thead.map((cell, index) => {
const checked = cell.hidden ? '' : 'checked';
return `<label><input type="checkbox" name="${cell.field}" ${checked}>${cell.label || cell}</label>`;
}).join('')}`;
}
export function renderTable(context) {
try {
context.log(`render: table`, '#52B', context.settings.debug);
renderColGroup(context.colgroup, context.state.cols);
renderColumnFilter(context);
renderTHead(context);
renderTBody(context);
} catch (error) {
context.log(`Error rendering table: ${error}`, '#F00', context.settings.debug);
}
}
export function renderTBody(context) {
try {
const { tbody, thead, cols, selected } = context.state;
if (!tbody.length) return;
// Remove existing popovers
context.querySelectorAll('.ui-table-expand').forEach(popover => popover.remove());
// Filter for visible columns
const visibleColumns = thead.filter(cell => !cell.hidden);
context.state.cols = visibleColumns.length + (context.settings.selectable ? 1 : 0);
// Filter and sort the data
let data = filterData(context, [...tbody]);
if (context.settings.sortable) {
applySorting(context, data);
}
// If no data is found, show "no result" message
if (!data.length) {
context.table.tBodies[0].innerHTML = `<tr><td colspan="${cols}">${t('noResult', context.lang, context.i18n)}</td></tr>`;
Object.assign(context.state, { pageItems: 0, items: 0, pages: 0 });
updateNavigation(context, true);
return;
}
// Use the paginate function to slice the data for the current page
const page = paginate(context, data);
// Get the fields that make up the composite key/s
const keyFields = thead.filter(cell => cell.key).map(cell => cell.field);
const searchterm = context.getAttribute('searchterm')?.toLowerCase();
// If externalNavigation is disabled, or a search term is present, update the state.
if (!context.settings.externalNavigation || searchterm) {
const updatedState = {
pageItems: page.length,
};
// If there is a search term, use search-specific state variables.
if (searchterm) {
updatedState.searchItems = data.length;
updatedState.searchPages = calculatePages(data.length, context.state.itemsPerPage);
} else {
// Revert to original full dataset state if search is cleared.
updatedState.items = data.length;
updatedState.pages = calculatePages(data.length, context.state.itemsPerPage);
updatedState.searchItems = null;
updatedState.searchPages = null;
}
Object.assign(context.state, updatedState);
}
// Determine the first visible column for checkbox handling
const firstVisibleColumnIndex = visibleColumns[0] ? thead.indexOf(visibleColumns[0]) : 0;
const lastVisibleColumnIndex = context.settings.selectable ? visibleColumns.length : visibleColumns.length - 1;
// Generate HTML for table body
const tbodyHTML = page.map((row) => {
// Construct the composite key/s for this row
const rowKeys = keyFields.map(field => row[field]).join(',');
// Check if the current row is selected based on the composite key/s
const rowSelected = selected.has(rowKeys) ? ' aria-selected' : '';
// Build the row HTML by iterating over visible columns
let rowHTML = Object.values(row).map((cell, index) => {
if (thead[index].hidden) return '';
const isEditable = thead[index].editable;
const classList = thead[index].classList ? ` class="${thead[index].classList}"` : '';
const formatter = context.formatters?.[thead[index].formatter] || ((value) => value);
const selectable = (context.settings.selectable && index === firstVisibleColumnIndex) ?
`<td><label><input type="checkbox" tabindex="-1"${rowSelected ? ` checked` : ''} data-toggle-row></label></td>` : '';
let cellValue = (cell === null || cell === 'null' || cell === undefined) ? '' : cell.toString();
// Apply search term highlighting if applicable
cellValue = searchterm ? cellValue.replace(new RegExp(`(${searchterm})`, 'gi'), '<mark>$1</mark>') : cellValue;
// Pass both cell value and entire row to formatter
return `${selectable}<td tabindex="-1"${classList}${isEditable ? ` contenteditable`:''}>${formatter(cellValue, row)}</td>`;
}).join('');
// Handle the expand / popover feature
if (context.settings.expandable) {
const expandFields = thead
.map((header) => header.hidden ? `<p><strong>${header.label}</strong><br>${row[header.field]}</p>` : '')
.filter(content => content !== '')
.join('');
if (expandFields) {
// Generate a unique popover ID and insert the expand button
const popoverId = `p${window.crypto.randomUUID()}`;
const buttonHTML = ` <button type="button" tabindex="-1" popovertarget="${popoverId}">${context.renderIcon(icons.dots)}</button>`;
const popoverHTML = `
<div id="${popoverId}" popover class="ui-table-expand ${context.settings.expandType ? context.settings.expandType : '--inline-end'}">
<button type="button" popovertarget="${popoverId}" popovertargetaction="hide" class="--icon">${context.renderIcon(icons.close)}</button>
${expandFields}
</div>
`;
context.wrapper.insertAdjacentHTML('beforeend', popoverHTML);
// Insert the button into the last rendered <td> by appending to it
const cellHTML = rowHTML.split('</td>');
cellHTML[lastVisibleColumnIndex] = cellHTML[lastVisibleColumnIndex].replace(
/(<td[^>]*>)(.*)/,
`$1<span class="ui-table-expand--trigger"><span>$2</span>${buttonHTML}</span>`
);
rowHTML = cellHTML.join('</td>');
}
}
// Return the row HTML with the composite key/s in `data-keys`
return `<tr${rowSelected} data-keys="${rowKeys}">${rowHTML}</tr>`;
}).join('');
// Inject the newly generated HTML into the table body
context.table.tBodies[0].innerHTML = tbodyHTML;
// Log the rendering process for debugging purposes
context.log(`render: tbody`, '#584');
// Update the navigation UI
updateNavigation(context);
} catch (error) {
context.log(`Error rendering table body (tbody): ${error}`, '#F00');
}
}
export function renderTHead(context) {
try {
const { thead } = context.state;
const { selectable } = context.settings;
let firstVisibleColumnFound = false;
const selectableHeader = selectable ? `<th tabindex="0"><label><input type="checkbox" tabindex="-1" data-toggle-all></label></th>` : '';
const tableHeaderHTML = thead.reduce((html, cell) => {
if (cell.hidden) return html;
const tabIndex = !firstVisibleColumnFound ? 0 : -1;
firstVisibleColumnFound = true;
return html + `<th tabindex="${tabIndex}"${cell.key ? ` data-key` : ''} data-field="${cell.field}" data-sort-index="${thead.indexOf(cell)}"><span>${cell.label || cell}</span></th>`;
}, `<tr>${selectableHeader}`) + '</tr>';
context.table.tHead.innerHTML = tableHeaderHTML;
if (selectable) {
context.toggle = context.table.querySelector('input[data-toggle-all]');
}
context.log(`render: thead`, '#56F');
} catch (error) {
context.log(`Error rendering table header (thead): ${error}`, '#F00');
}
}
export function updateNavigation(context, noData = false) {
try {
const { page, itemsPerPage, searchItems, searchPages } = context.state;
const E = context.form.elements;
// Check if a search is active and use search-specific values if available
const isSearchActive = !!context.getAttribute('searchterm');
const totalItems = noData ? 0 : (isSearchActive ? context.state.searchItems : context.state.items);
const totalPages = noData ? 0 : (isSearchActive ? context.state.searchPages : context.state.pages);
const isItemsPresent = !!totalItems;
const isItemsPerPagePresent = context.hasAttribute('itemsperpage');
// Toggle visibility based on whether items are present
E.navigation.hidden = !isItemsPerPagePresent || !isItemsPresent;
// Only show selection if both conditions are met: selectable is true AND there are items
E.selection.hidden = !context.settings.selectable || !isItemsPresent;
if (isItemsPresent) {
// Update navigation elements based on current page and total items
E.end.value = Math.min((page + 1) * itemsPerPage, totalItems);
E.page.setAttribute('max', totalPages);
E.page.size = (page + 1).toString().length;
E.page.value = page + 1;
E.pages.value = totalPages;
E.start.value = page * itemsPerPage + 1;
E.first.disabled = page === 0;
E.stepdown.disabled = page === 0;
E.stepup.disabled = page === totalPages - 1;
E.last.disabled = page === totalPages - 1;
E.total.value = totalItems;
}
} catch (error) {
context.log(`Error updating navigation: ${error}`, '#F00', context.settings.debug);
}
}