diff --git a/change/@ni-nimble-components-df1cc26c-9976-477a-8fa0-f9b72492eaa0.json b/change/@ni-nimble-components-df1cc26c-9976-477a-8fa0-f9b72492eaa0.json new file mode 100644 index 0000000000..362e0db3b6 --- /dev/null +++ b/change/@ni-nimble-components-df1cc26c-9976-477a-8fa0-f9b72492eaa0.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add keyboard navigation functionality to the table component", + "packageName": "@ni/nimble-components", + "email": "20709258+msmithNI@users.noreply.github.com", + "dependentChangeType": "patch" +} 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..b124fff803 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 @@ -54,9 +54,22 @@ TableColumnAnchorColumnConfig return ''; } + /** @internal */ + @volatile + public get showAnchor(): boolean { + return typeof this.cellRecord?.href === 'string'; + } + public override focusedRecycleCallback(): void { this.anchor?.blur(); } + + public override get tabbableChildren(): HTMLElement[] { + if (this.showAnchor) { + 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..5d09e866d8 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 @@ -15,10 +15,12 @@ export const template = html` }}" class="${x => (x.isPlaceholder ? 'placeholder' : '')}" > - ${when(x => typeof x.cellRecord?.href === 'string', html` + ${when(x => x.showAnchor, html` <${anchorTag} ${ref('anchor')} ${overflow('hasOverflow')} + ${'' /* tabindex managed dynamically by KeyboardNavigationManager */} + tabindex="-1" href="${x => x.cellRecord?.href}" hreflang="${x => x.columnConfig?.hreflang}" ping="${x => x.columnConfig?.ping}" @@ -33,7 +35,7 @@ export const template = html` > ${x => x.text} `)} - ${when(x => typeof x.cellRecord?.href !== 'string', html` + ${when(x => !x.showAnchor, html` (x.hasOverflow ? x.text : null)} diff --git a/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.spec.ts b/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.spec.ts index 27b6c2dc5a..205fdfb5a8 100644 --- a/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.spec.ts +++ b/packages/nimble-components/src/table-column/anchor/tests/table-column-anchor.spec.ts @@ -1,5 +1,6 @@ import { html, ref } from '@microsoft/fast-element'; import { parameterizeSpec } from '@ni/jasmine-parameterized'; +import { keyArrowDown, keyEscape, keyTab } from '@microsoft/fast-web-utilities'; import { tableTag, type Table } from '../../../table'; import { TableColumnAnchor, tableColumnAnchorTag } from '..'; import { waitForUpdatesAsync } from '../../../testing/async-helpers'; @@ -8,6 +9,7 @@ import { TableColumnSortDirection, TableRecord } from '../../../table/types'; import { TablePageObject } from '../../../table/testing/table.pageobject'; import { wackyStrings } from '../../../utilities/tests/wacky-strings'; import type { Anchor } from '../../../anchor'; +import { sendKeyDownEvent } from '../../../utilities/tests/component'; interface SimpleTableRecord extends TableRecord { label?: string | null; @@ -164,6 +166,16 @@ describe('TableColumnAnchor', () => { expect(pageObject.getCellTitle(0, 0)).toBe(''); }); + it('cell view tabbableChildren is an empty array', async () => { + const cellContents = 'value'; + await table.setData([{ label: cellContents }]); + await connect(); + await waitForUpdatesAsync(); + + const cellView = pageObject.getRenderedCellView(0, 0); + expect(cellView.tabbableChildren).toEqual([]); + }); + describe('various string values render as expected', () => { parameterizeSpec(wackyStrings, (spec, name) => { spec(`data "${name}" renders correctly`, async () => { @@ -247,6 +259,16 @@ describe('TableColumnAnchor', () => { ).toBeFalse(); }); + it('cell view tabbableChildren returns the anchor', async () => { + await table.setData([{ link: 'foo' }]); + await connect(); + await waitForUpdatesAsync(); + + const cellView = pageObject.getRenderedCellView(0, 0); + const anchor = pageObject.getRenderedCellAnchor(0, 0); + expect(cellView.tabbableChildren).toEqual([anchor]); + }); + const linkOptionData = [ { name: 'hreflang', accessor: (x: Anchor) => x.hreflang }, { name: 'ping', accessor: (x: Anchor) => x.ping }, @@ -616,5 +638,61 @@ describe('TableColumnAnchor', () => { placeholder ); }); + + it('for cells with placeholder, cellView tabbableChildren is an empty array', async () => { + await initializeColumnAndTable([{}], 'placeholder'); + + const cellView = pageObject.getRenderedCellView(0, 0); + expect(cellView.tabbableChildren).toEqual([]); + }); + }); + + describe('keyboard navigation', () => { + beforeEach(async () => { + const tableData = [ + { + id: '1', + label: 'Link 1a', + link: 'http://www.ni.com/a1' + } + ]; + await table.setData(tableData); + column.groupIndex = null; + await connect(); + await waitForUpdatesAsync(); + table.focus(); + await waitForUpdatesAsync(); + }); + + afterEach(async () => { + await disconnect(); + }); + + function isAnchorFocused(anchor: Anchor): boolean { + return anchor.shadowRoot?.activeElement !== null; + } + + describe('with cell[0, 0] focused,', () => { + beforeEach(async () => { + await sendKeyDownEvent(table, keyArrowDown); + }); + + it('anchors in cells are reachable via Tab', async () => { + await sendKeyDownEvent(table, keyTab); + + expect( + isAnchorFocused(pageObject.getRenderedCellAnchor(0, 0)) + ).toBe(true); + }); + + it('when an anchor is focused, pressing Esc will blur the anchor', async () => { + await sendKeyDownEvent(table, keyTab); + await sendKeyDownEvent(table, keyEscape); + + expect( + isAnchorFocused(pageObject.getRenderedCellAnchor(0, 0)) + ).toBe(false); + }); + }); }); }); 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/components/cell/index.ts b/packages/nimble-components/src/table/components/cell/index.ts index 20c215cc81..c2bc7aa0b4 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 { @@ -47,11 +48,21 @@ export class TableCell< @observable public cellViewTemplate?: ViewTemplate; + /** @internal */ + @observable + public cellViewContainer!: HTMLElement; + @observable public nestingLevel = 0; public readonly actionMenuButton?: MenuButton; + /** @internal */ + public get cellView(): TableCellView { + return this.cellViewContainer + .firstElementChild as TableCellView; + } + public onActionMenuBeforeToggle( event: CustomEvent ): void { @@ -64,6 +75,22 @@ export class TableCell< this.menuOpen = event.detail.newState; this.$emit('cell-action-menu-toggle', event.detail); } + + public onActionMenuBlur(): void { + this.$emit('cell-action-menu-blur', this); + } + + public onCellViewFocusIn(): void { + this.$emit('cell-view-focus-in', this); + } + + public onCellFocusIn(): void { + this.$emit('cell-focus-in', this); + } + + public onCellBlur(): void { + this.$emit('cell-blur', this); + } } const nimbleTableCell = TableCell.compose({ diff --git a/packages/nimble-components/src/table/components/cell/styles.ts b/packages/nimble-components/src/table/components/cell/styles.ts index 55c05ed6a4..7fcb88c73d 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 '../../../utilities/style/display'; 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,15 @@ export const styles = css` --ni-private-table-cell-action-menu-display: block; } + :host(${focusVisible}) { + outline: calc(2 * ${borderWidth}) solid ${borderHoverColor}; + outline-offset: -2px; + } + + .cell-view-container { + display: contents; + } + .cell-view { overflow: hidden; } @@ -34,4 +46,11 @@ export const styles = css` height: ${controlSlimHeight}; align-self: center; } + + ${ + /* This CSS class is applied dynamically by KeyboardNavigationManager */ '' + } + .action-menu.cell-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..85b65ab13a 100644 --- a/packages/nimble-components/src/table/components/cell/template.ts +++ b/packages/nimble-components/src/table/components/cell/template.ts @@ -10,15 +10,23 @@ import { tableCellActionMenuLabel } from '../../../label-provider/table/label-to // prettier-ignore export const template = html` -