UNPKG

@financial-times/o-table

Version:

Provides styling and behvaiour for tables across FT products.

335 lines (316 loc) 11.5 kB
/** * Extracts the contents of img alt text. * * @example String argument for example purposes only, to represent a HTMLElement. * extractAltFromImages('<img alt="text">'); // text * @param {HTMLElement} cell The DOM node to operate on, possibly a <td> * @access private * @returns {HTMLElement} the parameter */ function extractAltFromImages(cell){ const images = Array.from(cell.getElementsByTagName('img')); images.forEach(image => { const contents = image.getAttribute('alt'); image.insertAdjacentHTML('beforebegin', contents); image.remove(); }); return cell; } /** * Returns the text represantation of an HTML node. * If a node contains no `dateTime` attribute, content, `aria-label` or `title` attributes of <a>, <span>, or <i> child nodes are used. * * @example String argument for example purposes only, to represent a HTMLElement. * extractText('<i class="o-icons-icon o-icons-icon--mail"><a href="mailto:example@ft.com" title="Email Example at example@ft.com"></a>'); //Email Example at example@ft.com * extractText('<span class="o-icons-icon o-icons-icon--tick">Correct</span>'); //Correct * extractText('<span class="o-icons-icon o-icons-icon--tick" title="Correct"></span>'); //Correct * extractText('<span class="o-icons-icon o-icons-icon--tick" aria-label="Correct"></span>'); //Correct * extractText('<time class="o-date" data-o-component="o-date" datetime="2020-06-19T07:56:18Z">2 hours ago</time>'); //Correct * @param {HTMLElement} cell The DOM node to operate on, possibly a <td> * @access private * @returns {HTMLElement} text representation of the HTML node */ function extractText(cell){ const time = cell.querySelector('time'); if (time && time.dateTime) { const date = new Date(time.dateTime); if (!isNaN(date.getTime())){ return String(date.getTime()); } } let text = cell.textContent.trim(); // No text found, check aria labels and titles. // Useful for icon-only cells. if (text === '') { const nodes = cell.querySelectorAll('a, span, i'); text = Array.from(nodes).reduce((accumulator, node) => { const nodeText = node.getAttribute('aria-label') || node.getAttribute('title'); return nodeText ? `${accumulator} ${nodeText}` : accumulator; }, ''); } return text.trim(); } /** * Returns the text with abbreviations expanded. * Supports million 'm', billion 'bn' (1,000 million), and trillion 'tn' (1,000 billion). * * @example * expandAbbreviations('1m') //1000000 * expandAbbreviations('1.2bn') //2200000000 * expandAbbreviations('1tn') //1000000000000 * expandAbbreviations('5m-10m') //5000000-10000000 * @param {string} text The string to operate on * @access private * @returns {string} Text with any supported abbreviations expanded */ function expandAbbreviations(text) { text = text.replace(/([\d,.]+)([a-zA-Z]+)/g, (match, digit, abbreviation) => { const zeros = { 'm': 6, 'bn': 9, 'tn': 12 }; return `${digit * Math.pow(10, zeros[abbreviation] || 0)}`; }); return text; } /** * Returns the text with digit group separators removed. * * @example * removeDigitGroupSeparator('1,000') //1000 * removeDigitGroupSeparator('40') //40 * removeDigitGroupSeparator('4,000,000') //4000000 * @param {string} text The string to operate on * @access private * @returns {string} Text with digit group separators (commas) removed. */ function removeDigitGroupSeparators(text) { return text.replace(/,/g, ''); } /** * Returns the text with non-number characters removed (e.g. currency symbols). * Does not effect range characters e.g. "–" will be maintained. * If no digits were found to remove, returns the text unchanged. * * @example * extractDigitsIfFound('Rmb100') //100 * extractDigitsIfFound('CFA Fr830') //830 * extractDigitsIfFound('HK$12') //12 * extractDigitsIfFound('HK$12-HK$20') //12–20 * extractDigitsIfFound('1534956593-1534956620') //1534956593–1534956620 * extractDigitsIfFound('Some text') //Some text * extractDigitsIfFound('Some text 123') //123 * @param {string} text The string to operate on * @access private * @returns {string} Text with digits characters only. */ function extractDigitsIfFound(text) { const digitsAndRange = text.replace(/([^\d.,\-\–]+)/g, ''); if (digitsAndRange === '') { return text; } return digitsAndRange; } /** * Returns a number from a range * * @example * removeRange('1534956593–1534956620') //1534956593 * removeRange('123–345') //123 * removeRange('123') //123 * removeRange('No numbers') //No numbers * @param {string} text The string to operate on * @access private * @returns {number|string} the extracted number, or the original string */ function extractNumberFromRange(text) { const number = parseFloat(text); return isNaN(number) ? text : number; } /** * Parses FT style date and time and formats as a number for sorting. * FT date or date and time returns a UNIX epoch (UTC). * FT time returns a positive float for pm, negative for am. * * @example * ftDateTimeToNumber('August 17') //UNIX epoch, assumes current year * ftDateTimeToNumber('September 12 2012') //UNIX epoch * ftDateTimeToNumber('January 2012') //UNIX epoch, first of month * ftDateTimeToNumber('March 12 2015 1am') //UNIX epoch including time * ftDateTimeToNumber('April 20 2014 1.30pm') //UNIX epoch including time * ftDateTimeToNumber('1am') //1 * ftDateTimeToNumber('1.30am') //1.3 * ftDateTimeToNumber('1.40pm') //13.4 * ftDateTimeToNumber('3pm') //15 * ftDateTimeToNumber('Not a known date') //Note a known date * @param {string} text The string to operate on * @access private * @returns {number} Number representation of date and/or time for sorting. */ function ftDateTimeToNumber(text) { const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; // FT style for writing dates: is June 23 2016 (no commas, month date year) const date = text.match(/^([A-Za-z]{3,})(?:[\s])(?=[\d])((?:\d{1,2})?(?![\d]))?(?:\s)?(\d{4})?/); // FT style for writing time: // The 12 hour clock should be used: 1am, 9.30pm const time = text.match(/(?:\s|^)(\d{1,2}(?:[.](\d{2}))?)(pm|am)$/); // Get date. const month = date && date[1] ? date[1] : null; // Get index of the month name from a given month e.g. 'January' for 'Jan'. let monthIndex = null; if (month) { for (let index = 0; index < monthNames.length; index++) { const name = monthNames[index]; if (name.startsWith(month)) { monthIndex = index; break; } } } const day = date && date[2] ? parseInt(date[2], 10) : null; let year = date && date[3] ? parseInt(date[3], 10) : null; if (month && !year) { // For sorting purposes, assume a month is for this year if not otherwise specified. year = new Date().getFullYear(); } // Get time. const hour = time && time[1] ? parseInt(time[1], 10) : null; const minute = time && time[2] ? parseInt(time[2], 10) : null; const period = time ? time[3] : null; const twentyFourHour = hour && period === 'pm' ? hour + 12 : hour; // Sort number for FT formated time. if (hour && !(year && monthIndex)) { return parseFloat(`${twentyFourHour}.${minute}`); } if (year !== null || monthIndex !== null || day !== null || twentyFourHour !== null || minute !== null) { // Unix epoch to sort FT formated date. const dateObj = new Date(Date.UTC(year, monthIndex, day, twentyFourHour, minute)); return isNaN(dateObj.getTime()) ? text : dateObj.getTime(); } else { return text; } } /** * Removes and number of asterisk's which are at the end of the line. * * @example * removeRefereneAsterisk('Durian*') //Durian * removeRefereneAsterisk('1,439,165.43**') //1,439,165.43 * @param {string} text The string to operate on * @access private * @returns {string} Text without source/reference asterisk. */ function removeRefereneAsterisk(text) { return text.replace(/\*+$/, ''); } /** * Removes indicators of an empty cell. * * @example * removeEmptyCellIndicators('n/a'); // * removeEmptyCellIndicators('-'); // * removeEmptyCellIndicators('Cell-content'); //Cell-content * @param {string} text The string to operate on * @access private * @returns {string} An empty string or the original text. */ function removeEmptyCellIndicators(text) { // Remove n/a text = text.replace(/^n[./]a[.]?$/i, ''); // Remove - return text === '-' ? '' : text; } /** * Group of filters to extract text from a cell. * * @param {HTMLElement} cell The node to extract sortable text from. * @access private * @returns {string} The node content to sort on. */ function extractNodeContent(cell) { const steps = [extractAltFromImages, extractText, removeRefereneAsterisk, removeEmptyCellIndicators]; let text = cell; steps.forEach(step => { text = step(text); }); return typeof text === 'string' ? text : ''; } /** * Group of filters to extract a number for sorting. * * @param {string} text The string to operate on * @access private * @returns {number | string} A number if one could a extracted, string otherwise. */ function extractNumber(text) { const steps = [removeDigitGroupSeparators, expandAbbreviations, extractDigitsIfFound, extractNumberFromRange]; steps.forEach(step => { text = step(text); }); return text; } /** * Methods to format table cells for sorting. * * @access public */ class CellFormatter { constructor () { // This object is used to keep the running order of filter methods this.filters = { text: [extractNodeContent], number: [extractNodeContent, extractNumber], percent: [extractNodeContent, extractNumber], currency: [extractNodeContent, extractNumber], numeric: [extractNodeContent, extractNumber], date: [extractNodeContent, ftDateTimeToNumber] }; } /** * The `formatFunction` take the table cell HTMLElement, * and converts it to a String or Number of sorting. * * @callback formatFunction * @param {HTMLElement} cell * @returns {string | object} */ /** * @param {string} type The data type of the cell to apply the filter function to. * @param {formatFunction} formatFunction The function to take the cell and return a sortable value (string/number). * @example * mySortFormatter.setFormatter('emoji-time', (cell) => { * const text = cell.textContent.trim(); * if (text === '🌑') { * return 1; * } * if (text === '🌤️️') { * return 2; * } * return 0; * }); * @access public */ setFormatter(type, formatFunction) { this.filters[type] = [formatFunction]; } /** * @param {object} args the argument object * @param {HTMLElement} args.cell the td to format * @param {string} args.type The data type of the cell, e.g. date, number, currency. Custom types are supported. * @see {@link setFormatter} to support add support for a custom type. * @access public * @returns {string | number} A representation of cell which can be used for sorting. */ formatCell({ cell, type = 'text' }) { type = type || 'text'; let sortValue = cell.getAttribute('data-o-table-sort-value'); if (sortValue === null) { if (this.filters[type]) { const cellClone = cell.cloneNode({ deep: true }); sortValue = cellClone; this.filters[type].forEach(fn => { sortValue = fn(sortValue); }); } cell.setAttribute('data-o-table-sort-value', sortValue); } const sortValueIsNumber = sortValue !== '' && !isNaN(sortValue); return sortValueIsNumber ? parseFloat(sortValue) : sortValue; } } export default CellFormatter;