UNPKG

@hashicorp/design-system-components

Version:
496 lines (480 loc) 24.3 kB
import Component from '@glimmer/component'; import { action } from '@ember/object'; import { assert } from '@ember/debug'; import { tracked } from '@glimmer/tracking'; import { guidFor } from '@ember/object/internals'; import { modifier } from 'ember-modifier'; import HdsAdvancedTableTableModel from './models/table.js'; import { HdsAdvancedTableThSortOrderValues, HdsAdvancedTableDensityValues, HdsAdvancedTableVerticalAlignmentValues } from './types.js'; import { precompileTemplate } from '@ember/template-compilation'; import { g, i, n } from 'decorator-transforms/runtime'; import { setComponentTemplate } from '@ember/component'; var TEMPLATE = precompileTemplate("{{!\n Copyright (c) HashiCorp, Inc.\n SPDX-License-Identifier: MPL-2.0\n}}\n<div\n class=\"hds-advanced-table__container\n {{(if this.isStickyHeaderPinned \'hds-advanced-table__container--header-is-pinned\')}}\"\n ...attributes\n>\n {{! Caption }}\n <div id={{this._captionId}} class=\"sr-only hds-advanced-table__caption\" aria-live=\"polite\">\n {{@caption}}\n {{this.sortedMessageText}}\n </div>\n\n {{! Grid }}\n <div\n class={{this.classNames}}\n role=\"grid\"\n aria-describedby={{this._captionId}}\n {{style\n grid-template-columns=this.gridTemplateColumns\n --hds-advanced-table-sticky-column-offset=this.stickyColumnOffset\n }}\n {{this._setUpScrollWrapper}}\n >\n {{! Header }}\n <div class={{this.theadClassNames}} role=\"rowgroup\" {{this._setUpThead}}>\n <Hds::AdvancedTable::Tr\n @selectionScope=\"col\"\n @onClickSortBySelected={{if @selectableColumnKey (fn this.setSortBy @selectableColumnKey)}}\n @sortBySelectedOrder={{if (eq this._sortBy @selectableColumnKey) this._sortOrder}}\n @isSelectable={{this.isSelectable}}\n @onSelectionChange={{this.onSelectionAllChange}}\n @didInsert={{this.didInsertSelectAllCheckbox}}\n @willDestroy={{this.willDestroySelectAllCheckbox}}\n @selectionAriaLabelSuffix=\"all rows\"\n @hasStickyColumn={{@hasStickyFirstColumn}}\n @isStickyColumnPinned={{this.isStickyColumnPinned}}\n >\n {{#each @columns as |column index|}}\n {{#if column.isSortable}}\n <Hds::AdvancedTable::ThSort\n @sortOrder={{if (eq column.key this._sortBy) this._sortOrder}}\n @onClickSort={{fn this.setSortBy column.key}}\n @align={{column.align}}\n @tooltip={{column.tooltip}}\n @isStickyColumn={{if (and (eq index 0) @hasStickyFirstColumn) true}}\n @isStickyColumnPinned={{this.isStickyColumnPinned}}\n >\n {{column.label}}\n </Hds::AdvancedTable::ThSort>\n {{else}}\n <Hds::AdvancedTable::Th\n @align={{column.align}}\n @tooltip={{column.tooltip}}\n @isVisuallyHidden={{column.isVisuallyHidden}}\n @isExpandable={{column.isExpandable}}\n @onClickToggle={{this._tableModel.toggleAll}}\n @isExpanded={{this._tableModel.expandState}}\n @hasExpandAllButton={{this._tableModel.hasRowsWithChildren}}\n @isStickyColumn={{if (and (eq index 0) @hasStickyFirstColumn) true}}\n @isStickyColumnPinned={{this.isStickyColumnPinned}}\n >\n {{column.label}}\n </Hds::AdvancedTable::Th>\n {{/if}}\n {{/each}}\n </Hds::AdvancedTable::Tr>\n </div>\n\n {{! Body }}\n <div class=\"hds-advanced-table__tbody\" role=\"rowgroup\">\n {{! ----------------------------------------------------------------------------------------\n IMPORTANT: we loop on the `model` array and for each record\n we yield the Tr/Td/Th elements _and_ the record itself as `data`\n this means the consumer will *have to* use the `data` key to access it in their template\n -------------------------------------------------------------------------------------------- }}\n {{#each (sort-by this.getSortCriteria this._tableModel.rows) key=this.identityKey as |record index|}}\n {{#if this._tableModel.hasRowsWithChildren}}\n <Hds::AdvancedTable::ExpandableTrGroup\n @record={{record}}\n @rowIndex={{index}}\n @onClickToggle={{record.onClickToggle}}\n as |T|\n >\n {{yield\n (hash\n Tr=(component\n \"hds/advanced-table/tr\"\n isLastRow=(eq this._tableModel.lastVisibleRow.id T.data.id)\n isParentRow=T.isExpandable\n depth=T.depth\n displayRow=T.shouldDisplayChildRows\n )\n Th=(component\n \"hds/advanced-table/th\"\n depth=T.depth\n isExpandable=T.isExpandable\n isExpanded=T.isExpanded\n newLabel=T.id\n parentId=T.parentId\n scope=\"row\"\n onClickToggle=T.onClickToggle\n )\n Td=(component \"hds/advanced-table/td\" align=@align)\n data=T.data\n isOpen=T.isExpanded\n rowIndex=T.rowIndex\n )\n to=\"body\"\n }}\n </Hds::AdvancedTable::ExpandableTrGroup>\n {{else}}\n {{yield\n (hash\n Tr=(component\n \"hds/advanced-table/tr\"\n selectionScope=\"row\"\n isLastRow=(eq this._tableModel.lastVisibleRow.id record.id)\n isSelectable=this.isSelectable\n onSelectionChange=this.onSelectionRowChange\n didInsert=this.didInsertRowCheckbox\n willDestroy=this.willDestroyRowCheckbox\n selectionAriaLabelSuffix=@selectionAriaLabelSuffix\n hasStickyColumn=@hasStickyFirstColumn\n isStickyColumnPinned=this.isStickyColumnPinned\n )\n Th=(component\n \"hds/advanced-table/th\"\n scope=\"row\"\n isStickyColumn=@hasStickyFirstColumn\n isStickyColumnPinned=this.isStickyColumnPinned\n )\n Td=(component \"hds/advanced-table/td\" align=@align)\n data=record\n rowIndex=index\n )\n to=\"body\"\n }}\n {{/if}}\n {{/each}}\n </div>\n </div>\n {{#if this.showScrollIndicatorLeft}}\n <div\n class=\"hds-advanced-table__scroll-indicator hds-advanced-table__scroll-indicator-left\"\n {{style height=this.scrollIndicatorDimensions.height left=this.scrollIndicatorDimensions.left}}\n />\n {{/if}}\n\n {{#if this.showScrollIndicatorRight}}\n <div\n class=\"hds-advanced-table__scroll-indicator hds-advanced-table__scroll-indicator-right\"\n {{style height=this.scrollIndicatorDimensions.height right=this.scrollIndicatorDimensions.right}}\n />\n {{/if}}\n\n {{#if this.showScrollIndicatorTop}}\n <div\n class=\"hds-advanced-table__scroll-indicator hds-advanced-table__scroll-indicator-top\"\n {{style top=this.scrollIndicatorDimensions.top width=this.scrollIndicatorDimensions.width}}\n />\n {{/if}}\n\n {{#if this.showScrollIndicatorBottom}}\n <div\n class=\"hds-advanced-table__scroll-indicator hds-advanced-table__scroll-indicator-bottom\"\n {{style bottom=this.scrollIndicatorDimensions.bottom width=this.scrollIndicatorDimensions.width}}\n />\n {{/if}}\n</div>"); /** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ const DENSITIES = Object.values(HdsAdvancedTableDensityValues); const DEFAULT_DENSITY = HdsAdvancedTableDensityValues.Medium; const VALIGNMENTS = Object.values(HdsAdvancedTableVerticalAlignmentValues); const DEFAULT_VALIGN = HdsAdvancedTableVerticalAlignmentValues.Top; const DEFAULT_SCROLL_DIMENSIONS = { bottom: '0px', height: '0px', left: '0px', right: '0px', top: '0px', width: '0px' }; const getScrollIndicatorDimensions = (scrollWrapper, theadElement, hasStickyHeader, hasStickyFirstColumn) => { const horizontalScrollBarHeight = scrollWrapper.offsetHeight - scrollWrapper.clientHeight; const verticalScrollBarWidth = scrollWrapper.offsetWidth - scrollWrapper.clientWidth; let leftOffset = 0; if (hasStickyFirstColumn) { const stickyColumnHeaders = theadElement.querySelectorAll('.hds-advanced-table__th--is-sticky-column'); stickyColumnHeaders?.forEach(el => { // querySelectorAll returns Elements, which don't have offsetWidth // need to use offsetWidth to account for the cell borders const elAsHTMLElement = el; leftOffset += elAsHTMLElement.offsetWidth; }); // offsets the left: -1px position if there are multiple sticky columns if (stickyColumnHeaders.length > 1) { leftOffset -= 1; } } return { bottom: `${horizontalScrollBarHeight}px`, height: `${scrollWrapper.offsetHeight - horizontalScrollBarHeight}px`, left: `${leftOffset}px`, right: `${verticalScrollBarWidth}px`, top: hasStickyHeader ? `${theadElement.offsetHeight}px` : '0px', width: `${scrollWrapper.offsetWidth - verticalScrollBarWidth}px` }; }; const getStickyColumnLeftOffset = (theadElement, hasRowSelection) => { // if there is no select checkbox column, the sticky column is all the way to the left if (!hasRowSelection) return '0px'; const selectableCell = theadElement.querySelector('.hds-advanced-table__th--is-selectable'); return `${selectableCell?.offsetWidth}px`; }; class HdsAdvancedTable extends Component { static { g(this.prototype, "_sortBy", [tracked], function () { return this.args.sortBy ?? undefined; }); } #_sortBy = (i(this, "_sortBy"), void 0); static { g(this.prototype, "_sortOrder", [tracked], function () { return this.args.sortOrder || HdsAdvancedTableThSortOrderValues.Asc; }); } #_sortOrder = (i(this, "_sortOrder"), void 0); static { g(this.prototype, "_selectAllCheckbox", [tracked], function () { return undefined; }); } #_selectAllCheckbox = (i(this, "_selectAllCheckbox"), void 0); static { g(this.prototype, "_isSelectAllCheckboxSelected", [tracked], function () { return undefined; }); } #_isSelectAllCheckboxSelected = (i(this, "_isSelectAllCheckboxSelected"), void 0); _selectableRows = []; _captionId = 'caption-' + guidFor(this); _tableModel; _scrollHandler; _resizeObserver; _theadElement; static { g(this.prototype, "scrollIndicatorDimensions", [tracked], function () { return DEFAULT_SCROLL_DIMENSIONS; }); } #scrollIndicatorDimensions = (i(this, "scrollIndicatorDimensions"), void 0); static { g(this.prototype, "isStickyColumnPinned", [tracked], function () { return false; }); } #isStickyColumnPinned = (i(this, "isStickyColumnPinned"), void 0); static { g(this.prototype, "isStickyHeaderPinned", [tracked], function () { return false; }); } #isStickyHeaderPinned = (i(this, "isStickyHeaderPinned"), void 0); static { g(this.prototype, "showScrollIndicatorLeft", [tracked], function () { return false; }); } #showScrollIndicatorLeft = (i(this, "showScrollIndicatorLeft"), void 0); static { g(this.prototype, "showScrollIndicatorRight", [tracked], function () { return false; }); } #showScrollIndicatorRight = (i(this, "showScrollIndicatorRight"), void 0); static { g(this.prototype, "showScrollIndicatorTop", [tracked], function () { return false; }); } #showScrollIndicatorTop = (i(this, "showScrollIndicatorTop"), void 0); static { g(this.prototype, "showScrollIndicatorBottom", [tracked], function () { return false; }); } #showScrollIndicatorBottom = (i(this, "showScrollIndicatorBottom"), void 0); static { g(this.prototype, "stickyColumnOffset", [tracked], function () { return '0px'; }); } #stickyColumnOffset = (i(this, "stickyColumnOffset"), void 0); constructor(owner, args) { super(owner, args); const { model, childrenKey, columns, hasStickyFirstColumn } = args; this._tableModel = new HdsAdvancedTableTableModel({ model, childrenKey }); if (this._tableModel.hasRowsWithChildren) { const sortableColumns = columns.filter(column => column.isSortable); const sortableColumnLabels = sortableColumns.map(column => column.label); assert(`Cannot have sortable columns if there are nested rows. Sortable columns are ${sortableColumnLabels.toString()}`, sortableColumns.length === 0); assert('Cannot have a sticky first column if there are nested rows.', !hasStickyFirstColumn); } } get getSortCriteria() { // get the current column const currentColumn = this.args?.columns?.find(column => column.key === this._sortBy); if ( // check if there is a custom sorting function associated with the current `sortBy` column (we assume the column has `isSortable`) currentColumn?.sortingFunction && typeof currentColumn.sortingFunction === 'function') { return currentColumn.sortingFunction; } else { // otherwise fallback to the default format "sortBy:sortOrder" return `${this._sortBy}:${this._sortOrder}`; } } get columnWidths() { const { columns } = this.args; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const widths = new Array(columns.length); let hasCustomColumnWidth = false; for (let i = 0; i < columns.length; i++) { const column = columns[i]; if (column?.['width']) { widths[i] = column.width; if (!hasCustomColumnWidth) hasCustomColumnWidth = true; } } return hasCustomColumnWidth ? widths : undefined; } get identityKey() { // we have to provide a way for the consumer to pass undefined because Ember tries to interpret undefined as missing an arg and therefore falls back to the default if (this.args.identityKey === 'none') { return undefined; } else { return this.args.identityKey ?? '@identity'; } } get childrenKey() { const { childrenKey = 'children' } = this.args; return childrenKey; } get hasScrollIndicator() { if (this.args.hasStickyFirstColumn) { return true; } return false; } get sortedMessageText() { if (this.args.sortedMessageText) { return this.args.sortedMessageText; } else if (this._sortBy && this._sortOrder) { // we should allow the user to define a custom value here (e.g., for i18n) - tracked with HDS-965 return `Sorted by ${this._sortBy} ${this._sortOrder}ending`; } else { return ''; } } get isSelectable() { const { isSelectable = false } = this.args; if (this._tableModel.hasRowsWithChildren) { assert('@isSelectable must not be true if there are nested rows.', !isSelectable); return isSelectable; } return isSelectable; } get isStriped() { const { isStriped = false } = this.args; if (this._tableModel.hasRowsWithChildren) { assert('@isStriped must not be true if there are nested rows.', !isStriped); return isStriped; } return isStriped; } get density() { const { density = DEFAULT_DENSITY } = this.args; assert(`@density for "Hds::Table" must be one of the following: ${DENSITIES.join(', ')}; received: ${density}`, DENSITIES.includes(density)); return density; } get valign() { const { valign = DEFAULT_VALIGN } = this.args; assert(`@valign for "Hds::Table" must be one of the following: ${VALIGNMENTS.join(', ')}; received: ${valign}`, VALIGNMENTS.includes(valign)); return valign; } // returns the grid-template-columns CSS attribute for the grid get gridTemplateColumns() { const { isSelectable, columns } = this.args; const DEFAULT_COLUMN_WIDTH = '1fr'; // if there is a select checkbox, the first column has a 'min-content' width to hug the checkbox content let style = isSelectable ? 'min-content ' : ''; if (!this.columnWidths) { // if there are no custom column widths, each column is the same width and they take up the available space style += `repeat(${columns.length}, ${DEFAULT_COLUMN_WIDTH})`; } else { // check the custom column widths, if the current column has a custom width use the custom width. otherwise take the available space. for (let i = 0; i < this.columnWidths.length; i++) { style += ` ${this.columnWidths[i] ? this.columnWidths[i] : DEFAULT_COLUMN_WIDTH}`; } } return style; } get classNames() { const classes = ['hds-advanced-table']; if (this.isStriped) { classes.push('hds-advanced-table--striped'); } if (this.density) { classes.push(`hds-advanced-table--density-${this.density}`); } if (this.valign) { classes.push(`hds-advanced-table--valign-${this.valign}`); } if (this._tableModel.hasRowsWithChildren) { classes.push(`hds-advanced-table--nested`); } return classes.join(' '); } get theadClassNames() { const classes = ['hds-advanced-table__thead']; if (this.args.hasStickyHeader) { classes.push('hds-advanced-table__thead--sticky'); } if (this.isStickyHeaderPinned) { classes.push('hds-advanced-table__thead--is-pinned'); } return classes.join(' '); } _setUpScrollWrapper = modifier(element => { this._scrollHandler = () => { // 6px as a buffer so the shadow doesn't appear over the border radius on the edge of the table const SCROLL_BUFFER = 6; // left scroll indicator and sticky column styles if (element.scrollLeft > SCROLL_BUFFER && !this.showScrollIndicatorLeft) { if (this.args.hasStickyFirstColumn) { this.isStickyColumnPinned = true; } this.showScrollIndicatorLeft = true; } else if (element.scrollLeft === 0 && this.showScrollIndicatorLeft) { this.isStickyColumnPinned = false; this.showScrollIndicatorLeft = false; } // the right edge is how far the user can scroll, which is the full width of the table - the visible section of the table (also subtract the buffer) const rightEdge = element.scrollWidth - element.clientWidth - SCROLL_BUFFER; // right scroll indicator if (element.scrollLeft < rightEdge) { this.showScrollIndicatorRight = true; } else { this.showScrollIndicatorRight = false; } // sticky header if (element.scrollTop > 0) { if (this.args.hasStickyHeader) { this.isStickyHeaderPinned = true; } this.showScrollIndicatorTop = true; } else { if (this.args.hasStickyHeader) { this.isStickyHeaderPinned = false; } this.showScrollIndicatorTop = false; } // the bottom edge is how far the user can scroll, which is the full height of the table - the visible section of the table (also subtract the buffer) const bottomEdge = element.scrollHeight - element.clientHeight - SCROLL_BUFFER; // bottom scroll indicator if (element.scrollTop < bottomEdge) { this.showScrollIndicatorBottom = true; } else { this.showScrollIndicatorBottom = false; } }; element.addEventListener('scroll', this._scrollHandler); const updateMeasurements = () => { this.scrollIndicatorDimensions = getScrollIndicatorDimensions(element, this._theadElement, hasStickyHeader, hasStickyFirstColumn); if (hasStickyFirstColumn) { this.stickyColumnOffset = getStickyColumnLeftOffset(this._theadElement, isSelectable); } }; const { hasStickyHeader = false, hasStickyFirstColumn = false, isSelectable = false } = this.args; this._resizeObserver = new ResizeObserver(entries => { entries.forEach(() => { updateMeasurements(); }); }); this._resizeObserver.observe(element); updateMeasurements(); // on render check if should show right scroll indicator if (element.clientWidth < element.scrollWidth) { this.showScrollIndicatorRight = true; } // on render check if should show bottom scroll indicator if (element.clientHeight < element.scrollHeight) { this.showScrollIndicatorBottom = true; } return () => { element.removeEventListener('scroll', this._scrollHandler); this._resizeObserver.disconnect(); }; }); _setUpThead = modifier(element => { this._theadElement = element; }); setSortBy(column) { if (this._sortBy === column) { // check to see if the column is already sorted and invert the sort order if so this._sortOrder = this._sortOrder === HdsAdvancedTableThSortOrderValues.Asc ? HdsAdvancedTableThSortOrderValues.Desc : HdsAdvancedTableThSortOrderValues.Asc; } else { // otherwise, set the sort order to ascending this._sortBy = column; this._sortOrder = HdsAdvancedTableThSortOrderValues.Asc; } const { onSort } = this.args; if (typeof onSort === 'function') { onSort(this._sortBy, this._sortOrder); } } static { n(this.prototype, "setSortBy", [action]); } onSelectionChangeCallback(checkbox, selectionKey) { const { onSelectionChange } = this.args; if (typeof onSelectionChange !== 'function') return; onSelectionChange({ selectionKey: selectionKey, selectionCheckboxElement: checkbox, selectedRowsKeys: this._selectableRows.reduce((acc, row) => { if (row.checkbox.checked) { acc.push(row.selectionKey); } return acc; }, []), selectableRowsStates: this._selectableRows.reduce((acc, row) => { acc.push({ selectionKey: row.selectionKey, isSelected: row.checkbox.checked }); return acc; }, []) }); } onSelectionAllChange() { this._selectableRows.forEach(row => { row.checkbox.checked = this._selectAllCheckbox?.checked ?? false; }); this._isSelectAllCheckboxSelected = this._selectAllCheckbox?.checked ?? false; this.onSelectionChangeCallback(this._selectAllCheckbox, 'all'); } static { n(this.prototype, "onSelectionAllChange", [action]); } onSelectionRowChange(checkbox, selectionKey) { this.setSelectAllState(); this.onSelectionChangeCallback(checkbox, selectionKey); } static { n(this.prototype, "onSelectionRowChange", [action]); } didInsertSelectAllCheckbox(checkbox) { this._selectAllCheckbox = checkbox; } static { n(this.prototype, "didInsertSelectAllCheckbox", [action]); } willDestroySelectAllCheckbox() { this._selectAllCheckbox = undefined; } static { n(this.prototype, "willDestroySelectAllCheckbox", [action]); } didInsertRowCheckbox(checkbox, selectionKey) { if (selectionKey) { this._selectableRows.push({ selectionKey, checkbox }); } this.setSelectAllState(); } static { n(this.prototype, "didInsertRowCheckbox", [action]); } willDestroyRowCheckbox(selectionKey) { this._selectableRows = this._selectableRows.filter(row => row.selectionKey !== selectionKey); this.setSelectAllState(); } static { n(this.prototype, "willDestroyRowCheckbox", [action]); } setSelectAllState() { if (this._selectAllCheckbox) { const selectableRowsCount = this._selectableRows.length; const selectedRowsCount = this._selectableRows.filter(row => row.checkbox.checked).length; this._selectAllCheckbox.checked = selectedRowsCount === selectableRowsCount; this._selectAllCheckbox.indeterminate = selectedRowsCount > 0 && selectedRowsCount < selectableRowsCount; this._isSelectAllCheckboxSelected = this._selectAllCheckbox.checked; } } static { n(this.prototype, "setSelectAllState", [action]); } } setComponentTemplate(TEMPLATE, HdsAdvancedTable); export { DEFAULT_DENSITY, DEFAULT_VALIGN, DENSITIES, VALIGNMENTS, HdsAdvancedTable as default }; //# sourceMappingURL=index.js.map