diff --git a/change/@ni-nimble-angular-3b488452-3822-410a-a684-15d5f131dd98.json b/change/@ni-nimble-angular-3b488452-3822-410a-a684-15d5f131dd98.json new file mode 100644 index 0000000000..2e606db68c --- /dev/null +++ b/change/@ni-nimble-angular-3b488452-3822-410a-a684-15d5f131dd98.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "add support for pinning table columns", + "packageName": "@ni/nimble-angular", + "email": "5265744+hellovolcano@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-nimble-blazor-ef211a30-d326-4325-9b2f-40bad3951887.json b/change/@ni-nimble-blazor-ef211a30-d326-4325-9b2f-40bad3951887.json new file mode 100644 index 0000000000..0501942c1e --- /dev/null +++ b/change/@ni-nimble-blazor-ef211a30-d326-4325-9b2f-40bad3951887.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "add support for pinning table columns", + "packageName": "@ni/nimble-blazor", + "email": "5265744+hellovolcano@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-nimble-components-817561ce-db7b-4dc2-866f-c794a24b0428.json b/change/@ni-nimble-components-817561ce-db7b-4dc2-866f-c794a24b0428.json new file mode 100644 index 0000000000..383df5816d --- /dev/null +++ b/change/@ni-nimble-components-817561ce-db7b-4dc2-866f-c794a24b0428.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "add support for pinning table columns", + "packageName": "@ni/nimble-components", + "email": "5265744+hellovolcano@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/angular-workspace/nimble-angular/table-column/mapping/tests/nimble-table-column-mapping.directive.spec.ts b/packages/angular-workspace/nimble-angular/table-column/mapping/tests/nimble-table-column-mapping.directive.spec.ts index c96ecad232..41953d2bf0 100644 --- a/packages/angular-workspace/nimble-angular/table-column/mapping/tests/nimble-table-column-mapping.directive.spec.ts +++ b/packages/angular-workspace/nimble-angular/table-column/mapping/tests/nimble-table-column-mapping.directive.spec.ts @@ -1,5 +1,6 @@ import { Component, ElementRef, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { BooleanValueOrAttribute } from '@ni/nimble-angular/internal-utilities'; import { NimbleTableModule } from '../../../table/nimble-table.module'; import { NimbleTableColumnMappingModule } from '../nimble-table-column-mapping.module'; import { NimbleTableColumnMappingDirective, type TableColumnMapping, TableColumnMappingWidthMode } from '../nimble-table-column-mapping.directive'; @@ -30,6 +31,7 @@ describe('NimbleTableColumnMapping', () => { action-menu-slot="my-slot" action-menu-label="my menu" column-hidden="true" + pinned fractional-width="2" min-pixel-width="40" sort-direction="${TableColumnSortDirection.ascending}" @@ -93,6 +95,11 @@ describe('NimbleTableColumnMapping', () => { expect(nativeElement.columnHidden).toBe(true); }); + it('will use template string value for pinned', () => { + expect(directive.pinned).toBeTrue(); + expect(nativeElement.pinned).toBeTrue(); + }); + it('will use template string values for sortDirection', () => { expect(directive.sortDirection).toBe(TableColumnSortDirection.ascending); expect(nativeElement.sortDirection).toBe(TableColumnSortDirection.ascending); @@ -146,6 +153,7 @@ describe('NimbleTableColumnMapping', () => { [actionMenuSlot]="actionMenuSlot" [actionMenuLabel]="actionMenuLabel" [column-hidden]="columnHidden" + [pinned]="pinned" [fractional-width]="fractionalWidth" [min-pixel-width]="minPixelWidth" [sort-direction]="sortDirection" @@ -170,6 +178,7 @@ describe('NimbleTableColumnMapping', () => { public minPixelWidth: number | null = 40; public columnId = 'my-column'; public columnHidden = true; + public pinned: BooleanValueOrAttribute = null; public sortDirection: TableColumnSortDirection = TableColumnSortDirection.ascending; public sortIndex: number | null = 0; public sortingDisabled = false; @@ -259,6 +268,17 @@ describe('NimbleTableColumnMapping', () => { expect(nativeElement.columnHidden).toBe(false); }); + it('can be configured with property binding for pinned', () => { + expect(directive.pinned).toBeFalse(); + expect(nativeElement.pinned).toBeFalse(); + + fixture.componentInstance.pinned = true; + fixture.detectChanges(); + + expect(directive.pinned).toBeTrue(); + expect(nativeElement.pinned).toBeTrue(); + }); + it('can be configured with property binding for sortDirection', () => { expect(directive.sortDirection).toBe(TableColumnSortDirection.ascending); expect(nativeElement.sortDirection).toBe(TableColumnSortDirection.ascending); @@ -404,6 +424,7 @@ describe('NimbleTableColumnMapping', () => { [attr.action-menu-slot]="actionMenuSlot" [attr.action-menu-label]="actionMenuLabel" [attr.column-hidden]="columnHidden" + [attr.pinned]="pinned" [attr.fractional-width]="fractionalWidth" [attr.min-pixel-width]="minPixelWidth" [attr.sort-direction]="sortDirection" @@ -428,6 +449,7 @@ describe('NimbleTableColumnMapping', () => { public minPixelWidth: number | null = 40; public columnId = 'my-column'; public columnHidden = true; + public pinned: BooleanValueOrAttribute = null; public sortDirection: TableColumnSortDirection = TableColumnSortDirection.ascending; public sortIndex: number | null = 0; public sortingDisabled = false; @@ -517,6 +539,17 @@ describe('NimbleTableColumnMapping', () => { expect(nativeElement.columnHidden).toBe(false); }); + it('can be configured with attribute binding for pinned', () => { + expect(directive.pinned).toBeFalse(); + expect(nativeElement.pinned).toBeFalse(); + + fixture.componentInstance.pinned = ''; + fixture.detectChanges(); + + expect(directive.pinned).toBeTrue(); + expect(nativeElement.pinned).toBeTrue(); + }); + it('can be configured with attribute binding for sortDirection', () => { expect(directive.sortDirection).toBe(TableColumnSortDirection.ascending); expect(nativeElement.sortDirection).toBe(TableColumnSortDirection.ascending); diff --git a/packages/angular-workspace/nimble-angular/table-column/nimble-table-column-base.directive.ts b/packages/angular-workspace/nimble-angular/table-column/nimble-table-column-base.directive.ts index 179f3d44c4..16b9f05350 100644 --- a/packages/angular-workspace/nimble-angular/table-column/nimble-table-column-base.directive.ts +++ b/packages/angular-workspace/nimble-angular/table-column/nimble-table-column-base.directive.ts @@ -43,6 +43,14 @@ export class NimbleTableColumnBaseDirective { this.renderer.setProperty(this.elementRef.nativeElement, 'columnHidden', toBooleanProperty(value)); } + public get pinned(): BooleanValueOrAttribute { + return this.elementRef.nativeElement.pinned; + } + + @Input('pinned') public set pinned(value: BooleanValueOrAttribute) { + this.renderer.setProperty(this.elementRef.nativeElement, 'pinned', toBooleanProperty(value)); + } + public constructor(protected readonly renderer: Renderer2, protected readonly elementRef: ElementRef) {} public checkValidity(): boolean { diff --git a/packages/angular-workspace/nimble-angular/table/tests/nimble-table.directive.spec.ts b/packages/angular-workspace/nimble-angular/table/tests/nimble-table.directive.spec.ts index ffd8d77d4b..7c88209179 100644 --- a/packages/angular-workspace/nimble-angular/table/tests/nimble-table.directive.spec.ts +++ b/packages/angular-workspace/nimble-angular/table/tests/nimble-table.directive.spec.ts @@ -161,7 +161,8 @@ describe('Nimble table', () => { duplicateGroupIndex: false, idFieldNameNotConfigured: false, invalidColumnConfiguration: false, - invalidParentIdConfiguration: false + invalidParentIdConfiguration: false, + invalidPinnedColumnConfiguration: false }; expect(directive.validity).toEqual(expectedValidity); expect(nativeElement.validity).toEqual(expectedValidity); diff --git a/packages/blazor-workspace/NimbleBlazor/Source/TableColumn/Mapping/NimbleTableColumnMapping.razor b/packages/blazor-workspace/NimbleBlazor/Source/TableColumn/Mapping/NimbleTableColumnMapping.razor index 1a3fb15c42..9779634972 100644 --- a/packages/blazor-workspace/NimbleBlazor/Source/TableColumn/Mapping/NimbleTableColumnMapping.razor +++ b/packages/blazor-workspace/NimbleBlazor/Source/TableColumn/Mapping/NimbleTableColumnMapping.razor @@ -11,6 +11,7 @@ column-hidden="@ColumnHidden" fractional-width="@FractionalWidthAsString" min-pixel-width="@MinPixelWidthAsString" + pinned="@Pinned" sort-direction="@SortDirection.ToAttributeValue()" sort-index="@SortIndex" sorting-disabled="@SortingDisabled" diff --git a/packages/blazor-workspace/NimbleBlazor/Source/TableColumn/Mapping/NimbleTableColumnMapping.razor.cs b/packages/blazor-workspace/NimbleBlazor/Source/TableColumn/Mapping/NimbleTableColumnMapping.razor.cs index ebc2172bf4..fe707a73df 100644 --- a/packages/blazor-workspace/NimbleBlazor/Source/TableColumn/Mapping/NimbleTableColumnMapping.razor.cs +++ b/packages/blazor-workspace/NimbleBlazor/Source/TableColumn/Mapping/NimbleTableColumnMapping.razor.cs @@ -17,6 +17,12 @@ public partial class NimbleTableColumnMapping : NimbleTableColumnEnumBase< [Parameter] public double? MinPixelWidth { get; set; } + /// + /// Indicates whether the column is pinned. + /// + [Parameter] + public bool Pinned { get; set; } + /// /// Sets the width mode on the column. /// diff --git a/packages/nimble-components/src/table-column/base/index.ts b/packages/nimble-components/src/table-column/base/index.ts index f60852b68a..ff56e88dce 100644 --- a/packages/nimble-components/src/table-column/base/index.ts +++ b/packages/nimble-components/src/table-column/base/index.ts @@ -37,6 +37,9 @@ export abstract class TableColumn< @attr({ attribute: 'column-hidden', mode: 'boolean' }) public columnHidden = false; + @attr({ attribute: 'pinned', mode: 'boolean' }) + public pinned = false; + /** @internal */ @observable public hasOverflow = false; diff --git a/packages/nimble-components/src/table-column/base/tests/table-column.spec.ts b/packages/nimble-components/src/table-column/base/tests/table-column.spec.ts index a18b8ff8d9..a605bced28 100644 --- a/packages/nimble-components/src/table-column/base/tests/table-column.spec.ts +++ b/packages/nimble-components/src/table-column/base/tests/table-column.spec.ts @@ -55,6 +55,13 @@ describe('TableColumn', () => { expect(element.columnInternals.currentPixelWidth).toBe(200); }); + it('has expected defaults for pinned', async () => { + await connect(); + + expect(element.pinned).toBeFalse(); + expect(element.hasAttribute('pinned')).toBeFalse(); + }); + describe('with a custom constructor', () => { // Seems subject to change how errors are handled during custom // element construction: https://github.com/WICG/webcomponents/issues/635 diff --git a/packages/nimble-components/src/table/components/group-row/index.ts b/packages/nimble-components/src/table/components/group-row/index.ts index 4d9eefab43..c9362fd922 100644 --- a/packages/nimble-components/src/table/components/group-row/index.ts +++ b/packages/nimble-components/src/table/components/group-row/index.ts @@ -27,6 +27,9 @@ export class TableGroupRow extends FoundationElement { @observable public nestingLevel = 0; + @observable + public pinnedColumnOffset = 0; + /** * Row index in the flattened set of all regular and group header rows. * Represents the index in table.tableData (TableRowState[]). diff --git a/packages/nimble-components/src/table/components/group-row/styles.ts b/packages/nimble-components/src/table/components/group-row/styles.ts index 00b4015959..abf69c1f2c 100644 --- a/packages/nimble-components/src/table/components/group-row/styles.ts +++ b/packages/nimble-components/src/table/components/group-row/styles.ts @@ -8,7 +8,8 @@ import { controlHeight, fillHoverColor, mediumPadding, - standardPadding + standardPadding, + tableRowBorderColor } from '../../../theme-provider/design-tokens'; import { Theme } from '../../../theme-provider/types'; import { hexToRgbaCssColor } from '../../../utilities/style/colors'; @@ -16,6 +17,7 @@ import { themeBehavior } from '../../../utilities/style/theme'; import { userSelectNone } from '../../../utilities/style/user-select'; import { styles as expandCollapseStyles } from '../../../patterns/expand-collapse/styles'; import { focusVisible } from '../../../utilities/style/focus'; +import { ZIndexLevels } from '../../../utilities/style/types'; export const styles = css` ${display('grid')} @@ -26,6 +28,7 @@ export const styles = css` height: calc(${controlHeight} + 2 * ${borderWidth}); border-top: calc(2 * ${borderWidth}) solid ${applicationBackgroundColor}; grid-template-columns: + calc(var(--ni-private-table-group-row-pinned-column-offset)) calc( ${controlHeight} * (var(--ni-private-table-group-row-indent-level) + 1) @@ -35,6 +38,7 @@ export const styles = css` :host([selectable]) { grid-template-columns: + calc(var(--ni-private-table-group-row-pinned-column-offset)) ${controlHeight} calc( ${controlHeight} * @@ -56,11 +60,31 @@ export const styles = css` background-color: ${fillHoverColor}; } + :host([allow-hover]:hover) .pinned-column-spacer.has-pinned-columns, + :host([allow-hover]:hover) .checkbox-container.has-pinned-columns { + background: linear-gradient(${fillHoverColor}, ${fillHoverColor}), + ${tableRowBorderColor}; + } + :host(${focusVisible}) { outline: calc(2 * ${borderWidth}) solid ${borderHoverColor}; outline-offset: calc(-2 * ${borderWidth}); } + .pinned-column-spacer { + height: 100%; + } + + .pinned-column-spacer.has-pinned-columns { + position: sticky; + left: 0; + background: ${tableRowBorderColor}; + z-index: ${ZIndexLevels.zIndex1000}; + } + + .expand-collapse-button-container.selectable { + } + .expand-collapse-button { margin-left: calc( ${mediumPadding} + ${standardPadding} * 2 * @@ -90,6 +114,13 @@ export const styles = css` display: flex; } + .checkbox-container.has-pinned-columns { + position: sticky; + left: var(--ni-private-table-group-row-pinned-column-offset); + z-index: ${ZIndexLevels.zIndex1000}; + background: ${tableRowBorderColor}; + } + .selection-checkbox { margin-left: ${standardPadding}; } @@ -104,6 +135,28 @@ export const styles = css` :host([allow-hover]:hover)::before { background-color: ${hexToRgbaCssColor(White, 0.05)}; } + + :host([allow-hover]:hover) .pinned-column-spacer.has-pinned-columns, + :host([allow-hover]:hover) .checkbox-container.has-pinned-columns { + background: linear-gradient( + ${hexToRgbaCssColor(White, 0.05)}, + ${hexToRgbaCssColor(White, 0.05)} + ), + linear-gradient( + ${hexToRgbaCssColor(White, 0.1)}, + ${hexToRgbaCssColor(White, 0.1)} + ), + ${tableRowBorderColor}; + } + + .pinned-column-spacer.has-pinned-columns, + .checkbox-container.has-pinned-columns { + background: linear-gradient( + ${hexToRgbaCssColor(White, 0.1)}, + ${hexToRgbaCssColor(White, 0.1)} + ), + ${tableRowBorderColor}; + } ` ), themeBehavior( @@ -112,6 +165,15 @@ export const styles = css` :host([allow-hover]:hover)::before { background-color: ${hexToRgbaCssColor(White, 0.1)}; } + + :host([allow-hover]:hover) .pinned-column-spacer.has-pinned-columns, + :host([allow-hover]:hover) .checkbox-container.has-pinned-columns { + background: linear-gradient( + ${hexToRgbaCssColor(White, 0.1)}, + ${hexToRgbaCssColor(White, 0.1)} + ), + ${tableRowBorderColor}; + } ` ) ); diff --git a/packages/nimble-components/src/table/components/group-row/template.ts b/packages/nimble-components/src/table/components/group-row/template.ts index cf053c6a6e..07ab43c2dd 100644 --- a/packages/nimble-components/src/table/components/group-row/template.ts +++ b/packages/nimble-components/src/table/components/group-row/template.ts @@ -15,10 +15,15 @@ export const template = html` role="row" @click=${x => x.onGroupExpandToggle()} aria-expanded=${x => x.expanded} - style="--ni-private-table-group-row-indent-level: ${x => x.nestingLevel};" + style=" + --ni-private-table-group-row-indent-level: ${x => x.nestingLevel}; + --ni-private-table-group-row-pinned-column-offset: ${x => x.pinnedColumnOffset}px; + " > + + ${when(x => x.selectable, html` - + <${checkboxTag} ${ref('selectionCheckbox')} class="selection-checkbox" @@ -31,7 +36,7 @@ export const template = html` `)} - + <${buttonTag} appearance="${ButtonAppearance.ghost}" content-hidden diff --git a/packages/nimble-components/src/table/components/row/index.ts b/packages/nimble-components/src/table/components/row/index.ts index 265e31fe1e..3ee24d603f 100644 --- a/packages/nimble-components/src/table/components/row/index.ts +++ b/packages/nimble-components/src/table/components/row/index.ts @@ -330,8 +330,9 @@ export class TableRow< } private updateCellIndentLevels(): void { + const firstNonPinnedIndex = this.columns.findIndex(col => !col.pinned); this.cellIndentLevels = this.columns.map((_, i) => { - return i === 0 ? this.nestingLevel : 0; + return i === firstNonPinnedIndex ? this.nestingLevel : 0; }); } diff --git a/packages/nimble-components/src/table/components/row/styles.ts b/packages/nimble-components/src/table/components/row/styles.ts index e477f17964..506d9524df 100644 --- a/packages/nimble-components/src/table/components/row/styles.ts +++ b/packages/nimble-components/src/table/components/row/styles.ts @@ -19,6 +19,7 @@ import { hexToRgbaCssColor } from '../../../utilities/style/colors'; import { themeBehavior } from '../../../utilities/style/theme'; import { styles as expandCollapseStyles } from '../../../patterns/expand-collapse/styles'; import { focusVisible } from '../../../utilities/style/focus'; +import { ZIndexLevels } from '../../../utilities/style/types'; export const styles = css` ${display('flex')} @@ -54,6 +55,24 @@ export const styles = css` background-color: ${fillHoverSelectedColor}; } + :host([selectable]:not([selected])[allow-hover]:hover) .pinned-cell-container { + background: linear-gradient(${fillHoverColor}, ${fillHoverColor}), + ${applicationBackgroundColor}; + } + + :host([selected]) .pinned-cell-container { + background: linear-gradient(${fillSelectedColor}, ${fillSelectedColor}), + ${applicationBackgroundColor}; + } + + :host([selected][allow-hover]:hover) .pinned-cell-container { + background: linear-gradient( + ${fillHoverSelectedColor}, + ${fillHoverSelectedColor} + ), + ${applicationBackgroundColor}; + } + :host(${focusVisible}) { outline: none; box-shadow: @@ -115,6 +134,15 @@ export const styles = css` width: ${mediumPadding}; } + .pinned-cell-container { + display: grid; + grid-template-columns: var(--ni-private-table-pinned-columns-row-grid-columns); + position: sticky; + left: 0; + background: ${applicationBackgroundColor}; + z-index: ${ZIndexLevels.zIndex1000}; + } + .cell-container { display: grid; width: 100%; @@ -193,6 +221,30 @@ export const styles = css` :host([selected][allow-hover]:hover)::before { background-color: ${hexToRgbaCssColor(White, 0.2)}; } + + :host([selectable]:not([selected])[allow-hover]:hover) .pinned-cell-container { + background: linear-gradient( + ${hexToRgbaCssColor(White, 0.05)}, + ${hexToRgbaCssColor(White, 0.05)} + ), + ${applicationBackgroundColor}; + } + + :host([selected]) .pinned-cell-container { + background: linear-gradient( + ${hexToRgbaCssColor(White, 0.25)}, + ${hexToRgbaCssColor(White, 0.25)} + ), + ${applicationBackgroundColor}; + } + + :host([selected][allow-hover]:hover) .pinned-cell-container { + background: linear-gradient( + ${hexToRgbaCssColor(White, 0.2)}, + ${hexToRgbaCssColor(White, 0.2)} + ), + ${applicationBackgroundColor}; + } ` ) ); diff --git a/packages/nimble-components/src/table/components/row/template.ts b/packages/nimble-components/src/table/components/row/template.ts index f7260d5c6e..ffa2027e8b 100644 --- a/packages/nimble-components/src/table/components/row/template.ts +++ b/packages/nimble-components/src/table/components/row/template.ts @@ -27,6 +27,42 @@ export const template = html` aria-expanded=${x => x.expanded} style="--ni-private-table-row-indent-level: ${x => x.nestingLevel};" > + + ${repeat(x => x.columns, html` + ${when(x => !x.columnHidden && x.pinned, html` + <${tableCellTag} + class="cell" + :cellState="${(_, c) => c.parent.cellStates[c.index]}" + :cellViewTemplate="${x => x.columnInternals.cellViewTemplate}" + :column="${x => x}" + column-id="${x => x.columnId}" + :recordId="${(_, c) => c.parent.recordId}" + ?has-action-menu="${x => !!x.actionMenuSlot}" + action-menu-label="${x => x.actionMenuLabel}" + @cell-action-menu-beforetoggle="${(x, c) => c.parent.onCellActionMenuBeforeToggle(c.event as CustomEvent, x)}" + @cell-action-menu-toggle="${(x, c) => c.parent.onCellActionMenuToggle(c.event as CustomEvent, x)}" + @cell-view-slots-request="${(x, c) => c.parent.onCellViewSlotsRequest(x, c.event as CustomEvent)}" + :nestingLevel="${(_, c) => c.parent.cellIndentLevels[c.index]}" + > + + ${when((x, c) => ((c.parent as TableRow).currentActionMenuColumn === x) && x.actionMenuSlot, html` + + `)} + + ${repeat(x => x.columnInternals.slotNames, html` + + `)} + + `)} + `, { recycle: false, positioning: true })} + + ${when(x => !x.rowOperationGridCellHidden, html` ${when(x => x.showSelectionCheckbox, html` @@ -44,7 +80,7 @@ export const template = html` `)} `)} - + ${when(x => x.isParentRow, html` ${when(x => x.loading, html` @@ -75,7 +111,7 @@ export const template = html` class="cell-container ${x => (x.isNestedParent ? 'nested-parent' : '')}" > ${repeat(x => x.columns, html` - ${when(x => !x.columnHidden, html` + ${when(x => !x.columnHidden && !x.pinned, html` <${tableCellTag} class="cell" :cellState="${(_, c) => c.parent.cellStates[c.index]}" diff --git a/packages/nimble-components/src/table/index.ts b/packages/nimble-components/src/table/index.ts index e75000ffa7..74a9fd412e 100644 --- a/packages/nimble-components/src/table/index.ts +++ b/packages/nimble-components/src/table/index.ts @@ -207,6 +207,51 @@ export class Table< return ''; } + /** + * @internal + */ + @volatile + public get pinnedColumnOffset(): number { + let offset = 0; + for (const column of this.pinnedColumns) { + const resolvedPixelWidth = this.getPinnedColumnResolvedPixelWidth( + column + ); + if (resolvedPixelWidth !== undefined) { + const coercedPixelWidth = Math.max( + column.columnInternals.minPixelWidth, + resolvedPixelWidth + ); + offset += coercedPixelWidth; + } + } + return offset; + } + + /** + * @internal + */ + @volatile + public get pinnedColumnsGridTemplateColumns(): string { + return this.pinnedColumns.map(column => { + const { + minPixelWidth + } = column.columnInternals; + const resolvedPixelWidth = this.getPinnedColumnResolvedPixelWidth( + column + ); + if (resolvedPixelWidth !== undefined) { + const coercedPixelWidth = Math.max( + minPixelWidth, + resolvedPixelWidth + ); + return `${coercedPixelWidth}px`; + } + return ''; + }) + .join(' '); + } + /** * @internal */ @@ -249,6 +294,12 @@ export class Table< @observable public visibleColumns: TableColumn[] = []; + /** + * @internal + */ + @observable + public pinnedColumns: TableColumn[] = []; + /** * @internal * This value determines the size of the viewport area when a user has created horizontal scrollable @@ -699,7 +750,10 @@ export class Table< if (this.tableUpdateTracker.updateColumnWidths) { this.rowGridColumns = this.layoutManager.getGridTemplateColumns(); this.visibleColumns = this.columns.filter( - column => !column.columnHidden + column => !column.columnHidden && !column.pinned + ); + this.pinnedColumns = this.columns.filter( + column => column.pinned && !column.columnHidden ); } @@ -821,6 +875,16 @@ export class Table< this.tableUpdateTracker.trackColumnInstancesChanged(); } + private getPinnedColumnResolvedPixelWidth( + column: TableColumn + ): number | undefined { + const { + currentPixelWidth, + pixelWidth + } = column.columnInternals; + return currentPixelWidth ?? pixelWidth; + } + private updateRequestedSlotsForOpeningActionMenu( openActionMenuRecordId: string ): void { @@ -1088,6 +1152,7 @@ export class Table< ) ); this.tableValidator.validateColumnConfigurations(this.columns); + this.tableValidator.validatePinnedColumnConfigurations(this.columns); if (this.dataHierarchyManager) { this.validateWithData(this.dataHierarchyManager.getAllRecords()); } diff --git a/packages/nimble-components/src/table/models/table-layout-manager.ts b/packages/nimble-components/src/table/models/table-layout-manager.ts index 6fa2161fa4..889a804d85 100644 --- a/packages/nimble-components/src/table/models/table-layout-manager.ts +++ b/packages/nimble-components/src/table/models/table-layout-manager.ts @@ -362,7 +362,7 @@ export class TableLayoutManager { } private getVisibleColumns(): TableColumn[] { - return this.table.columns.filter(column => !column.columnHidden); + return this.table.columns.filter(column => !column.columnHidden && !column.pinned); } private getLeftColumnIndexFromDivider(dividerIndex: number): number { diff --git a/packages/nimble-components/src/table/models/table-update-tracker.ts b/packages/nimble-components/src/table/models/table-update-tracker.ts index 39fb4c4c5e..1969255791 100644 --- a/packages/nimble-components/src/table/models/table-update-tracker.ts +++ b/packages/nimble-components/src/table/models/table-update-tracker.ts @@ -29,7 +29,8 @@ const trackedItems = [ 'columnDefinition', 'actionMenuSlots', 'selectionMode', - 'actionMenusPreserveSelection' + 'actionMenusPreserveSelection', + 'columnPinned' ] as const; /** @@ -109,6 +110,7 @@ export class TableUpdateTracker< this.isTracked('columnSortDisabled') || this.isTracked('columnDefinition') || this.isTracked('columnHidden') + || this.isTracked('columnPinned') || this.isTracked('selectionMode') || this.isTracked('actionMenuSlots') ); @@ -160,6 +162,9 @@ export class TableUpdateTracker< } else if (isColumnProperty(changedColumnProperty, 'columnHidden')) { this.track('columnWidths'); this.track('columnHidden'); + } else if (isColumnProperty(changedColumnProperty, 'pinned')) { + this.track('columnWidths'); + this.track('columnPinned'); } else if (isColumnProperty(changedColumnProperty, 'actionMenuSlot')) { this.track('actionMenuSlots'); } else if ( diff --git a/packages/nimble-components/src/table/models/table-validator.ts b/packages/nimble-components/src/table/models/table-validator.ts index 2a2dbf563a..fbc8a4de6e 100644 --- a/packages/nimble-components/src/table/models/table-validator.ts +++ b/packages/nimble-components/src/table/models/table-validator.ts @@ -21,6 +21,7 @@ export class TableValidator { private idFieldNameNotConfigured = false; private invalidColumnConfiguration = false; private invalidParentIdConfiguration = false; + private invalidPinnedColumnConfiguration = false; private readonly recordIds = new Set(); @@ -35,7 +36,8 @@ export class TableValidator { duplicateGroupIndex: this.duplicateGroupIndex, idFieldNameNotConfigured: this.idFieldNameNotConfigured, invalidColumnConfiguration: this.invalidColumnConfiguration, - invalidParentIdConfiguration: this.invalidParentIdConfiguration + invalidParentIdConfiguration: this.invalidParentIdConfiguration, + invalidPinnedColumnConfiguration: this.invalidPinnedColumnConfiguration }; } @@ -152,9 +154,19 @@ export class TableValidator { this.invalidColumnConfiguration = columns.some( x => !x.columnInternals.validator.isColumnValid ); + return !this.invalidColumnConfiguration; } + public validatePinnedColumnConfigurations( + columns: readonly TableColumn[] + ): boolean { + this.invalidPinnedColumnConfiguration = columns.some( + x => x.pinned && x.columnInternals.currentPixelWidth === undefined + ); + return !this.invalidPinnedColumnConfiguration; + } + public getPresentRecordIds( requestedRecordIds: readonly string[] ): string[] { diff --git a/packages/nimble-components/src/table/styles.ts b/packages/nimble-components/src/table/styles.ts index 5c56f19acb..6f5ed27e96 100644 --- a/packages/nimble-components/src/table/styles.ts +++ b/packages/nimble-components/src/table/styles.ts @@ -121,6 +121,15 @@ export const styles = css` visibility: visible; } + .pinned-columns-header-container { + display: grid; + grid-template-columns: var(--ni-private-table-pinned-columns-row-grid-columns); + position: sticky; + left: 0; + background: ${applicationBackgroundColor}; + z-index: ${ZIndexLevels.zIndex1000}; + } + .header-container { display: flex; align-items: center; diff --git a/packages/nimble-components/src/table/template.ts b/packages/nimble-components/src/table/template.ts index 6d13e8ba27..2724e96f81 100644 --- a/packages/nimble-components/src/table/template.ts +++ b/packages/nimble-components/src/table/template.ts @@ -49,9 +49,26 @@ export const template = html` --ni-private-table-row-grid-columns: ${x => (x.rowGridColumns ? x.rowGridColumns : '')}; --ni-private-table-cursor-override: ${x => (x.layoutManager.isColumnBeingSized ? 'col-resize' : 'default')}; --ni-private-table-scrollable-min-width: ${x => x.tableScrollableMinWidth}px; + --ni-private-table-pinned-columns-row-grid-columns: ${x => x.pinnedColumnsGridTemplateColumns}; ">
+
+ ${repeat(x => x.pinnedColumns, html` + <${tableHeaderTag} + class="header" + ${'' /* tabindex managed dynamically by KeyboardNavigationManager (if column sorting not disabled) */} + sort-direction="${x => (typeof x.columnInternals.currentSortIndex === 'number' ? x.columnInternals.currentSortDirection : TableColumnSortDirection.none)}" + ?first-sorted-column="${(x, c) => x === c.parent.firstSortedColumn}" + ?indicators-hidden="${x => x.columnInternals.hideHeaderIndicators}" + @keydown="${(x, c) => c.parent.onHeaderKeyDown(x, c.event as KeyboardEvent)}" + @click="${(x, c) => c.parent.toggleColumnSort(x, (c.event as MouseEvent).shiftKey)}" + :alignment="${x => x.columnInternals.headerAlignment}" + > + + + `, { positioning: true })} +
${when(x => x.showRowOperationColumn, html
` @@ -148,6 +165,7 @@ export const template = html
` :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}" + :pinnedColumnOffset="${(_, c) => c.parent.pinnedColumnOffset}" :immediateChildCount="${(x, c) => c.parent.tableData[x.index]?.immediateChildCount}" :groupColumn="${(x, c) => c.parent.tableData[x.index]?.groupColumn}" ?selectable="${(_, c) => c.parent.selectionMode === TableRowSelectionMode.multiple}" diff --git a/packages/nimble-components/src/table/tests/table.spec.ts b/packages/nimble-components/src/table/tests/table.spec.ts index 3e93476b81..b2b4fbf1a8 100644 --- a/packages/nimble-components/src/table/tests/table.spec.ts +++ b/packages/nimble-components/src/table/tests/table.spec.ts @@ -859,6 +859,88 @@ describe('Table', () => { }); }); + describe('pinned columns', () => { + it('renders pinned columns separately from visible columns', async () => { + column1.columnInternals.currentPixelWidth = 120; + column1.pinned = true; + await connect(); + await waitForUpdatesAsync(); + + expect(element.pinnedColumns).toEqual([column1]); + expect(element.visibleColumns).toEqual([column2]); + expect(element.pinnedColumnOffset).toBe(120); + expect(element.pinnedColumnsGridTemplateColumns).toBe('120px'); + expect( + element.shadowRoot!.querySelector( + '.pinned-columns-header-container' + )!.children.length + ).toBe(1); + expect( + element.shadowRoot!.querySelectorAll( + '.column-headers-container .header-container' + ).length + ).toBe(1); + }); + + it('excludes hidden pinned columns from pinned layout state', async () => { + column1.columnInternals.currentPixelWidth = 120; + column1.pinned = true; + column1.columnHidden = true; + await connect(); + await waitForUpdatesAsync(); + + expect(element.pinnedColumns).toEqual([]); + expect(element.visibleColumns).toEqual([column2]); + expect(element.pinnedColumnOffset).toBe(0); + expect(element.pinnedColumnsGridTemplateColumns).toBe(''); + }); + + it('updates pinned column collections when a pinned column becomes unpinned', async () => { + column1.columnInternals.currentPixelWidth = 120; + column1.pinned = true; + await connect(); + await waitForUpdatesAsync(); + + expect(element.pinnedColumns).toEqual([column1]); + + column1.pinned = false; + await waitForUpdatesAsync(); + + expect(element.pinnedColumns).toEqual([]); + expect(element.visibleColumns).toEqual([column1, column2]); + expect(element.pinnedColumnOffset).toBe(0); + expect(element.pinnedColumnsGridTemplateColumns).toBe(''); + }); + + it('reports invalidPinnedColumnConfiguration when a pinned column has no pixel width', async () => { + await connect(); + + expect(element.checkValidity()).toBeTrue(); + expect(element.validity.invalidPinnedColumnConfiguration).toBeFalse(); + + column1.pinned = true; + await waitForUpdatesAsync(); + + expect(element.checkValidity()).toBeFalse(); + expect(element.validity.invalidPinnedColumnConfiguration).toBeTrue(); + }); + + it('clears invalidPinnedColumnConfiguration when a pinned column gets a pixel width', async () => { + await connect(); + + column1.pinned = true; + await waitForUpdatesAsync(); + expect(element.checkValidity()).toBeFalse(); + expect(element.validity.invalidPinnedColumnConfiguration).toBeTrue(); + + column1.columnInternals.currentPixelWidth = 120; + await waitForUpdatesAsync(); + + expect(element.checkValidity()).toBeTrue(); + expect(element.validity.invalidPinnedColumnConfiguration).toBeFalse(); + }); + }); + describe('multiple updates', () => { it('can update action menu slots and column sort', async () => { await element.setData(simpleTableData); diff --git a/packages/nimble-components/src/table/types.ts b/packages/nimble-components/src/table/types.ts index ac2c4cf393..13b4ca7f4d 100644 --- a/packages/nimble-components/src/table/types.ts +++ b/packages/nimble-components/src/table/types.ts @@ -79,6 +79,7 @@ export interface TableValidity extends ValidityObject { readonly idFieldNameNotConfigured: boolean; readonly invalidColumnConfiguration: boolean; readonly invalidParentIdConfiguration: boolean; + readonly invalidPinnedColumnConfiguration: boolean; } /** diff --git a/packages/storybook/src/nimble/table-column/mapping/table-column-mapping.stories.ts b/packages/storybook/src/nimble/table-column/mapping/table-column-mapping.stories.ts index f2c3b517dd..5e8101b90e 100644 --- a/packages/storybook/src/nimble/table-column/mapping/table-column-mapping.stories.ts +++ b/packages/storybook/src/nimble/table-column/mapping/table-column-mapping.stories.ts @@ -73,6 +73,7 @@ interface MappingColumnTableArgs extends SharedTableArgs { fieldName: string; keyType: string; widthMode: keyof typeof TableColumnMappingWidthMode; + pinned: boolean; checkValidity: () => void; validity: () => void; content: undefined; @@ -100,7 +101,7 @@ export const mappingColumn: StoryObj = { <${mappingSpinnerTag} key="calculating" text="Calculating" text-hidden> <${mappingEmptyTag} key="unknown" text="Unknown"> - <${tableColumnMappingTag} field-name="isChild" key-type="boolean" width-mode="${x => TableColumnMappingWidthMode[x.widthMode]}"> + <${tableColumnMappingTag} field-name="isChild" key-type="boolean" width-mode="${x => TableColumnMappingWidthMode[x.widthMode]}" ?pinned="${x => x.pinned}"> <${iconChartDiagramChildFocusTag} title="Is child"> <${mappingIconTag} key="false" icon="${iconXmarkTag}" severity="error" text="Not a child" text-hidden> @@ -142,6 +143,11 @@ export const mappingColumn: StoryObj = { description: widthModeDescription, table: { category: apiCategory.attributes } }, + pinned: { + description: 'When `true`, pin a fixed width column to the left side of the table.', + control: { type: 'boolean' }, + table: { category: apiCategory.attributes } + }, checkValidity: { name: 'checkValidity()', description: checkValidityDescription({ @@ -201,6 +207,7 @@ export const mappingColumn: StoryObj = { fieldName: 'firstName', keyType: 'string', widthMode: 'iconSize', + pinned: false, checkValidity: () => {}, validity: () => {} }