mirror of
https://github.com/desktop/desktop
synced 2024-09-19 16:12:20 +00:00
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:
parent
0b711717fd
commit
51a5d7cd4f
|
@ -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>
|
||||||
|
|
|
@ -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={''}
|
||||||
|
|
Loading…
Reference in a new issue