@financial-times/o-table
Version:
Provides styling and behvaiour for tables across FT products.
698 lines (640 loc) • 19.6 kB
JavaScript
import BaseTable from './BaseTable.js';
class OverflowTable extends 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}
*/
/**
* Initialises an `o-table` component with "overflow" responsive behaviour.
*
* @param {HTMLElement} rootEl - The `o-table` element.
* @param {TableSorter} sorter a tablesorter instance
* @param {object} opts [{}]
* @param {boolean} opts.sortable [true] - is the table sortable
* @param {undefined | boolean} opts.expanded - is the table expanded
* @param {number} opts.minimumRowCount [20] - the fewest number of rows to show
* @access public
* @returns {OverflowTable} - Your new table
*/
constructor(rootEl, sorter, opts = {}) {
super(rootEl, sorter, opts);
this._opts = Object.assign(
{
expanded: this.rootEl.hasAttribute('data-o-table-expanded')
? this.rootEl.getAttribute('data-o-table-expanded') !== 'false'
: null,
minimumRowCount: this.rootEl.getAttribute(
'data-o-table-minimum-row-count'
),
},
this._opts
);
// Add scroll and expander controls immediately.
this._addControlsToDom();
// Defer other tasks.
window.setTimeout(this.addSortButtons.bind(this), 0);
window.setTimeout(this._setupScroll.bind(this), 0);
window.setTimeout(this._setupExpander.bind(this), 0);
this._ready();
return this;
}
/**
* Filter the table.
*
* @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();
}
/**
* Check if the table is expanded (true) or collapsed (false).
*
* @access public
* @returns {boolean} is the table expanded?
*/
isExpanded() {
const expand =
this._expand === undefined
? Boolean(this._opts.expanded)
: Boolean(this._expand);
return this.canExpand() && expand;
}
/**
* Check if the table is collapsed (true) or expanded (false).
*
* @access public
* @returns {boolean} is the table contracted?
*/
isContracted() {
const expand =
this._expand === undefined
? Boolean(this._opts.expanded)
: Boolean(this._expand);
return this.canExpand() && !expand;
}
/**
* Check if the table supports the expand/contract feature.
*
* @access public
* @returns {boolean} can the table expand and contract?
*/
canExpand() {
return (
typeof this._opts.expanded === 'boolean' &&
this._minimumRowCount <
this.tableRows.length - this._filteredTableRows.length
);
}
/**
* Updates the dom to account for row updates.
*
* @returns {undefined}
*/
renderRowUpdates() {
this._updateExpander();
this._updateRowAriaHidden();
this._hideFilteredRows();
this._updateTableHeight();
this._updateRowOrder();
}
_updateExpander() {
if (typeof this._opts.expanded !== 'boolean' || !this.controls) {
return;
}
if (this._expanderUpdateScheduled) {
window.cancelAnimationFrame(this._expanderUpdateScheduled);
}
const expand = this.isExpanded();
const contract = this.isContracted();
const canExpand = expand || contract;
const expanderButtonContainer = this.controls.expanderButton;
const expanderButton = expanderButtonContainer.querySelector('button');
this._updateTableHeight();
this._updateRowAriaHidden();
this._updateControls();
this._expanderUpdateScheduled = window.requestAnimationFrame(
function () {
this.rootEl.setAttribute('data-o-table-expanded', Boolean(expand));
this.container.classList.toggle('o-table-container--expanded', expand);
this.container.classList.toggle(
'o-table-container--contracted',
contract
);
expanderButton.style.display = canExpand ? '' : 'none';
if (!canExpand) {
this.rootEl.removeAttribute('aria-expanded');
}
if (expand) {
expanderButton.textContent = 'Show fewer';
this.rootEl.setAttribute('aria-expanded', true);
}
if (contract) {
expanderButton.textContent = 'Show more';
this.rootEl.setAttribute('aria-expanded', false);
}
}.bind(this)
);
}
/**
* Hides table rows if the table can be expanded.
*
* @access public
* @returns {void}
*/
contractTable() {
if (!this.canExpand()) {
return;
}
this._expand = false;
this._updateExpander();
}
/**
* Expands the table, revealing hidden table rows, if it can be expanded and has been contracted.
*
* @access public
* @returns {void}
*/
expandTable() {
if (!this.canExpand()) {
return;
}
this._expand = true;
this._updateExpander();
}
/**
* Get the table height, accounting for "hidden" rows.
*
* @returns {number | null} the height
*/
_getTableHeight() {
if (this.isContracted()) {
const maxTableHeight = super._getTableHeight();
if (
!this._contractedWrapperHeight ||
this._contractedWrapperHeight > maxTableHeight
) {
const rowsToHide = this._rowsToHide;
const buttonHeight =
this.controls.expanderButton.getBoundingClientRect().height;
const extraHeight = rowsToHide
? rowsToHide[0].getBoundingClientRect().height / 2
: 0;
this._contractedWrapperHeight =
maxTableHeight + buttonHeight + extraHeight;
}
return this._contractedWrapperHeight;
}
if (this.isExpanded()) {
const buttonHeight =
this.controls.expanderButton.getBoundingClientRect().height;
return super._getTableHeight() + buttonHeight;
}
return super._getTableHeight();
}
/**
* Add controls such as the back, forward, "show more" buttons to DOM,
* plus wrappers needed for them to function.
*
* @returns {undefined}
*/
_addControlsToDom() {
if (this.overlayWrapper && !this.controls) {
const supportsArrows = OverflowTable._supportsArrows();
const overlayWrapperHtml = `
${
this.wrapper
? `
<div class="o-table-overflow-fade-overlay"></div>
`
: ''
}
<div class="o-table-overflow-control-overlay">
${
this.wrapper && supportsArrows
? `
<div class="o-table-control o-table-control--back o-table-control--hide">
<button aria-label="visually scroll table back" disabled="true"></button>
</div>
`
: ''
}
${
this.wrapper && supportsArrows
? `
<div class="o-table-control o-table-control--forward o-table-control--hide">
<button aria-label="visually scroll table forward" disabled="true"></button>
</div>
`
: ''
}
${
typeof this._opts.expanded === 'boolean'
? `
<div class="o-table-control o-table-control--expander">
<button>Show fewer</button>
</div>
`
: ''
}
</div>
`;
const range = document.createRange();
range.selectNode(this.overlayWrapper);
const overlayFragment =
range.createContextualFragment(overlayWrapperHtml);
this.controls = {
controlsOverlay: overlayFragment.querySelector(
'.o-table-overflow-control-overlay'
),
fadeOverlay: overlayFragment.querySelector(
'.o-table-overflow-fade-overlay'
),
expanderButton: overlayFragment.querySelector(
'.o-table-control--expander'
),
forwardButton: overlayFragment.querySelector(
'.o-table-control--forward'
),
backButton: overlayFragment.querySelector('.o-table-control--back'),
};
// Add controls to the dom.
this._updateControlOverlayPosition();
window.requestAnimationFrame(
function () {
this.overlayWrapper.appendChild(overlayFragment);
}.bind(this)
);
}
}
_updateControlOverlayPosition() {
const theadHeight = this.thead
? this.thead.getBoundingClientRect().height
: 0;
const captionHeight = this.tableCaption
? this.tableCaption.getBoundingClientRect().height
: 0;
window.requestAnimationFrame(
function () {
this.controls.controlsOverlay.style['top'] = `${
theadHeight + captionHeight
}px`;
}.bind(this)
);
}
/**
* Add functionality to improve the experience when scrolling a table,
* such as showing forward/back buttons to indicate that scroll is possible.
*
* @returns {undefined}
*/
_setupScroll() {
// Does not warn of a missing wrapper: assumes no overflow is desired.
if (this.container && this.overlayWrapper && !this.wrapper) {
// eslint-disable-next-line no-console
console.warn(
'Controls to scroll table left/right could not be added to "o-table" as it is missing markup. ' +
'Please add the container and wrapper elements according to the documentation https://registry.origami.ft.com/components/o-table.',
{table: this.rootEl}
);
}
// Can not add controls without a container or wrapper.
if (!this.container || !this.overlayWrapper || !this.wrapper) {
return;
}
// Add table controls (e.g. left/right button).
if (!this.controls) {
this._addControlsToDom();
}
// Add forward button behaviour.
if (this.controls.forwardButton) {
const scrollForward = function () {
this.wrapper.scrollBy({
left: document.body.clientWidth / 2,
behavior: 'smooth',
});
}.bind(this);
this.controls.forwardButton.addEventListener('click', scrollForward);
this._listeners.push({
element: this.controls.forwardButton,
scrollForward,
type: 'click',
});
}
// Add back button behaviour.
if (this.controls.backButton) {
const scrollBackward = function () {
this.wrapper.scrollBy({
left: -(document.body.clientWidth / 2),
behavior: 'smooth',
});
}.bind(this);
this.controls.backButton.addEventListener('click', scrollBackward);
this._listeners.push({
element: this.controls.backButton,
scrollBackward,
type: 'click',
});
}
// Set scroll position and update controls.
const updateScroll = function () {
if (!this._controlUpdateScheduled) {
this._controlUpdateScheduled = true;
window.setTimeout(
function () {
this._controlUpdateScheduled = false;
this._fromEnd =
this.wrapper.scrollWidth -
this.wrapper.clientWidth -
this.wrapper.scrollLeft;
this._fromStart = this.wrapper.scrollLeft;
this._updateControls();
}.bind(this),
33
);
}
}.bind(this);
updateScroll();
// Observe controls scrolling beyond table and update.
if (this.controls.controlsOverlay && window.IntersectionObserver) {
// Fade forward/back buttons at start and end of table.
const arrowFadeObserverConfig = {
root: this.controls.controlsOverlay,
threshold: 1.0,
rootMargin: `-20px 0px ${this.canExpand() ? '0px' : '-20px'} 0px`,
};
const arrowFadeObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
entry.target.setAttribute(
'data-o-table-intersection',
entry.intersectionRatio !== 1
);
updateScroll();
});
}, arrowFadeObserverConfig);
if (this.controls.backButton) {
arrowFadeObserver.observe(this.controls.backButton);
}
if (this.controls.forwardButton) {
arrowFadeObserver.observe(this.controls.forwardButton);
}
}
// Add other event listeners to update controls.
this.wrapper.addEventListener('scroll', updateScroll);
window.addEventListener('resize', updateScroll);
window.addEventListener('load', updateScroll);
this._listeners.push({element: this.wrapper, updateScroll, type: 'scroll'});
this._listeners.push({element: window, updateScroll, type: 'resize'});
this._listeners.push({element: window, updateScroll, type: 'load'});
}
/**
* Add hide/show functionality for long tables.
*
* @returns {undefined}
*/
_setupExpander() {
if (typeof this._opts.expanded !== 'boolean') {
return;
}
if (!this.container || !this.overlayWrapper || !this.wrapper) {
throw new Error(
'Controls to expand/contract the table could not be added to "o-table" as it is missing markup.' +
'Please add the container and wrapper element according to the documentation https://registry.origami.ft.com/components/o-table.'
);
}
// Add table controls (e.g. "more" button).
if (!this.controls) {
this._addControlsToDom();
}
if (this.controls.expanderButton) {
const toggleExpanded = function () {
if (this.isExpanded()) {
const expanderButtonContainer = this.controls.expanderButton;
const buttonOffset =
expanderButtonContainer.getBoundingClientRect().top;
this.contractTable();
window.requestAnimationFrame(() => {
const top =
window.pageYOffset +
expanderButtonContainer.getBoundingClientRect().top -
buttonOffset;
window.scroll(null, top);
});
} else {
this.expandTable();
}
}.bind(this);
this.controls.expanderButton.addEventListener('click', toggleExpanded);
this._listeners.push({
element: this.controls.expanderButton,
toggleExpanded,
type: 'click',
});
}
this._updateExpander();
}
/**
* Update all controls and their overlays,
* e.g. forward/back arrow visibility, visibility of arrow dock, overlay fade.
*
* @returns {undefined}
*/
_updateControls() {
if (!this.controls) {
return;
}
// Toggle fade.
const canScrollTable = this._canScrollTable;
window.requestAnimationFrame(
function () {
this.controls.fadeOverlay.classList.toggle(
'o-table-overflow-fade-overlay--scroll',
canScrollTable
);
this.controls.fadeOverlay.style.setProperty(
'--o-table-fade-from-end',
`${Math.min(this._fromEnd, 10)}px`
);
this.controls.fadeOverlay.style.setProperty(
'--o-table-fade-from-start',
`${Math.min(this._fromStart, 10)}px`
);
}.bind(this)
);
// Toggle arrow dock.
const showArrowDock = this._showArrowDock;
window.requestAnimationFrame(
function () {
this.controls.controlsOverlay.classList.toggle(
'o-table-overflow-control-overlay--arrow-dock',
showArrowDock
);
}.bind(this)
);
// Update forward/back scroll controls.
if (OverflowTable._supportsArrows()) {
this._updateScrollControl(this.controls.forwardButton);
this._updateScrollControl(this.controls.backButton);
}
// Update controls overlay to cover the body.
this._updateControlOverlayPosition();
}
/**
* Update the visibility of a scroll forward/back button.
*
* @param {HTMLElement} element - The button wrapper.
* @returns {undefined}
*/
_updateScrollControl(element) {
const showStickyArrows = this._stickyArrows;
const canScrollTable = this._canScrollTable;
const arrowsDocked = this._showArrowDock && !showStickyArrows;
const scrolledToBoundary =
(this._fromEnd <= 0 && element === this.controls.forwardButton) ||
(this._fromStart <= 0 && element === this.controls.backButton);
const hideAtBoundary =
!arrowsDocked &&
(!this._stickyArrows ||
(this._stickyArrows && !this._canScrollPastTable));
const outsideTable =
element.getAttribute('data-o-table-intersection') === 'true';
const elementButton = element.querySelector('button');
window.requestAnimationFrame(() => {
// Show scroll control if the table does not fit within the viewport.
element.style.display = canScrollTable ? '' : 'none';
// Make arrows sticky if table is tall and can be scrolled past.
element.classList.toggle('o-table-control--sticky', showStickyArrows);
// Place the arrows in the dock if they are not sticky.
element.classList.toggle('o-table-control--dock', arrowsDocked);
// Hide scroll control if they are outside the table boundry.
// E.g. the table has been scrolled past.
if (outsideTable) {
elementButton.setAttribute('disabled', true);
element.classList.add('o-table-control--hide');
}
// Show scroll control if they are inside the table and the table is scrollable.
if (!scrolledToBoundary && !outsideTable) {
elementButton.removeAttribute('disabled');
element.classList.remove('o-table-control--hide');
}
// Disable scroll control if it is inside the table but scrolled to the end horizontally.
if (scrolledToBoundary && !outsideTable) {
elementButton.setAttribute('disabled', true);
element.classList.toggle('o-table-control--hide', hideAtBoundary);
}
});
}
/**
* The number of rows to display if the table is collapsed.
*
* @returns {number} the number of rows, or 20
*/
get _minimumRowCount() {
const minimumRowCount = this._opts.minimumRowCount;
return isNaN(parseInt(minimumRowCount, 10))
? 20
: parseInt(minimumRowCount, 10);
}
/**
* Which rows are hidden, either by a filter or by the expander.
*
* @returns {Node[]} the hidden trs
*/
get _rowsToHide() {
return [...this._filteredTableRows, ...this._rowsHiddenByExpander];
}
/**
* The rows which will be hidden if the table is collapsed.
*
* @returns {Node[]} the rows that will disappear when collapsing
*/
get _rowsHiddenByExpander() {
const visibleRowCount = Math.min(
this.tableRows.length,
this._minimumRowCount
);
const nonFilteredRows = this.tableRows.filter(
row => this._filteredTableRows.indexOf(row) === -1
);
return this.isContracted()
? nonFilteredRows.slice(visibleRowCount, nonFilteredRows.length)
: [];
}
/**
* Check if the table can be scrolled.
*
* @returns {boolean} can the table be scrolled?
*/
get _canScrollTable() {
return this._fromEnd > 0 || this._fromStart > 0;
}
/**
* Check if the table can fit within the viewport vertically.
*
* @returns {boolean} is the table too big for the viewport?
*/
get _tableTallerThanViewport() {
return (
this.container.getBoundingClientRect().height >
document.documentElement.clientHeight
);
}
/**
* Check if the document is long enough to scroll beyond the table enough for sticky arrows to dock at the bottom.
* I.e. Scroll past the table by at least 50% of the viewport.
*
* @returns {boolean} is the table so big that the viewport can scroll past it by over 50%?
*/
get _canScrollPastTable() {
return (
this.container.getBoundingClientRect().bottom +
document.documentElement.clientHeight / 2 <
document.documentElement.getBoundingClientRect().bottom
);
}
/**
* Check if the "dock" at the bottom of the table should be shown.
* After scrolling past the table, sticky arrows sit within the dock at the bottom of the table.
*
* @returns {boolean} should the dock be shown?
*/
get _showArrowDock() {
return (
OverflowTable._supportsArrows() &&
this._canScrollTable &&
this._canScrollPastTable &&
this.canExpand()
);
}
/**
* Check if left/right controls should be sticky.
*
* @returns {boolean} does the browser support stickiness, and is the table big?
*/
get _stickyArrows() {
return OverflowTable._supportsArrows() && this._tableTallerThanViewport;
}
/**
* Check if sticky buttons are supported.
*
* @returns {boolean} is stickiness supported by the user's browser?
*/
static _supportsArrows() {
return (
typeof CSS !== 'undefined' &&
(CSS.supports('position', 'sticky') ||
CSS.supports('position', '-webkit-sticky'))
);
}
}
export default OverflowTable;