diff --git a/change/@ni-nimble-components-752d1b02-6122-4d2f-b737-809aff6729d2.json b/change/@ni-nimble-components-752d1b02-6122-4d2f-b737-809aff6729d2.json new file mode 100644 index 0000000000..4d036d0008 --- /dev/null +++ b/change/@ni-nimble-components-752d1b02-6122-4d2f-b737-809aff6729d2.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Adding interactive column sizing", + "packageName": "@ni/nimble-components", + "email": "26874831+atmgrifter00@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-components/src/table-column/base/tests/table-column.stories.ts b/packages/nimble-components/src/table-column/base/tests/table-column.stories.ts index 1626aaa89e..367209702e 100644 --- a/packages/nimble-components/src/table-column/base/tests/table-column.stories.ts +++ b/packages/nimble-components/src/table-column/base/tests/table-column.stories.ts @@ -713,10 +713,11 @@ const fractionalWidthOptions = { } as const; const fractionalWidthDescription = `Configure each column's width relative to the other columns with the \`fractional-width\` property. For example, a column with a \`fractional-width\` set to 2 will be twice as wide as a column with a \`fractional-width\` set to 1. -The default value for \`fractional-width\` is 1, and columns that don't support \`fractional-width\` explicitly, or another API responsible for managing the width of the column, will also behave as if they have a \`fractional-width\` of 1.`; +The default value for \`fractional-width\` is 1, and columns that don't support \`fractional-width\` explicitly, or another API responsible for managing the width of the column, will also behave as if they have a \`fractional-width\` of 1. This value only serves +as an initial state for a column. Once a column has been manually resized the column will use a fractional width calculated by the table from the resize.`; const minPixelWidthDescription = `Table columns that support having a \`fractional-width\` can also be configured to have a minimum width such that its width -will never shrink below the specified pixel width.`; +will never shrink below the specified pixel width. This applies to both when a table is resized as well as when a column is interactively resized.`; export const fractionalWidthColumn: StoryObj = { parameters: { diff --git a/packages/nimble-components/src/table/index.ts b/packages/nimble-components/src/table/index.ts index 431487bf6f..577c4229bb 100644 --- a/packages/nimble-components/src/table/index.ts +++ b/packages/nimble-components/src/table/index.ts @@ -48,8 +48,8 @@ import { } from './types'; import { Virtualizer } from './models/virtualizer'; import { getTanStackSortingFunction } from './models/sort-operations'; +import { TableLayoutManager } from './models/table-layout-manager'; import { TableUpdateTracker } from './models/table-update-tracker'; -import { TableLayoutHelper } from './models/table-layout-helper'; import type { TableRow } from './components/row'; import { ColumnInternals } from '../table-column/base/models/column-internals'; import { InteractiveSelectionManager } from './models/interactive-selection-manager'; @@ -153,16 +153,31 @@ export class Table< @observable public showCollapseAll = false; + /** + * @internal + */ + public readonly headerRowActionContainer!: HTMLElement; + /** * @internal */ public readonly rowContainer!: HTMLElement; + /** + * @internal + */ + public readonly columnHeadersContainer!: Element; + /** * @internal */ public readonly virtualizer: Virtualizer; + /** + * @internal + */ + public readonly layoutManager: TableLayoutManager; + /** * @internal */ @@ -173,6 +188,16 @@ export class Table< * @internal */ @observable + public visibleColumns: TableColumn[] = []; + + /** + * @internal + * This value determines the size of the viewport area when a user has created horizontal scrollable + * space through a column resize operation. + */ + @observable + public tableScrollableMinWidth = 0; + public documentShiftKeyDown = false; private readonly table: TanStackTable; @@ -217,6 +242,7 @@ export class Table< }; this.table = tanStackCreateTable(this.options); this.virtualizer = new Virtualizer(this, this.table); + this.layoutManager = new TableLayoutManager(this); this.selectionManager = new InteractiveSelectionManager( this.table, this.selectionMode @@ -394,6 +420,32 @@ export class Table< this.table.toggleAllRowsExpanded(false); } + /** @internal */ + public onRightDividerMouseDown( + event: MouseEvent, + columnIndex: number + ): void { + if (event.button === 0) { + this.layoutManager.beginColumnInteractiveSize( + event.clientX, + columnIndex * 2 + ); + } + } + + /** @internal */ + public onLeftDividerMouseDown( + event: MouseEvent, + columnIndex: number + ): void { + if (event.button === 0) { + this.layoutManager.beginColumnInteractiveSize( + event.clientX, + columnIndex * 2 - 1 + ); + } + } + /** @internal */ public handleGroupRowExpanded(rowIndex: number, event: Event): void { this.toggleGroupExpanded(rowIndex); @@ -464,7 +516,10 @@ export class Table< } if (this.tableUpdateTracker.updateColumnWidths) { - this.updateRowGridColumns(); + this.rowGridColumns = this.layoutManager.getGridTemplateColumns(); + this.visibleColumns = this.columns.filter( + column => !column.columnHidden + ); } if (this.tableUpdateTracker.updateGroupRows) { @@ -483,6 +538,15 @@ export class Table< } } + /** + * @internal + */ + public getHeaderContainerElements(): NodeListOf { + return this.columnHeadersContainer.querySelectorAll( + '.header-container' + ); + } + protected selectionModeChanged( _prev: string | undefined, _next: string | undefined @@ -691,12 +755,6 @@ export class Table< this.actionMenuSlots = Array.from(slots); } - private updateRowGridColumns(): void { - this.rowGridColumns = TableLayoutHelper.getGridTemplateColumns( - this.columns - ); - } - private validate(): void { this.tableValidator.validateSelectionMode( this.selectionMode, diff --git a/packages/nimble-components/src/table/models/table-layout-helper.ts b/packages/nimble-components/src/table/models/table-layout-helper.ts deleted file mode 100644 index c0d5b3685a..0000000000 --- a/packages/nimble-components/src/table/models/table-layout-helper.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { TableColumn } from '../../table-column/base'; - -/** - * This class provides helper methods for managing the layout of cells within - * a Table. - */ -export class TableLayoutHelper { - public static getGridTemplateColumns(columns: TableColumn[]): string { - return columns - ?.filter(column => !column.columnHidden) - .map(column => { - const { - minPixelWidth, - currentPixelWidth, - currentFractionalWidth - } = column.columnInternals; - if (currentPixelWidth) { - const coercedPixelWidth = Math.max( - minPixelWidth, - currentPixelWidth - ); - return `${coercedPixelWidth}px`; - } - - return `minmax(${minPixelWidth}px, ${currentFractionalWidth}fr)`; - }) - .join(' '); - } -} diff --git a/packages/nimble-components/src/table/models/table-layout-manager.ts b/packages/nimble-components/src/table/models/table-layout-manager.ts new file mode 100644 index 0000000000..ba7ee9a395 --- /dev/null +++ b/packages/nimble-components/src/table/models/table-layout-manager.ts @@ -0,0 +1,255 @@ +import { observable } from '@microsoft/fast-element'; +import type { Table } from '..'; +import type { TableColumn } from '../../table-column/base'; +import type { TableRecord } from '../types'; + +/** + * This class manages the layout of columns within a Table. + * @interal + */ +export class TableLayoutManager { + @observable + public isColumnBeingSized = false; + + @observable + public activeColumnIndex?: number; + + private activeColumnDivider?: number; + private gridSizedColumns?: TableColumn[]; + private visibleColumns: TableColumn[] = []; + private initialTableScrollableWidth?: number; + private initialTableScrollableMinWidth?: number; + private initialColumnTotalWidth?: number; + private currentTotalDelta = 0; + private dragStart = 0; + private leftColumnIndex?: number; + private rightColumnIndex?: number; + private initialColumnPixelWidths: { + initalColumnFractionalWidth: number, + initialPixelWidth: number, + minPixelWidth: number + }[] = []; + + public constructor(private readonly table: Table) {} + + public getGridTemplateColumns(): string { + return this.getVisibleColumns() + .map(column => { + const { + minPixelWidth, + currentPixelWidth, + currentFractionalWidth + } = column.columnInternals; + if (currentPixelWidth) { + const coercedPixelWidth = Math.max( + minPixelWidth, + currentPixelWidth + ); + return `${coercedPixelWidth}px`; + } + + return `minmax(${minPixelWidth}px, ${currentFractionalWidth}fr)`; + }) + .join(' '); + } + + /** + * Sets up state related to interactively sizing a column. + * @param dragStart The x-position from which a column size was started + * @param activeColumnDivider The divider that was clicked on + */ + public beginColumnInteractiveSize( + dragStart: number, + activeColumnDivider: number + ): void { + this.activeColumnDivider = activeColumnDivider; + this.leftColumnIndex = this.getLeftColumnIndexFromDivider( + this.activeColumnDivider + ); + this.rightColumnIndex = this.leftColumnIndex + 1; + this.activeColumnIndex = this.leftColumnIndex + (this.activeColumnDivider % 2); + this.dragStart = dragStart; + this.currentTotalDelta = 0; + this.visibleColumns = this.getVisibleColumns(); + this.setColumnsToFixedSize(); + this.initialTableScrollableWidth = this.table.viewport.scrollWidth; + this.initialTableScrollableMinWidth = this.table.tableScrollableMinWidth; + this.initialColumnTotalWidth = this.getTotalColumnFixedWidth(); + this.isColumnBeingSized = true; + document.addEventListener('mousemove', this.onDividerMouseMove); + document.addEventListener('mouseup', this.onDividerMouseUp); + } + + private readonly onDividerMouseMove = (event: Event): void => { + const mouseEvent = event as MouseEvent; + for (let i = 0; i < this.visibleColumns.length; i++) { + this.visibleColumns[i]!.columnInternals.currentPixelWidth = this.initialColumnPixelWidths[i]?.initialPixelWidth; + } + this.currentTotalDelta = this.getAllowedSizeDelta( + mouseEvent.clientX - this.dragStart + ); + this.performCascadeSizeLeft( + this.leftColumnIndex!, + this.currentTotalDelta + ); + this.performCascadeSizeRight( + this.rightColumnIndex!, + this.currentTotalDelta + ); + + const totalColumnWidthDelta = this.getTotalColumnFixedWidth() - this.initialColumnTotalWidth!; + if (totalColumnWidthDelta > 0) { + this.table.tableScrollableMinWidth = this.initialTableScrollableWidth! + totalColumnWidthDelta; + } else { + this.table.tableScrollableMinWidth = this.initialTableScrollableMinWidth!; + } + }; + + private readonly onDividerMouseUp = (): void => { + document.removeEventListener('mousemove', this.onDividerMouseMove); + document.removeEventListener('mouseup', this.onDividerMouseUp); + this.resetGridSizedColumns(); + this.isColumnBeingSized = false; + this.activeColumnIndex = undefined; + }; + + private getTotalColumnFixedWidth(): number { + let totalColumnFixedWidth = 0; + for (const column of this.visibleColumns) { + totalColumnFixedWidth + += column.columnInternals.currentPixelWidth ?? 0; + } + return totalColumnFixedWidth; + } + + private setColumnsToFixedSize(): void { + this.cacheGridSizedColumns(); + const headers = this.table.getHeaderContainerElements(); + for (let i = 0; i < headers.length; i++) { + this.visibleColumns[i]!.columnInternals.currentPixelWidth = headers[i]!.getBoundingClientRect().width; + } + this.cacheColumnInitialPixelWidths(); + } + + private getAllowedSizeDelta(requestedResizeAmount: number): number { + let availableSpace = 0; + if (requestedResizeAmount > 0) { + // size right + return requestedResizeAmount; + } + + // size left + let currentIndex = this.leftColumnIndex!; + while (currentIndex >= 0) { + const columnInitialWidths = this.initialColumnPixelWidths[currentIndex]!; + availableSpace + += columnInitialWidths.initialPixelWidth + - columnInitialWidths.minPixelWidth; + currentIndex -= 1; + } + return Math.max(requestedResizeAmount, -availableSpace); + } + + private performCascadeSizeLeft( + leftColumnIndex: number, + delta: number + ): void { + let currentDelta = delta; + const leftColumnInitialWidths = this.initialColumnPixelWidths[leftColumnIndex]!; + const allowedDelta = delta < 0 + ? Math.max( + leftColumnInitialWidths.minPixelWidth + - leftColumnInitialWidths.initialPixelWidth, + currentDelta + ) + : delta; + const actualDelta = Math.round(allowedDelta); + const leftColumn = this.visibleColumns[leftColumnIndex]!; + leftColumn.columnInternals.currentPixelWidth! += actualDelta; + + if (actualDelta > currentDelta && leftColumnIndex > 0 && delta < 0) { + currentDelta -= allowedDelta; + this.performCascadeSizeLeft(leftColumnIndex - 1, currentDelta); + } + } + + private performCascadeSizeRight( + rightColumnIndex: number, + delta: number + ): void { + let currentDelta = delta; + const rightColumnInitialWidths = this.initialColumnPixelWidths[rightColumnIndex]!; + const allowedDelta = delta > 0 + ? Math.min( + rightColumnInitialWidths.initialPixelWidth + - rightColumnInitialWidths.minPixelWidth, + currentDelta + ) + : delta; + const actualDelta = Math.round(allowedDelta); + const rightColumn = this.visibleColumns[rightColumnIndex]!; + rightColumn.columnInternals.currentPixelWidth! -= actualDelta; + + if ( + actualDelta < currentDelta + && rightColumnIndex < this.visibleColumns.length - 1 + && delta > 0 + ) { + currentDelta -= allowedDelta; + this.performCascadeSizeRight(rightColumnIndex + 1, currentDelta); + } + } + + private cacheGridSizedColumns(): void { + this.gridSizedColumns = []; + for (const column of this.visibleColumns) { + if (column.columnInternals.currentPixelWidth === undefined) { + this.gridSizedColumns.push(column); + } + } + } + + private cacheColumnInitialPixelWidths(): void { + this.initialColumnPixelWidths = []; + for (const column of this.visibleColumns) { + this.initialColumnPixelWidths.push({ + initalColumnFractionalWidth: + column.columnInternals.currentFractionalWidth, + initialPixelWidth: column.columnInternals.currentPixelWidth!, + minPixelWidth: column.columnInternals.minPixelWidth + }); + } + } + + private resetGridSizedColumns(): void { + if (!this.gridSizedColumns) { + return; + } + + let gridColumnIndex = 0; + for ( + let i = 0; + i < this.visibleColumns.length + && gridColumnIndex < this.gridSizedColumns.length; + i++ + ) { + const column = this.visibleColumns[i]!; + if (column === this.gridSizedColumns[gridColumnIndex]) { + gridColumnIndex += 1; + column.columnInternals.currentFractionalWidth = (column.columnInternals.currentPixelWidth! + / this.initialColumnPixelWidths[i]!.initialPixelWidth) + * this.initialColumnPixelWidths[i]! + .initalColumnFractionalWidth; + column.columnInternals.currentPixelWidth = undefined; + } + } + } + + private getVisibleColumns(): TableColumn[] { + return this.table.columns.filter(column => !column.columnHidden); + } + + private getLeftColumnIndexFromDivider(dividerIndex: number): number { + return Math.floor(dividerIndex / 2); + } +} diff --git a/packages/nimble-components/src/table/models/virtualizer.ts b/packages/nimble-components/src/table/models/virtualizer.ts index 021bc40e7e..02f2ff87b4 100644 --- a/packages/nimble-components/src/table/models/virtualizer.ts +++ b/packages/nimble-components/src/table/models/virtualizer.ts @@ -23,7 +23,7 @@ export class Virtualizer { public visibleItems: VirtualItem[] = []; @observable - public allRowsHeight = 0; + public scrollHeight = 0; @observable public headerContainerMarginRight = 0; @@ -105,7 +105,7 @@ export class Virtualizer { this.notifyFocusedCellRecycling(); const virtualizer = this.virtualizer!; this.visibleItems = virtualizer.getVirtualItems(); - this.allRowsHeight = virtualizer.getTotalSize(); + this.scrollHeight = virtualizer.getTotalSize(); // We're using a separate div ('table-scroll') to represent the full height of all rows, and // the row container's height is only big enough to hold the virtualized rows. So we don't // use the TanStackVirtual-provided 'start' offset (which is in terms of the full height) diff --git a/packages/nimble-components/src/table/styles.ts b/packages/nimble-components/src/table/styles.ts index bb8096cf45..3f0d45afae 100644 --- a/packages/nimble-components/src/table/styles.ts +++ b/packages/nimble-components/src/table/styles.ts @@ -5,6 +5,7 @@ import { applicationBackgroundColor, bodyFont, bodyFontColor, + popupBorderColor, controlSlimHeight, smallPadding, standardPadding, @@ -20,6 +21,8 @@ export const styles = css` :host { height: 480px; + --ni-private-column-divider-width: 2px; + --ni-private-column-divider-padding: 3px; } .disable-select { @@ -33,32 +36,17 @@ export const styles = css` width: 100%; font: ${bodyFont}; color: ${bodyFontColor}; + cursor: var(--ni-private-table-cursor-override); } - .table-viewport { - overflow: auto; - display: block; - height: 100%; - position: relative; - } - - .table-scroll { - pointer-events: none; - position: absolute; - top: 0px; + .glass-overlay { width: 100%; - height: var(--ni-private-table-scroll-height); - } - - .table-row-container { - width: fit-content; - min-width: 100%; - position: relative; - top: var(--ni-private-table-row-container-top); - background-color: ${tableRowBorderColor}; + height: 100%; + display: contents; + pointer-events: var(--ni-private-glass-overlay-pointer-events); } - .header-container { + .header-row-container { position: sticky; top: 0; } @@ -68,12 +56,26 @@ export const styles = css` background: ${applicationBackgroundColor}; position: relative; width: fit-content; - min-width: 100%; + min-width: max( + 100%, + calc( + var(--ni-private-table-scrollable-min-width) + + var(--ni-private-table-header-container-margin-right) + ) + ); left: var(--ni-private-table-scroll-x); align-items: center; } - .column-header-container { + .header-row-action-container { + display: flex; + } + + .checkbox-container { + display: flex; + } + + .column-headers-container { display: grid; width: 100%; grid-template-columns: var(--ni-private-table-row-grid-columns) auto; @@ -89,16 +91,84 @@ export const styles = css` visibility: visible; } + .header-container { + display: flex; + align-items: center; + position: relative; + } + .header-scrollbar-spacer { - width: var(--ni-private-table-header-scrollbar-spacer-width); + width: var(--ni-private-table-header-container-margin-right); } .header { flex: 1; + overflow: hidden; } - .checkbox-container { - display: flex; + .column-divider { + border-left: var(--ni-private-column-divider-width) solid + ${popupBorderColor}; + display: none; + height: ${controlSlimHeight}; + cursor: col-resize; + position: absolute; + } + + .column-divider::before { + content: ''; + position: absolute; + width: calc( + var(--ni-private-column-divider-width) + + (2 * var(--ni-private-column-divider-padding)) + ); + height: 100%; + left: calc( + -1 * (var(--ni-private-column-divider-width) + + var(--ni-private-column-divider-padding)) + ); + } + + .column-divider.active { + display: block; + z-index: 1; + } + + .header-container:hover .column-divider.left, + .header-container:hover .column-divider.right { + display: block; + z-index: 1; + } + + .column-divider.left { + left: -1px; + } + + .column-divider.right { + left: calc(100% - 1px); + } + + .table-viewport { + overflow: auto; + display: block; + height: 100%; + position: relative; + } + + .table-scroll { + pointer-events: none; + position: absolute; + top: 0px; + width: 100%; + height: var(--ni-private-table-scroll-height); + } + + .table-row-container { + width: fit-content; + min-width: max(100%, var(--ni-private-table-scrollable-min-width)); + position: relative; + top: var(--ni-private-table-row-container-top); + background-color: ${tableRowBorderColor}; } .selection-checkbox { diff --git a/packages/nimble-components/src/table/template.ts b/packages/nimble-components/src/table/template.ts index dc9df1b544..071287d5e3 100644 --- a/packages/nimble-components/src/table/template.ts +++ b/packages/nimble-components/src/table/template.ts @@ -35,101 +35,118 @@ export const template = html`
-
-
- ${when(x => x.selectionMode === TableRowSelectionMode.multiple, html
` - - <${checkboxTag} - ${ref('selectionCheckbox')} - class="${x => `selection-checkbox ${x.selectionMode ?? ''}`}" - @change="${(x, c) => x.onAllRowsSelectionChange(c.event as CustomEvent)}" - > - - - `)} - - <${buttonTag} - class="collapse-all-button ${x => `${x.showCollapseAll ? 'visible' : ''}`}" - content-hidden - appearance="${ButtonAppearance.ghost}" - title="${x => tableGroupsCollapseAllLabel.getValueFor(x)}" - @click="${x => x.handleCollapseAllGroupRows()}" - > - <${iconTriangleTwoLinesHorizontalTag} slot="start"> - ${x => tableGroupsCollapseAllLabel.getValueFor(x)} - - - - ${repeat(x => x.columns, html` - ${when(x => !x.columnHidden, html` - <${tableHeaderTag} - class="header" - sort-direction="${x => (typeof x.columnInternals.currentSortIndex === 'number' ? x.columnInternals.currentSortDirection : TableColumnSortDirection.none)}" - ?first-sorted-column="${(x, c) => x === c.parent.firstSortedColumn}" - @click="${(x, c) => c.parent.toggleColumnSort(x, (c.event as MouseEvent).shiftKey)}" - :isGrouped=${x => (typeof x.columnInternals.groupIndex === 'number' && !x.columnInternals.groupingDisabled)} - > - - - `)} - `)} -
-
- - -
-
-
- ${when(x => x.columns.length > 0 && x.canRenderRows, html
` - ${repeat(x => x.virtualizer.visibleItems, html` - ${when((x, c) => (c.parent as Table).tableData[x.index]?.isGrouped, html` - <${tableGroupRowTag} - class="group-row" - :groupRowValue="${(x, c) => c.parent.tableData[x.index]?.groupRowValue}" - ?expanded="${(x, c) => c.parent.tableData[x.index]?.isExpanded}" - :nestingLevel="${(x, c) => c.parent.tableData[x.index]?.nestingLevel}" - :leafItemCount="${(x, c) => c.parent.tableData[x.index]?.leafItemCount}" - :groupColumn="${(x, c) => c.parent.tableData[x.index]?.groupColumn}" - ?selectable="${(_, c) => c.parent.selectionMode === TableRowSelectionMode.multiple}" - selection-state="${(x, c) => c.parent.tableData[x.index]?.selectionState}" - @group-selection-toggle="${(x, c) => c.parent.onRowSelectionToggle(x.index, c.event as CustomEvent)}" - @group-expand-toggle="${(x, c) => c.parent.handleGroupRowExpanded(x.index, c.event)}" - > - +
+
+
+ + ${when(x => x.selectionMode === TableRowSelectionMode.multiple, html
` + + <${checkboxTag} + ${ref('selectionCheckbox')} + class="${x => `selection-checkbox ${x.selectionMode ?? ''}`}" + @change="${(x, c) => x.onAllRowsSelectionChange(c.event as CustomEvent)}" + > + + `)} - ${when((x, c) => !(c.parent as Table).tableData[x.index]?.isGrouped, html` - <${tableRowTag} - class="row" - record-id="${(x, c) => c.parent.tableData[x.index]?.id}" - ?selectable="${(_, c) => c.parent.selectionMode !== TableRowSelectionMode.none}" - ?selected="${(x, c) => c.parent.tableData[x.index]?.selectionState === TableRowSelectionState.selected}" - ?hide-selection="${(_, c) => c.parent.selectionMode !== TableRowSelectionMode.multiple}" - :dataRecord="${(x, c) => c.parent.tableData[x.index]?.record}" - :columns="${(_, c) => c.parent.columns}" - :nestingLevel="${(x, c) => c.parent.tableData[x.index]?.nestingLevel}" - @click="${(x, c) => c.parent.onRowClick(x.index, c.event as MouseEvent)}" - @row-selection-toggle="${(x, c) => c.parent.onRowSelectionToggle(x.index, c.event as CustomEvent)}" - @row-action-menu-beforetoggle="${(x, c) => c.parent.onRowActionMenuBeforeToggle(x.index, c.event as CustomEvent)}" - @row-action-menu-toggle="${(_, c) => c.parent.onRowActionMenuToggle(c.event as CustomEvent)}" + + <${buttonTag} + class="collapse-all-button ${x => `${x.showCollapseAll ? 'visible' : ''}`}" + content-hidden + appearance="${ButtonAppearance.ghost}" + title="${x => tableGroupsCollapseAllLabel.getValueFor(x)}" + @click="${x => x.handleCollapseAllGroupRows()}" > - ${when((x, c) => (c.parent as Table).openActionMenuRecordId === (c.parent as Table).tableData[x.index]?.id, html` - ${repeat((_, c) => (c.parent as Table).actionMenuSlots, html` - - + <${iconTriangleTwoLinesHorizontalTag} slot="start"> + ${x => tableGroupsCollapseAllLabel.getValueFor(x)} + + + + + ${repeat(x => x.visibleColumns, html` +
+ ${when((_, c) => c.index > 0, html` +
+
+ `)} + <${tableHeaderTag} + class="header" + sort-direction="${x => (typeof x.columnInternals.currentSortIndex === 'number' ? x.columnInternals.currentSortDirection : TableColumnSortDirection.none)}" + ?first-sorted-column="${(x, c) => x === c.parent.firstSortedColumn}" + @click="${(x, c) => c.parent.toggleColumnSort(x, (c.event as MouseEvent).shiftKey)}" + :isGrouped=${x => (typeof x.columnInternals.groupIndex === 'number' && !x.columnInternals.groupingDisabled)} + > + + + ${when((_, c) => c.index < c.length - 1, html` +
+
+ `)} +
+ `, { positioning: true })} +
+
+ + +
+
+
+ ${when(x => x.columns.length > 0 && x.canRenderRows, html
` + ${repeat(x => x.virtualizer.visibleItems, html` + ${when((x, c) => (c.parent as Table).tableData[x.index]?.isGrouped, html` + <${tableGroupRowTag} + class="group-row" + :groupRowValue="${(x, c) => c.parent.tableData[x.index]?.groupRowValue}" + ?expanded="${(x, c) => c.parent.tableData[x.index]?.isExpanded}" + :nestingLevel="${(x, c) => c.parent.tableData[x.index]?.nestingLevel}" + :leafItemCount="${(x, c) => c.parent.tableData[x.index]?.leafItemCount}" + :groupColumn="${(x, c) => c.parent.tableData[x.index]?.groupColumn}" + ?selectable="${(_, c) => c.parent.selectionMode === TableRowSelectionMode.multiple}" + selection-state="${(x, c) => c.parent.tableData[x.index]?.selectionState}" + @group-selection-toggle="${(x, c) => c.parent.onRowSelectionToggle(x.index, c.event as CustomEvent)}" + @group-expand-toggle="${(x, c) => c.parent.handleGroupRowExpanded(x.index, c.event)}" + > + + `)} + ${when((x, c) => !(c.parent as Table).tableData[x.index]?.isGrouped, html` + <${tableRowTag} + class="row" + record-id="${(x, c) => c.parent.tableData[x.index]?.id}" + ?selectable="${(_, c) => c.parent.selectionMode !== TableRowSelectionMode.none}" + ?selected="${(x, c) => c.parent.tableData[x.index]?.selectionState === TableRowSelectionState.selected}" + ?hide-selection="${(_, c) => c.parent.selectionMode !== TableRowSelectionMode.multiple}" + :dataRecord="${(x, c) => c.parent.tableData[x.index]?.record}" + :columns="${(_, c) => c.parent.columns}" + :nestingLevel="${(x, c) => c.parent.tableData[x.index]?.nestingLevel}" + @click="${(x, c) => c.parent.onRowClick(x.index, c.event as MouseEvent)}" + @row-selection-toggle="${(x, c) => c.parent.onRowSelectionToggle(x.index, c.event as CustomEvent)}" + @row-action-menu-beforetoggle="${(x, c) => c.parent.onRowActionMenuBeforeToggle(x.index, c.event as CustomEvent)}" + @row-action-menu-toggle="${(_, c) => c.parent.onRowActionMenuToggle(c.event as CustomEvent)}" + > + ${when((x, c) => (c.parent as Table).openActionMenuRecordId === (c.parent as Table).tableData[x.index]?.id, html` + ${repeat((_, c) => (c.parent as Table).actionMenuSlots, html` + + + `)} `)} + `)} - `)} `)} - `)} + diff --git a/packages/nimble-components/src/table/testing/table.pageobject.ts b/packages/nimble-components/src/table/testing/table.pageobject.ts index b74cf06d33..b664e045e5 100644 --- a/packages/nimble-components/src/table/testing/table.pageobject.ts +++ b/packages/nimble-components/src/table/testing/table.pageobject.ts @@ -245,35 +245,17 @@ export class TablePageObject { ); } - const collapseButton = this.getCollapseAllButton(); - const buttonWidth = collapseButton!.getBoundingClientRect().width; - const buttonStyle = window.getComputedStyle(collapseButton!); table.style.width = `${ rowWidth - + buttonWidth - + parseFloat(buttonStyle.marginLeft) - + parseFloat(buttonStyle.marginRight) + + table.headerRowActionContainer.getBoundingClientRect().width + + table.virtualizer.headerContainerMarginRight }px`; await waitForUpdatesAsync(); } - public getCellRenderedWidth(columnIndex: number, rowIndex = 0): number { - if (columnIndex >= this.tableElement.columns.length) { - throw new Error( - 'Attempting to index past the total number of columns' - ); - } - - const row = this.getRow(rowIndex); - const cells = row?.shadowRoot?.querySelectorAll('nimble-table-cell'); - if (columnIndex >= (cells?.length ?? 0)) { - throw new Error( - 'Attempting to index past the total number of cells' - ); - } - - const columnCell = cells![columnIndex]!; - return columnCell.getBoundingClientRect().width; + public getCellRenderedWidth(rowIndex: number, columnIndex: number): number { + const cell = this.getCell(rowIndex, columnIndex); + return cell.getBoundingClientRect().width; } public getTotalCellRenderedWidth(): number { @@ -459,6 +441,112 @@ export class TablePageObject { } } + /** + * @param columnIndex The index of the column to the left of a divider being dragged. Thus, this + * can not be given a value representing the last visible column index. + * @param deltas The series of mouse movements in the x-direction while sizing a column. + */ + public dragSizeColumnByRightDivider( + columnIndex: number, + deltas: number[] + ): void { + const divider = this.getColumnRightDivider(columnIndex); + if (!divider) { + throw new Error( + 'The provided column index has no right divider associated with it.' + ); + } + const dividerRect = divider.getBoundingClientRect(); + let currentMouseX = (dividerRect.x + dividerRect.width) / 2; + const mouseDownEvent = new MouseEvent('mousedown', { + clientX: currentMouseX, + clientY: (dividerRect.y + dividerRect.height) / 2 + }); + divider.dispatchEvent(mouseDownEvent); + + for (const delta of deltas) { + currentMouseX += delta; + const mouseMoveEvent = new MouseEvent('mousemove', { + clientX: currentMouseX + }); + document.dispatchEvent(mouseMoveEvent); + } + + const mouseUpEvent = new MouseEvent('mouseup'); + document.dispatchEvent(mouseUpEvent); + } + + /** + * @param columnIndex The index of the column to the right of a divider being dragged. Thus, this + * value must be greater than 0 and less than the total number of visible columns. + * @param deltas The series of mouse movements in the x-direction while sizing a column. + */ + public dragSizeColumnByLeftDivider( + columnIndex: number, + deltas: number[] + ): void { + const divider = this.getColumnLeftDivider(columnIndex); + if (!divider) { + throw new Error( + 'The provided column index has no left divider associated with it.' + ); + } + const dividerRect = divider.getBoundingClientRect(); + let currentMouseX = (dividerRect.x + dividerRect.width) / 2; + const mouseDownEvent = new MouseEvent('mousedown', { + clientX: currentMouseX, + clientY: (dividerRect.y + dividerRect.height) / 2 + }); + divider.dispatchEvent(mouseDownEvent); + + for (const delta of deltas) { + currentMouseX += delta; + const mouseMoveEvent = new MouseEvent('mousemove', { + clientX: currentMouseX + }); + document.dispatchEvent(mouseMoveEvent); + } + + const mouseUpEvent = new MouseEvent('mouseup'); + document.dispatchEvent(mouseUpEvent); + } + + public getColumnRightDivider(index: number): HTMLElement | null { + const headerContainers = this.tableElement.shadowRoot!.querySelectorAll('.header-container'); + if (index < 0 || index >= headerContainers.length) { + throw new Error( + 'Invalid column index. Index must be greater than or equal to 0 and less than the number of visible columns.' + ); + } + + return headerContainers[index]!.querySelector('.column-divider.right'); + } + + public getColumnLeftDivider(index: number): HTMLElement | null { + const headerContainers = this.tableElement.shadowRoot!.querySelectorAll('.header-container'); + if (index < 0 || index >= headerContainers.length) { + throw new Error( + 'Invalid column index. Index must be greater than or equal to 0 and less than the number of visible columns.' + ); + } + + return headerContainers[index]!.querySelector('.column-divider.left'); + } + + public isHorizontalScrollbarVisible(): boolean { + return ( + this.tableElement.viewport.clientHeight + !== this.tableElement.viewport.getBoundingClientRect().height + ); + } + + public isVerticalScrollbarVisible(): boolean { + return ( + this.tableElement.viewport.clientWidth + !== this.tableElement.viewport.getBoundingClientRect().width + ); + } + public getSortedColumns(): SortedColumn[] { return this.tableElement.columns .filter( diff --git a/packages/nimble-components/src/table/tests/table-column-sizing.spec.ts b/packages/nimble-components/src/table/tests/table-column-sizing.spec.ts index e8d484dd50..1d14d6328f 100644 --- a/packages/nimble-components/src/table/tests/table-column-sizing.spec.ts +++ b/packages/nimble-components/src/table/tests/table-column-sizing.spec.ts @@ -10,27 +10,37 @@ import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized'; interface SimpleTableRecord extends TableRecord { stringData: string; moreStringData: string; + moreStringData2: string; + moreStringData3: string; } const simpleTableData = [ { stringData: 'string 1', - moreStringData: 'foo' + moreStringData: 'foo', + moreStringData2: 'foo', + moreStringData3: 'foo' }, { stringData: 'hello world', - moreStringData: 'foo' + moreStringData: 'foo', + moreStringData2: 'foo', + moreStringData3: 'foo' }, { stringData: 'another string', - moreStringData: 'foo' + moreStringData: 'foo', + moreStringData2: 'foo', + moreStringData3: 'foo' } ] as const; const largeTableData = Array.from(Array(500), (_, i) => { return { stringData: `string ${i}`, - moreStringData: 'foo' + moreStringData: 'foo', + moreStringData2: `foo ${i}`, + moreStringData3: `foo ${i + 1}` }; }); @@ -46,6 +56,22 @@ async function setup(): Promise>> { ); } +// prettier-ignore +async function setupInteractiveTests(): Promise>> { + return fixture>( + html` + + + + + + + + + ` + ); +} + describe('Table Column Sizing', () => { let element: Table; let connect: () => Promise; @@ -202,8 +228,8 @@ describe('Table Column Sizing', () => { } await waitForUpdatesAsync(); - const column1RenderedWidth = pageObject.getCellRenderedWidth(0); - const column2RenderedWidth = pageObject.getCellRenderedWidth(1); + const column1RenderedWidth = pageObject.getCellRenderedWidth(0, 0); + const column2RenderedWidth = pageObject.getCellRenderedWidth(0, 1); const header1RenderedWidth = pageObject.getHeaderRenderedWidth(0); const header2RenderedWidth = pageObject.getHeaderRenderedWidth(1); expect(column1RenderedWidth).toBe( @@ -232,8 +258,8 @@ describe('Table Column Sizing', () => { await pageObject.sizeTableToGivenRowWidth(300, element); await waitForUpdatesAsync(); - const column1RenderedWidth = pageObject.getCellRenderedWidth(0); - const column2RenderedWidth = pageObject.getCellRenderedWidth(1); + const column1RenderedWidth = pageObject.getCellRenderedWidth(0, 0); + const column2RenderedWidth = pageObject.getCellRenderedWidth(0, 1); expect(column1RenderedWidth).toBe(150); expect(column2RenderedWidth).toBe(150); }); @@ -248,7 +274,7 @@ describe('Table Column Sizing', () => { column1.columnHidden = true; await waitForUpdatesAsync(); - const column1RenderedWidth = pageObject.getCellRenderedWidth(0); + const column1RenderedWidth = pageObject.getCellRenderedWidth(0, 0); expect(column1RenderedWidth).toBe(400); }); }); @@ -304,11 +330,11 @@ describe('Table Column Sizing', () => { await waitForUpdatesAsync(); const firstRowColumn1RenderedWidth = pageObject.getCellRenderedWidth(0, 0); - const firstRowColumn2RenderedWidth = pageObject.getCellRenderedWidth(1, 0); + const firstRowColumn2RenderedWidth = pageObject.getCellRenderedWidth(0, 1); await pageObject.scrollToLastRowAsync(); const lastRowIndex = pageObject.getRenderedRowCount() - 1; - const lastRowColumn1RenderedWidth = pageObject.getCellRenderedWidth(0, lastRowIndex); - const lastRowColumn2RenderedWidth = pageObject.getCellRenderedWidth(1, lastRowIndex); + const lastRowColumn1RenderedWidth = pageObject.getCellRenderedWidth(lastRowIndex, 0); + const lastRowColumn2RenderedWidth = pageObject.getCellRenderedWidth(lastRowIndex, 1); expect(firstRowColumn1RenderedWidth).toBe( lastRowColumn1RenderedWidth @@ -321,3 +347,494 @@ describe('Table Column Sizing', () => { } }); }); + +describe('Table Interactive Column Sizing', () => { + let element: Table; + let connect: () => Promise; + let disconnect: () => Promise; + let pageObject: TablePageObject; + + beforeEach(async () => { + ({ element, connect, disconnect } = await setupInteractiveTests()); + pageObject = new TablePageObject(element); + await connect(); + await element.setData(simpleTableData); + await waitForUpdatesAsync(); + await pageObject.sizeTableToGivenRowWidth(400, element); + }); + + afterEach(async () => { + await disconnect(); + }); + + describe('No hidden columns ', () => { + const columnSizeTests = [ + { + name: 'sizing right only affects adjacent right column with delta less than min width', + dragDeltas: [1], + columnDragIndex: 0, + expectedColumnWidths: [101, 99, 100, 100] + }, + { + name: 'sizing right past the minimum size of adjacent right column cascades to next column', + dragDeltas: [51], + columnDragIndex: 0, + expectedColumnWidths: [151, 50, 99, 100] + }, + { + name: 'sizing right past the minimum size of all columns to right shrinks all columns to minimum size, but allows left column to keep growing', + dragDeltas: [151], + columnDragIndex: 0, + expectedColumnWidths: [251, 50, 50, 50] + }, + { + name: 'sizing left only affects adjacent left column with delta less than min width', + dragDeltas: [-1], + columnDragIndex: 2, + expectedColumnWidths: [100, 100, 99, 101] + }, + { + name: 'sizing left past the minimum size of adjacent left column cascades to next column', + dragDeltas: [-51], + columnDragIndex: 2, + expectedColumnWidths: [100, 99, 50, 151] + }, + { + name: 'sizing left past the minimum size of all columns to left shrinks all columns to minimum size, and stops growing right most column', + dragDeltas: [-151], + columnDragIndex: 2, + expectedColumnWidths: [50, 50, 50, 250] + }, + { + name: `sizing left past the minimum size of all columns to left shrinks all columns to minimum size, and stops growing right most column, + and then moving cursor slightly to right causes no column width changes`, + dragDeltas: [-152, 1], + columnDragIndex: 2, + expectedColumnWidths: [50, 50, 50, 250] + }, + { + name: 'sizing right causing cascade and then sizing left in same interaction reverts cascade effect', + dragDeltas: [100, -50], + columnDragIndex: 2, + expectedColumnWidths: [100, 100, 150, 50] + }, + { + name: 'sizing left causing cascade and then sizing right in same interaction reverts cascade effect', + dragDeltas: [-50, 25], + columnDragIndex: 0, + expectedColumnWidths: [75, 125, 100, 100] + } + ]; + const focused: string[] = []; + const disabled: string[] = []; + for (const columnSizeTest of columnSizeTests) { + const specType = getSpecTypeByNamedList( + columnSizeTest, + focused, + disabled + ); + specType( + `${columnSizeTest.name}`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + async () => { + pageObject.dragSizeColumnByRightDivider( + columnSizeTest.columnDragIndex, + columnSizeTest.dragDeltas + ); + await waitForUpdatesAsync(); + columnSizeTest.expectedColumnWidths.forEach((width, i) => expect(pageObject.getCellRenderedWidth(0, i)).toBe( + width + )); + } + ); + } + + it('when table width is smaller than total column min width, dragging column still expands column', async () => { + await pageObject.sizeTableToGivenRowWidth(100, element); + await waitForUpdatesAsync(); + pageObject.dragSizeColumnByRightDivider(2, [50]); + await waitForUpdatesAsync(); + const cellWidth = pageObject.getCellRenderedWidth(0, 2); + expect(cellWidth).toBe(100); + }); + + it('sizing column beyond table width creates horizontal scrollbar', async () => { + pageObject.dragSizeColumnByRightDivider(2, [100]); + await waitForUpdatesAsync(); + expect(pageObject.isHorizontalScrollbarVisible()).toBeTrue(); + }); + + it('sizing table with a horizontal scrollbar does not change column widths until sized beyond current column pixel widths', async () => { + // create horizontal scrollbar with total column width of 450 + pageObject.dragSizeColumnByRightDivider(2, [100]); + // size table below threshhold of total column widths + await pageObject.sizeTableToGivenRowWidth(425, element); + expect(pageObject.getTotalCellRenderedWidth()).toBe(450); + // size table 50 pixels beyond total column widths + await pageObject.sizeTableToGivenRowWidth(500, element); + expect(pageObject.getTotalCellRenderedWidth()).toBe(500); + expect(pageObject.isHorizontalScrollbarVisible()).toBeFalse(); + }); + + it('after table gets horizontal scrollbar, growing right-most column to left does not remove scroll area', async () => { + // create horizontal scrollbar with total column width of 450 + pageObject.dragSizeColumnByRightDivider(2, [100]); + await waitForUpdatesAsync(); + pageObject.dragSizeColumnByRightDivider(2, [-100]); + await waitForUpdatesAsync(); + expect(pageObject.getTotalCellRenderedWidth()).toBe(450); + }); + + it('sizing column results in updated currentFractionalWidths for columns', () => { + pageObject.dragSizeColumnByRightDivider(0, [150]); + const updatedFractionalWidths = element.columns.map( + column => column.columnInternals.currentFractionalWidth + ); + expect(updatedFractionalWidths).toEqual([2.5, 0.5, 0.5, 0.5]); + }); + + it('sizing column left of hidden column to the right cascade to columns to right of hidden column', async () => { + element.columns[1]!.columnHidden = true; + await waitForUpdatesAsync(); + const secondVisibleCellWidth = pageObject.getCellRenderedWidth( + 0, + 1 + ); + pageObject.dragSizeColumnByRightDivider(0, [50]); + await waitForUpdatesAsync(); + expect(pageObject.getCellRenderedWidth(0, 1)).toBe( + secondVisibleCellWidth - 50 + ); + }); + + it('sizing column right of hidden column to the left cascade to columns to left of hidden column', async () => { + element.columns[2]!.columnHidden = true; + await waitForUpdatesAsync(); + const secondVisibleCellWidth = pageObject.getCellRenderedWidth( + 0, + 1 + ); + pageObject.dragSizeColumnByRightDivider(1, [-50]); + await waitForUpdatesAsync(); + expect(pageObject.getCellRenderedWidth(0, 1)).toBe( + secondVisibleCellWidth - 50 + ); + }); + + it('hiding column after creating horizontal scroll space does not change scroll area', async () => { + // create horizontal scrollbar with total column width of 450 + pageObject.dragSizeColumnByRightDivider(2, [100]); + await waitForUpdatesAsync(); + element.columns[1]!.columnHidden = true; + await waitForUpdatesAsync(); + expect(pageObject.getTotalCellRenderedWidth()).toBe(450); + expect(pageObject.getRenderedCellCountForRow(0)).toBe(3); + }); + }); + + describe('hidden column drag right divider tests ', () => { + const hiddenColumDragRightDividerTests = [ + { + name: 'first column hidden, drag first right divider to right results in correct columns widths', + tableWidth: 300, + hiddenColumns: [0], + dragColumnIndex: 0, + dragDeltas: [50], + expectedColumnWidths: [150, 50, 100] + }, + { + name: 'first column hidden, drag second right divider to right results in correct columns widths', + tableWidth: 300, + hiddenColumns: [0], + dragColumnIndex: 1, + dragDeltas: [50], + expectedColumnWidths: [100, 150, 50] + }, + { + name: 'second column hidden, drag first right divider to right results in correct columns widths', + tableWidth: 300, + hiddenColumns: [1], + dragColumnIndex: 0, + dragDeltas: [50], + expectedColumnWidths: [150, 50, 100] + }, + { + name: 'second column hidden, drag second right divider to right results in correct columns widths', + tableWidth: 300, + hiddenColumns: [1], + dragColumnIndex: 1, + dragDeltas: [50], + expectedColumnWidths: [100, 150, 50] + }, + { + name: 'first column hidden, drag first right divider to left results in correct columns widths', + tableWidth: 300, + hiddenColumns: [0], + dragColumnIndex: 0, + dragDeltas: [-50], + expectedColumnWidths: [50, 150, 100] + }, + { + name: 'first column hidden, drag second right divider to left results in correct columns widths', + tableWidth: 300, + hiddenColumns: [0], + dragColumnIndex: 1, + dragDeltas: [-50], + expectedColumnWidths: [100, 50, 150] + }, + { + name: 'second column hidden, drag first right divider to left results in correct columns widths', + tableWidth: 300, + hiddenColumns: [1], + dragColumnIndex: 0, + dragDeltas: [-50], + expectedColumnWidths: [50, 150, 100] + }, + { + name: 'second column hidden, drag second right divider to left results in correct columns widths', + tableWidth: 300, + hiddenColumns: [1], + dragColumnIndex: 1, + dragDeltas: [-50], + expectedColumnWidths: [100, 50, 150] + } + ]; + const focused: string[] = []; + const disabled: string[] = []; + for (const columnSizeTest of hiddenColumDragRightDividerTests) { + const specType = getSpecTypeByNamedList( + columnSizeTest, + focused, + disabled + ); + specType( + `${columnSizeTest.name}`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + async () => { + await pageObject.sizeTableToGivenRowWidth( + columnSizeTest.tableWidth, + element + ); + columnSizeTest.hiddenColumns.forEach(columnIndex => { + element.columns[columnIndex]!.columnHidden = true; + }); + await waitForUpdatesAsync(); + pageObject.dragSizeColumnByRightDivider( + columnSizeTest.dragColumnIndex, + columnSizeTest.dragDeltas + ); + await waitForUpdatesAsync(); + columnSizeTest.expectedColumnWidths.forEach((width, i) => expect(pageObject.getCellRenderedWidth(0, i)).toBe( + width + )); + } + ); + } + }); + + describe('hidden column drag left divider tests ', () => { + const hiddenColumDragRightDividerTests = [ + { + name: 'first column hidden, drag first left divider to right results in correct columns widths', + tableWidth: 300, + hiddenColumns: [0], + dragColumnIndex: 1, + dragDeltas: [50], + expectedColumnWidths: [150, 50, 100] + }, + { + name: 'first column hidden, drag second left divider to right results in correct columns widths', + tableWidth: 300, + hiddenColumns: [0], + dragColumnIndex: 2, + dragDeltas: [50], + expectedColumnWidths: [100, 150, 50] + }, + { + name: 'second column hidden, drag first left divider to right results in correct columns widths', + tableWidth: 300, + hiddenColumns: [1], + dragColumnIndex: 1, + dragDeltas: [50], + expectedColumnWidths: [150, 50, 100] + }, + { + name: 'second column hidden, drag second left divider to right results in correct columns widths', + tableWidth: 300, + hiddenColumns: [1], + dragColumnIndex: 2, + dragDeltas: [50], + expectedColumnWidths: [100, 150, 50] + }, + { + name: 'first column hidden, drag first left divider to left results in correct columns widths', + tableWidth: 300, + hiddenColumns: [0], + dragColumnIndex: 1, + dragDeltas: [-50], + expectedColumnWidths: [50, 150, 100] + }, + { + name: 'first column hidden, drag second left divider to left results in correct columns widths', + tableWidth: 300, + hiddenColumns: [0], + dragColumnIndex: 2, + dragDeltas: [-50], + expectedColumnWidths: [100, 50, 150] + }, + { + name: 'second column hidden, drag first left divider to left results in correct columns widths', + tableWidth: 300, + hiddenColumns: [1], + dragColumnIndex: 1, + dragDeltas: [-50], + expectedColumnWidths: [50, 150, 100] + }, + { + name: 'second column hidden, drag second left divider to left results in correct columns widths', + tableWidth: 300, + hiddenColumns: [1], + dragColumnIndex: 2, + dragDeltas: [-50], + expectedColumnWidths: [100, 50, 150] + } + ]; + const focused: string[] = []; + const disabled: string[] = []; + for (const columnSizeTest of hiddenColumDragRightDividerTests) { + const specType = getSpecTypeByNamedList( + columnSizeTest, + focused, + disabled + ); + specType( + `${columnSizeTest.name}`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + async () => { + await pageObject.sizeTableToGivenRowWidth( + columnSizeTest.tableWidth, + element + ); + columnSizeTest.hiddenColumns.forEach(columnIndex => { + element.columns[columnIndex]!.columnHidden = true; + }); + await waitForUpdatesAsync(); + pageObject.dragSizeColumnByLeftDivider( + columnSizeTest.dragColumnIndex, + columnSizeTest.dragDeltas + ); + await waitForUpdatesAsync(); + columnSizeTest.expectedColumnWidths.forEach((width, i) => expect(pageObject.getCellRenderedWidth(0, i)).toBe( + width + )); + } + ); + } + }); + + describe('active divider tests', () => { + const dividerActiveTests = [ + { + name: 'click on first column right divider only results in one active divider', + dividerClickIndex: 0, + leftDividerClick: false, + expectedActiveIndexes: [0] + }, + { + name: 'click on second column left divider results in two active dividers', + dividerClickIndex: 1, + expectedActiveIndexes: [1, 2] + }, + { + name: 'click on second column right divider results in two active dividers', + dividerClickIndex: 2, + expectedActiveIndexes: [1, 2] + }, + { + name: 'click on third column left divider results in two active dividers', + dividerClickIndex: 3, + expectedActiveIndexes: [3, 4] + }, + { + name: 'click on third column right divider results in two active dividers', + dividerClickIndex: 4, + expectedActiveIndexes: [3, 4] + }, + { + name: 'click on last column left divider only results in one active divider', + dividerClickIndex: 5, + expectedActiveIndexes: [5] + } + ]; + const focusedActiveDividerTests: string[] = []; + const disabledActiveDividerTests: string[] = []; + for (const dividerActiveTest of dividerActiveTests) { + const specType = getSpecTypeByNamedList( + dividerActiveTest, + focusedActiveDividerTests, + disabledActiveDividerTests + ); + specType( + `${dividerActiveTest.name}`, + // eslint-disable-next-line @typescript-eslint/no-loop-func + async () => { + const dividers = Array.from( + element.shadowRoot!.querySelectorAll('.column-divider') + ); + const divider = dividers[dividerActiveTest.dividerClickIndex]!; + const dividerRect = divider.getBoundingClientRect(); + const mouseDownEvent = new MouseEvent('mousedown', { + clientX: (dividerRect.x + dividerRect.width) / 2, + clientY: (dividerRect.y + dividerRect.height) / 2 + }); + const mouseUpEvent = new MouseEvent('mouseup'); + divider.dispatchEvent(mouseDownEvent); + await waitForUpdatesAsync(); + const activeDividers = []; + for (let i = 0; i < dividers.length; i++) { + if (dividers[i]!.classList.contains('active')) { + activeDividers.push(i); + } + } + document.dispatchEvent(mouseUpEvent); // clean up registered event handlers + expect(activeDividers).toEqual( + dividerActiveTest.expectedActiveIndexes + ); + } + ); + } + + it('first column only has right divider', () => { + const rightDivider = pageObject.getColumnRightDivider(0); + const leftDivider = pageObject.getColumnLeftDivider(0); + + expect(rightDivider).not.toBeNull(); + expect(leftDivider).toBeNull(); + }); + + it('last column only has left divider', () => { + const rightDivider = pageObject.getColumnRightDivider(3); + const leftDivider = pageObject.getColumnLeftDivider(3); + + expect(rightDivider).toBeNull(); + expect(leftDivider).not.toBeNull(); + }); + + it('after releasing divider, it is no longer marked as active', async () => { + const divider = pageObject.getColumnRightDivider(0)!; + const dividerRect = divider.getBoundingClientRect(); + const mouseDownEvent = new MouseEvent('mousedown', { + clientX: (dividerRect.x + dividerRect.width) / 2, + clientY: (dividerRect.y + dividerRect.height) / 2 + }); + divider.dispatchEvent(mouseDownEvent); + await waitForUpdatesAsync(); + expect(divider.classList.contains('active')).toBeTruthy(); + + const mouseUpEvent = new MouseEvent('mouseup'); + document.dispatchEvent(mouseUpEvent); + await waitForUpdatesAsync(); + expect(divider.classList.contains('active')).toBeFalsy(); + }); + }); +});