Retain list item focus while scrolling

Moves focus away from unmounted list items to the grid such that keyboard navigation still works after scrolling

Co-Authored-By: Mark Hicken <849930+markhicken@users.noreply.github.com>
This commit is contained in:
Markus Olsson 2022-11-15 17:48:16 +01:00
parent 0b711717fd
commit 51a5d7cd4f
2 changed files with 79 additions and 15 deletions

View file

@ -24,7 +24,7 @@ interface IListRowProps {
readonly selected?: boolean readonly selected?: boolean
/** callback to fire when the DOM element is created */ /** callback to fire when the DOM element is created */
readonly onRef?: (element: HTMLDivElement | null) => void readonly onRowRef?: (index: number, element: HTMLDivElement | null) => void
/** callback to fire when the row receives a mouseover event */ /** callback to fire when the row receives a mouseover event */
readonly onRowMouseOver: (index: number, e: React.MouseEvent<any>) => void readonly onRowMouseOver: (index: number, e: React.MouseEvent<any>) => void
@ -41,6 +41,18 @@ interface IListRowProps {
/** callback to fire when the row receives a keyboard event */ /** callback to fire when the row receives a keyboard event */
readonly onRowKeyDown: (index: number, e: React.KeyboardEvent<any>) => void readonly onRowKeyDown: (index: number, e: React.KeyboardEvent<any>) => void
/** called when the row (or any of its descendants) receives focus */
readonly onRowFocus?: (
index: number,
e: React.FocusEvent<HTMLDivElement>
) => void
/** called when the row (and all of its descendants) loses focus */
readonly onRowBlur?: (
index: number,
e: React.FocusEvent<HTMLDivElement>
) => void
/** /**
* Whether or not this list row is going to be selectable either through * Whether or not this list row is going to be selectable either through
* keyboard navigation, pointer clicks, or both. This is used to determine * keyboard navigation, pointer clicks, or both. This is used to determine
@ -53,6 +65,10 @@ interface IListRowProps {
} }
export class ListRow extends React.Component<IListRowProps, {}> { export class ListRow extends React.Component<IListRowProps, {}> {
private onRef = (elem: HTMLDivElement | null) => {
this.props.onRowRef?.(this.props.rowIndex, elem)
}
private onRowMouseOver = (e: React.MouseEvent<HTMLDivElement>) => { private onRowMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
this.props.onRowMouseOver(this.props.rowIndex, e) this.props.onRowMouseOver(this.props.rowIndex, e)
} }
@ -73,6 +89,14 @@ export class ListRow extends React.Component<IListRowProps, {}> {
this.props.onRowKeyDown(this.props.rowIndex, e) this.props.onRowKeyDown(this.props.rowIndex, e)
} }
private onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
this.props.onRowFocus?.(this.props.rowIndex, e)
}
private onBlur = (e: React.FocusEvent<HTMLDivElement>) => {
this.props.onRowBlur?.(this.props.rowIndex, e)
}
public render() { public render() {
const selected = this.props.selected const selected = this.props.selected
const className = classNames( const className = classNames(
@ -102,13 +126,15 @@ export class ListRow extends React.Component<IListRowProps, {}> {
role={role} role={role}
className={className} className={className}
tabIndex={this.props.tabIndex} tabIndex={this.props.tabIndex}
ref={this.props.onRef} ref={this.onRef}
onMouseOver={this.onRowMouseOver} onMouseOver={this.onRowMouseOver}
onMouseDown={this.onRowMouseDown} onMouseDown={this.onRowMouseDown}
onMouseUp={this.onRowMouseUp} onMouseUp={this.onRowMouseUp}
onClick={this.onRowClick} onClick={this.onRowClick}
onKeyDown={this.onRowKeyDown} onKeyDown={this.onRowKeyDown}
style={style} style={style}
onFocus={this.onFocus}
onBlur={this.onBlur}
> >
{this.props.children} {this.props.children}
</div> </div>

View file

@ -269,6 +269,8 @@ export class List extends React.Component<IListProps, IListState> {
private fakeScroll: HTMLDivElement | null = null private fakeScroll: HTMLDivElement | null = null
private focusRow = -1 private focusRow = -1
private readonly rowRefs = new Map<number, HTMLDivElement>()
/** /**
* The style prop for our child Grid. We keep this here in order * The style prop for our child Grid. We keep this here in order
* to not create a new object on each render and thus forcing * to not create a new object on each render and thus forcing
@ -567,6 +569,15 @@ export class List extends React.Component<IListProps, IListState> {
} }
} }
private onFocusWithinChanged = (focusWithin: boolean) => {
// So the grid lost focus (we manually focus the grid if the focused list
// item is unmounted) so we mustn't attempt to refocus the previously
// focused list item if it scrolls back into view.
if (!focusWithin) {
this.focusRow = -1
}
}
private toggleSelection = (event: React.KeyboardEvent<any>) => { private toggleSelection = (event: React.KeyboardEvent<any>) => {
this.props.selectedRows.forEach(row => { this.props.selectedRows.forEach(row => {
if (!this.props.onRowClick) { if (!this.props.onRowClick) {
@ -586,6 +597,16 @@ export class List extends React.Component<IListProps, IListState> {
}) })
} }
private onRowFocus = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
this.focusRow = index
}
private onRowBlur = (index: number, e: React.FocusEvent<HTMLDivElement>) => {
if (this.focusRow === index) {
this.focusRow = -1
}
}
private onRowMouseOver = (row: number, event: React.MouseEvent<any>) => { private onRowMouseOver = (row: number, event: React.MouseEvent<any>) => {
if (this.props.selectOnHover && this.canSelectRow(row)) { if (this.props.selectOnHover && this.canSelectRow(row)) {
if (!this.props.selectedRows.includes(row)) { if (!this.props.selectedRows.includes(row)) {
@ -595,7 +616,7 @@ export class List extends React.Component<IListProps, IListState> {
// more importantly `scrollRowToVisible` automatically manages focus so // more importantly `scrollRowToVisible` automatically manages focus so
// using it here allows us to piggy-back on its focus-preserving magic // using it here allows us to piggy-back on its focus-preserving magic
// even though we could theoretically live without scrolling // even though we could theoretically live without scrolling
this.scrollRowToVisible(row) this.scrollRowToVisible(row, this.props.focusOnHover !== false)
} }
} }
} }
@ -717,10 +738,14 @@ export class List extends React.Component<IListProps, IListState> {
this.scrollRowToVisible(row) this.scrollRowToVisible(row)
} }
private scrollRowToVisible(row: number) { private scrollRowToVisible(row: number, moveFocus = true) {
if (this.grid !== null) { if (this.grid !== null) {
this.grid.scrollToCell({ rowIndex: row, columnIndex: 0 }) this.grid.scrollToCell({ rowIndex: row, columnIndex: 0 })
this.focusRow = row
if (moveFocus) {
this.focusRow = row
this.rowRefs.get(row)?.focus({ preventScroll: true })
}
} }
} }
@ -801,12 +826,27 @@ export class List extends React.Component<IListProps, IListState> {
} }
} }
private onFocusedItemRef = (element: HTMLDivElement | null) => { private onRowRef = (rowIndex: number, element: HTMLDivElement | null) => {
if (this.props.focusOnHover !== false && element !== null) { if (element === null) {
element.focus() this.rowRefs.delete(rowIndex)
} else {
this.rowRefs.set(rowIndex, element)
} }
this.focusRow = -1 if (rowIndex === this.focusRow) {
// The currently focused row is going being unmounted so we'll move focus
// programmatically to the grid so that keyboard navigation still works
if (element === null) {
const grid = ReactDOM.findDOMNode(this.grid)
if (grid instanceof HTMLElement) {
grid.focus({ preventScroll: true })
}
} else {
// A previously focused row is being mounted again, we'll move focus
// back to it
element.focus({ preventScroll: true })
}
}
} }
private getCustomRowClassNames = (rowIndex: number) => { private getCustomRowClassNames = (rowIndex: number) => {
@ -833,17 +873,12 @@ export class List extends React.Component<IListProps, IListState> {
const selected = this.props.selectedRows.indexOf(rowIndex) !== -1 const selected = this.props.selectedRows.indexOf(rowIndex) !== -1
const customClasses = this.getCustomRowClassNames(rowIndex) const customClasses = this.getCustomRowClassNames(rowIndex)
const focused = rowIndex === this.focusRow
// An unselectable row shouldn't be focusable // An unselectable row shouldn't be focusable
let tabIndex: number | undefined = undefined let tabIndex: number | undefined = undefined
if (selectable) { if (selectable) {
tabIndex = selected && this.props.selectedRows[0] === rowIndex ? 0 : -1 tabIndex = selected && this.props.selectedRows[0] === rowIndex ? 0 : -1
} }
// We only need to keep a reference to the focused element
const ref = focused ? this.onFocusedItemRef : undefined
const row = this.props.rowRenderer(rowIndex) const row = this.props.rowRenderer(rowIndex)
const element = const element =
@ -867,7 +902,7 @@ export class List extends React.Component<IListProps, IListState> {
<ListRow <ListRow
key={params.key} key={params.key}
id={id} id={id}
onRef={ref} onRowRef={this.onRowRef}
rowCount={this.props.rowCount} rowCount={this.props.rowCount}
rowIndex={rowIndex} rowIndex={rowIndex}
selected={selected} selected={selected}
@ -877,6 +912,8 @@ export class List extends React.Component<IListProps, IListState> {
onRowMouseDown={this.onRowMouseDown} onRowMouseDown={this.onRowMouseDown}
onRowMouseUp={this.onRowMouseUp} onRowMouseUp={this.onRowMouseUp}
onRowMouseOver={this.onRowMouseOver} onRowMouseOver={this.onRowMouseOver}
onRowFocus={this.onRowFocus}
onRowBlur={this.onRowBlur}
style={params.style} style={params.style}
tabIndex={tabIndex} tabIndex={tabIndex}
children={element} children={element}
@ -975,6 +1012,7 @@ export class List extends React.Component<IListProps, IListState> {
<FocusContainer <FocusContainer
className="list-focus-container" className="list-focus-container"
onKeyDown={this.onFocusContainerKeyDown} onKeyDown={this.onFocusContainerKeyDown}
onFocusWithinChanged={this.onFocusWithinChanged}
> >
<Grid <Grid
aria-label={''} aria-label={''}