UNPKG

@financial-times/o-table

Version:

Provides styling and behvaiour for tables across FT products.

639 lines (588 loc) 21.6 kB
import Delegate from 'ftdomdelegate'; /** * Append rows to table. * * @access private * @param {Element} tbody - The table body to append the row batch to. * @param {Array} rowBatch - An array of rows to append to the table body. * @returns {void} */ function append(tbody, rowBatch) { if (tbody.append) { tbody.append(...rowBatch); } else { rowBatch.forEach(row => tbody.appendChild(row)); } } /** * Prepend rows to table. * * @access private * @param {Element} tbody - The table body to prepend the row batch to. * @param {Array} rowBatch - An array of rows to prepend to the table body. * @returns {void} */ function prepend(tbody, rowBatch) { if (tbody.prepend) { tbody.prepend(...rowBatch); } else { rowBatch.reverse().forEach(row => { tbody.insertBefore(row, tbody.firstChild); }); } } class BaseTable { /** * Sort the given table. * * @typedef TableSorter * @access public * @param {BaseTable} table - The table instance to sort. * @param {number} columnIndex - The index of the table column to sort. * @param {string} sortOrder - How to sort the column, "ascending" or "descending" * @param {number} batch [100] - Deprecated. No longer used. How many rows to render at once when sorting. * @returns {void} */ /** * The shared functionality of all `o-table` variants. * * @access public * @param {HTMLElement} rootEl - The `o-table` element. * @param {TableSorter} sorter a TableSorter instance * @param {object} opts [{}] * @param {boolean} opts.sortable [true] * @returns {BaseTable} the new base table */ constructor(rootEl, sorter, opts = {}) { this._listeners = []; this._sorter = sorter; this.rootEl = rootEl; this._opts = Object.assign({ sortable: this.rootEl.getAttribute('data-o-table-sortable') !== 'false', preferredSortOrder: this.rootEl.getAttribute('data-o-table-preferred-sort-order') }, opts); this.thead = this.rootEl.querySelector('thead'); this.tbody = this.rootEl.querySelector('tbody'); this.tableCaption = this.rootEl.querySelector('caption'); this.tableHeaders = this.thead ? Array.from( this.thead.querySelectorAll('tr:last-of-type > th') ) : []; this.tableRows = this.tbody ? Array.from(this.tbody.getElementsByTagName('tr')) : []; this._filteredTableRows = []; this.wrapper = this.rootEl.closest('.o-table-scroll-wrapper'); this.container = this.rootEl.closest('.o-table-container'); this.overlayWrapper = this.rootEl.closest('.o-table-overlay-wrapper'); this.filterContainer = this.wrapper || this.container; this._updateTableHeightListenerSet = false; /** * @property {object | null} _currentSort - The current sort applied. * @property {number} _currentSort.columnIndex - The index of the currently sorted column. * @property {string} _currentSort.sortOrder - The type of sort, "ascending" or "descending" */ this._currentSort = null; /** * @property {object | null} _currentFilter - The filter currently applied. * @property {number} _currentFilter.columnIndex - The index of the column which is filtered. * @property {string | Function} _currentFilter.filter - The filter applied. */ this._currentFilter = null; // Defer filter setup. window.setTimeout(this._setupFilters.bind(this), 0); } /** * Apply and add event listeners to any filter controls for this table. * E.g. form inputs with the data attribute `[data-o-table-filter-id="tableId"]` * * @access private */ _setupFilters() { const tableId = this.rootEl.getAttribute('id'); if (!tableId) { return; } // Do nothing if no filter is found for this table. const filter = window.document.querySelector(`[data-o-table-filter-id="${tableId}"]`); if (!filter) { return; } // Do not setup filter if markup is missing. if (!this.filterContainer) { // eslint-disable-next-line no-console console.warn(`Could not setup the filter for the table "${tableId}" as markup is missing. A filterable table must be within a div with class "o-table-container".`); return; } // Warn if a misconfigured filter was found. const filterColumn = parseInt(filter.getAttribute('data-o-table-filter-column'), 10); if (isNaN(filterColumn)) { // eslint-disable-next-line no-console console.warn(`Could not setup the filter for the table "${tableId}" as no column index was given to filter on. Add a \`data-o-table-filter-column="{columnIndex}"\` attribute to the filter.`, filter); return; } // Apply the filter . if (filter.value) { this.filter(filterColumn, filter.value); } // Add a listener to filter the table. let pendingFilterTimeout; const debouncedFilterHandler = function(event) { if (pendingFilterTimeout) { clearTimeout(pendingFilterTimeout); } pendingFilterTimeout = setTimeout(function () { this.filter(filterColumn, event.target.value || ''); pendingFilterTimeout = null; }.bind(this), 33); }.bind(this); filter.addEventListener('input', debouncedFilterHandler); filter.addEventListener('change', debouncedFilterHandler); this._listeners.push({ element: filter, debouncedFilterHandler, type: 'input' }); this._listeners.push({ element: filter, debouncedFilterHandler, type: 'change' }); } /** * Update the o-table instance with rows added or removed dynamically from * the table. Applies any existing sort and filter to new rows. * * @returns {void} */ updateRows() { const rows = this._getLatestRowNodes(); // Set o-table rows. this.tableRows = rows; // Re-apply sort. if (this._currentSort) { const { columnIndex, sortOrder } = this._currentSort; this.sortRowsByColumn(columnIndex, sortOrder); } // Re-apply filter. if (this._currentFilter) { const { columnIndex, filter } = this._currentFilter; this._filterRowsByColumn(columnIndex, filter); } // Render rows. this.renderRowUpdates(); } /** * Get all the table body's current row nodes. * * @returns {Array<Node>} all the trs * @access private */ _getLatestRowNodes() { return this.tbody ? Array.from(this.tbody.getElementsByTagName('tr')) : []; } /** * Updates the dom to account for row updates, including when their sort * order changes, or any filter is applied. E.g. changes the dom order, * applies aria-labels to hidden rows, updates the table height to * efficiently hide them. * * Note this does not calculate which rows should be sorted or filtered, * and does not look for new rows added to the dom. See `updateRows`. * * @see updateRows * @returns {void} */ renderRowUpdates() { this._updateRowAriaHidden(); this._hideFilteredRows(); this._updateTableHeight(); this._updateRowOrder(); } /** * Hide filtered rows by updating the container height. * Filtered rows are not removed from the table so column width is not * recalculated. Unfortunately "visibility: collaposed" has inconsistent * support. */ _updateTableHeight() { if (!this.filterContainer) { // eslint-disable-next-line no-console console.warn(`The table has missing markup. A responsive or filterable table must be within a div with class "o-table-container".`, this.rootEl); return; } if (this._updateTableHeightScheduled) { window.cancelAnimationFrame(this._updateTableHeightScheduled); } const tableHeight = this._getTableHeight(); this._updateTableHeightScheduled = window.requestAnimationFrame(function () { this.filterContainer.style.height = !isNaN(tableHeight) ? `${tableHeight}px` : ''; }.bind(this)); // If the table height has been set, it should be updated on resize, // so listen to resize events. if (!this._updateTableHeightListenerSet) { // debounce the height update on resize // breaking: on the next major release use the o-utils // debounce function instead let pendingTableHeightUpdate; const updateTableHeightDebounced = function () { if (pendingTableHeightUpdate) { clearTimeout(pendingTableHeightUpdate); } pendingTableHeightUpdate = setTimeout(function () { this._updateTableHeight(); }.bind(this), 33); }.bind(this); // set the resize listener window.addEventListener('resize', updateTableHeightDebounced); // add to the listeners array so it can be removed on `.destroy` this._listeners.push({ element: window, updateTableHeightDebounced, type: 'resize' }); // set a flag so the listener isn't set again, without having to // loop through `this._listeners`. this._updateTableHeightListenerSet = true; } } /** * Get the table height, accounting for "hidden" rows. * * @returns {number} the height in pixels */ _getTableHeight() { const tableHeight = this.rootEl.getBoundingClientRect().height; const filteredRowsHeight = this._rowsToHide.reduce((accumulatedHeight, row) => { return accumulatedHeight + row.getBoundingClientRect().height; }, 0); return tableHeight - filteredRowsHeight; } /** * Update the "aria-hidden" attribute of all hidden table rows. * Rows may be hidden for a number of reasons, including being filtered. */ _updateRowAriaHidden() { if (this._updateRowAriaHiddenScheduled) { window.cancelAnimationFrame(this._updateRowAriaHiddenScheduled); } const rowsToHide = this._rowsToHide || []; this._updateRowAriaHiddenScheduled = window.requestAnimationFrame(function () { this.tableRows.forEach((row) => { row.setAttribute('aria-hidden', rowsToHide.indexOf(row) !== -1); }); }.bind(this)); } /** * Hide filtered rows by updating the "data-o-table-filtered" attribute. * Filtered rows are removed from the table using CSS so column width is * not recalculated. */ _hideFilteredRows() { if (this._hideFilteredRowsScheduled) { window.cancelAnimationFrame(this._hideFilteredRowsScheduled); } const filteredRows = this._filteredTableRows || []; this._hideFilteredRowsScheduled = window.requestAnimationFrame(function () { this.tableRows.forEach((row) => { row.setAttribute('data-o-table-filtered', filteredRows.indexOf(row) !== -1); }); }.bind(this)); } /** * Updates the order of table rows in the DOM. This is required upon sort, * but also on filter as hidden rows must be at the bottom of the table. * Otherwise the stripped pattern of the stripped table is not maintained. */ _updateRowOrder() { if (this._updateRowOrderScheduled) { window.cancelAnimationFrame(this._updateRowOrderScheduled); } if (this._updateRowOrderFilrtedBatchScheduled) { window.cancelAnimationFrame(this._updateRowOrderFilrtedBatchScheduled); } if (!this._currentSort && !this._currentFilter) { return; } const nonFilteredRows = this.tableRows.filter(row => this._filteredTableRows.indexOf(row) === -1); this._updateRowOrderScheduled = window.requestAnimationFrame(function () { // Move all non-filtered rows to the top, with current sort order. prepend(this.tbody, nonFilteredRows); this._updateRowOrderFilrtedBatchScheduled = window.requestAnimationFrame(function () { // Move all filtered rows to the bottom, with current sort order. append(this.tbody, this._filteredTableRows); }.bind(this)); }.bind(this)); } /** * Filter the table and render the result. * * @access public * @param {number} headerIndex - The index of the table column to filter. * @param {string | Function} filter - How to filter the column (either a string to match or a callback function). * @returns {undefined} */ filter(headerIndex, filter) { this._filterRowsByColumn(headerIndex, filter); this.renderRowUpdates(); } /** * Filters the table rows by a given column and filter. * This does not render the result to the DOM. * * @access private * @param {number} columnIndex - The index of the table column to filter. * @param {string | Function} filter - How to filter the column (either a string to match or a callback function). * @returns {undefined} */ _filterRowsByColumn(columnIndex, filter) { this._currentFilter = { columnIndex, filter }; if (typeof filter !== 'string' && typeof filter !== 'function') { throw new Error(`Could not filter table column "${columnIndex}". Expected the filter to a string or function.`, this); } // Filter column headings. this._filteredTableRows = []; this.tableRows.forEach(row => { const cell = row.querySelector(`td:nth-of-type(${columnIndex + 1})`); if (cell) { const hideRow = BaseTable._filterMatch(cell, filter); if (hideRow) { this._filteredTableRows.push(row); } } }); } /** * Check if a given table cell matches the table filter. * * @access private * @param {Element} cell - The table cell to test the filter function against. * @param {string | Function} filter - The filter, either a string or callback function. * @returns {boolean} does the fliter match? */ static _filterMatch(cell, filter) { // If the filter is a string create a filter function which: // - Always matches an emtpy string (no filter). // - Matches against only alpha numeric characters and ".". // - Case insentivie. // - Whitespace insentivie. if (typeof filter === 'string') { const filterValue = filter.replace(/[^\w\.]+/g, '').toLowerCase(); filter = (cell) => { const cellValue = cell.textContent.replace(/[^\w\.]+/g, '').toLowerCase(); return filterValue ? cellValue.indexOf(filterValue) > -1 : true; }; } // Check if the filter matches the given table cell. return filter(cell) !== true; } /** * Which rows are hidden, e.g. by a filter. * * @returns {Array<Node>} the rows that should be hidden */ get _rowsToHide() { return this._filteredTableRows; } /** * Gets a table header for a given column index. * * @access public * @param {number} columnIndex - The index of the table column to get the header for. * @throws When no header is not found. * @returns {HTMLElement} the table header */ getTableHeader(columnIndex) { const th = this.tableHeaders[columnIndex]; if (!th) { throw new Error(`Could not find header for column index "${columnIndex}".`); } return th; } /** * Sort the table. * * @access public * @param {number} columnIndex - The index of the table column to sort. * @param {number} sortOrder - How to sort the column, "ascending" or "descending" * @returns {undefined} */ sortRowsByColumn(columnIndex, sortOrder) { /** * Fires an "oTable.sorting" event. The sorting event can be cancelled to * provide a totally custom method of sorting the table. * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent */ const defaultSort = this._dispatchEvent('sorting', { sort: sortOrder, columnIndex }, { cancelable: true }); if (defaultSort) { this._sorter.sortRowsByColumn(this, columnIndex, sortOrder); } } /** * Add sort buttons to the DOM within the table header. * * @returns {undefined} */ addSortButtons() { if (!this._opts.sortable) { return; } // Create buttons for each table header. const tableHeaderButtons = this.tableHeaders.map((th) => { // Don't add sort buttons to unsortable columns. if (th.hasAttribute('data-o-table-heading-disable-sort')) { return null; } // Don't add sort buttons to columns with no headings. if (!th.hasChildNodes()) { return null; } // Move heading text into button. const headingNodes = Array.from(th.childNodes); const headingHTML = headingNodes.reduce((html, node) => { // Maintain child elements of the heading which make sense in a button. const maintainedElements = ['ABBR', 'B', 'BDI', 'BDO', 'BR', 'CODE', 'CITE', 'DATA', 'DFN', 'DEL', 'EM', 'I', 'S', 'SMALL', 'SPAN', 'STRONG', 'SUB', 'SUP', 'TIME', 'U', 'VAR', 'WBR']; if (node.nodeType === Node.ELEMENT_NODE && maintainedElements.indexOf(node.nodeName) !== -1) { return html + node.outerHTML; } // Otherwise return text content. if (node.nodeType === Node.ELEMENT_NODE) { // eslint-disable-next-line no-console console.warn(`o-table has removed the element "${node.nodeName}" from the table heading to add a sort button on the column. Please remove this element from your table heading, disable sort on this column, or contact the Origami team for help.`, th); } return html + node.textContent; }, ''); const sortButton = document.createElement('button'); sortButton.innerHTML = headingHTML; sortButton.classList.add('o-table__sort'); // In VoiceOver, button `aria-label` is repeated when moving from one column of tds to the next. // Using `title` avoids this, but risks not being announced by other screen readers. const nextSort = this._getNextSortOrder(th); sortButton.setAttribute('title', `sort table by "${th.textContent}" ${nextSort}`); return sortButton; }); // Add sort buttons to table headers. window.requestAnimationFrame(function (){ this.rootEl.classList.add('o-table--sortable'); this.tableHeaders.forEach((th, index) => { if (tableHeaderButtons[index]) { th.innerHTML = ''; th.appendChild(tableHeaderButtons[index]); } }); }.bind(this)); // Add click event to buttons. const listener = this._sortButtonHandler.bind(this); this._rootElDomDelegate = this._rootElDomDelegate || new Delegate(this.rootEl); this._rootElDomDelegate.on('click', '.o-table__sort', listener); } /** * Indicate that the table has been sorted after intercepting the sorting event. * * @access public * @param {object} sortDetails - Details of the current sort state. * @param {number | null} sortDetails.columnIndex - The index of the currently sorted column. * @param {string | null} sortDetails.sortOrder - The type of sort, "ascending" or "descending" */ sorted({ columnIndex, sortOrder }) { if (isNaN(columnIndex)) { throw new Error(`Expected a numerical column index but received "${columnIndex}".`); } if (!sortOrder) { throw new Error(`Expected a sort order e.g. "ascending" or "descending".`); } this._currentSort = { sortOrder, columnIndex }; // Update the button title to reflect the new sort. const th = this.getTableHeader(columnIndex); const sortButton = th.querySelector('button'); if (sortButton) { let buttonTitle = sortButton.getAttribute('title'); buttonTitle = sortOrder === 'ascending' ? buttonTitle.replace('ascending', 'descending') : buttonTitle.replace('descending', 'ascending'); sortButton.setAttribute('title', buttonTitle); } this._dispatchEvent('sorted', this._currentSort); } /** * Gets the instance ready for deletion. * Removes event listeners that were added during instatiation of the component. * * @access public * @returns {undefined} */ destroy() { if (this._rootElDomDelegate) { this._rootElDomDelegate.destroy(); } this._listeners.forEach(({ type, listener, element }) => { element.removeEventListener(type, listener); }); // Remove DOM references. delete this.thead; delete this.tbody; delete this.tableHeaders; delete this.tableRows; delete this._filteredTableRows; delete this.wrapper; delete this.container; delete this.overlayWrapper; delete this.filterContainer; } /** * Indicate that the table has been constructed successfully. * * @returns {undefined} */ _ready() { this._dispatchEvent('ready'); } /** * Column sort orders are toggled. For a given column heading, return * the next sort order which should be applied. * * @param {Element} th - The heading for the column to be sorted. * @returns {string} - What the next sort order for the heading should be, 'ascending' or 'descending'. */ _getNextSortOrder(th) { // Get the current table sort. Use the `aria-sort` attribute // which may have been applied by a client or server side sort. const currentSort = th.getAttribute('aria-sort'); // If there is no existing sort use a descending sort if that has been // configured as a preferred sort order for the given heading. const noExistingSort = [null, 'none'].indexOf(currentSort) !== -1; if (noExistingSort && this._opts.preferredSortOrder === 'descending') { return 'descending'; } // Otherwise the next sort will be ascending by default, or descending // if the column is already sorted ascending. return currentSort !== 'ascending' ? 'ascending' : 'descending'; } /** * Handles a sort button click event. Toggles column sort. * * @param {MouseEvent} event - The click event. * @returns {undefined} */ _sortButtonHandler(event) { const sortButton = event.target; const th = sortButton.closest('th'); const columnIndex = this.tableHeaders.indexOf(th); if (th && !isNaN(columnIndex)) { const sortOrder = this._getNextSortOrder(th); this.sortRowsByColumn(columnIndex, sortOrder); } } /** * Helper function to dispatch namespaced events. * * @param {string} event - The event name within `oTable` e.g. "sorted". * @param {object} data - Event data. `instance` is added automatically. * @param {object} opts - [Event options]{@link https://developer.mozilla.org/en-US/docs/Web/API/Event/Event#Values} (o-table events bubble by default). * @returns {boolean} false is cancelable and canceled, otherwise true */ _dispatchEvent(event, data = {}, opts = {}) { Object.assign(data , { instance: this }); return this.rootEl.dispatchEvent(new CustomEvent(`oTable.${event}`, Object.assign({ detail: data, bubbles: true }, opts))); } } export default BaseTable;