diff --git a/packages/nimble-components/src/table-column/anchor/cell-view/index.ts b/packages/nimble-components/src/table-column/anchor/cell-view/index.ts index 9f97ac797a..5e52e5022b 100644 --- a/packages/nimble-components/src/table-column/anchor/cell-view/index.ts +++ b/packages/nimble-components/src/table-column/anchor/cell-view/index.ts @@ -57,6 +57,14 @@ TableColumnAnchorColumnConfig public override focusedRecycleCallback(): void { this.anchor?.blur(); } + + public override get tabbableChildren(): HTMLElement[] { + // this.anchor can be initialized even when not active in the template, so make sure it's in our DOM + if (this.anchor?.getRootNode() === this.shadowRoot) { + return [this.anchor]; + } + return []; + } } const anchorCellView = TableColumnAnchorCellView.compose({ diff --git a/packages/nimble-components/src/table-column/anchor/cell-view/template.ts b/packages/nimble-components/src/table-column/anchor/cell-view/template.ts index d369615412..12b33b75da 100644 --- a/packages/nimble-components/src/table-column/anchor/cell-view/template.ts +++ b/packages/nimble-components/src/table-column/anchor/cell-view/template.ts @@ -19,6 +19,7 @@ export const template = html` <${anchorTag} ${ref('anchor')} ${overflow('hasOverflow')} + tabindex="-1" href="${x => x.cellRecord?.href}" hreflang="${x => x.columnConfig?.hreflang}" ping="${x => x.columnConfig?.ping}" diff --git a/packages/nimble-components/src/table-column/base/cell-view/index.ts b/packages/nimble-components/src/table-column/base/cell-view/index.ts index fcae105db2..37e67d6e05 100644 --- a/packages/nimble-components/src/table-column/base/cell-view/index.ts +++ b/packages/nimble-components/src/table-column/base/cell-view/index.ts @@ -29,6 +29,14 @@ export abstract class TableCellView< @observable public recordId?: string; + /** + * Gets the child elements in this cell view that should be able to be reached via Tab/ Shift-Tab, + * if any. + */ + public get tabbableChildren(): HTMLElement[] { + return []; + } + private delegatedEvents: readonly string[] = []; /** diff --git a/packages/nimble-components/src/table-column/base/tests/types.ts b/packages/nimble-components/src/table-column/base/tests/types.ts index 1c575126ac..62cb1521c7 100644 --- a/packages/nimble-components/src/table-column/base/tests/types.ts +++ b/packages/nimble-components/src/table-column/base/tests/types.ts @@ -5,7 +5,8 @@ export const ExampleSortType = { secondColumnDescendingFirstColumnAscending: 'SecondColumnDescendingFirstColumnAscending', firstColumnAscendingSecondColumnDisabled: - 'FirstColumnAscendingSecondColumnDisabled' + 'FirstColumnAscendingSecondColumnDisabled', + allColumnsDisabled: 'AllColumnsDisabled' } as const; export type ExampleSortType = (typeof ExampleSortType)[keyof typeof ExampleSortType]; diff --git a/packages/nimble-components/src/table/components/cell/index.ts b/packages/nimble-components/src/table/components/cell/index.ts index 20c215cc81..fc7f2d35dc 100644 --- a/packages/nimble-components/src/table/components/cell/index.ts +++ b/packages/nimble-components/src/table/components/cell/index.ts @@ -9,6 +9,7 @@ import type { } from '../../../table-column/base/types'; import { styles } from './styles'; import { template } from './template'; +import type { TableCellView } from '../../../table-column/base/cell-view'; declare global { interface HTMLElementTagNameMap { @@ -52,6 +53,11 @@ export class TableCell< public readonly actionMenuButton?: MenuButton; + /** @internal */ + public get cellView(): TableCellView { + return this.shadowRoot?.firstElementChild as TableCellView; + } + public onActionMenuBeforeToggle( event: CustomEvent ): void { diff --git a/packages/nimble-components/src/table/components/cell/styles.ts b/packages/nimble-components/src/table/components/cell/styles.ts index 55c05ed6a4..c1b54b4c6d 100644 --- a/packages/nimble-components/src/table/components/cell/styles.ts +++ b/packages/nimble-components/src/table/components/cell/styles.ts @@ -1,10 +1,12 @@ import { css } from '@microsoft/fast-element'; import { display } from '../../../utilities/style/display'; import { + borderHoverColor, controlHeight, controlSlimHeight, mediumPadding } from '../../../theme-provider/design-tokens'; +import { focusVisible } from '../../../utilities/style/focus'; export const styles = css` ${display('flex')} @@ -22,6 +24,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; } @@ -34,4 +41,8 @@ export const styles = css` height: ${controlSlimHeight}; align-self: center; } + + .action-menu.focused { + display: block; + } `; diff --git a/packages/nimble-components/src/table/components/cell/template.ts b/packages/nimble-components/src/table/components/cell/template.ts index b3a30a4cdd..5485c525f7 100644 --- a/packages/nimble-components/src/table/components/cell/template.ts +++ b/packages/nimble-components/src/table/components/cell/template.ts @@ -16,6 +16,7 @@ export const template = html` <${menuButtonTag} ${ref('actionMenuButton')} content-hidden appearance="${ButtonAppearance.ghost}" + tabindex="-1" @beforetoggle="${(x, c) => x.onActionMenuBeforeToggle(c.event as CustomEvent)}" @toggle="${(x, c) => x.onActionMenuToggle(c.event as CustomEvent)}" @click="${(_, c) => c.event.stopPropagation()}" 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..a2fb5aa0d6 100644 --- a/packages/nimble-components/src/table/components/group-row/index.ts +++ b/packages/nimble-components/src/table/components/group-row/index.ts @@ -1,6 +1,5 @@ import { attr, observable } from '@microsoft/fast-element'; import { - Checkbox, DesignSystem, FoundationElement } from '@microsoft/fast-foundation'; @@ -8,9 +7,11 @@ import type { TableColumn } from '../../../table-column/base'; import { styles } from './styles'; import { template } from './template'; import { + TableRowFocusableElements, TableRowSelectionState, TableRowSelectionToggleEventDetail } from '../../types'; +import type { Checkbox } from '../../../checkbox'; declare global { interface HTMLElementTagNameMap { @@ -29,6 +30,9 @@ export class TableGroupRow extends FoundationElement { @observable public nestingLevel = 0; + @observable + public dataIndex?: number; + @observable public immediateChildCount?: number; @@ -83,7 +87,7 @@ export class TableGroupRow extends FoundationElement { } /** @internal */ - public onSelectionChange(event: CustomEvent): void { + public onSelectionCheckboxChange(event: CustomEvent): void { if (this.ignoreSelectionChangeEvents) { return; } @@ -100,6 +104,11 @@ export class TableGroupRow extends FoundationElement { this.$emit('group-selection-toggle', detail); } + /** @internal */ + public getFocusableElements(): TableRowFocusableElements { + return { selectionCheckbox: this.selectionCheckbox, cells: [] }; + } + 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 1e9c15e0d8..2fe2943650 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 { White } from '@ni/nimble-tokens/dist/styledictionary/js/tokens'; import { display } from '../../../utilities/style/display'; 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/group-row/template.ts b/packages/nimble-components/src/table/components/group-row/template.ts index c13a8b707e..43ce72a177 100644 --- a/packages/nimble-components/src/table/components/group-row/template.ts +++ b/packages/nimble-components/src/table/components/group-row/template.ts @@ -24,7 +24,7 @@ export const template = html` <${checkboxTag} ${ref('selectionCheckbox')} class="selection-checkbox" - @change="${(x, c) => x.onSelectionChange(c.event as CustomEvent)}" + @change="${(x, c) => x.onSelectionCheckboxChange(c.event as CustomEvent)}" @click="${(_, c) => c.event.stopPropagation()}" title="${x => tableGroupSelectAllLabel.getValueFor(x)}" aria-label="${x => tableGroupSelectAllLabel.getValueFor(x)}" diff --git a/packages/nimble-components/src/table/components/header/styles.ts b/packages/nimble-components/src/table/components/header/styles.ts index 465763ec63..5ed60af62a 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 '../../../utilities/style/display'; 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..87da34fc51 100644 --- a/packages/nimble-components/src/table/components/row/index.ts +++ b/packages/nimble-components/src/table/components/row/index.ts @@ -6,7 +6,6 @@ import { volatile } from '@microsoft/fast-element'; import { - Checkbox, DesignSystem, FoundationElement } from '@microsoft/fast-foundation'; @@ -18,15 +17,17 @@ import type { TableFieldName, TableRecord, TableRowExpansionToggleEventDetail, + TableRowFocusableElements, TableRowSelectionToggleEventDetail } from '../../types'; import type { TableColumn } from '../../../table-column/base'; import type { MenuButtonToggleEventDetail } from '../../../menu-button/types'; -import { TableCell } from '../cell'; +import { TableCell, tableCellTag } from '../cell'; import { ColumnInternals, isColumnInternalsProperty } from '../../../table-column/base/models/column-internals'; +import type { Checkbox } from '../../../checkbox'; 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; @@ -133,6 +141,11 @@ export class TableRow< return this.isParentRow && this.nestingLevel > 0; } + @volatile + public get isInHierarchy(): boolean { + return this.isParentRow || this.nestingLevel > 0; + } + // Programmatically updating the selection state of a checkbox fires the 'change' event. // Therefore, selection change events that occur due to programmatically updating // the selection checkbox 'checked' value should be ingored. @@ -149,17 +162,22 @@ export class TableRow< } /** @internal */ - public onSelectionChange(event: CustomEvent): void { + public onSelectionCheckboxChange(event: CustomEvent): void { if (this.ignoreSelectionChangeEvents) { return; } const checkbox = event.target as Checkbox; const checked = checkbox.checked; - this.selected = checked; + this.onSelectionChange(!checked, checked); + } + + /** @internal */ + public onSelectionChange(oldState: boolean, newState: boolean): void { + this.selected = newState; const detail: TableRowSelectionToggleEventDetail = { - oldState: !checked, - newState: checked + oldState, + newState }; this.$emit('row-selection-toggle', detail); } @@ -213,14 +231,27 @@ export class TableRow< } } - public onRowExpandToggle(event: Event): void { + /** @internal */ + public getFocusableElements(): TableRowFocusableElements { + const result: TableRowFocusableElements = { cells: [] }; + result.selectionCheckbox = this.selectionCheckbox; + this.shadowRoot!.querySelectorAll(tableCellTag).forEach(cell => { + result.cells.push({ + actionMenuButton: cell.actionMenuButton, + cell + }); + }); + return result; + } + + 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 cf664f031e..23dcb6c5c6 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 { White } from '@ni/nimble-tokens/dist/styledictionary/js/tokens'; import { display } from '../../../utilities/style/display'; 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` ${display('flex')} @@ -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,34 @@ export const styles = css` --ni-private-table-cell-action-menu-display: block; } + nimble-table-cell${focusVisible} { + --ni-private-table-cell-action-menu-display: block; + } + + /* TODO: Scope this to only hasDataHierarchy; ~4px more padding on left, so border doesn't touch expand/collapse button border */ + nimble-table-cell:first-of-type${focusVisible} { + margin-left: calc( + -28px * var(--ni-private-cell-focus-offset-multiplier) + ); + padding-left: calc( + 24px * var(--ni-private-cell-focus-offset-multiplier) + 8px + ); + } + + /*.is-in-hierarchy */ + nimble-table-cell:first-of-type${focusVisible}::before { + content: ''; + display: block; + width: calc( + ( + var(--ni-nimble-control-height) * + var(--ni-private-table-cell-nesting-level) + 4px + ) * var(--ni-private-cell-focus-offset-multiplier) + ); + height: 32px; + box-sizing: border-box; + } + :host(:hover) nimble-table-cell { --ni-private-table-cell-action-menu-display: block; } @@ -126,6 +161,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..2f6c0c2727 100644 --- a/packages/nimble-components/src/table/components/row/template.ts +++ b/packages/nimble-components/src/table/components/row/template.ts @@ -32,7 +32,8 @@ export const template = html` <${checkboxTag} ${ref('selectionCheckbox')} class="selection-checkbox" - @change="${(x, c) => x.onSelectionChange(c.event as CustomEvent)}" + tabindex="-1" + @change="${(x, c) => x.onSelectionCheckboxChange(c.event as CustomEvent)}" @click="${(_, c) => c.event.stopPropagation()}" title="${x => tableRowSelectLabel.getValueFor(x)}" aria-label="${x => tableRowSelectLabel.getValueFor(x)}" @@ -55,13 +56,13 @@ export const template = html` `)} ${when(x => !x.loading, html` <${buttonTag} + ${ref('expandCollapseButton')} + tabindex="-1" 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}"> @@ -69,7 +70,7 @@ export const template = html` `)} ${repeat(x => x.columns, html` ${when(x => !x.columnHidden, html` diff --git a/packages/nimble-components/src/table/index.ts b/packages/nimble-components/src/table/index.ts index f02b10e9e5..48dd74f807 100644 --- a/packages/nimble-components/src/table/index.ts +++ b/packages/nimble-components/src/table/index.ts @@ -28,7 +28,7 @@ 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 { TableColumn } from '../table-column/base'; import { TableValidator } from './models/table-validator'; import { styles } from './styles'; @@ -53,12 +53,14 @@ 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 { ColumnValidator } from '../table-column/base/models/column-validator'; +import { KeyboardNavigationManager } from './models/keyboard-navigation-manager'; declare global { interface HTMLElementTagNameMap { @@ -103,7 +105,7 @@ export class Table< * @internal */ @observable - public readonly rowElements: TableRow[] = []; + public readonly rowElements: (TableRow | TableGroupRow)[] = []; /** * @internal @@ -166,6 +168,12 @@ export class Table< @observable public readonly selectionCheckbox?: Checkbox; + /** + * @internal + */ + @observable + public readonly collapseAllButton?: HTMLElement; + /** * @internal */ @@ -197,6 +205,11 @@ export class Table< */ public readonly layoutManager: TableLayoutManager; + /** + * @internal + */ + public readonly keyboardNavigationManager: KeyboardNavigationManager; + /** * @internal */ @@ -266,6 +279,10 @@ export class Table< }; this.table = tanStackCreateTable(this.options); this.virtualizer = new Virtualizer(this, this.table); + this.keyboardNavigationManager = new KeyboardNavigationManager( + this, + this.virtualizer + ); this.layoutManager = new TableLayoutManager(this); this.layoutManagerNotifier = Observable.getNotifier(this.layoutManager); this.layoutManagerNotifier.subscribe(this, 'isColumnBeingSized'); @@ -320,6 +337,7 @@ export class Table< this.viewport.addEventListener('scroll', this.onViewPortScroll, { passive: true }); + this.keyboardNavigationManager.connect(); document.addEventListener('keydown', this.onKeyDown); document.addEventListener('keyup', this.onKeyUp); } @@ -399,6 +417,11 @@ export class Table< return true; } + /** @internal */ + public onRowFocusIn(event: FocusEvent): void { + this.keyboardNavigationManager.onRowFocusIn(event); + } + /** @internal */ public onAllRowsSelectionChange(event: CustomEvent): void { event.stopPropagation(); @@ -530,6 +553,19 @@ export class Table< this.emitColumnConfigurationChangeEvent(); } + /** + * @internal + */ + public onHeaderKeyDown(column: TableColumn, event: KeyboardEvent): boolean { + const allowMultiSort = event.shiftKey; + if (event.key === keyEnter) { + this.toggleColumnSort(column, allowMultiSort); + } + // Return true so that we don't prevent default behavior. Without this, Tab navigation + // gets stuck on the column headers. + return true; + } + /** * @internal */ @@ -672,6 +708,7 @@ export class Table< private async handleRowActionMenuToggleEvent( event: CustomEvent ): Promise { + this.keyboardNavigationManager.onRowActionMenuToggle(event); const detail = await this.getActionMenuToggleEventDetail(event); this.$emit('action-menu-toggle', detail); if (!event.detail.newState) { @@ -964,6 +1001,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/keyboard-navigation-manager.ts b/packages/nimble-components/src/table/models/keyboard-navigation-manager.ts new file mode 100644 index 0000000000..f5be13367b --- /dev/null +++ b/packages/nimble-components/src/table/models/keyboard-navigation-manager.ts @@ -0,0 +1,1070 @@ +/* eslint-disable no-console */ +import { Notifier, Subscriber, Observable } from '@microsoft/fast-element'; +import { + keyArrowDown, + keyArrowLeft, + keyArrowRight, + keyArrowUp, + keyEnd, + keyEnter, + keyEscape, + keyFunction2, + keyHome, + keyPageDown, + keyPageUp, + keySpace, + keyTab +} from '@microsoft/fast-web-utilities'; +import { FoundationElement } from '@microsoft/fast-foundation'; +import type { ScrollToOptions } from '@tanstack/virtual-core'; +import type { Table } from '..'; +import { + TableActionMenuToggleEventDetail, + TableFocusType, + TableHeaderFocusableElements, + TableRowFocusableElements, + type TableRecord +} from '../types'; +import type { Virtualizer } from './virtualizer'; +import { TableGroupRow } from '../components/group-row'; +import { TableRow } from '../components/row'; +import { TableCell } from '../components/cell'; +import { MenuButton } from '../../menu-button'; +import { tableHeaderTag } from '../components/header'; +import { TableCellView } from '../../table-column/base/cell-view'; + +/** + * Manages the keyboard navigation and focus within the table. + * @internal + */ +export class KeyboardNavigationManager +implements Subscriber { + private focusType: TableFocusType = TableFocusType.none; + private headerActionIndex = -1; + private rowIndex = -1; + private cellContentIndex = -1; + private columnIndex = -1; + private inNavigationMode = true; + private readonly tableNotifier: Notifier; + private readonly virtualizerNotifier: Notifier; + private visibleRowNotifiers: Notifier[] = []; + + public constructor( + private readonly table: Table, + private readonly virtualizer: Virtualizer + ) { + table.addEventListener('keydown', e => this.onCaptureKeyDown(e), { + capture: true + }); + table.addEventListener('keydown', e => this.onKeyDown(e)); + table.addEventListener('focusin', e => this.handleFocus(e)); + table.addEventListener('focusout', e => { + console.log( + 'table focusout', + 'target', + e.target, + 'relatedTarget', + e.relatedTarget, + 'nav mode', + this.inNavigationMode, + 'focusType', + this.focusType + ); + }); + table.addEventListener('blur', e => this.handleBlur(e)); + this.tableNotifier = Observable.getNotifier(this.table); + this.tableNotifier.subscribe(this, 'rowElements'); + this.virtualizerNotifier = Observable.getNotifier(this.virtualizer); + this.virtualizerNotifier.subscribe(this, 'visibleItems'); + window.setTimeout(() => this.printActiveElement(), 8000); + } + + public connect(): void { + this.table.viewport.addEventListener('keydown', e => this.onViewportKeyDown(e)); + } + + public printActiveElement(): void { + console.log('Current Active Element', this.getActiveElementDebug()); + console.log('Current Focus State', this.focusType, 'rowIndex', this.rowIndex, 'columnIndex', this.columnIndex, 'headerActionIndex', this.headerActionIndex, 'cellContentIndex', this.cellContentIndex); + window.setTimeout(() => this.printActiveElement(), 8000); + } + + public handleChange(source: unknown, args: unknown): void { + let focusRow = false; + if (source === this.virtualizer && args === 'visibleItems') { + focusRow = true; + } 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'); + if (visibleRow.dataIndex === this.rowIndex) { + focusRow = true; + } + } + } else if (args === 'dataIndex') { + const dataIndex = (source as TableRow | TableGroupRow).dataIndex; + if (dataIndex === this.rowIndex) { + focusRow = true; + } + } + + if (focusRow && this.hasRowOrCellFocusType() && this.inNavigationMode) { + this.focusCurrentRow(false); + } + } + + public onRowFocusIn(event: FocusEvent): void { + // If user focuses a row some other way (e.g. mouse), update our focus state so future keyboard nav + // will start from that row + const row = event.target; + if (row instanceof TableRow || row instanceof TableGroupRow) { + if (this.rowIndex !== row.dataIndex) { + this.setRowFocusState(row.dataIndex); + } + } + } + + public onRowActionMenuToggle( + event: CustomEvent + ): void { + const isOpen = event.detail.newState; + if (isOpen) { + const row = event.target as TableRow; + const columnIndex = this.table.visibleColumns.findIndex(column => column.columnId === event.detail.columnId); + this.setCellActionMenuFocusState(row.dataIndex!, columnIndex, false); + } + } + + private readonly handleFocus = (event: FocusEvent): void => { + // Sets initial focus on the appropriate table content + const actionMenuOpen = this.table.openActionMenuRecordId !== undefined; + if ((event.target === this.table || this.focusType === TableFocusType.none) && !actionMenuOpen) { + let focusHeader = true; + if (this.hasRowOrCellFocusType() && this.scrollToAndFocusRow(this.rowIndex)) { + focusHeader = false; + } + this.updateNavigationMode(); + if (focusHeader && !this.setFocusOnHeader()) { + // nothing to focus + this.table.blur(); + return; + } + } + + // User may have clicked elsewhere in the table (on an element not reflected in this.focusState). Update our focusState + // based on the current active element in a few cases: + // - user is interacting with tabbable content of a cell + // - user clicked an action menu. In this case, the active element can either be the MenuButton (no table focus beforehand), or a + // MenuItem (if focus was already in the table when the action menu button was clicked). If it's a MenuItem, we need to look up + // the linked action menu and cell to figure out what to set our focusState to. + const activeElement = this.getActiveElement(); + let row: TableRow | TableGroupRow | undefined; + let cell: TableCell | undefined; + console.log( + 'table focusin', + 'target', + event.target, + 'relatedTarget', + event.relatedTarget + ); + if (activeElement) { + row = this.getContainingRow(activeElement); + cell = this.getContainingCell(activeElement); + if (row && !(row instanceof TableGroupRow)) { + if (cell) { + const columnIndex = this.table.visibleColumns.indexOf(cell.column!); + if (cell.actionMenuButton === activeElement) { + this.setCellActionMenuFocusState(row.dataIndex!, columnIndex, false); + } else { + const contentIndex = cell.cellView.tabbableChildren.indexOf(activeElement); + if (contentIndex > -1) { + this.setCellContentFocusState(contentIndex, row.dataIndex!, columnIndex, false); + } + } + } + } + } + }; + + private readonly handleBlur = (e: FocusEvent): void => { + console.log('table blur', 'target', e.target, 'relatedTarget', e.relatedTarget, 'nav mode', this.inNavigationMode, 'focusType', this.focusType); + if (this.focusType === TableFocusType.cellActionMenu || this.focusType === TableFocusType.cellContent || this.focusType === TableFocusType.cell) { + const source = e.composedPath()[0]; + if (source instanceof Element) { + const cell = this.getContainingCell(source); + if (cell) { + if (this.focusType === TableFocusType.cellActionMenu && cell.actionMenuButton) { + this.setActionMenuButtonFocused(cell.actionMenuButton, false); + } else { + this.setCellFocusState(this.columnIndex, this.rowIndex, false); + } + } + } + } + }; + + private readonly onCaptureKeyDown = (event: KeyboardEvent): void => { + let handled = false; + if (event.key === keyTab) { + handled = this.onTabPressed(event.shiftKey); + } else if (this.inNavigationMode) { + switch (event.key) { + case keyArrowLeft: + handled = this.onLeftArrowPressed(); + break; + case keyArrowRight: + handled = this.onRightArrowPressed(); + break; + case keyArrowUp: + handled = this.onUpArrowPressed(); + break; + case keyArrowDown: + handled = this.onDownArrowPressed(); + break; + case keyPageUp: + handled = this.onPageUpPressed(); + break; + case keyPageDown: + handled = this.onPageDownPressed(); + break; + case keyHome: + handled = this.onHomePressed(event.ctrlKey); + break; + case keyEnd: + handled = this.onEndPressed(event.ctrlKey); + break; + case keyEnter: + handled = this.onEnterPressed(event.ctrlKey); + break; + case keySpace: + handled = this.onSpacePressed(event.shiftKey); + break; + case keyFunction2: + handled = this.onF2Pressed(); + break; + default: + break; + } + } + if (handled) { + event.preventDefault(); + } + }; + + private readonly onKeyDown = (event: KeyboardEvent): void => { + if (!this.inNavigationMode && !event.defaultPrevented) { + if ( + event.key === keyEscape + && (this.focusType === TableFocusType.cellActionMenu || this.focusType === TableFocusType.cellContent) + ) { + const row = this.getCurrentRow(); + if (row) { + this.trySetCellFocus(row.getFocusableElements()); + } + } + } + }; + + private readonly onViewportKeyDown = (event: KeyboardEvent): void => { + let handleEvent = !this.inNavigationMode + && (event.key === keyArrowUp || event.key === keyArrowDown); + switch (event.key) { + case keyPageUp: + case keyPageDown: + case keyHome: + case keyEnd: + handleEvent = true; + break; + default: + break; + } + if (handleEvent) { + // Swallow key presses that would cause table scrolling, independently of keyboard navigation + event.preventDefault(); + event.stopImmediatePropagation(); + } + }; + + private onEnterPressed(ctrlKey: boolean): boolean { + let row: TableRow | TableGroupRow | undefined; + let rowElements!: TableRowFocusableElements; + if (this.hasRowOrCellFocusType()) { + row = this.getCurrentRow(); + rowElements = row!.getFocusableElements(); + } + if (this.focusType === TableFocusType.row) { + if (row instanceof TableGroupRow) { + this.toggleRowExpanded(row); + return true; + } + } + if (this.focusType === TableFocusType.cell) { + if (ctrlKey) { + const cell = rowElements.cells[this.columnIndex]!; + if (cell.actionMenuButton && !cell.actionMenuButton.open) { + cell.actionMenuButton.toggleButton!.control.click(); + return true; + } + } + return this.focusFirstInteractiveElementInCurrentCell(rowElements); + } + return false; + } + + private onF2Pressed(): boolean { + if (this.focusType === TableFocusType.cell) { + const row = this.getCurrentRow(); + const rowElements = row!.getFocusableElements(); + return this.focusFirstInteractiveElementInCurrentCell(rowElements); + } + return false; + } + + private onSpacePressed(shiftKey: boolean): boolean { + if ( + this.focusType === TableFocusType.row + || this.focusType === TableFocusType.cell + ) { + if (this.focusType === TableFocusType.row || shiftKey) { + const row = this.getCurrentRow(); + if (row instanceof TableRow) { + row.onSelectionChange(row.selected, !row.selected); + } else if (row instanceof TableGroupRow) { + this.toggleRowExpanded(row); + } + } + // Default Space behavior scrolls down, which is redundant given the rest of our keyboard nav code, and we'd still try to focus a + // row that you scrolled away from. So suppress default Space behavior if a row or cell is selected, regardless of if we're + // toggling selection or not. + return true; + } + return false; + } + + private onLeftArrowPressed(): boolean { + let row!: TableRow | TableGroupRow; + let rowElements!: TableRowFocusableElements; + let headerElements!: TableHeaderFocusableElements; + if (this.hasRowOrCellFocusType()) { + row = this.getCurrentRow()!; + rowElements = row.getFocusableElements(); + } else if (this.hasHeaderFocusType()) { + headerElements = this.getTableHeaderFocusableElements(); + } + + switch (this.focusType) { + case TableFocusType.headerActions: + return this.trySetHeaderActionFocus(headerElements, this.headerActionIndex - 1); + case TableFocusType.columnHeader: + return this.trySetColumnHeaderFocus(headerElements, this.columnIndex - 1) || this.trySetHeaderActionFocus(headerElements, headerElements.headerActions.length - 1); + case TableFocusType.row: + if (this.isRowExpanded(row) === true) { + this.toggleRowExpanded(row); + return true; + } + return false; + case TableFocusType.rowSelectionCheckbox: + this.setRowFocusState(); + return this.focusCurrentRow(true); + case TableFocusType.cell: + if (!this.trySetCellFocus(rowElements, this.columnIndex - 1) + && !this.trySetRowSelectionCheckboxFocus(rowElements)) { + this.setRowFocusState(); + this.focusCurrentRow(true); + } + return true; + default: + break; + } + + return false; + } + + private onRightArrowPressed(): boolean { + let row!: TableRow | TableGroupRow; + let rowElements!: TableRowFocusableElements; + let headerElements!: TableHeaderFocusableElements; + if (this.hasRowOrCellFocusType()) { + row = this.getCurrentRow()!; + rowElements = row.getFocusableElements(); + } else if (this.hasHeaderFocusType()) { + headerElements = this.getTableHeaderFocusableElements(); + } + + switch (this.focusType) { + case TableFocusType.headerActions: + return this.trySetHeaderActionFocus(headerElements, this.headerActionIndex + 1) || this.trySetColumnHeaderFocus(headerElements, 0); + case TableFocusType.columnHeader: + return this.trySetColumnHeaderFocus(headerElements, this.columnIndex + 1); + case TableFocusType.row: + if (this.isRowExpanded(row) === false) { + this.toggleRowExpanded(row); + return true; + } + return this.trySetRowSelectionCheckboxFocus(rowElements) || this.trySetCellFocus(rowElements, 0); + case TableFocusType.rowSelectionCheckbox: + return this.trySetCellFocus(rowElements, 0); + case TableFocusType.cell: + return this.trySetCellFocus(rowElements, this.columnIndex + 1); + default: + break; + } + + return false; + } + + private onUpArrowPressed(): boolean { + return this.onMoveUp(1); + } + + private onPageUpPressed(): boolean { + return this.onMoveUp(this.virtualizer.pageSize); + } + + private onHomePressed(ctrlKey: boolean): boolean { + if (this.handleHomeEndWithinRow(ctrlKey)) { + const row = this.getCurrentRow(); + const rowElements = row!.getFocusableElements(); + return this.trySetRowSelectionCheckboxFocus(rowElements) || this.trySetCellFocus(rowElements, 0); + } + + return this.onMoveUp(0, 0); + } + + private onDownArrowPressed(): boolean { + return this.onMoveDown(1); + } + + private onPageDownPressed(): boolean { + if (!this.inNavigationMode) { + return true; + } + return this.onMoveDown(this.virtualizer.pageSize); + } + + private onEndPressed(ctrlKey: boolean): boolean { + if (this.handleHomeEndWithinRow(ctrlKey)) { + const row = this.getCurrentRow(); + const rowElements = row!.getFocusableElements(); + return this.trySetCellFocus(rowElements, rowElements.cells.length - 1); + } + + return this.onMoveDown(0, this.table.tableData.length - 1); + } + + private handleHomeEndWithinRow(ctrlKey: boolean): boolean { + return (this.focusType === TableFocusType.cell || this.focusType === TableFocusType.rowSelectionCheckbox) && !ctrlKey; + } + + private onTabPressed(shiftKeyPressed: boolean): boolean { + const activeElement = this.getActiveElement(); + if (activeElement === null || activeElement === this.table) { + return false; + } + const nextFocusState = this.hasRowOrCellFocusType() ? this.getNextRowTabStop(shiftKeyPressed) : this.getNextHeaderTabStop(shiftKeyPressed); + if (nextFocusState) { + this.focusType = nextFocusState.focusType; + this.rowIndex = nextFocusState.rowIndex ?? this.rowIndex; + this.columnIndex = nextFocusState.columnIndex ?? this.columnIndex; + this.headerActionIndex = nextFocusState.headerActionIndex ?? this.headerActionIndex; + this.cellContentIndex = nextFocusState.cellContentIndex ?? this.cellContentIndex; + this.updateNavigationMode(); + if (this.hasRowOrCellFocusType()) { + this.focusCurrentRow(false); + } else { + this.focusHeaderElement(); + } + return true; + } + this.blurAfterLastTab(activeElement); + return false; + } + + private getNextRowTabStop(shiftKeyPressed: boolean): { focusType: TableFocusType, headerActionIndex?: number, rowIndex?: number, columnIndex?: number, cellContentIndex?: number } | undefined { + const row = this.getCurrentRow(); + if (row === undefined) { + return undefined; + } + let startIndex = -1; + const focusStates = []; + const rowElements = row.getFocusableElements(); + if (rowElements.selectionCheckbox) { + focusStates.push({ focusType: TableFocusType.rowSelectionCheckbox }); + if (this.focusType === TableFocusType.rowSelectionCheckbox) { + startIndex = 0; + } + } + let cellIndex = 0; + while (cellIndex < this.table.visibleColumns.length) { + const firstCellTabbableIndex = focusStates.length; + const cellInfo = rowElements.cells[cellIndex]!; + const cellViewTabbableChildren = cellInfo.cell.cellView.tabbableChildren; + for (let i = 0; i < cellViewTabbableChildren.length; i++) { + focusStates.push({ focusType: TableFocusType.cellContent, columnIndex: cellIndex, cellContentIndex: i }); + if (this.focusType === TableFocusType.cellContent && this.columnIndex === cellIndex && this.cellContentIndex === i) { + startIndex = focusStates.length - 1; + } + } + if (cellInfo.actionMenuButton) { + focusStates.push({ focusType: TableFocusType.cellActionMenu, columnIndex: cellIndex }); + if (this.focusType === TableFocusType.cellActionMenu && this.columnIndex === cellIndex) { + startIndex = focusStates.length - 1; + } + } + const lastCellTabbableIndex = focusStates.length - 1; + if (this.focusType === TableFocusType.cell && this.columnIndex === cellIndex) { + startIndex = shiftKeyPressed ? lastCellTabbableIndex + 1 : firstCellTabbableIndex - 1; + } + cellIndex += 1; + } + if (this.focusType === TableFocusType.row) { + startIndex = shiftKeyPressed ? focusStates.length : -1; + } + const direction = shiftKeyPressed ? -1 : 1; + return focusStates[startIndex + direction]; + } + + private getNextHeaderTabStop(shiftKeyPressed: boolean): { focusType: TableFocusType, headerActionIndex?: number, rowIndex?: number, columnIndex?: number, cellContentIndex?: number } | undefined { + let startIndex = -1; + const focusStates = []; + const headerTabbableElements = this.getTableHeaderFocusableElements().headerActions; + for (let i = 0; i < headerTabbableElements.length; i++) { + focusStates.push({ focusType: TableFocusType.headerActions, headerActionIndex: i }); + } + if (this.focusType === TableFocusType.headerActions) { + startIndex = this.headerActionIndex; + } else { // TableFocusType.columnHeader + startIndex = focusStates.length; + } + const direction = shiftKeyPressed ? -1 : 1; + return focusStates[startIndex + direction]; + } + + private blurAfterLastTab(activeElement: HTMLElement): void { + // In order to get the desired browser-provided Tab/Shift-Tab behavior of focusing the + // element before/after the table, the table shouldn't have tabIndex=0 when this event + // handling ends. However it needs to be tabIndex=0 so we can re-focus the table the next time + // it's tabbed to, so set tabIndex back to 0 after a rAF. + // Note: In Chrome this is only needed for Shift-Tab, but in Firefox both Tab+Shift-Tab need this + // to work as expected. + this.table.tabIndex = -1; + window.requestAnimationFrame(() => { + this.table.tabIndex = 0; + }); + + // Don't explicitly call blur() on activeElement (causes unexpected behavior on Safari / Mac Firefox) + this.setElementFocusable(activeElement, false); + } + + private onMoveUp(rowDelta: number, newRowIndex?: number): boolean { + const coerceRowIndex = rowDelta > 1; + switch (this.focusType) { + case TableFocusType.row: + case TableFocusType.rowSelectionCheckbox: + case TableFocusType.cell: { + const scrollOptions: ScrollToOptions = {}; + let rowIndex = this.rowIndex; + if (newRowIndex !== undefined) { + rowIndex = newRowIndex; + } + rowIndex -= rowDelta; + if (coerceRowIndex && rowIndex < 0) { + rowIndex = 0; + } + if (rowDelta > 1) { + scrollOptions.align = 'start'; + } + + if (rowIndex < this.rowIndex && rowIndex >= 0) { + return this.scrollToAndFocusRow(rowIndex, scrollOptions); + } + if (rowIndex === -1) { + const headerElements = this.getTableHeaderFocusableElements(); + if (this.focusType === TableFocusType.row || this.focusType === TableFocusType.rowSelectionCheckbox) { + return this.trySetHeaderActionFocus(headerElements, 0) || this.trySetColumnHeaderFocus(headerElements, 0); + } + return this.trySetColumnHeaderFocus(headerElements, this.columnIndex); + } + return false; + } + default: + break; + } + + return false; + } + + private onMoveDown(rowDelta: number, newRowIndex?: number): boolean { + const coerceRowIndex = rowDelta > 1; + switch (this.focusType) { + case TableFocusType.headerActions: { + this.setRowFocusState(0); + return this.scrollToAndFocusRow(0); + } + case TableFocusType.columnHeader: { + this.setCellFocusState(this.columnIndex, 0, false); + return this.scrollToAndFocusRow(0); + } + case TableFocusType.row: + case TableFocusType.rowSelectionCheckbox: + case TableFocusType.cell: { + const scrollOptions: ScrollToOptions = {}; + let rowIndex = this.rowIndex; + if (newRowIndex !== undefined) { + rowIndex = newRowIndex; + } + rowIndex += rowDelta; + if (coerceRowIndex && rowIndex >= this.table.tableData.length) { + rowIndex = this.table.tableData.length - 1; + } + if (rowDelta > 1) { + scrollOptions.align = 'end'; + } + if ( + rowIndex > this.rowIndex + && rowIndex < this.table.tableData.length + ) { + return this.scrollToAndFocusRow(rowIndex, scrollOptions); + } + return false; + } + default: + break; + } + + return false; + } + + private focusElement( + element: HTMLElement, + focusOptions?: FocusOptions + ): void { + const previousActiveElement = this.getActiveElement(); + if (previousActiveElement !== element) { + this.setElementFocusable(element, true); + element.focus(focusOptions); + if ( + previousActiveElement + && this.isInTable(previousActiveElement) + ) { + this.setElementFocusable(previousActiveElement, false); + } + } + } + + private setElementFocusable( + element: HTMLElement, + focusable: boolean + ): void { + if (element === this.table) { + return; + } + + const tabIndex = focusable ? 0 : -1; + const menuButton = element instanceof MenuButton + ? element + : this.getContainingMenuButton(element); + let tabIndexTarget = element; + if (menuButton) { + tabIndexTarget = menuButton; + this.setActionMenuButtonFocused(menuButton, focusable); + } + + tabIndexTarget.tabIndex = tabIndex; + } + + private setActionMenuButtonFocused(menuButton: MenuButton, focused: boolean): void { + // The action MenuButton needs to be visible in order to be focused, so this 'focused' CSS class styling + // handles that (see cell/styles.ts). + if (focused) { + menuButton.classList.add('focused'); + } else { + menuButton.classList.remove('focused'); + } + } + + private setFocusOnHeader(): boolean { + if (this.hasHeaderFocusType()) { + return this.focusHeaderElement(); + } + const headerElements = this.getTableHeaderFocusableElements(); + if (this.trySetHeaderActionFocus(headerElements, 0) || this.trySetColumnHeaderFocus(headerElements, 0) || this.scrollToAndFocusRow(0)) { + return true; + } + this.focusType = TableFocusType.none; + return false; + } + + private scrollToAndFocusRow( + totalRowIndex: number, + scrollOptions?: ScrollToOptions + ): boolean { + if (totalRowIndex >= 0 && totalRowIndex < this.table.tableData.length) { + switch (this.focusType) { + case TableFocusType.none: + case TableFocusType.headerActions: + case TableFocusType.columnHeader: + this.setRowFocusState(totalRowIndex); + break; + default: + break; + } + this.rowIndex = totalRowIndex; + this.virtualizer.scrollToIndex(totalRowIndex, scrollOptions); + this.focusCurrentRow(true); + return true; + } + return false; + } + + private focusCurrentRow(allowScroll: boolean): boolean { + const visibleRowIndex = this.getVisibleRowIndex(); + if (visibleRowIndex < 0) { + return false; + } + const focusedRow = this.table.rowElements[visibleRowIndex]!; + + let focusRowOnly = false; + switch (this.focusType) { + case TableFocusType.row: + focusRowOnly = true; + break; + case TableFocusType.cell: + case TableFocusType.cellActionMenu: + case TableFocusType.cellContent: + focusRowOnly = focusedRow instanceof TableGroupRow; + break; + default: + break; + } + const focusOptions = { preventScroll: !allowScroll }; + if (focusRowOnly) { + this.focusElement(focusedRow, focusOptions); + return true; + } + this.focusRowElement(focusedRow, focusOptions); + return true; + } + + private focusRowElement(row: TableRow | TableGroupRow, focusOptions?: FocusOptions): void { + const rowElements = row.getFocusableElements(); + let focusableElement: HTMLElement | undefined; + switch (this.focusType) { + case TableFocusType.rowSelectionCheckbox: + focusableElement = rowElements.selectionCheckbox; + break; + case TableFocusType.cell: { + focusableElement = rowElements.cells[this.columnIndex]!.cell; + break; + } + case TableFocusType.cellActionMenu: + focusableElement = rowElements.cells[this.columnIndex]! + .actionMenuButton; + break; + case TableFocusType.cellContent: { + const cell = rowElements.cells[this.columnIndex]!; + focusableElement = cell.cell.cellView.tabbableChildren[ + this.cellContentIndex + ]; + break; + } + default: + break; + } + if (focusableElement) { + this.focusElement(focusableElement, focusOptions); + } + } + + private focusHeaderElement(): boolean { + const headerElements = this.getTableHeaderFocusableElements(); + let focusableElement: HTMLElement | undefined; + switch (this.focusType) { + case TableFocusType.headerActions: + focusableElement = headerElements.headerActions[this.headerActionIndex]!; + break; + case TableFocusType.columnHeader: + focusableElement = headerElements.columnHeaders[this.columnIndex]!; + break; + default: + break; + } + if (focusableElement) { + this.focusElement(focusableElement); + return true; + } + return false; + } + + private getVisibleRowIndex(): number { + return this.table.rowElements.findIndex( + row => row.dataIndex === this.rowIndex + ); + } + + private getTableHeaderFocusableElements(): TableHeaderFocusableElements { + const headerActions: HTMLElement[] = []; + if (this.table.selectionCheckbox) { + headerActions.push(this.table.selectionCheckbox); + } + + if (this.table.showCollapseAll) { + headerActions.push(this.table.collapseAllButton!); + } + + const columnHeaders: HTMLElement[] = []; + if (this.canFocusColumnHeaders()) { + this.table.columnHeadersContainer + .querySelectorAll(tableHeaderTag) + .forEach(header => columnHeaders.push(header)); + } + + return { headerActions, columnHeaders }; + } + + private canFocusColumnHeaders(): boolean { + return this.table.columns.find(c => !c.sortingDisabled) !== undefined; + } + + private getCurrentRow(): TableRow | TableGroupRow | undefined { + const visibleRowIndex = this.getVisibleRowIndex(); + if (visibleRowIndex >= 0) { + return this.table.rowElements[visibleRowIndex]; + } + return undefined; + } + + private isRowExpanded( + row: TableRow | TableGroupRow | undefined + ): boolean | undefined { + if (row instanceof TableRow && row.isParentRow) { + return row.expanded; + } + if (row instanceof TableGroupRow) { + return row.expanded; + } + return undefined; + } + + private toggleRowExpanded(row: TableRow | TableGroupRow): void { + if (row instanceof TableGroupRow) { + row.onGroupExpandToggle(); + } else { + row.onRowExpandToggle(); + } + this.focusRowElement(row); + } + + private getContainingRow( + start: Element | undefined | null + ): TableRow | TableGroupRow | undefined { + return this.getContainingElement( + start, + e => e instanceof TableRow || e instanceof TableGroupRow + ); + } + + private getContainingCell( + start: Element | undefined | null + ): TableCell | undefined { + return this.getContainingElement(start, e => e instanceof TableCell); + } + + private getContainingMenuButton( + start: Element | undefined | null + ): MenuButton | undefined { + return this.getContainingElement(start, e => e instanceof MenuButton); + } + + private getContainingElement( + start: Element | undefined | null, + isElementMatch: (element: Element) => boolean + ): TElement | undefined { + let possibleMatch = start; + while (possibleMatch && possibleMatch !== this.table) { + if (isElementMatch(possibleMatch)) { + return possibleMatch as TElement; + } + possibleMatch = possibleMatch.parentElement + ?? (possibleMatch.parentNode as ShadowRoot)?.host; + } + + return undefined; + } + + private isInTable(start: Element): boolean { + let possibleMatch = start; + while (possibleMatch && possibleMatch !== this.table) { + possibleMatch = possibleMatch.parentElement + ?? (possibleMatch.parentNode as ShadowRoot)?.host; + } + + return possibleMatch === this.table; + } + + private getActiveElement(stopAtTableBoundaries = true): HTMLElement | null { + let activeElement = document.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + // In some cases, the active element may be a sub-part of a control (example: MenuButton -> ToggleButton -> a div with tabindex=0). Stop at the outer control boundary, so that + // we can more simply check equality against the elements of getTableHeaderFocusableElements() / row.getFocusableElements(). + // (For rows/cells/cell views, we do need to recurse into them, to get to the appropriate focused controls though) + if ( + stopAtTableBoundaries + && activeElement instanceof FoundationElement + && !(activeElement instanceof TableRow) + && !(activeElement instanceof TableCell) + && !(activeElement instanceof TableCellView) + ) { + break; + } + } + + return activeElement as HTMLElement; + } + + private focusFirstInteractiveElementInCurrentCell(rowElements: TableRowFocusableElements): boolean { + return this.trySetCellContentFocus(rowElements, 0) || this.trySetCellActionMenuFocus(rowElements); + } + + private hasRowOrCellFocusType(): boolean { + switch (this.focusType) { + case TableFocusType.cell: + case TableFocusType.cellActionMenu: + case TableFocusType.cellContent: + case TableFocusType.row: + case TableFocusType.rowSelectionCheckbox: + return true; + default: + return false; + } + } + + private hasHeaderFocusType(): boolean { + switch (this.focusType) { + case TableFocusType.headerActions: + case TableFocusType.columnHeader: + return true; + default: + return false; + } + } + + private getActiveElementDebug(): HTMLElement | string | null { + const result = this.getActiveElement(false); + if (result === document.body) { + return ''; + } + return result; + } + + private trySetRowSelectionCheckboxFocus(rowElements: TableRowFocusableElements): boolean { + if (rowElements.selectionCheckbox) { + this.focusType = TableFocusType.rowSelectionCheckbox; + this.updateNavigationMode(); + return true; + } + return false; + } + + private trySetColumnHeaderFocus(headerElements: TableHeaderFocusableElements, columnIndex: number): boolean { + if (columnIndex >= 0 && columnIndex < headerElements.columnHeaders.length) { + this.focusType = TableFocusType.columnHeader; + this.columnIndex = columnIndex; + this.updateNavigationMode(); + this.focusHeaderElement(); + return true; + } + return false; + } + + private trySetHeaderActionFocus(headerElements: TableHeaderFocusableElements, headerActionIndex: number): boolean { + if (headerActionIndex >= 0 && headerActionIndex < headerElements.headerActions.length) { + this.focusType = TableFocusType.headerActions; + this.headerActionIndex = headerActionIndex; + this.updateNavigationMode(); + this.focusHeaderElement(); + return true; + } + return false; + } + + private trySetCellFocus(rowElements: TableRowFocusableElements, columnIndex?: number, rowIndex?: number): boolean { + const newColumnIndex = columnIndex ?? this.columnIndex; + const newRowIndex = rowIndex ?? this.rowIndex; + + if (newColumnIndex >= 0 && newColumnIndex < rowElements.cells.length) { + this.focusType = TableFocusType.cell; + this.setRowCellFocusState(newColumnIndex, newRowIndex, true); + return true; + } + + return false; + } + + private trySetCellContentFocus(rowElements: TableRowFocusableElements, cellContentIndex: number, columnIndex?: number, rowIndex?: number): boolean { + const newColumnIndex = columnIndex ?? this.columnIndex; + const newRowIndex = rowIndex ?? this.rowIndex; + + if (newColumnIndex >= 0 && newColumnIndex < rowElements.cells.length + && cellContentIndex >= 0 && cellContentIndex <= rowElements.cells[newColumnIndex]!.cell.cellView.tabbableChildren.length) { + this.setCellContentFocusState(cellContentIndex, newRowIndex, newColumnIndex, true); + return true; + } + + return false; + } + + private trySetCellActionMenuFocus(rowElements: TableRowFocusableElements, columnIndex?: number, rowIndex?: number): boolean { + const newColumnIndex = columnIndex ?? this.columnIndex; + const newRowIndex = rowIndex ?? this.rowIndex; + + if (newColumnIndex >= 0 && newColumnIndex < rowElements.cells.length && rowElements.cells[newColumnIndex]!.actionMenuButton) { + this.setCellActionMenuFocusState(newRowIndex, newColumnIndex, true); + return true; + } + + return false; + } + + private setCellActionMenuFocusState(rowIndex: number, columnIndex: number, focusElement: boolean): void { + this.focusType = TableFocusType.cellActionMenu; + this.setRowCellFocusState(columnIndex, rowIndex, focusElement); + } + + private setCellContentFocusState(cellContentIndex: number, rowIndex: number, columnIndex: number, focusElement: boolean): void { + this.focusType = TableFocusType.cellContent; + this.cellContentIndex = cellContentIndex; + this.setRowCellFocusState(columnIndex, rowIndex, focusElement); + } + + private setRowFocusState(rowIndex?: number): void { + this.focusType = TableFocusType.row; + if (rowIndex !== undefined) { + this.rowIndex = rowIndex; + } + this.updateNavigationMode(); + } + + private setCellFocusState(columnIndex: number, rowIndex: number, focusElement: boolean): void { + this.focusType = TableFocusType.cell; + this.setRowCellFocusState(columnIndex, rowIndex, focusElement); + } + + private setRowCellFocusState(columnIndex: number, rowIndex: number, focusElement: boolean): void { + this.rowIndex = rowIndex; + this.columnIndex = columnIndex; + this.updateNavigationMode(); + if (focusElement) { + this.focusCurrentRow(true); + } + } + + private updateNavigationMode(): void { + this.inNavigationMode = this.focusType !== TableFocusType.cellActionMenu && this.focusType !== TableFocusType.cellContent; + } +} diff --git a/packages/nimble-components/src/table/models/virtualizer.ts b/packages/nimble-components/src/table/models/virtualizer.ts index a676c4d856..a21d90dd9b 100644 --- a/packages/nimble-components/src/table/models/virtualizer.ts +++ b/packages/nimble-components/src/table/models/virtualizer.ts @@ -6,12 +6,16 @@ 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'; +import { TableCell } from '../components/cell'; +import { MenuButton } from '../../menu-button'; /** * Helper class for the nimble-table for row virtualization. @@ -31,6 +35,17 @@ export class Virtualizer { @observable public rowContainerYOffset = 0; + public get pageSize(): number { + return Math.round(this.table.viewport.clientHeight / this.rowHeight); + } + + private get rowHeight(): number { + return ( + parseFloat(controlHeight.getValueFor(this.table)) + + 2 * parseFloat(borderWidth.getValueFor(this.table)) + ); + } + private readonly table: Table; private readonly tanStackTable: TanStackTable>; private readonly viewportResizeObserver: ResizeObserver; @@ -69,6 +84,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) { @@ -84,8 +103,7 @@ export class Virtualizer { HTMLElement, HTMLElement > { - const rowHeight = parseFloat(controlHeight.getValueFor(this.table)) - + 2 * parseFloat(borderWidth.getValueFor(this.table)); + const rowHeight = this.rowHeight; return { count: this.tanStackTable.getRowModel().rows.length, getScrollElement: () => { @@ -122,11 +140,20 @@ export class Virtualizer { private notifyFocusedCellRecycling(): void { let tableFocusedElement = this.table.shadowRoot!.activeElement; + let parentFocusedElement: Element | undefined; + let focusedActionMenuButton: MenuButton | undefined; while ( tableFocusedElement !== null && !(tableFocusedElement instanceof TableCellView) ) { + if ( + tableFocusedElement instanceof MenuButton + && parentFocusedElement instanceof TableCell + ) { + focusedActionMenuButton = tableFocusedElement; + } if (tableFocusedElement.shadowRoot) { + parentFocusedElement = tableFocusedElement; tableFocusedElement = tableFocusedElement.shadowRoot.activeElement; } else { break; @@ -137,9 +164,13 @@ 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(); } + if (focusedActionMenuButton) { + focusedActionMenuButton.blur(); + } } } diff --git a/packages/nimble-components/src/table/styles.ts b/packages/nimble-components/src/table/styles.ts index c891be4946..5b259a372b 100644 --- a/packages/nimble-components/src/table/styles.ts +++ b/packages/nimble-components/src/table/styles.ts @@ -18,6 +18,7 @@ import { themeBehavior } from '../utilities/style/theme'; import { userSelectNone } from '../utilities/style/user-select'; import { accessiblyHidden } from '../utilities/style/accessibly-hidden'; import { ZIndexLevels } from '../utilities/style/types'; +import { focusVisible } from '../utilities/style/focus'; export const styles = css` ${display('flex')} @@ -28,6 +29,16 @@ export const styles = css` --ni-private-column-divider-padding: 3px; } + :host(${focusVisible}) { + ${ + /* The table can briefly be focused in some keyboard nav cases (e.g. regaining focus and we + need to scroll to the previously focused row first). Ensure that we don't get the browser-default + focus outline in that case. + ) */ '' + } + outline: none; + } + .disable-select { ${userSelectNone} } @@ -184,10 +195,17 @@ export const styles = css` .group-row { position: relative; + --ni-private-cell-focus-offset-multiplier: 0; } .row { position: relative; + --ni-private-cell-focus-offset-multiplier: 0; + } + + .collapse-all-visible .row, + .collapse-all-visible .group-row { + --ni-private-cell-focus-offset-multiplier: 1; } .accessibly-hidden { diff --git a/packages/nimble-components/src/table/template.ts b/packages/nimble-components/src/table/template.ts index 8353f3861f..b11d788294 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`