From e823d57ae18234b08529297d9408a7216c085053 Mon Sep 17 00:00:00 2001 From: Malcolm Smith <20709258+msmithNI@users.noreply.github.com> Date: Wed, 3 Apr 2024 23:01:56 -0500 Subject: [PATCH 001/100] Prototype as of 4/3 --- package-lock.json | 6 + packages/nimble-components/package.json | 1 + .../src/table/components/cell/styles.ts | 8 + .../src/table/components/group-row/index.ts | 12 + .../src/table/components/group-row/styles.ts | 7 + .../src/table/components/header/styles.ts | 7 + .../src/table/components/row/index.ts | 32 +- .../src/table/components/row/styles.ts | 15 + .../src/table/components/row/template.ts | 5 +- packages/nimble-components/src/table/index.ts | 41 +- .../table/models/table-navigation-manager.ts | 497 ++++++++++++++++++ .../src/table/models/virtualizer.ts | 12 +- .../nimble-components/src/table/template.ts | 7 +- packages/nimble-components/src/table/types.ts | 1 + 14 files changed, 641 insertions(+), 10 deletions(-) create mode 100644 packages/nimble-components/src/table/models/table-navigation-manager.ts diff --git a/package-lock.json b/package-lock.json index 1f10fb0a25..8f995c6839 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34015,6 +34015,7 @@ "prosemirror-markdown": "^1.11.2", "prosemirror-model": "^1.19.2", "prosemirror-state": "^1.4.3", + "tabbable": "^6.2.0", "tslib": "^2.2.0" }, "devDependencies": { @@ -34869,6 +34870,11 @@ "url": "https://opencollective.com/unified" } }, + "packages/nimble-components/node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "packages/nimble-components/node_modules/unified": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", diff --git a/packages/nimble-components/package.json b/packages/nimble-components/package.json index e5f92fa550..b2ddf22ea9 100644 --- a/packages/nimble-components/package.json +++ b/packages/nimble-components/package.json @@ -100,6 +100,7 @@ "prosemirror-markdown": "^1.11.2", "prosemirror-model": "^1.19.2", "prosemirror-state": "^1.4.3", + "tabbable": "^6.2.0", "tslib": "^2.2.0" }, "peerDependencies": { diff --git a/packages/nimble-components/src/table/components/cell/styles.ts b/packages/nimble-components/src/table/components/cell/styles.ts index 671049a444..e268436437 100644 --- a/packages/nimble-components/src/table/components/cell/styles.ts +++ b/packages/nimble-components/src/table/components/cell/styles.ts @@ -1,10 +1,13 @@ import { css } from '@microsoft/fast-element'; import { display } from '@microsoft/fast-foundation'; import { + borderHoverColor, + borderWidth, controlHeight, controlSlimHeight, mediumPadding } from '../../../theme-provider/design-tokens'; +import { focusVisible } from '../../../utilities/style/focus'; export const styles = css` ${display('flex')} @@ -22,6 +25,11 @@ export const styles = css` --ni-private-table-cell-action-menu-display: block; } + :host(${focusVisible}) { + outline: 2px solid ${borderHoverColor}; + outline-offset: -2px; + } + .cell-view { overflow: hidden; display: flex; 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 52503a4aee..bfa051c9c3 100644 --- a/packages/nimble-components/src/table/components/group-row/index.ts +++ b/packages/nimble-components/src/table/components/group-row/index.ts @@ -29,6 +29,9 @@ export class TableGroupRow extends FoundationElement { @observable public nestingLevel = 0; + @observable + public dataIndex?: number; + @observable public immediateChildCount?: number; @@ -100,6 +103,15 @@ export class TableGroupRow extends FoundationElement { this.$emit('group-selection-toggle', detail); } + /** @internal */ + public getFocusableElements(): HTMLElement[] { + if (this.selectionCheckbox) { + return [this.selectionCheckbox]; + } + + return []; + } + private selectionStateChanged(): void { this.setSelectionCheckboxState(); } 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 c49b7d65d3..5ac45a6e92 100644 --- a/packages/nimble-components/src/table/components/group-row/styles.ts +++ b/packages/nimble-components/src/table/components/group-row/styles.ts @@ -3,6 +3,7 @@ import { display } from '@microsoft/fast-foundation'; import { White } from '@ni/nimble-tokens/dist/styledictionary/js/tokens'; import { applicationBackgroundColor, + borderHoverColor, borderWidth, controlHeight, fillHoverColor, @@ -14,6 +15,7 @@ import { hexToRgbaCssColor } from '../../../utilities/style/colors'; 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'; export const styles = css` ${display('grid')} @@ -55,6 +57,11 @@ export const styles = css` background-color: ${fillHoverColor}; } + :host(${focusVisible}) { + outline: 2px solid ${borderHoverColor}; + outline-offset: -2px; + } + .expand-collapse-button { margin-left: calc( ${mediumPadding} + ${standardPadding} * 2 * diff --git a/packages/nimble-components/src/table/components/header/styles.ts b/packages/nimble-components/src/table/components/header/styles.ts index ae0492bc54..60534956f8 100644 --- a/packages/nimble-components/src/table/components/header/styles.ts +++ b/packages/nimble-components/src/table/components/header/styles.ts @@ -1,12 +1,14 @@ import { css } from '@microsoft/fast-element'; import { display } from '@microsoft/fast-foundation'; import { + borderHoverColor, controlHeight, iconColor, mediumPadding, tableHeaderFont, tableHeaderFontColor } from '../../../theme-provider/design-tokens'; +import { focusVisible } from '../../../utilities/style/focus'; export const styles = css` ${display('flex')} @@ -23,6 +25,11 @@ export const styles = css` cursor: default; } + :host(${focusVisible}) { + outline: 2px solid ${borderHoverColor}; + outline-offset: -2px; + } + .sort-indicator, .grouped-indicator { flex: 0 0 auto; diff --git a/packages/nimble-components/src/table/components/row/index.ts b/packages/nimble-components/src/table/components/row/index.ts index ec4cb5b7c5..4ef92d5f3c 100644 --- a/packages/nimble-components/src/table/components/row/index.ts +++ b/packages/nimble-components/src/table/components/row/index.ts @@ -27,6 +27,7 @@ import { ColumnInternals, isColumnInternalsProperty } from '../../../table-column/base/models/column-internals'; +import type { Button } from '../../../button'; declare global { interface HTMLElementTagNameMap { @@ -77,6 +78,9 @@ export class TableRow< @observable public nestingLevel = 0; + @observable + public dataIndex?: number; + @attr({ attribute: 'is-parent-row', mode: 'boolean' }) public isParentRow = false; @@ -109,6 +113,10 @@ export class TableRow< @observable public readonly selectionCheckbox?: Checkbox; + /** @internal */ + @observable + public readonly expandCollapseButton?: HTMLElement; + /** @internal */ public readonly cellContainer!: HTMLSpanElement; @@ -148,6 +156,13 @@ export class TableRow< return null; } + public override connectedCallback(): void { + super.connectedCallback(); + if (this.selectionCheckbox) { + this.selectionCheckbox.tabIndex = -1; + } + } + /** @internal */ public onSelectionChange(event: CustomEvent): void { if (this.ignoreSelectionChangeEvents) { @@ -213,14 +228,27 @@ export class TableRow< } } - public onRowExpandToggle(event: Event): void { + /** @internal */ + public getFocusableElements(): HTMLElement[] { + const focusableElements: HTMLElement[] = []; + if (this.selectionCheckbox) { + focusableElements.push(this.selectionCheckbox); + } + if (this.expandCollapseButton) { + focusableElements.push(this.expandCollapseButton); + } + this.shadowRoot!.querySelectorAll('nimble-table-cell').forEach(cell => focusableElements.push(cell)); + return focusableElements; + } + + public onRowExpandToggle(event?: Event): void { const expandEventDetail: TableRowExpansionToggleEventDetail = { oldState: this.expanded, newState: !this.expanded, recordId: this.recordId! }; this.$emit('row-expand-toggle', expandEventDetail); - event.stopImmediatePropagation(); + event?.stopImmediatePropagation(); // To avoid a visual glitch with improper expand/collapse icons performing an // animation (due to visual re-use apparently), we apply a class to the // contained expand-collapse button temporarily. We use the 'transitionend' event diff --git a/packages/nimble-components/src/table/components/row/styles.ts b/packages/nimble-components/src/table/components/row/styles.ts index 72ed9e40cf..ea8b574e14 100644 --- a/packages/nimble-components/src/table/components/row/styles.ts +++ b/packages/nimble-components/src/table/components/row/styles.ts @@ -3,6 +3,7 @@ import { display } from '@microsoft/fast-foundation'; import { White } from '@ni/nimble-tokens/dist/styledictionary/js/tokens'; import { applicationBackgroundColor, + borderHoverColor, borderWidth, controlHeight, controlSlimHeight, @@ -16,6 +17,7 @@ import { Theme } from '../../../theme-provider/types'; 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'; export const styles = css` ${expandCollapseStyles} @@ -53,6 +55,11 @@ export const styles = css` background-color: ${fillHoverSelectedColor}; } + :host(${focusVisible}) { + outline: 2px solid ${borderHoverColor}; + outline-offset: -2px; + } + .expand-collapse-button { flex: 0 0 auto; padding-left: calc( @@ -119,6 +126,10 @@ export const styles = css` --ni-private-table-cell-action-menu-display: block; } + nimble-table-cell${focusVisible} { + --ni-private-table-cell-action-menu-display: block; + } + :host(:hover) nimble-table-cell { --ni-private-table-cell-action-menu-display: block; } @@ -126,6 +137,10 @@ export const styles = css` :host([selected]) nimble-table-cell { --ni-private-table-cell-action-menu-display: block; } + + :host(${focusVisible}) nimble-table-cell { + --ni-private-table-cell-action-menu-display: block; + } `.withBehaviors( themeBehavior( Theme.color, diff --git a/packages/nimble-components/src/table/components/row/template.ts b/packages/nimble-components/src/table/components/row/template.ts index fe9944ca89..eb334c2137 100644 --- a/packages/nimble-components/src/table/components/row/template.ts +++ b/packages/nimble-components/src/table/components/row/template.ts @@ -32,6 +32,8 @@ export const template = html` <${checkboxTag} ${ref('selectionCheckbox')} class="selection-checkbox" + tabindex="-1" + :tabIndex="${_ => -1}" @change="${(x, c) => x.onSelectionChange(c.event as CustomEvent)}" @click="${(_, c) => c.event.stopPropagation()}" title="${x => tableRowSelectLabel.getValueFor(x)}" @@ -55,13 +57,12 @@ export const template = html` `)} ${when(x => !x.loading, html` <${buttonTag} + ${ref('expandCollapseButton')} appearance="${ButtonAppearance.ghost}" content-hidden class="expand-collapse-button" - tabindex="-1" @click="${(x, c) => x.onRowExpandToggle(c.event)}" title="${x => (x.expanded ? tableRowCollapseLabel.getValueFor(x) : tableRowExpandLabel.getValueFor(x))}" - aria-hidden="true" > <${iconArrowExpanderRightTag} ${ref('expandIcon')} slot="start" class="expander-icon ${x => x.animationClass}"> diff --git a/packages/nimble-components/src/table/index.ts b/packages/nimble-components/src/table/index.ts index d13ea2c918..2b7102d9ef 100644 --- a/packages/nimble-components/src/table/index.ts +++ b/packages/nimble-components/src/table/index.ts @@ -28,7 +28,8 @@ import { ExpandedState as TanStackExpandedState, OnChangeFn as TanStackOnChangeFn } from '@tanstack/table-core'; -import { keyShift } from '@microsoft/fast-web-utilities'; +import { keyEnter, keyShift } from '@microsoft/fast-web-utilities'; +import { tabbable } from 'tabbable'; import { TableColumn } from '../table-column/base'; import { TableValidator } from './models/table-validator'; import { styles } from './styles'; @@ -53,11 +54,13 @@ import { getTanStackSortingFunction } from './models/sort-operations'; import { TableLayoutManager } from './models/table-layout-manager'; import { TableUpdateTracker } from './models/table-update-tracker'; import type { TableRow } from './components/row'; +import type { TableGroupRow } from './components/group-row'; import { ColumnInternals } from '../table-column/base/models/column-internals'; import { InteractiveSelectionManager } from './models/interactive-selection-manager'; import { DataHierarchyManager } from './models/data-hierarchy-manager'; import { ExpansionManager } from './models/expansion-manager'; import { waitUntilCustomElementsDefinedAsync } from '../utilities/wait-until-custom-elements-defined-async'; +import { TableNavigationManager } from './models/table-navigation-manager'; declare global { interface HTMLElementTagNameMap { @@ -102,7 +105,7 @@ export class Table< * @internal */ @observable - public readonly rowElements: TableRow[] = []; + public readonly rowElements: (TableRow | TableGroupRow)[] = []; /** * @internal @@ -165,6 +168,12 @@ export class Table< @observable public readonly selectionCheckbox?: Checkbox; + /** + * @internal + */ + @observable + public readonly collapseAllButton?: HTMLElement; + /** * @internal */ @@ -223,6 +232,7 @@ export class Table< private options: TanStackTableOptionsResolved>; private readonly tableValidator = new TableValidator(); private readonly tableUpdateTracker = new TableUpdateTracker(this); + private readonly tableNavigationManager: TableNavigationManager; private readonly selectionManager: InteractiveSelectionManager; private dataHierarchyManager?: DataHierarchyManager; private readonly expansionManager: ExpansionManager; @@ -265,6 +275,10 @@ export class Table< }; this.table = tanStackCreateTable(this.options); this.virtualizer = new Virtualizer(this, this.table); + this.tableNavigationManager = new TableNavigationManager( + this, + this.virtualizer + ); this.layoutManager = new TableLayoutManager(this); this.layoutManagerNotifier = Observable.getNotifier(this.layoutManager); this.layoutManagerNotifier.subscribe(this, 'isColumnBeingSized'); @@ -335,6 +349,18 @@ export class Table< return this.tableValidator.isValid(); } + public getTabbableElements(): (HTMLElement | SVGElement)[] { + const results1 = tabbable(this, { getShadowRoot: true }); + // eslint-disable-next-line no-console + console.log('tabbable', 'from table', results1); + return results1; + } + + public getTabbableElementsFrom(element: HTMLElement): (HTMLElement | SVGElement)[] { + const results1 = tabbable(element, { getShadowRoot: true }); + return results1; + } + /** * @internal * @@ -528,6 +554,16 @@ export class Table< this.emitColumnConfigurationChangeEvent(); } + /** + * @internal + */ + public onHeaderKeyDown(column: TableColumn, event: KeyboardEvent): void { + const allowMultiSort = event.shiftKey; + if (event.key === keyEnter) { + this.toggleColumnSort(column, allowMultiSort); + } + } + /** * @internal */ @@ -957,6 +993,7 @@ export class Table< isParentRow: isParent, immediateChildCount: row.subRows.length, groupColumn: this.getGroupRowColumn(row), + dataIndex: row.index, isLoadingChildren: this.expansionManager.isLoadingChildren( row.id ) diff --git a/packages/nimble-components/src/table/models/table-navigation-manager.ts b/packages/nimble-components/src/table/models/table-navigation-manager.ts new file mode 100644 index 0000000000..3c981b5668 --- /dev/null +++ b/packages/nimble-components/src/table/models/table-navigation-manager.ts @@ -0,0 +1,497 @@ +import { Notifier, Subscriber, Observable } from '@microsoft/fast-element'; +import { + keyArrowDown, + keyArrowLeft, + keyArrowRight, + keyArrowUp, + keyEnd, + keyEnter, + keyEscape, + keyFunction2, + keyHome, + keyPageDown, + keyPageUp, + keyTab +} from '@microsoft/fast-web-utilities'; +import type { ScrollToOptions } from '@tanstack/virtual-core'; +import type { Table } from '..'; +import type { TableRecord } from '../types'; +import type { Virtualizer } from './virtualizer'; +import { TableGroupRow } from '../components/group-row'; +import { TableRow } from '../components/row'; +import type { TableCell } from '../components/cell'; +import { tabbable } from 'tabbable'; + +const tableFocusState = { + none: 0, + tableFocused: 1, + rowFocused: 2, + cellFocused: 3, + cellContentFocused: 4 +} as const; + +/** + * This class manages the keyboard navigation within the table + * @internal + */ +export class TableNavigationManager +implements Subscriber { + private _focusedTotalRowIndex?: number; + private _focusedRowElementIndex = -1; + private _focusedHeaderElementIndex = -1; + private _focusedRow?: TableRow | TableGroupRow; + private _focusedCell?: TableCell; + private _tableActiveElement?: Element; + private readonly virtualizerNotifier: Notifier; + private readonly tableNotifier: Notifier; + private visibleRowNotifiers: Notifier[] = []; + private focusableRowElements: HTMLElement[] = []; + private focusableHeaderElements: HTMLElement[] = []; + private _inNavigationMode = true; + private _tableFocusState = tableFocusState.none; + + public get focusedRow(): TableRow | TableGroupRow | undefined { + return this._focusedRow; + } + + public constructor( + private readonly table: Table, + private readonly virtualizer: Virtualizer + ) { + table.addEventListener('keydown', e => this.onKeyDown(e), { capture: true }); + table.addEventListener('focusout', () => this.resetState); + table.addEventListener('focusin', e => this.handleFocus(e), true); + table.addEventListener('blur', () => this.resetState); + this.tableNotifier = Observable.getNotifier(this.table); + // this.tableNotifier.subscribe(this, 'rowElements'); + this.virtualizerNotifier = Observable.getNotifier(this.virtualizer); + this.virtualizerNotifier.subscribe(this, 'visibleItems'); + } + + public setFocusedRow(rowIndex: number): void { + this.scrollToAndFocusRow(rowIndex); + } + + public handleChange(source: unknown, args: unknown): void { + if (source === this.virtualizer && args === 'visibleItems') { + this.focusVisibleRow(); + } else if (source === this.table && args === 'rowElements') { + for (const notifier of this.visibleRowNotifiers) { + notifier.unsubscribe(this); + } + this.visibleRowNotifiers = []; + for (const visibleRow of this.table.rowElements) { + const rowNotifier = Observable.getNotifier(visibleRow); + rowNotifier.subscribe(this, 'dataIndex'); + } + this.focusVisibleRow(); + } else if (args === 'dataIndex') { + const dataIndex = (source as TableRow | TableGroupRow).dataIndex; + if (dataIndex === this._focusedTotalRowIndex) { + this.focusVisibleRow(); + } + } + } + + private readonly onKeyDown = (event: KeyboardEvent): boolean => { + if (!this.table.rowElements.length) { + return true; + } + + if (!this._inNavigationMode + && !(event.key === keyFunction2 || event.key === keyEscape)) { + return false; + } + + switch (event.key) { + case keyTab: { + // this.resetState(true); + this.onTabPressed(); + break; + } + case keyArrowRight: { + const handled = this.handleNavigationRight(); + if (handled) { + event.preventDefault(); + } + return !handled; + } + case keyArrowLeft: { + if (this._focusedTotalRowIndex === undefined && this.focusableHeaderElements.length > 0) { + if (this._focusedHeaderElementIndex > 0) { + this._focusedHeaderElementIndex -= 1; + this.focusHeaderElement(); + return false; + } + return true; + } + + if (!this.focusableRowElements.length || this._focusedRowElementIndex === -1) { + if (this.getFocusedRowExpanded() === true) { + this.toggleFocusedRowExpanded(); + } + return true; + } + + if (this._focusedRowElementIndex === 0) { + this._focusedRowElementIndex -= 1; + this.focusVisibleRow(); + return true; + } + + this._focusedRowElementIndex -= 1; + this.focusRowElement(); + event.preventDefault(); + return false; + } + case keyArrowDown: { + const rowWithFocus = this.getRowWithFocus(); + const newFocusedTotalRowIndex = rowWithFocus === undefined + ? 0 + : rowWithFocus.dataIndex! + 1; + if (newFocusedTotalRowIndex < this.table.tableData.length) { + if (newFocusedTotalRowIndex === 0) { + this._focusedRowElementIndex = this._focusedHeaderElementIndex; + } + this.scrollToAndFocusRow(newFocusedTotalRowIndex); + event.preventDefault(); + event.stopPropagation(); + return false; + } + break; + } + case keyArrowUp: { + const rowWithFocus = this.getRowWithFocus(); + const currentRowIndex = rowWithFocus === undefined + ? undefined + : rowWithFocus.dataIndex!; + + if (currentRowIndex === undefined) { + return true; + } + + if (currentRowIndex === 0) { + if (this._focusedRowElementIndex > -1) { + this._focusedHeaderElementIndex = this._focusedRowElementIndex; + if (this._focusedHeaderElementIndex >= this.focusableHeaderElements.length) { + this._focusedHeaderElementIndex = this.focusableHeaderElements.length - 1; + } + } else { + this._focusedHeaderElementIndex = 0; + } + + this.setFocusOnHeader(); + event.preventDefault(); + event.stopPropagation(); + return false; + } + + if (currentRowIndex > 0) { + this.scrollToAndFocusRow(currentRowIndex - 1); + event.preventDefault(); + event.stopPropagation(); + return false; + } + break; + } + case keyPageUp: { + const newFocusedRowIndex = Math.max( + this.table.rowElements[0]!.dataIndex! + - this.table.rowElements.length + + 1, + 0 + ); + this.scrollToAndFocusRow(newFocusedRowIndex, { + align: 'start' + }); + event.preventDefault(); + return false; + } + case keyPageDown: { + const newFocusedRowIndex = this.table.rowElements[this.table.rowElements.length - 1]! + .dataIndex!; + this.scrollToAndFocusRow(newFocusedRowIndex, { + align: 'start' + }); + event.preventDefault(); + return false; + } + case keyHome: { + if (event.ctrlKey) { + this.scrollToAndFocusRow(0); + } + event.preventDefault(); + return false; + } + case keyEnd: { + if (event.ctrlKey) { + this.scrollToAndFocusRow(this.table.tableData.length - 1); + } + event.preventDefault(); + return false; + } + case keyEnter: + case keyFunction2: { + /* if (this._inNavigationMode && this._focusedRowElementIndex >= 0) { + this._inNavigationMode = false; + return false; + } */ + + this._inNavigationMode = true; + break; + } + case keyEscape: { + this._inNavigationMode = true; + break; + } + + default: + break; + } + + return true; + }; + + private handleNavigationRight(): boolean { + if (this._focusedTotalRowIndex === undefined && this.focusableHeaderElements.length > 0) { + if (this._focusedHeaderElementIndex < this.focusableHeaderElements.length - 1) { + this._focusedHeaderElementIndex += 1; + this.focusHeaderElement(); + return true; + } + return false; + } + + if (!this.focusableRowElements.length) { + if (this.getFocusedRowExpanded() === false) { + this.toggleFocusedRowExpanded(); + return true; + } + return false; + } + + if (this._focusedRowElementIndex === -1) { + if (this.getFocusedRowExpanded() === false) { + this.toggleFocusedRowExpanded(); + } else { + this._focusedRowElementIndex = 0; + this.focusRowElement(); + } + return true; + } + + if (this._focusedRowElementIndex !== undefined + && this._focusedRowElementIndex < this.focusableRowElements.length - 1) { + this._focusedRowElementIndex += 1; + this.focusRowElement(); + return true; + } + + return false; + } + + private getFocusedRowExpanded(): boolean | undefined { + if (this.focusedRow instanceof TableRow && this.focusedRow.isParentRow) { + return this.focusedRow.expanded; + } + if (this.focusedRow instanceof TableGroupRow) { + return this.focusedRow.expanded; + } + return undefined; + } + + private onTabPressed(): void { + // const focusedRow = this.getRowWithFocus(); + const activeElement = this.getActiveElement(); + const allTabbableElements = tabbable(this.table, { getShadowRoot: true }); + let index = activeElement !== null ? allTabbableElements.indexOf(activeElement) : -1; + if (index > -1) { + index += 1; + if (index >= allTabbableElements.length) { + index = 0; + } + } else { + index = 0; + } + allTabbableElements[index]!.focus(); + } + + private toggleFocusedRowExpanded(): void { + const focusedRow = this._focusedRow!; + if (focusedRow instanceof TableGroupRow) { + focusedRow.onGroupExpandToggle(); + } else { + focusedRow.onRowExpandToggle(); + } + } + + private setFocusOnHeader(): void { + if (this._focusedRow) { + this._focusedRow.blur(); + this._focusedRow.tabIndex = -1; + this._focusedTotalRowIndex = undefined; + this._focusedRow = undefined; + this._focusedRowElementIndex = -1; + } + + if (this.focusableHeaderElements.length === 0) { + this.focusableHeaderElements = this.getTableHeaderFocusableElements(); + } + if (this.focusableHeaderElements.length > 0) { + if (this._focusedHeaderElementIndex === -1) { + this._focusedHeaderElementIndex = 0; + } + const focusableElement = this.focusableHeaderElements[this._focusedHeaderElementIndex]!; + focusableElement.tabIndex = 0; + focusableElement.focus(); + } + } + + private scrollToAndFocusRow( + totalRowIndex: number, + scrollOptions?: ScrollToOptions + ): void { + this._focusedTotalRowIndex = totalRowIndex; + this.virtualizer.scrollToIndex( + this._focusedTotalRowIndex, + scrollOptions + ); + this.focusVisibleRow(); + } + + private focusVisibleRow(): void { + if (this._focusedRow) { + // this._focusedRow.removeFocus(); + this._focusedRow.tabIndex = -1; + } + const visibleRowIndex = this.getVisibleRowIndex(); + if (visibleRowIndex < 0) { + return; + } + this._focusedRow = this.table.rowElements[visibleRowIndex]!; + + // this._focusedRow.setFocus(); + this._focusedRow.tabIndex = 0; + this._focusedRow.focus(); + + this._focusedRow.addEventListener('focusout', e => this.focusOutHandler(e)); + this.focusableRowElements = this.getFocusedRowFocusableElements(); + if (this.focusableRowElements.length > 0 && this._focusedRowElementIndex !== -1) { + this._focusedRow.removeAttribute('has-focus'); + if (this._focusedRowElementIndex >= this.focusableRowElements.length) { + this._focusedRowElementIndex = this.focusableRowElements.length - 1; + } + this.focusRowElement(); + } + } + + private focusRowElement(): void { + const elementToFocus = this.focusableRowElements[this._focusedRowElementIndex]!; + elementToFocus.tabIndex = 0; + elementToFocus.focus({ preventScroll: true }); + elementToFocus.addEventListener('focusout', e => this.focusOutHandler(e)); + } + + private focusHeaderElement(): void { + const elementToFocus = this.focusableHeaderElements[this._focusedHeaderElementIndex]!; + elementToFocus.tabIndex = 0; + elementToFocus.focus({ preventScroll: true }); + } + + private getFocusedRowFocusableElements(): HTMLElement[] { + return this._focusedRow!.getFocusableElements(); + } + + private getVisibleRowIndex(): number { + return this.table.rowElements.findIndex( + row => row.dataIndex === this._focusedTotalRowIndex + ); + } + + /* + private getTableHeaderFocusableElement(): HTMLElement | undefined { + if (this.table.selectionCheckbox) { + return this.table.selectionCheckbox; + } + + if (this.table.showCollapseAll) { + return this.table.collapseAllButton; + } + + return undefined; + } + */ + + private getTableHeaderFocusableElements(): HTMLElement[] { + const focusableElements: HTMLElement[] = []; + if (this.table.selectionCheckbox) { + focusableElements.push(this.table.selectionCheckbox); + } + + if (this.table.showCollapseAll) { + focusableElements.push(this.table.collapseAllButton!); + } + + if (this.table.columns.find(c => !c.sortingDisabled)) { + this.table.columnHeadersContainer.querySelectorAll('nimble-table-header').forEach(header => focusableElements.push(header)); + } + + return focusableElements; + } + + private readonly focusOutHandler = (event: Event): void => { + (event.target as HTMLElement).tabIndex = -1; + + (event.target as HTMLElement).removeEventListener( + 'focusout', + this.focusOutHandler + ); + }; + + private readonly resetState = (resetRowIndex = true): void => { + this._focusedRowElementIndex = -1; + if (this._focusedRow) { + this._focusedRow.tabIndex = -1; + this._focusedRow.blur(); + } + if (resetRowIndex) { + this._focusedTotalRowIndex = undefined; + } + }; + + // The row with focus is the row that either has focus or an element + // inside of it has focus + private getRowWithFocus(): TableRow | TableGroupRow | undefined { + return this.getContainingRow(this.getActiveElement()); + } + + private getContainingRow(start: Element | undefined | null): TableRow | TableGroupRow | undefined { + let possibleRow = start; + while (possibleRow && possibleRow !== this.table) { + if (possibleRow instanceof TableRow || possibleRow instanceof TableGroupRow) { + return possibleRow; + } + possibleRow = possibleRow.parentElement ?? (possibleRow.parentNode as ShadowRoot)?.host; + } + + return undefined; + } + + private getActiveElement(): HTMLElement | null { + let documentActiveElement = document.activeElement; + while (documentActiveElement?.shadowRoot?.activeElement) { + documentActiveElement = documentActiveElement.shadowRoot.activeElement; + } + + return documentActiveElement as HTMLElement; + } + + private readonly handleFocus = (event: FocusEvent): void => { + const targetElement = event.composedPath()[0] as Element; + this._tableActiveElement = targetElement; + + if (targetElement === this.table && this._focusedTotalRowIndex === undefined) { + this.setFocusOnHeader(); + } + }; +} \ No newline at end of file diff --git a/packages/nimble-components/src/table/models/virtualizer.ts b/packages/nimble-components/src/table/models/virtualizer.ts index a676c4d856..d41ba58ef0 100644 --- a/packages/nimble-components/src/table/models/virtualizer.ts +++ b/packages/nimble-components/src/table/models/virtualizer.ts @@ -6,12 +6,14 @@ import { elementScroll, observeElementOffset, observeElementRect, - VirtualItem + VirtualItem, + ScrollToOptions } from '@tanstack/virtual-core'; import { borderWidth, controlHeight } from '../../theme-provider/design-tokens'; import type { Table } from '..'; import type { TableNode, TableRecord } from '../types'; import { TableCellView } from '../../table-column/base/cell-view'; +import { TableRow } from '../components/row'; /** * Helper class for the nimble-table for row virtualization. @@ -69,6 +71,10 @@ export class Virtualizer { } } + public scrollToIndex(index: number, options?: ScrollToOptions): void { + this.virtualizer?.scrollToIndex(index, options); + } + private updateVirtualizer(): void { const options = this.createVirtualizerOptions(); if (this.virtualizer) { @@ -137,8 +143,8 @@ export class Virtualizer { } if (this.table.openActionMenuRecordId !== undefined) { const activeRow = this.table.rowElements.find( - row => row.recordId === this.table.openActionMenuRecordId - ); + row => row instanceof TableRow && row.recordId === this.table.openActionMenuRecordId + ) as TableRow | undefined; activeRow?.closeOpenActionMenus(); } } diff --git a/packages/nimble-components/src/table/template.ts b/packages/nimble-components/src/table/template.ts index fd9ad33b96..ea1a6ad241 100644 --- a/packages/nimble-components/src/table/template.ts +++ b/packages/nimble-components/src/table/template.ts @@ -33,6 +33,7 @@ import { export const template = html`