diff --git a/app/src/ui/lib/filter-list.tsx b/app/src/ui/lib/filter-list.tsx index 19ed4cbead..17bc6af842 100644 --- a/app/src/ui/lib/filter-list.tsx +++ b/app/src/ui/lib/filter-list.tsx @@ -173,6 +173,7 @@ interface IFilterListState { readonly rows: ReadonlyArray> readonly selectedRow: number readonly filterValue: string + readonly filterValueChanged: boolean } /** @@ -199,17 +200,15 @@ export class FilterList extends React.Component< public constructor(props: IFilterListProps) { super(props) - this.state = createStateUpdate(props) - } - - public componentWillMount() { - if (this.props.filterTextBox !== undefined) { - this.filterTextBox = this.props.filterTextBox + if (props.filterTextBox !== undefined) { + this.filterTextBox = props.filterTextBox } + + this.state = createStateUpdate(props, null) } public componentWillReceiveProps(nextProps: IFilterListProps) { - this.setState(createStateUpdate(nextProps)) + this.setState(createStateUpdate(nextProps, this.state)) } public componentDidUpdate( @@ -276,6 +275,23 @@ export class FilterList extends React.Component< ) } + public renderLiveContainer() { + if (!this.state.filterValueChanged) { + return null + } + + const itemRows = this.state.rows.filter(row => row.kind === 'item') + const resultsPluralized = itemRows.length === 1 ? 'result' : 'results' + const screenReaderMessage = `${itemRows.length} ${resultsPluralized}` + + return ( + + ) + } + public renderFilterRow() { if (this.props.hideFilterRow === true) { return null @@ -290,16 +306,10 @@ export class FilterList extends React.Component< } public render() { - const itemRows = this.state.rows.filter(row => row.kind === 'item') - const resultsPluralized = itemRows.length === 1 ? 'result' : 'results' - const screenReaderMessage = `${itemRows.length} ${resultsPluralized}` - return (
- + {this.renderLiveContainer()} + {this.props.renderPreList ? this.props.renderPreList() : null} {this.renderFilterRow()} @@ -577,7 +587,8 @@ export function getText( } function createStateUpdate( - props: IFilterListProps + props: IFilterListProps, + state: IFilterListState | null ) { const flattenedRows = new Array>() const filter = (props.filterText || '').toLowerCase() @@ -618,7 +629,17 @@ function createStateUpdate( selectedRow = flattenedRows.findIndex(i => i.kind === 'item') } - return { rows: flattenedRows, selectedRow, filterValue: filter } + // Stay true if already set, otherwise become true if the filter has content + const filterValueChanged = state?.filterValueChanged + ? true + : filter.length > 0 + + return { + rows: flattenedRows, + selectedRow, + filterValue: filter, + filterValueChanged, + } } function getItemFromRowIndex( diff --git a/app/src/ui/lib/list/list.tsx b/app/src/ui/lib/list/list.tsx index 659d173e9e..283c139cc2 100644 --- a/app/src/ui/lib/list/list.tsx +++ b/app/src/ui/lib/list/list.tsx @@ -711,6 +711,16 @@ export class List extends React.Component { return this.props.canSelectRow ? this.props.canSelectRow(rowIndex) : true } + private getFirstSelectableRowIndexPath(): number | null { + for (let i = 0; i < this.props.rowCount; i++) { + if (this.canSelectRow(i)) { + return i + } + } + + return null + } + private addSelection(direction: SelectionDirection, source: SelectionSource) { if (this.props.selectedRows.length === 0) { return this.moveSelection(direction, source) @@ -955,66 +965,73 @@ export class List extends React.Component { return customClasses.length === 0 ? undefined : customClasses.join(' ') } - private renderRow = (params: IRowRendererParams) => { - const rowIndex = params.rowIndex - const selectable = this.canSelectRow(rowIndex) - const selected = this.props.selectedRows.indexOf(rowIndex) !== -1 - const customClasses = this.getCustomRowClassNames(rowIndex) + private getRowRenderer = (firstSelectableRowIndex: number | null) => { + return (params: IRowRendererParams) => { + const { selectedRows } = this.props + const rowIndex = params.rowIndex + const selectable = this.canSelectRow(rowIndex) + const selected = selectedRows.indexOf(rowIndex) !== -1 + const customClasses = this.getCustomRowClassNames(rowIndex) - // An unselectable row shouldn't be focusable - let tabIndex: number | undefined = undefined - if (selectable) { - tabIndex = selected && this.props.selectedRows[0] === rowIndex ? 0 : -1 - } + // An unselectable row shouldn't be focusable + let tabIndex: number | undefined = undefined + if (selectable) { + tabIndex = + (selected && selectedRows[0] === rowIndex) || + (selectedRows.length === 0 && firstSelectableRowIndex === rowIndex) + ? 0 + : -1 + } - const row = this.props.rowRenderer(rowIndex) + const row = this.props.rowRenderer(rowIndex) - const element = - this.props.insertionDragType !== undefined ? ( - - {row} - - ) : ( - row + const element = + this.props.insertionDragType !== undefined ? ( + + {row} + + ) : ( + row + ) + + const id = this.getRowId(rowIndex) + + const ariaLabel = + this.props.getRowAriaLabel !== undefined + ? this.props.getRowAriaLabel(rowIndex) + : undefined + + return ( + ) - - const id = this.getRowId(rowIndex) - - const ariaLabel = - this.props.getRowAriaLabel !== undefined - ? this.props.getRowAriaLabel(rowIndex) - : undefined - - return ( - - ) + } } public render() { @@ -1132,7 +1149,9 @@ export class List extends React.Component { } rowCount={this.props.rowCount} rowHeight={this.props.rowHeight} - cellRenderer={this.renderRow} + cellRenderer={this.getRowRenderer( + this.getFirstSelectableRowIndexPath() + )} onScroll={this.onScroll} scrollTop={this.props.setScrollTop} overscanRowCount={4} diff --git a/app/src/ui/lib/list/section-list-selection.ts b/app/src/ui/lib/list/section-list-selection.ts index 87e1473bdd..3040c0526e 100644 --- a/app/src/ui/lib/list/section-list-selection.ts +++ b/app/src/ui/lib/list/section-list-selection.ts @@ -71,12 +71,21 @@ export interface ISelectAllSource { readonly kind: 'select-all' } +/** + * Interface describing a user initiated selection change event originating + * when focusing the list when it has no selection. + */ +export interface IFocusSource { + readonly kind: 'focus' +} + /** A type union of possible sources of a selection changed event */ export type SelectionSource = | IMouseClickSource | IHoverSource | IKeyboardSource | ISelectAllSource + | IFocusSource /** * Determine the next selectable row, given the direction and a starting diff --git a/app/src/ui/lib/list/section-list.tsx b/app/src/ui/lib/list/section-list.tsx index 1c814482ed..da7728a162 100644 --- a/app/src/ui/lib/list/section-list.tsx +++ b/app/src/ui/lib/list/section-list.tsx @@ -742,6 +742,11 @@ export class SectionList extends React.Component< // focused list item if it scrolls back into view. if (!focusWithin) { this.focusRow = InvalidRowIndexPath + } else if (this.props.selectedRows.length === 0) { + const firstSelectableRowIndexPath = this.getFirstSelectableRowIndexPath() + if (firstSelectableRowIndexPath !== null) { + this.moveSelectionTo(firstSelectableRowIndexPath, { kind: 'focus' }) + } } } @@ -810,6 +815,22 @@ export class SectionList extends React.Component< return this.props.canSelectRow ? this.props.canSelectRow(rowIndex) : true } + private getFirstSelectableRowIndexPath(): RowIndexPath | null { + const { rowCount } = this.props + + for (let section = 0; section < rowCount.length; section++) { + const rowCountInSection = rowCount[section] + for (let row = 0; row < rowCountInSection; row++) { + const indexPath = { section, row } + if (this.canSelectRow(indexPath)) { + return indexPath + } + } + } + + return null + } + private addSelection(direction: SelectionDirection, source: SelectionSource) { if (this.props.selectedRows.length === 0) { return this.moveSelection(direction, source) @@ -1110,8 +1131,12 @@ export class SectionList extends React.Component< return customClasses.length === 0 ? undefined : customClasses.join(' ') } - private getRowRenderer = (section: number) => { + private getRowRenderer = ( + section: number, + firstSelectableRowIndexPath: RowIndexPath | null + ) => { return (params: IRowRendererParams) => { + const { selectedRows } = this.props const indexPath: RowIndexPath = { section: section, row: params.rowIndex, @@ -1119,16 +1144,17 @@ export class SectionList extends React.Component< const selectable = this.canSelectRow(indexPath) const selected = - this.props.selectedRows.findIndex(r => - rowIndexPathEquals(r, indexPath) - ) !== -1 + selectedRows.findIndex(r => rowIndexPathEquals(r, indexPath)) !== -1 const customClasses = this.getCustomRowClassNames(indexPath) // An unselectable row shouldn't be focusable let tabIndex: number | undefined = undefined if (selectable) { tabIndex = - selected && rowIndexPathEquals(this.props.selectedRows[0], indexPath) + (selected && rowIndexPathEquals(selectedRows[0], indexPath)) || + (selectedRows.length === 0 && + firstSelectableRowIndexPath !== null && + rowIndexPathEquals(firstSelectableRowIndexPath, indexPath)) ? 0 : -1 } @@ -1303,7 +1329,10 @@ export class SectionList extends React.Component< columnCount={1} rowCount={this.props.rowCount[section]} rowHeight={this.getRowHeight(section)} - cellRenderer={this.getRowRenderer(section)} + cellRenderer={this.getRowRenderer( + section, + this.getFirstSelectableRowIndexPath() + )} scrollTop={relativeScrollTop} overscanRowCount={4} style={{ ...params.style, width: '100%' }} diff --git a/app/src/ui/lib/list/selection.ts b/app/src/ui/lib/list/selection.ts index 0c288d7390..023159f69e 100644 --- a/app/src/ui/lib/list/selection.ts +++ b/app/src/ui/lib/list/selection.ts @@ -62,12 +62,21 @@ export interface ISelectAllSource { readonly kind: 'select-all' } +/** + * Interface describing a user initiated selection change event originating + * when focusing the list when it has no selection. + */ +export interface IFocusSource { + readonly kind: 'focus' +} + /** A type union of possible sources of a selection changed event */ export type SelectionSource = | IMouseClickSource | IHoverSource | IKeyboardSource | ISelectAllSource + | IFocusSource /** * Determine the next selectable row, given the direction and a starting diff --git a/app/src/ui/lib/section-filter-list.tsx b/app/src/ui/lib/section-filter-list.tsx index f6b3401dec..bb49ed95dc 100644 --- a/app/src/ui/lib/section-filter-list.tsx +++ b/app/src/ui/lib/section-filter-list.tsx @@ -166,6 +166,7 @@ interface IFilterListState { readonly rows: ReadonlyArray>> readonly selectedRow: RowIndexPath readonly filterValue: string + readonly filterValueChanged: boolean // Indices of groups in the filtered list readonly groups: ReadonlyArray } @@ -180,17 +181,15 @@ export class SectionFilterList< public constructor(props: ISectionFilterListProps) { super(props) - this.state = createStateUpdate(props) - } - - public componentWillMount() { - if (this.props.filterTextBox !== undefined) { - this.filterTextBox = this.props.filterTextBox + if (props.filterTextBox !== undefined) { + this.filterTextBox = props.filterTextBox } + + this.state = createStateUpdate(props, null) } public componentWillReceiveProps(nextProps: ISectionFilterListProps) { - this.setState(createStateUpdate(nextProps)) + this.setState(createStateUpdate(nextProps, this.state)) } public componentDidUpdate( @@ -257,6 +256,23 @@ export class SectionFilterList< ) } + public renderLiveContainer() { + if (!this.state.filterValueChanged) { + return null + } + + const itemRows = this.state.rows.flat().filter(row => row.kind === 'item') + const resultsPluralized = itemRows.length === 1 ? 'result' : 'results' + const screenReaderMessage = `${itemRows.length} ${resultsPluralized}` + + return ( + + ) + } + public renderFilterRow() { if (this.props.hideFilterRow === true) { return null @@ -271,16 +287,10 @@ export class SectionFilterList< } public render() { - const itemRows = this.state.rows.flat().filter(row => row.kind === 'item') - const resultsPluralized = itemRows.length === 1 ? 'result' : 'results' - const screenReaderMessage = `${itemRows.length} ${resultsPluralized}` - return (
- + {this.renderLiveContainer()} + {this.props.renderPreList ? this.props.renderPreList() : null} {this.renderFilterRow()} @@ -614,7 +624,8 @@ function getFirstVisibleRow( } function createStateUpdate( - props: ISectionFilterListProps + props: ISectionFilterListProps, + state: IFilterListState | null ) { const rows = new Array>>() const filter = (props.filterText || '').toLowerCase() @@ -664,7 +675,18 @@ function createStateUpdate( selectedRow = getFirstVisibleRow(rows) } - return { rows: rows, selectedRow, filterValue: filter, groups: groupIndices } + // Stay true if already set, otherwise become true if the filter has content + const filterValueChanged = state?.filterValueChanged + ? true + : filter.length > 0 + + return { + rows: rows, + selectedRow, + filterValue: filter, + filterValueChanged, + groups: groupIndices, + } } function getItemFromRowIndex(