-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support dynamic tabbableChildren for TableCellView #2340
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"type": "patch", | ||
"comment": "Update TableCellView tabbableChildren to be an observable property", | ||
"packageName": "@ni/nimble-components", | ||
"email": "[email protected]", | ||
"dependentChangeType": "patch" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -40,6 +40,12 @@ interface TableFocusState { | |
cellContentIndex?: number; | ||
} | ||
|
||
interface TableCellContentFocusState { | ||
index: number; | ||
columnId?: string; | ||
recordId?: string; | ||
} | ||
|
||
/** | ||
* Manages the keyboard navigation and focus within the table. | ||
* @internal | ||
|
@@ -49,13 +55,16 @@ implements Subscriber { | |
private focusType: TableFocusType = TableFocusType.none; | ||
private headerActionIndex = -1; | ||
private rowIndex = -1; | ||
private cellContentIndex = -1; | ||
private columnIndex = -1; | ||
private focusWithinTable = false; | ||
private isCurrentlyFocusingElement = false; | ||
private readonly tableNotifier: Notifier; | ||
private readonly virtualizerNotifier: Notifier; | ||
private visibleRowNotifiers: Notifier[] = []; | ||
private readonly cellContentState: TableCellContentFocusState = { | ||
index: -1 | ||
}; | ||
|
||
private get inNavigationMode(): boolean { | ||
return ( | ||
this.focusType !== TableFocusType.cellActionMenu | ||
|
@@ -117,6 +126,10 @@ implements Subscriber { | |
'cell-blur', | ||
this.onCellBlur as EventListener | ||
); | ||
this.table.viewport.addEventListener( | ||
'cell-tabbable-children-change', | ||
this.onCellTabbableChildrenChange as EventListener | ||
); | ||
} | ||
|
||
public disconnect(): void { | ||
|
@@ -157,6 +170,10 @@ implements Subscriber { | |
'cell-blur', | ||
this.onCellBlur as EventListener | ||
); | ||
this.table.viewport.removeEventListener( | ||
'cell-tabbable-children-change', | ||
this.onCellTabbableChildrenChange as EventListener | ||
); | ||
} | ||
|
||
public handleChange(source: unknown, args: unknown): void { | ||
|
@@ -323,6 +340,28 @@ implements Subscriber { | |
this.setElementFocusable(cell, false); | ||
}; | ||
|
||
private readonly onCellTabbableChildrenChange = ( | ||
event: CustomEvent<TableCellView> | ||
): void => { | ||
event.stopPropagation(); | ||
const cellView = event.detail; | ||
if ( | ||
this.focusType === TableFocusType.cellContent | ||
&& cellView.recordId === this.cellContentState.recordId | ||
&& cellView?.column?.columnId === this.cellContentState.columnId | ||
) { | ||
if ( | ||
this.cellContentState.index >= cellView.tabbableChildren.length | ||
) { | ||
Comment on lines
+348
to
+355
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you could collapse these two |
||
this.setCellFocusState( | ||
this.columnIndex, | ||
this.rowIndex, | ||
this.focusWithinTable | ||
); | ||
} | ||
} | ||
}; | ||
|
||
private readonly onCaptureKeyDown = (event: KeyboardEvent): void => { | ||
let handled = false; | ||
if (event.key === keyTab) { | ||
|
@@ -622,7 +661,14 @@ implements Subscriber { | |
this.rowIndex = nextFocusState.rowIndex ?? this.rowIndex; | ||
this.columnIndex = nextFocusState.columnIndex ?? this.columnIndex; | ||
this.headerActionIndex = nextFocusState.headerActionIndex ?? this.headerActionIndex; | ||
this.cellContentIndex = nextFocusState.cellContentIndex ?? this.cellContentIndex; | ||
if (nextFocusState.cellContentIndex !== undefined) { | ||
this.cellContentState.index = nextFocusState.cellContentIndex; | ||
const row = this.getCurrentRow() as TableRow; | ||
const elements = row?.getFocusableElements(); | ||
this.cellContentState.columnId = elements?.cells[this.columnIndex]?.cell.columnId; | ||
this.cellContentState.recordId = row?.recordId; | ||
} | ||
|
||
if (this.hasRowOrCellFocusType()) { | ||
this.focusCurrentRow(false); | ||
} else { | ||
|
@@ -666,7 +712,7 @@ implements Subscriber { | |
if ( | ||
this.focusType === TableFocusType.cellContent | ||
&& this.columnIndex === cellIndex | ||
&& this.cellContentIndex === i | ||
&& this.cellContentState.index === i | ||
) { | ||
startIndex = focusStates.length - 1; | ||
} | ||
|
@@ -853,6 +899,7 @@ implements Subscriber { | |
); | ||
if (contentIndex > -1) { | ||
this.setCellContentFocusState( | ||
cell, | ||
contentIndex, | ||
row.resolvedRowIndex!, | ||
columnIndex, | ||
|
@@ -1009,8 +1056,10 @@ implements Subscriber { | |
break; | ||
} | ||
case TableFocusType.cellContent: { | ||
focusableElement = rowElements.cells[this.columnIndex]?.cell.cellView | ||
.tabbableChildren[this.cellContentIndex]; | ||
focusableElement = rowElements.cells[this.columnIndex]?.cell | ||
.cellView.tabbableChildren[ | ||
this.cellContentState.index | ||
] as HTMLElement; | ||
break; | ||
} | ||
default: | ||
|
@@ -1273,22 +1322,21 @@ implements Subscriber { | |
} | ||
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; | ||
if (newColumnIndex >= 0 && newColumnIndex < rowElements.cells.length) { | ||
const cell = rowElements.cells[newColumnIndex]!.cell; | ||
if ( | ||
cellContentIndex >= 0 | ||
&& cellContentIndex < cell.cellView.tabbableChildren.length | ||
) { | ||
this.setCellContentFocusState( | ||
cell, | ||
cellContentIndex, | ||
newRowIndex, | ||
newColumnIndex, | ||
true | ||
); | ||
return true; | ||
} | ||
} | ||
|
||
return false; | ||
|
@@ -1324,13 +1372,16 @@ implements Subscriber { | |
} | ||
|
||
private setCellContentFocusState( | ||
cell: TableCell, | ||
cellContentIndex: number, | ||
rowIndex: number, | ||
columnIndex: number, | ||
focusElement: boolean | ||
): void { | ||
this.cellContentState.recordId = cell.recordId; | ||
this.cellContentState.columnId = cell.columnId; | ||
this.focusType = TableFocusType.cellContent; | ||
this.cellContentIndex = cellContentIndex; | ||
this.cellContentState.index = cellContentIndex; | ||
this.setRowCellFocusState(columnIndex, rowIndex, focusElement); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
/* eslint-disable no-await-in-loop */ | ||
import { customElement, html, observable, ref } from '@microsoft/fast-element'; | ||
import { customElement, html, observable, when } from '@microsoft/fast-element'; | ||
import { FoundationElement } from '@microsoft/fast-foundation'; | ||
import { | ||
keyArrowDown, | ||
|
@@ -49,6 +49,7 @@ import type { ColumnInternalsOptions } from '../../table-column/base/models/colu | |
import { ColumnValidator } from '../../table-column/base/models/column-validator'; | ||
import { mixinSortableColumnAPI } from '../../table-column/mixins/sortable-column'; | ||
import { MenuButtonPageObject } from '../../menu-button/testing/menu-button.pageobject'; | ||
import { dynamicRef } from '../../utilities/directive/dynamic-ref'; | ||
|
||
interface SimpleTableRecord extends TableRecord { | ||
id: string; | ||
|
@@ -1235,15 +1236,18 @@ describe('Table keyboard navigation', () => { | |
// prettier-ignore | ||
@customElement({ | ||
name: interactiveCellViewName, | ||
template: html<TestInteractiveCellView>`<span tabindex="-1" ${ref('spanElement')}>Test</span>` | ||
template: html<TestInteractiveCellView>`${when(x => x.isTabbable, html<TestInteractiveCellView>`<span tabindex="-1" ${dynamicRef('spanElement')}>Test</span>`)}` | ||
}) | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
class TestInteractiveCellView extends TableCellView { | ||
@observable | ||
public spanElement!: HTMLSpanElement; | ||
public isTabbable = true; | ||
|
||
public override get tabbableChildren(): HTMLElement[] { | ||
return [this.spanElement]; | ||
@observable | ||
public spanElement?: HTMLSpanElement; | ||
|
||
private spanElementChanged(): void { | ||
this.tabbableChildren = this.spanElement ? [this.spanElement] : []; | ||
} | ||
} | ||
// prettier-ignore | ||
|
@@ -1283,7 +1287,7 @@ describe('Table keyboard navigation', () => { | |
rowIndex, | ||
columnIndex | ||
) as TestInteractiveCellView | ||
).spanElement; | ||
).spanElement!; | ||
} | ||
|
||
beforeEach(async () => { | ||
|
@@ -1422,6 +1426,28 @@ describe('Table keyboard navigation', () => { | |
expect(blurSpy).toHaveBeenCalledTimes(1); | ||
expect(currentFocusedElement()).not.toBe(cellContent); | ||
}); | ||
|
||
it('and then the cell updates to no longer have tabbableChildren, the cell is focused instead', async () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I presume if we went with this approach (which I think I'm fine with), that there are some other tests we still need to add, yes? Namely, that after the tabbableChildren change (but not completely removed, as this test covers that) that tabbing (and shift-tabbing) behave as expected. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, those would be good tests to add too. |
||
const cellView = pageObject.getRenderedCellView( | ||
0, | ||
1 | ||
) as TestInteractiveCellView; | ||
cellView.isTabbable = false; | ||
await waitForUpdatesAsync(); | ||
|
||
// Note: At this point, the focused element in the cell has been already removed from the DOM. | ||
// KeyboardNavigationManager will only set focus to new elements when the table is already focused (so we don't | ||
// steal focus from elsewhere on the page if the table isn't being interacted with). | ||
// In Chrome, the table loses focus entirely, but not in Firefox/WebKit. | ||
if (element.shadowRoot!.activeElement === null) { | ||
element.focus(); // Refocus table if it's been lost | ||
await waitForUpdatesAsync(); | ||
} | ||
|
||
expect(currentFocusedElement()).toBe( | ||
pageObject.getCell(0, 1) | ||
); | ||
}); | ||
}); | ||
}); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@msmithNI: @jattasNI @rajsite @atmgrifter00 interested in your thoughts on this implementation direction so far (but note the concerns I listed in the PR description).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The general direction looks reasonable to me.
For the
cell-tabbable-children-change
I'm a bit curious how often that event is firing. Is it firing once per row continuously as you scroll for example? That could potentially be excessive object creation / gc pressure on scroll.Do you have a screencap of how that manifests? Not exactly clear how to try out the UX for that situation and if it's a new change in behavior or existing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No objections to the overall direction from me. (I left a couple comments while wrapping my head around the PR because I couldn't stop myself; neither one is existential, just normal code review feedback)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
cell-tabbable-children-change
fires:!cellView.$fastController.isConnected
, we could prevent that though.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not currently, I was just going by the behavior of the new autotest failing in Chrome without the code to refocus the table. If we updated an example to periodically update the table data with an anchor column periodically switching between a valid URL / no URL, that would probably show it.