import { Component, h, Element, Prop, State, Watch } from '@stencil/core'; import { Table, TableColumnOptions, TableRowOptions } from './table.interface'; import { validateTableColumns, validateTableRowOptions } from './utils/ontario-table-validation'; import { ConsoleMessageClass } from '../../utils/console-message/console-message'; import { ConsoleType } from '../../utils/console-message/console-message.enum'; import { extractValuesByKey, organizeObjectKeys, removeObjectsBySpecificKey } from '../../utils/helper/utils'; import { getImageAssetSrcPath } from '../../utils/helper/assets'; /** * Ontario Table presents structured tabular data with accessible semantics. * * For component guidance, see: * - https://designsystem.ontario.ca/components/detail/tables.html * - https://designsystem.ontario.ca/developer-docs/components/ontario-table/ */ @Component({ tag: 'ontario-table', styleUrl: 'ontario-table.scss', shadow: true, assetsDirs: ['./assets'], }) export class OntarioTable implements Table { @Element() element: HTMLElement; private tableScrollWrapper: HTMLElement; private tableScrollDiv: HTMLElement; private table: HTMLElement; /** * Specifies the caption (or title) of the table. * * This is optional. */ @Prop() caption?: string | undefined; /** * Used to define the columns of the table. * * @example; * * */ @Prop() tableColumns: string | TableColumnOptions[]; /** * Used to define the table body data. Note that the keys passed to the `data` object in the tableData should match the keys of the columns defined in the tableColumns prop. * * @example * * */ @Prop() tableData: string | TableRowOptions[]; /** * Indicates whether or not the table data should have alternate row zebra striping. * * This is optional. By default, zebra striping will be added when the table rows extend 5 rows. If zebra striping is needed to table rows less than 5 rows, the prop should be set to “enabled”. If no zebra stripes are needed, it should be set to “disabled”. * * The default will be set to “auto”. */ @Prop() zebraStripes?: 'auto' | 'disabled' | 'enabled' | undefined = 'auto'; /** * Used to specify whether or not table data in cells should have reduced top and bottom padding. This is useful for pages with multiple data-heavy tables such as a budget or financial data. * * This is optional. By default it will be set to “false”. */ @Prop() condensed?: boolean | undefined = false; /** * Used to specify whether or not the table should extend the full width of its container. * * This is optional. By default, it will be set to “false” */ @Prop() fullWidth?: boolean | undefined = false; @State() private tableColumnsState: TableColumnOptions[] = []; @State() private tableDataState: TableRowOptions[] = []; @State() private tableFooterState: TableRowOptions[] = []; @Watch('tableColumns') private processTableColumns() { if (this.tableColumns) this.parseTableColumns(this.tableColumns); } @Watch('tableData') private processTableData() { if (this.tableData) this.parseTableData(this.tableData); } /** * Parsse and validates the `tableColumns` prop. * If the prop is a string, it will be parsed as JSON. * Validated output is stored in `tableColumnsState`. * * If parsing or validation fails, an error message is logged and the state is set to an empty array as a fallback. */ private parseTableColumns(columns: string | TableColumnOptions[]) { try { this.tableColumnsState = typeof columns === 'string' ? JSON.parse(columns) : columns; validateTableColumns(this.tableColumnsState); } catch (error) { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addRegularText(' failed to parse ') .addMonospaceText('tableColumns') .addRegularText(' for ') .addMonospaceText('') .addRegularText(' — check for valid JSON format or structure.\n') .addMonospaceText(error.stack) .printMessage(ConsoleType.Error); this.tableColumnsState = []; // Fallback to prevent render failure } } /** * Parses and validates the `tableData` prop. * If the prop is a string, it will be parsed as JSON. * The parsed object is transformed for internal use and stored in `tableDataState` and `tableFooterState`. * * If parsing or validation fails, logs an error and sets both data states to empty arrays as a fallback. */ private parseTableData(data: string | TableRowOptions[]) { try { const parsed = typeof data === 'string' ? JSON.parse(data) : data; this.transformTableData(parsed); validateTableRowOptions(parsed); } catch (error) { const message = new ConsoleMessageClass(); message .addDesignSystemTag() .addRegularText(' failed to parse ') .addMonospaceText('tableData') .addRegularText(' for ') .addMonospaceText('') .addRegularText(' — check for valid JSON format or structure.\n') .addMonospaceText(error.stack) .printMessage(ConsoleType.Error); this.tableDataState = []; this.tableFooterState = []; // Prevents render issues when data is invalid } } private transformTableData = (tableData: TableRowOptions[]) => { // get column keys const columns = extractValuesByKey(this.tableColumnsState, 'key').filter( (column): column is keyof TableRowOptions => column !== undefined, ); // organize table data data object according to the order of the column keys const organizedTableData = organizeObjectKeys(tableData, columns); // remove the footer table data into it's own state so that it can be rendered in a `tfoot` element const [updatedTableData, tableFooterData] = removeObjectsBySpecificKey(organizedTableData, 'footer', true); this.tableDataState = updatedTableData; this.tableFooterState = tableFooterData; }; private getZebraStripeClass = () => { return this.zebraStripes === 'disabled' ? `ontario-table--no-zebra-stripes` : this.zebraStripes === 'enabled' ? `ontario-table--zebra-stripes-enabled` : ``; }; private getTableClasses = () => { let tableClass = ''; if (this.condensed && this.fullWidth) { tableClass = `ontario-table--condensed ontario-table--full-container-width`; } else if (this.condensed) { tableClass = `ontario-table--condensed`; } else if (this.fullWidth) { tableClass = `ontario-table--full-container-width`; } const zebraStripesClass = this.getZebraStripeClass(); if (tableClass) { tableClass = `${tableClass} ${zebraStripesClass}`; } else { tableClass = zebraStripesClass; } return tableClass; }; private getColumnClasses = (columnData: TableColumnOptions) => { if (columnData.type === 'numeric' && columnData.colSpan) { return `ontario-table-cell--numeric ontario-table-header--column-span-${columnData.colSpan}`; } else if (columnData.type === 'numeric') { return `ontario-table-cell--numeric`; } else if (columnData.colSpan) { return `ontario-table-header--column-span-${columnData.colSpan}`; } return; }; // A function used to generate the table header and table definitions for the table body and table footer sections. private generateTableDataHTML = ( dataType: 'tableData' | 'tableFooter', rowData: any, columns: (string | number | undefined)[], rowClass?: string | undefined, ) => { return ( {this.tableColumnsState.map((column, index) => { const type = column.type; const tdClass = type === 'numeric' ? `ontario-table-cell--numeric` : ''; return index === 0 ? ( {dataType === 'tableData' && rowData.highlight && ( )} ) : ( ); })} ); }; // Helper function to apply the scrollbar styles to the tops of tables private applyScrollbar(tableElement: Element, scrollerDiv: HTMLElement) { if (!scrollerDiv || !tableElement) return; scrollerDiv.style.visibility = 'visible'; scrollerDiv.style.height = '20px'; scrollerDiv.style.width = `${tableElement.scrollWidth}px`; } // The following logic adds scrollbar functionality to the tops of tables depending on their size. componentDidLoad() { const table = this.table; const scrollerDiv = this.tableScrollDiv; const scrollerWrapper = this.tableScrollWrapper; if (typeof window === 'undefined' || !table || !scrollerDiv || !scrollerWrapper) return; const resizeObserver = new ResizeObserver(() => { this.applyScrollbar(table, scrollerDiv); }); this.applyScrollbar(table, scrollerDiv); resizeObserver.observe(table); table.addEventListener('scroll', () => { this.applyScrollbar(table, scrollerDiv); scrollerWrapper.scrollLeft = table.scrollLeft; }); scrollerWrapper.addEventListener('scroll', () => { this.applyScrollbar(table, scrollerDiv); table.scrollLeft = scrollerWrapper.scrollLeft; }); } componentWillLoad() { this.processTableColumns(); this.processTableData(); } render() { const columns = extractValuesByKey(this.tableColumnsState, 'key'); return (
(this.tableScrollWrapper = el as HTMLElement)}>
(this.tableScrollDiv = el as HTMLElement)}>
(this.table = el as HTMLElement)}> {this.caption && } {this.tableColumnsState && ( {this.tableColumnsState.map((columnData) => { const columnClass = this.getColumnClasses(columnData); return ( ); })} )} {this.tableDataState && ( {this.tableDataState.map((rowData) => { const rowClass = rowData.highlight ? `ontario-table-row--highlight` : rowData.subtotal ? `ontario-table-row--subtotal` : undefined; return this.generateTableDataHTML('tableData', rowData, columns, rowClass); })} )} {!!this.tableFooterState.length && ( {this.tableFooterState.map((footerData) => { return this.generateTableDataHTML('tableData', footerData, columns, undefined); })} )}
{this.caption}
); } }