diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index cd9aa9cb089..ca1c40827b9 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -268,11 +268,11 @@ export class GridKeyboardDelegate> implements Key return key; } - private getItem(key: Key): HTMLElement { + protected getItem(key: Key): HTMLElement { return this.ref.current.querySelector(`[data-key="${key}"]`); } - private getItemRect(key: Key): Rect { + protected getItemRect(key: Key): Rect { if (this.layout) { return this.layout.getLayoutInfo(key)?.rect; } @@ -381,4 +381,3 @@ export class GridKeyboardDelegate> implements Key return null; } } - diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index 285f0c96aaa..30acd58c321 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -35,7 +35,9 @@ interface GridCellProps { * Please use onCellAction at the collection level instead. * @deprecated **/ - onAction?: () => void + onAction?: () => void, + // TODO: check naming convention + skipCell?: boolean } interface GridCellAria { @@ -56,17 +58,25 @@ export function useGridCell>(props: GridCellProps isVirtualized, focusMode = 'child', shouldSelectOnPressUp, - onAction + onAction, + skipCell } = props; let {direction} = useLocale(); let {keyboardDelegate, actions: {onCellAction}} = gridMap.get(state); // Handles focusing the cell. If there is a focusable child, - // it is focused, otherwise the cell itself is focused. + // it is focused, otherwise the cell itself is focused. If skipCell is + // true, always attempt to put focus on a focusable child if any or back on the parent + // row. let focus = () => { let treeWalker = getFocusableTreeWalker(ref.current); - if (focusMode === 'child') { + if (focusMode === 'child' || skipCell) { + // If focus is already on a focusable child within the cell, early return so we don't shift focus + if (ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { + return; + } + let focusable = state.selectionManager.childFocusStrategy === 'last' ? last(treeWalker) : treeWalker.firstChild() as HTMLElement; @@ -76,8 +86,13 @@ export function useGridCell>(props: GridCellProps } } - if (!ref.current.contains(document.activeElement)) { + if (!ref.current.contains(document.activeElement) && !skipCell) { focusSafely(ref.current); + return; + } + + if (node.parentKey != null && skipCell) { + state.selectionManager.setFocusedKey(node.parentKey); } }; diff --git a/packages/@react-aria/list/README.md b/packages/@react-aria/list/README.md new file mode 100644 index 00000000000..8c3905bdc98 --- /dev/null +++ b/packages/@react-aria/list/README.md @@ -0,0 +1,3 @@ +# @react-aria/list + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-aria/list/index.ts b/packages/@react-aria/list/index.ts new file mode 100644 index 00000000000..4e9931530d8 --- /dev/null +++ b/packages/@react-aria/list/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-aria/list/package.json b/packages/@react-aria/list/package.json new file mode 100644 index 00000000000..29ec3a72e86 --- /dev/null +++ b/packages/@react-aria/list/package.json @@ -0,0 +1,32 @@ +{ + "name": "@react-aria/list", + "version": "3.0.0-alpha.1", + "private": true, + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@babel/runtime": "^7.6.2", + "@react-aria/grid": "^3.2.3", + "@react-stately/virtualizer": "^3.1.7", + "@react-types/grid": "^3.0.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts new file mode 100644 index 00000000000..5467e8c90db --- /dev/null +++ b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {GridCollection} from '@react-types/grid'; +import {GridKeyboardDelegate, GridKeyboardDelegateOptions} from '@react-aria/grid'; +import {Key} from 'react'; +import {Rect} from '@react-stately/virtualizer'; + +// TODO: Open to feedback about name, ListKeyboardDelegate already exists +export class ListGridKeyboardDelegate extends GridKeyboardDelegate> { + constructor(options: Omit>, 'focusMode'>) { + super({...options, focusMode: 'row'}); + } + + private getRowKey(key: Key) { + let startItem = key != null ? this.collection.getItem(key) : null; + if (!startItem) { + return; + } + + // If focus was on a cell, start searching from the parent row + if (this.isCell(startItem)) { + key = startItem.parentKey; + } + + return key; + } + + // TODO: think about whether or not focus should be moved to the row or to the children in the cell + // Feels kinda weird to move it to the first child in the cell when focus used to be on the last child in the + // cell above... + getKeyBelow(key: Key) { + key = this.getRowKey(key); + return key != null ? this.findNextKey(key) : null; + } + + getKeyAbove(key: Key) { + key = this.getRowKey(key); + return this.findPreviousKey(key); + } + + getFirstKey() { + return this.collection.getFirstKey(); + } + + getLastKey() { + return this.collection.getLastKey(); + } + + protected getItemRect(key: Key): Rect { + key = this.getRowKey(key); + if (key == null) { + return; + } + + if (this.layout) { + return this.layout.getLayoutInfo(key)?.rect; + } + + let item = this.getItem(key); + if (item) { + return new Rect(item.offsetLeft, item.offsetTop, item.offsetWidth, item.offsetHeight); + } + } +} diff --git a/packages/@react-aria/list/src/index.ts b/packages/@react-aria/list/src/index.ts new file mode 100644 index 00000000000..505d9903d9a --- /dev/null +++ b/packages/@react-aria/list/src/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './ListGridKeyboardDelegate'; diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index f698114007a..b2140249000 100644 --- a/packages/@react-spectrum/list/package.json +++ b/packages/@react-spectrum/list/package.json @@ -37,6 +37,7 @@ "@react-aria/grid": "^3.2.4", "@react-aria/i18n": "^3.3.7", "@react-aria/interactions": "^3.8.2", + "@react-aria/list": "3.0.0-alpha.1", "@react-aria/listbox": "^3.4.3", "@react-aria/separator": "^3.1.6", "@react-aria/utils": "^3.11.3", diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index 2fe11fd306a..b6f6cdf4b5f 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -26,9 +26,9 @@ import {Content} from '@react-spectrum/view'; import type {DraggableCollectionState} from '@react-stately/dnd'; import {DragHooks} from '@react-spectrum/dnd'; import {GridCollection, GridState, useGridState} from '@react-stately/grid'; -import {GridKeyboardDelegate, useGrid, useGridSelectionCheckbox} from '@react-aria/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {ListGridKeyboardDelegate} from '@react-aria/list'; import ListGripper from '@spectrum-icons/ui/ListGripper'; import {ListLayout} from '@react-stately/layout'; import {ListState, useListState} from '@react-stately/list'; @@ -38,11 +38,12 @@ import {ProgressCircle} from '@react-spectrum/progress'; import {Provider, useProvider} from '@react-spectrum/provider'; import React, {ReactElement, useContext, useMemo, useRef} from 'react'; import {useCollator, useLocale, useMessageFormatter} from '@react-aria/i18n'; +import {useGrid, useGridSelectionCheckbox} from '@react-aria/grid'; import {Virtualizer} from '@react-aria/virtualizer'; interface ListViewContextValue { state: GridState>, - keyboardDelegate: GridKeyboardDelegate>, + keyboardDelegate: ListGridKeyboardDelegate, dragState: DraggableCollectionState, onAction:(key: string) => void, isListDraggable: boolean @@ -141,19 +142,17 @@ function ListView(props: ListViewProps, ref: DOMRef new GridKeyboardDelegate({ + let keyboardDelegate = useMemo(() => new ListGridKeyboardDelegate({ collection: state.collection, disabledKeys: state.disabledKeys, ref: domRef, direction, collator, - // Focus the ListView cell instead of the row so that focus doesn't change with left/right arrow keys when there aren't any - // focusable children in the cell. - focusMode: 'cell' + layout }), [state, domRef, direction, collator]); let provider = useProvider(); diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index 116918eb1bf..91a6c3cecbb 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -57,7 +57,8 @@ export function ListViewItem(props) { }, state, rowRef); let {gridCellProps} = useGridCell({ node: cellNode, - focusMode: 'cell' + skipCell: true, + isVirtualized: true }, state, cellRef); let draggableItem: DraggableItemResult; if (isListDraggable) { @@ -65,7 +66,9 @@ export function ListViewItem(props) { draggableItem = dragHooks.useDraggableItem({key: item.key}, dragState); } const mergedProps = mergeProps( - gridCellProps, + rowProps, + pressProps, + isDraggable && draggableItem?.dragProps, hoverProps, focusWithinProps, focusProps @@ -99,9 +102,11 @@ export function ListViewItem(props) { let {visuallyHiddenProps} = useVisuallyHidden(); return (
+ {...mergedProps} + ref={rowRef} + aria-label={item.textValue}>
+ {...gridCellProps}> {isListDraggable &&
diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 7cc95a77879..b8f6baf6666 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -37,7 +37,7 @@ function pointerEvent(type, opts) { } describe('ListView', function () { - let offsetWidth, offsetHeight; + let offsetWidth, offsetHeight, scrollHeight; let onSelectionChange = jest.fn(); let onAction = jest.fn(); let onDragStart = jest.fn(); @@ -48,10 +48,21 @@ describe('ListView', function () { expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(selectedKeys)); }; + let items = [ + {key: 'foo', label: 'Foo'}, + {key: 'bar', label: 'Bar'}, + {key: 'baz', label: 'Baz'} + ]; + + let manyItems = []; + for (let i = 1; i <= 100; i++) { + manyItems.push({id: i, label: 'Foo ' + i}); + } beforeAll(function () { offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + scrollHeight = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 40); jest.useFakeTimers(); }); @@ -62,6 +73,7 @@ describe('ListView', function () { afterAll(function () { offsetWidth.mockReset(); offsetHeight.mockReset(); + scrollHeight.mockReset(); }); let render = (children, locale = 'en-US', scale = 'medium') => { @@ -75,10 +87,50 @@ describe('ListView', function () { return tree; }; - let getCell = (tree, text) => { - // Find by text, then go up to the element with the cell role. + let renderList = (props = {}) => { + let { + locale, + scale, + ...otherProps + } = props; + return render( + + {item => ( + + {item.label} + + )} + , + locale, + scale + ); + }; + + let renderListWithFocusables = (props = {}) => { + let { + locale, + scale, + ...otherProps + } = props; + return render( + + {item => ( + + {item.label} + button1 {item.label} + button2 {item.label} + + )} + , + locale, + scale + ); + }; + + let getRow = (tree, text) => { + // Find by text, then go up to the element with the row role. let el = tree.getByText(text); - while (el && !/gridcell|rowheader|columnheader/.test(el.getAttribute('role'))) { + while (el && !/row/.test(el.getAttribute('role'))) { el = el.parentElement; } @@ -114,7 +166,7 @@ describe('ListView', function () { expect(gridCells[0]).toHaveTextContent('Foo'); }); - it('renders a dynamic table', function () { + it('renders a dynamic listview', function () { let items = [ {key: 'foo', label: 'Foo'}, {key: 'bar', label: 'Bar'}, @@ -166,40 +218,35 @@ describe('ListView', function () { expect(gridCells[0]).toHaveTextContent('Foo'); }); - describe('keyboard focus', function () { - let items = [ - {key: 'foo', label: 'Foo'}, - {key: 'bar', label: 'Bar'}, - {key: 'baz', label: 'Baz'} - ]; - let renderList = () => render( - - {item => ( - - {item.label} - - )} - - ); + it('should retain focus on the pressed child', function () { + let tree = renderListWithFocusables(); + let button = within(getRow(tree, 'Foo')).getAllByRole('button')[1]; + act(() => triggerPress(button)); + expect(document.activeElement).toBe(button); + }); - let renderListWithFocusables = (locale, scale) => render( - - {item => ( - - {item.label} - button1 {item.label} - button2 {item.label} - - )} - , - locale, - scale - ); + it('should focus the row if the cell is pressed', function () { + let tree = renderList(); + let cell = within(getRow(tree, 'Bar')).getByRole('gridcell'); + act(() => { + cell.focus(); + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(getRow(tree, 'Bar')); + }); + + it('should have an aria-label on the row for the row text content', function () { + let tree = renderList(); + expect(getRow(tree, 'Foo')).toHaveAttribute('aria-label', 'Foo'); + expect(getRow(tree, 'Bar')).toHaveAttribute('aria-label', 'Bar'); + expect(getRow(tree, 'Baz')).toHaveAttribute('aria-label', 'Baz'); + }); + describe('keyboard focus', function () { describe('Type to select', function () { it('focuses the correct cell when typing', function () { let tree = renderList(); - let target = getCell(tree, 'Baz'); + let target = getRow(tree, 'Baz'); let grid = tree.getByRole('grid'); act(() => grid.focus()); fireEvent.keyDown(grid, {key: 'B'}); @@ -215,7 +262,7 @@ describe('ListView', function () { describe('ArrowRight', function () { it('should not move focus if no focusables present', function () { let tree = renderList(); - let start = getCell(tree, 'Foo'); + let start = getRow(tree, 'Foo'); act(() => start.focus()); moveFocus('ArrowRight'); expect(document.activeElement).toBe(start); @@ -224,8 +271,8 @@ describe('ListView', function () { describe('with cell focusables', function () { it('should move focus to next cell and back to row', function () { let tree = renderListWithFocusables(); - let focusables = within(tree.getAllByRole('row')[0]).getAllByRole('button'); - let start = getCell(tree, 'Foo'); + let start = getRow(tree, 'Foo'); + let focusables = within(start).getAllByRole('button'); act(() => start.focus()); moveFocus('ArrowRight'); expect(document.activeElement).toBe(focusables[0]); @@ -236,12 +283,14 @@ describe('ListView', function () { }); it('should move focus to previous cell in RTL', function () { - let tree = renderListWithFocusables('ar-AE'); + let tree = renderListWithFocusables({locale: 'ar-AE'}); // Should move from button two to button one - let start = within(tree.getAllByRole('row')[0]).getAllByRole('button')[1]; - let end = within(tree.getAllByRole('row')[0]).getAllByRole('button')[0]; - act(() => start.focus()); + let start = within(getRow(tree, 'Foo')).getAllByRole('button')[1]; + let end = within(getRow(tree, 'Foo')).getAllByRole('button')[0]; + // Need to press to set a modality, otherwise useSelectableCollection will think this is a tab operation + act(() => triggerPress(start)); expect(document.activeElement).toHaveTextContent('button2 Foo'); + expect(document.activeElement).toBe(start); moveFocus('ArrowRight'); expect(document.activeElement).toBe(end); expect(document.activeElement).toHaveTextContent('button1 Foo'); @@ -252,7 +301,7 @@ describe('ListView', function () { describe('ArrowLeft', function () { it('should not move focus if no focusables present', function () { let tree = renderList(); - let start = getCell(tree, 'Foo'); + let start = getRow(tree, 'Foo'); act(() => start.focus()); moveFocus('ArrowLeft'); expect(document.activeElement).toBe(start); @@ -261,9 +310,8 @@ describe('ListView', function () { describe('with cell focusables', function () { it('should move focus to previous cell and back to row', function () { let tree = renderListWithFocusables(); - let focusables = within(tree.getAllByRole('row')[0]).getAllByRole('button'); - let start = getCell(tree, 'Foo'); - // console.log('start', start) + let focusables = within(getRow(tree, 'Foo')).getAllByRole('button'); + let start = getRow(tree, 'Foo'); act(() => start.focus()); moveFocus('ArrowLeft'); expect(document.activeElement).toBe(focusables[1]); @@ -274,12 +322,14 @@ describe('ListView', function () { }); it('should move focus to next cell in RTL', function () { - let tree = renderListWithFocusables('ar-AE'); + let tree = renderListWithFocusables({locale: 'ar-AE'}); // Should move from button one to button two - let start = within(tree.getAllByRole('row')[0]).getAllByRole('button')[0]; - let end = within(tree.getAllByRole('row')[0]).getAllByRole('button')[1]; - act(() => start.focus()); + let start = within(getRow(tree, 'Foo')).getAllByRole('button')[0]; + let end = within(getRow(tree, 'Foo')).getAllByRole('button')[1]; + // Need to press to set a modality, otherwise useSelectableCollection will think this is a tab operation + act(() => triggerPress(start)); expect(document.activeElement).toHaveTextContent('button1 Foo'); + expect(document.activeElement).toBe(start); moveFocus('ArrowLeft'); expect(document.activeElement).toBe(end); expect(document.activeElement).toHaveTextContent('button2 Foo'); @@ -288,9 +338,9 @@ describe('ListView', function () { }); describe('ArrowUp', function () { - it('should not change focus from first item', function () { + it('should not wrap focus', function () { let tree = renderListWithFocusables(); - let start = getCell(tree, 'Foo'); + let start = getRow(tree, 'Foo'); act(() => start.focus()); moveFocus('ArrowUp'); expect(document.activeElement).toBe(start); @@ -298,8 +348,8 @@ describe('ListView', function () { it('should move focus to above row', function () { let tree = renderListWithFocusables(); - let start = getCell(tree, 'Bar'); - let end = getCell(tree, 'Foo'); + let start = getRow(tree, 'Bar'); + let end = getRow(tree, 'Foo'); act(() => start.focus()); moveFocus('ArrowUp'); expect(document.activeElement).toBe(end); @@ -307,9 +357,9 @@ describe('ListView', function () { }); describe('ArrowDown', function () { - it('should not change focus from first item', function () { + it('should not wrap focus', function () { let tree = renderListWithFocusables(); - let start = getCell(tree, 'Baz'); + let start = getRow(tree, 'Baz'); act(() => start.focus()); moveFocus('ArrowDown'); expect(document.activeElement).toBe(start); @@ -317,13 +367,97 @@ describe('ListView', function () { it('should move focus to below row', function () { let tree = renderListWithFocusables(); - let start = getCell(tree, 'Foo'); - let end = getCell(tree, 'Bar'); + let start = getRow(tree, 'Foo'); + let end = getRow(tree, 'Bar'); act(() => start.focus()); moveFocus('ArrowDown'); expect(document.activeElement).toBe(end); }); }); + + describe('PageUp', function () { + it('should move focus to a row a page above when focus starts on a row', function () { + let tree = renderListWithFocusables({items: manyItems}); + let start = getRow(tree, 'Foo 25'); + act(() => start.focus()); + moveFocus('PageUp'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 1')); + }); + + it('should move focus to a row a page above when focus starts in the row cell', function () { + let tree = renderListWithFocusables({items: manyItems}); + let focusables = within(getRow(tree, 'Foo 25')).getAllByRole('button'); + let start = focusables[0]; + act(() => triggerPress(start)); + expect(document.activeElement).toBe(start); + moveFocus('PageUp'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 1')); + }); + }); + + describe('PageDown', function () { + it('should move focus to a row a page below when focus starts on a row', function () { + let tree = renderListWithFocusables({items: manyItems}); + let start = getRow(tree, 'Foo 1'); + act(() => start.focus()); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 25')); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 49')); + }); + + it('should move focus to a row a page below when focus starts in the row cell', function () { + let tree = renderListWithFocusables({items: manyItems}); + let focusables = within(getRow(tree, 'Foo 1')).getAllByRole('button'); + let start = focusables[0]; + act(() => triggerPress(start)); + expect(document.activeElement).toBe(start); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 25')); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 49')); + }); + }); + + describe('Home', function () { + it('should move focus to the first row when focus starts on a row', function () { + let tree = renderListWithFocusables({items: manyItems}); + let start = getRow(tree, 'Foo 15'); + act(() => start.focus()); + moveFocus('Home'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 1')); + }); + + it('should move focus to the first row when focus starts in the row cell', function () { + let tree = renderListWithFocusables({items: manyItems}); + let focusables = within(getRow(tree, 'Foo 15')).getAllByRole('button'); + let start = focusables[0]; + act(() => triggerPress(start)); + expect(document.activeElement).toBe(start); + moveFocus('Home'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 1')); + }); + }); + + describe('End', function () { + it('should move focus to the last row when focus starts on a row', function () { + let tree = renderListWithFocusables({items: manyItems}); + let start = getRow(tree, 'Foo 1'); + act(() => start.focus()); + moveFocus('End'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 100')); + }); + + it('should move focus to the last row when focus starts in the row cell', function () { + let tree = renderListWithFocusables({items: manyItems}); + let focusables = within(getRow(tree, 'Foo 1')).getAllByRole('button'); + let start = focusables[0]; + act(() => triggerPress(start)); + expect(document.activeElement).toBe(start); + moveFocus('End'); + expect(document.activeElement).toBe(getRow(tree, 'Foo 100')); + }); + }); }); it('should display loading affordance with proper height (isLoading)', function () { @@ -454,13 +588,13 @@ describe('ListView', function () { let rows = tree.getAllByRole('row'); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getCell(tree, 'Bar'), {ctrlKey: true})); + act(() => userEvent.click(getRow(tree, 'Bar'), {ctrlKey: true})); checkSelection(onSelectionChange, ['bar']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); onSelectionChange.mockClear(); - act(() => userEvent.click(getCell(tree, 'Baz'), {ctrlKey: true})); + act(() => userEvent.click(getRow(tree, 'Baz'), {ctrlKey: true})); checkSelection(onSelectionChange, ['baz']); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); expect(rows[2]).toHaveAttribute('aria-selected', 'true'); @@ -476,13 +610,13 @@ describe('ListView', function () { expect(rows[0]).toHaveAttribute('aria-selected', 'false'); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getCell(tree, 'Foo'), {ctrlKey: true})); + act(() => userEvent.click(getRow(tree, 'Foo'), {ctrlKey: true})); checkSelection(onSelectionChange, ['foo']); expect(rows[0]).toHaveAttribute('aria-selected', 'true'); onSelectionChange.mockClear(); - act(() => userEvent.click(getCell(tree, 'Baz'), {ctrlKey: true})); + act(() => userEvent.click(getRow(tree, 'Baz'), {ctrlKey: true})); checkSelection(onSelectionChange, ['foo', 'baz']); expect(rows[0]).toHaveAttribute('aria-selected', 'true'); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); @@ -498,13 +632,13 @@ describe('ListView', function () { let rows = tree.getAllByRole('row'); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getCell(tree, 'Bar'), {metaKey: true})); + act(() => userEvent.click(getRow(tree, 'Bar'), {metaKey: true})); checkSelection(onSelectionChange, ['bar']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); onSelectionChange.mockClear(); - act(() => userEvent.click(getCell(tree, 'Baz'), {metaKey: true})); + act(() => userEvent.click(getRow(tree, 'Baz'), {metaKey: true})); checkSelection(onSelectionChange, ['baz']); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); expect(rows[2]).toHaveAttribute('aria-selected', 'true'); @@ -566,7 +700,7 @@ describe('ListView', function () { let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getCell(tree, 'Bar'), {ctrlKey: true})); + act(() => userEvent.click(getRow(tree, 'Bar'), {ctrlKey: true})); checkSelection(onSelectionChange, ['bar']); expect(row).toHaveAttribute('aria-selected', 'true'); @@ -587,14 +721,7 @@ describe('ListView', function () { }); describe('scrolling', function () { - beforeAll(() => { - jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get') - .mockImplementation(function () { - return 40; - }); - }); - - it('should scroll to a cell when it is focused', function () { + it('should scroll to a row when it is focused', function () { let tree = render( ); + it('should retain focus on the pressed child', function () { + let tree = renderFocusable(); + let switchToPress = tree.getAllByRole('switch')[2]; + act(() => triggerPress(switchToPress)); + expect(document.activeElement).toBe(switchToPress); + }); + it('should marshall focus to the focusable element inside a cell', function () { let tree = renderFocusable(); focusCell(tree, 'Baz 1');