diff --git a/packages/@react-spectrum/list/stories/ListView.stories.tsx b/packages/@react-spectrum/list/stories/ListView.stories.tsx
index c72def863aa..86ba5ce27ef 100644
--- a/packages/@react-spectrum/list/stories/ListView.stories.tsx
+++ b/packages/@react-spectrum/list/stories/ListView.stories.tsx
@@ -419,7 +419,7 @@ storiesOf('ListView/Drag and Drop', module)
function Example(props?) {
return (
- -
+
-
Utilities
- Adobe Photoshop
@@ -630,7 +630,7 @@ export function ReorderExample() {
}
onDropAction(e);
onMove(keys, e.target);
- }
+ }
},
getDropOperation(target) {
if (target.type === 'root' || target.dropPosition === 'on') {
@@ -640,7 +640,7 @@ export function ReorderExample() {
return 'move';
}
});
-
+
return (
(
-
{item.type === 'folder' ? 'Drop items here' : `Item ${item.textValue}`}
- {item.type === 'folder' &&
+ {item.type === 'folder' &&
<>
contains {item.childNodes.length} dropped item(s)
@@ -768,7 +768,7 @@ export function DragBetweenListsExample() {
{id: '12', type: 'item', textValue: 'Twelve'}
]
});
-
+
let onMove = (keys: React.Key[], target: ItemDropTarget) => {
let sourceList = list1.getItem(keys[0]) ? list1 : list2;
let destinationList = list1.getItem(target.key) ? list1 : list2;
@@ -816,7 +816,7 @@ export function DragBetweenListsExample() {
}
onDropAction(e);
onMove(keys, e.target);
- }
+ }
},
getDropOperation(target) {
if (target.type === 'root' || target.dropPosition === 'on') {
@@ -891,7 +891,7 @@ export function DragBetweenListsRootOnlyExample() {
{id: '12', type: 'item', textValue: 'Twelve'}
]
});
-
+
let onMove = (keys: React.Key[]) => {
let sourceList = list1.getItem(keys[0]) ? list1 : list2;
let destinationList = sourceList === list1 ? list2 : list1;
@@ -932,7 +932,7 @@ export function DragBetweenListsRootOnlyExample() {
}
onDropAction(e);
onMove(keys);
- }
+ }
},
getDropOperation(target, types) {
if (target.type === 'root' && types.has('list2')) {
@@ -956,7 +956,7 @@ export function DragBetweenListsRootOnlyExample() {
}
onDropAction(e);
onMove(keys);
- }
+ }
},
getDropOperation(target, types) {
if (target.type === 'root' && types.has('list1')) {
diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js
index 1ea024b5034..588ecae066e 100644
--- a/packages/@react-spectrum/list/test/ListView.test.js
+++ b/packages/@react-spectrum/list/test/ListView.test.js
@@ -9,8 +9,11 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
+
+jest.mock('@react-aria/live-announcer');
import {act, fireEvent, render as renderComponent, within} from '@testing-library/react';
import {ActionButton} from '@react-spectrum/button';
+import {announce} from '@react-aria/live-announcer';
import {CUSTOM_DRAG_TYPE} from '@react-aria/dnd/src/constants';
import {DataTransfer, DataTransferItem, DragEvent} from '@react-aria/dnd/test/mocks';
import {DragExample} from '../stories/ListView.stories';
@@ -160,10 +163,14 @@ describe('ListView', function () {
let rows = getAllByRole('row');
expect(rows).toHaveLength(3);
+ expect(rows[0]).toHaveAttribute('aria-rowindex', '1');
+ expect(rows[1]).toHaveAttribute('aria-rowindex', '2');
+ expect(rows[2]).toHaveAttribute('aria-rowindex', '3');
let gridCells = within(rows[0]).getAllByRole('gridcell');
expect(gridCells).toHaveLength(1);
expect(gridCells[0]).toHaveTextContent('Foo');
+ expect(gridCells[0]).toHaveAttribute('aria-colindex', '1');
});
it('renders a dynamic listview', function () {
@@ -188,10 +195,14 @@ describe('ListView', function () {
let rows = getAllByRole('row');
expect(rows).toHaveLength(3);
+ expect(rows[0]).toHaveAttribute('aria-rowindex', '1');
+ expect(rows[1]).toHaveAttribute('aria-rowindex', '2');
+ expect(rows[2]).toHaveAttribute('aria-rowindex', '3');
let gridCells = within(rows[0]).getAllByRole('gridcell');
expect(gridCells).toHaveLength(1);
expect(gridCells[0]).toHaveTextContent('Foo');
+ expect(gridCells[0]).toHaveAttribute('aria-colindex', '1');
});
it('renders a falsy ids', function () {
@@ -242,6 +253,15 @@ describe('ListView', function () {
expect(getRow(tree, 'Baz')).toHaveAttribute('aria-label', 'Baz');
});
+ it('should label the checkboxes with the row label', function () {
+ let tree = renderList({selectionMode: 'single'});
+ let rows = tree.getAllByRole('row');
+ for (let row of rows) {
+ let checkbox = within(row).getByRole('checkbox');
+ expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${row.id}`);
+ }
+ });
+
describe('keyboard focus', function () {
describe('Type to select', function () {
it('focuses the correct cell when typing', function () {
@@ -522,7 +542,6 @@ describe('ListView', function () {
});
describe('selection', function () {
- installPointerEvent();
let items = [
{key: 'foo', label: 'Foo'},
{key: 'bar', label: 'Bar'},
@@ -531,13 +550,27 @@ describe('ListView', function () {
let renderSelectionList = (props) => render(
{item => (
-
-
+
-
{item.label}
)}
);
+ it('should announce the selected or deselected row', function () {
+ let onSelectionChange = jest.fn();
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'single'});
+
+ let row = tree.getAllByRole('row')[1];
+ triggerPress(row);
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+
+ triggerPress(row);
+ expect(announce).toHaveBeenLastCalledWith('Bar not selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+ });
+
it('should select an item from checkbox', function () {
let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'});
@@ -547,30 +580,38 @@ describe('ListView', function () {
checkSelection(onSelectionChange, ['bar']);
expect(row).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
});
it('should select a row by pressing the Space key on a row', function () {
let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'});
- let row = tree.getAllByRole('row')[1];
+ let row = tree.getAllByRole('row')[0];
+ userEvent.tab();
expect(row).toHaveAttribute('aria-selected', 'false');
fireEvent.keyDown(row, {key: ' '});
fireEvent.keyUp(row, {key: ' '});
- checkSelection(onSelectionChange, ['bar']);
+ checkSelection(onSelectionChange, ['foo']);
expect(row).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
});
it('should select a row by pressing the Enter key on a row', function () {
let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'});
- let row = tree.getAllByRole('row')[1];
+ let row = tree.getAllByRole('row')[0];
+ userEvent.tab();
expect(row).toHaveAttribute('aria-selected', 'false');
fireEvent.keyDown(row, {key: 'Enter'});
fireEvent.keyUp(row, {key: 'Enter'});
- checkSelection(onSelectionChange, ['bar']);
+ checkSelection(onSelectionChange, ['foo']);
expect(row).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
});
it('should only allow one item to be selected in single selection', function () {
@@ -582,6 +623,8 @@ describe('ListView', function () {
checkSelection(onSelectionChange, ['bar']);
expect(rows[1]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
onSelectionChange.mockClear();
act(() => userEvent.click(within(rows[2]).getByRole('checkbox')));
@@ -599,15 +642,68 @@ describe('ListView', function () {
checkSelection(onSelectionChange, ['bar']);
expect(rows[1]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
onSelectionChange.mockClear();
act(() => userEvent.click(within(rows[2]).getByRole('checkbox')));
checkSelection(onSelectionChange, ['bar', 'baz']);
expect(rows[1]).toHaveAttribute('aria-selected', 'true');
expect(rows[2]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Baz selected. 2 items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+
+ act(() => userEvent.click(within(rows[2]).getByRole('checkbox')));
+ expect(announce).toHaveBeenLastCalledWith('Baz not selected. 1 item selected.');
+ expect(announce).toHaveBeenCalledTimes(3);
+ });
+
+ it('should support range selection', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'});
+
+ let rows = tree.getAllByRole('row');
+ triggerPress(rows[0]);
+ checkSelection(onSelectionChange, ['foo']);
+ onSelectionChange.mockClear();
+ triggerPress(rows[2], {shiftKey: true});
+ checkSelection(onSelectionChange, ['foo', 'bar', 'baz']);
+ onSelectionChange.mockClear();
+ expect(announce).toHaveBeenLastCalledWith('3 items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+
+ triggerPress(rows[0], {shiftKey: true});
+ checkSelection(onSelectionChange, ['foo']);
+ expect(announce).toHaveBeenLastCalledWith('1 item selected.');
+ expect(announce).toHaveBeenCalledTimes(3);
+ });
+
+ it('should support select all and clear all via keyboard', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'});
+
+ let rows = tree.getAllByRole('row');
+ triggerPress(rows[0]);
+ checkSelection(onSelectionChange, ['foo']);
+ onSelectionChange.mockClear();
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+
+ fireEvent.keyDown(rows[0], {key: 'a', ctrlKey: true});
+ fireEvent.keyUp(rows[0], {key: 'a', ctrlKey: true});
+ checkSelection(onSelectionChange, 'all');
+ onSelectionChange.mockClear();
+ expect(announce).toHaveBeenLastCalledWith('All items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+
+ fireEvent.keyDown(rows[0], {key: 'Escape'});
+ fireEvent.keyUp(rows[0], {key: 'Escape'});
+ checkSelection(onSelectionChange, []);
+ onSelectionChange.mockClear();
+ expect(announce).toHaveBeenLastCalledWith('No items selected.');
+ expect(announce).toHaveBeenCalledTimes(3);
});
describe('onAction', function () {
+ installPointerEvent();
it('should trigger onAction when clicking items with the mouse', function () {
let onSelectionChange = jest.fn();
let onAction = jest.fn();
@@ -741,143 +837,348 @@ describe('ListView', function () {
});
});
- it('should toggle items in selection highlight with ctrl-click on Mac', function () {
- let uaMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac');
- let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'});
+ describe('selectionStyle highlight', function () {
+ installPointerEvent();
+ it('should toggle items in selection highlight with ctrl-click on Mac', function () {
+ let uaMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac');
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'});
- let rows = tree.getAllByRole('row');
- expect(rows[1]).toHaveAttribute('aria-selected', 'false');
- expect(rows[2]).toHaveAttribute('aria-selected', 'false');
- act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true}));
+ let rows = tree.getAllByRole('row');
+ expect(rows[1]).toHaveAttribute('aria-selected', 'false');
+ expect(rows[2]).toHaveAttribute('aria-selected', 'false');
+ act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true}));
- checkSelection(onSelectionChange, ['bar']);
- expect(rows[1]).toHaveAttribute('aria-selected', 'true');
+ checkSelection(onSelectionChange, ['bar']);
+ expect(rows[1]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
- onSelectionChange.mockClear();
- act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', ctrlKey: true}));
- checkSelection(onSelectionChange, ['baz']);
- expect(rows[1]).toHaveAttribute('aria-selected', 'false');
- expect(rows[2]).toHaveAttribute('aria-selected', 'true');
+ onSelectionChange.mockClear();
+ act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', ctrlKey: true}));
+ checkSelection(onSelectionChange, ['baz']);
+ expect(rows[1]).toHaveAttribute('aria-selected', 'false');
+ expect(rows[2]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Baz selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
- uaMock.mockRestore();
- });
+ uaMock.mockRestore();
+ });
- it('should allow multiple items to be selected in selection highlight with ctrl-click on Windows', function () {
- let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Windows');
- let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'});
+ it('should allow multiple items to be selected in selection highlight with ctrl-click on Windows', function () {
+ let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Windows');
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'});
- let rows = tree.getAllByRole('row');
- expect(rows[0]).toHaveAttribute('aria-selected', 'false');
- expect(rows[1]).toHaveAttribute('aria-selected', 'false');
- expect(rows[2]).toHaveAttribute('aria-selected', 'false');
- act(() => userEvent.click(getRow(tree, 'Foo'), {pointerType: 'mouse', ctrlKey: true}));
+ let rows = tree.getAllByRole('row');
+ expect(rows[0]).toHaveAttribute('aria-selected', 'false');
+ expect(rows[1]).toHaveAttribute('aria-selected', 'false');
+ expect(rows[2]).toHaveAttribute('aria-selected', 'false');
+ act(() => userEvent.click(getRow(tree, 'Foo'), {pointerType: 'mouse', ctrlKey: true}));
+
+ checkSelection(onSelectionChange, ['foo']);
+ expect(rows[0]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+
+ onSelectionChange.mockClear();
+ act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', ctrlKey: true}));
+ checkSelection(onSelectionChange, ['foo', 'baz']);
+ expect(rows[0]).toHaveAttribute('aria-selected', 'true');
+ expect(rows[1]).toHaveAttribute('aria-selected', 'false');
+ expect(rows[2]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Baz selected. 2 items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+
+ uaMock.mockRestore();
+ });
- checkSelection(onSelectionChange, ['foo']);
- expect(rows[0]).toHaveAttribute('aria-selected', 'true');
+ it('should toggle items in selection highlight with meta-click on Windows', function () {
+ let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Windows');
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'});
- onSelectionChange.mockClear();
- act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', ctrlKey: true}));
- checkSelection(onSelectionChange, ['foo', 'baz']);
- expect(rows[0]).toHaveAttribute('aria-selected', 'true');
- expect(rows[1]).toHaveAttribute('aria-selected', 'false');
- expect(rows[2]).toHaveAttribute('aria-selected', 'true');
+ let rows = tree.getAllByRole('row');
+ expect(rows[1]).toHaveAttribute('aria-selected', 'false');
+ expect(rows[2]).toHaveAttribute('aria-selected', 'false');
+ act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', metaKey: true}));
- uaMock.mockRestore();
- });
+ checkSelection(onSelectionChange, ['bar']);
+ expect(rows[1]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
- it('should toggle items in selection highlight with meta-click on Windows', function () {
- let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Windows');
- let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'});
+ onSelectionChange.mockClear();
+ act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', metaKey: true}));
+ checkSelection(onSelectionChange, ['baz']);
+ expect(rows[1]).toHaveAttribute('aria-selected', 'false');
+ expect(rows[2]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Baz selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
- let rows = tree.getAllByRole('row');
- expect(rows[1]).toHaveAttribute('aria-selected', 'false');
- expect(rows[2]).toHaveAttribute('aria-selected', 'false');
- act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', metaKey: true}));
+ uaMock.mockRestore();
+ });
- checkSelection(onSelectionChange, ['bar']);
- expect(rows[1]).toHaveAttribute('aria-selected', 'true');
+ it('should support single tap to perform row selection with screen reader if onAction isn\'t provided', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'});
- onSelectionChange.mockClear();
- act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', metaKey: true}));
- checkSelection(onSelectionChange, ['baz']);
- expect(rows[1]).toHaveAttribute('aria-selected', 'false');
- expect(rows[2]).toHaveAttribute('aria-selected', 'true');
+ let rows = tree.getAllByRole('row');
+ expect(rows[1]).toHaveAttribute('aria-selected', 'false');
- uaMock.mockRestore();
- });
+ act(() => userEvent.click(within(rows[1]).getByText('Bar'), {pointerType: 'touch', width: 0, height: 0}));
+ checkSelection(onSelectionChange, [
+ 'bar'
+ ]);
+ expect(rows[1]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+ onSelectionChange.mockClear();
+
+ // Android TalkBack double tap test, pointer event sets pointerType and onClick handles the rest
+ expect(rows[2]).toHaveAttribute('aria-selected', 'false');
+ act(() => {
+ let el = within(rows[2]).getByText('Baz');
+ fireEvent(el, pointerEvent('pointerdown', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0}));
+ fireEvent(el, pointerEvent('pointerup', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0}));
+ fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1});
+ });
+ checkSelection(onSelectionChange, [
+ 'bar', 'baz'
+ ]);
+ expect(rows[1]).toHaveAttribute('aria-selected', 'true');
+ expect(rows[2]).toHaveAttribute('aria-selected', 'true');
+ expect(announce).toHaveBeenLastCalledWith('Baz selected. 2 items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+ });
- it('should support single tap to perform row selection with screen reader if onAction isn\'t provided', function () {
- let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'});
+ it('should support single tap to perform onAction with screen reader', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight', onAction});
- let rows = tree.getAllByRole('row');
- expect(rows[1]).toHaveAttribute('aria-selected', 'false');
+ let rows = tree.getAllByRole('row');
+ act(() => userEvent.click(within(rows[1]).getByText('Bar'), {pointerType: 'touch', width: 0, height: 0}));
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(onAction).toHaveBeenCalledTimes(1);
+ expect(onAction).toHaveBeenCalledWith('bar');
+
+ // Android TalkBack double tap test, pointer event sets pointerType and onClick handles the rest
+ act(() => {
+ let el = within(rows[2]).getByText('Baz');
+ fireEvent(el, pointerEvent('pointerdown', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0}));
+ fireEvent(el, pointerEvent('pointerup', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0}));
+ fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1});
+ });
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(onAction).toHaveBeenCalledTimes(2);
+ expect(onAction).toHaveBeenCalledWith('baz');
+ expect(announce).not.toHaveBeenCalled();
+ });
- act(() => userEvent.click(within(rows[1]).getByText('Bar'), {pointerType: 'touch', width: 0, height: 0}));
- checkSelection(onSelectionChange, [
- 'bar'
- ]);
- expect(rows[1]).toHaveAttribute('aria-selected', 'true');
- onSelectionChange.mockReset();
+ it('should not call onSelectionChange when hitting Space/Enter on the currently selected row', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight', onAction});
- // Android TalkBack double tap test, pointer event sets pointerType and onClick handles the rest
- expect(rows[2]).toHaveAttribute('aria-selected', 'false');
- act(() => {
- let el = within(rows[2]).getByText('Baz');
- fireEvent(el, pointerEvent('pointerdown', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0}));
- fireEvent(el, pointerEvent('pointerup', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0}));
- fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1});
+ let row = tree.getAllByRole('row')[1];
+ expect(row).toHaveAttribute('aria-selected', 'false');
+ act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true}));
+
+ checkSelection(onSelectionChange, ['bar']);
+ expect(row).toHaveAttribute('aria-selected', 'true');
+ expect(onAction).toHaveBeenCalledTimes(0);
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+
+ fireEvent.keyDown(row, {key: 'Space'});
+ fireEvent.keyUp(row, {key: 'Space'});
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ expect(onAction).toHaveBeenCalledTimes(0);
+ expect(announce).toHaveBeenCalledTimes(1);
+
+ fireEvent.keyDown(row, {key: 'Enter'});
+ fireEvent.keyUp(row, {key: 'Enter'});
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ expect(onAction).toHaveBeenCalledTimes(1);
+ expect(onAction).toHaveBeenCalledWith('bar');
+ expect(announce).toHaveBeenCalledTimes(1);
});
- checkSelection(onSelectionChange, [
- 'bar', 'baz'
- ]);
- expect(rows[1]).toHaveAttribute('aria-selected', 'true');
- expect(rows[2]).toHaveAttribute('aria-selected', 'true');
- });
- it('should support single tap to perform onAction with screen reader', function () {
- let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight', onAction});
+ it('should perform onAction on single click with selectionMode: none', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionMode: 'none', selectionStyle: 'highlight', onAction});
- let rows = tree.getAllByRole('row');
- act(() => userEvent.click(within(rows[1]).getByText('Bar'), {pointerType: 'touch', width: 0, height: 0}));
- expect(onSelectionChange).not.toHaveBeenCalled();
- expect(onAction).toHaveBeenCalledTimes(1);
- expect(onAction).toHaveBeenCalledWith('bar');
+ let rows = tree.getAllByRole('row');
+ userEvent.click(rows[0], {pointerType: 'mouse'});
+ expect(announce).not.toHaveBeenCalled();
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(onAction).toHaveBeenCalledTimes(1);
+ expect(onAction).toHaveBeenCalledWith('foo');
+ });
- // Android TalkBack double tap test, pointer event sets pointerType and onClick handles the rest
- act(() => {
- let el = within(rows[2]).getByText('Baz');
- fireEvent(el, pointerEvent('pointerdown', {pointerType: 'touch', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0}));
- fireEvent(el, pointerEvent('pointerup', {pointerType: 'touch', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0}));
- fireEvent.click(el, {pointerType: 'touch', width: 1, height: 1, detail: 1});
+ it('should move selection when using the arrow keys', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'});
+
+ let rows = tree.getAllByRole('row');
+ userEvent.click(rows[0], {pointerType: 'mouse'});
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+ checkSelection(onSelectionChange, ['foo']);
+ onSelectionChange.mockClear();
+
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'});
+ fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'});
+ expect(announce).toHaveBeenLastCalledWith('Bar selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+ checkSelection(onSelectionChange, ['bar']);
+ onSelectionChange.mockClear();
+
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'});
+ fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'});
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(3);
+ checkSelection(onSelectionChange, ['foo']);
+ onSelectionChange.mockClear();
+
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true});
+ fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true});
+ expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.');
+ expect(announce).toHaveBeenCalledTimes(4);
+ checkSelection(onSelectionChange, ['foo', 'bar']);
});
- expect(onSelectionChange).not.toHaveBeenCalled();
- expect(onAction).toHaveBeenCalledTimes(2);
- expect(onAction).toHaveBeenCalledWith('baz');
- });
- it('should not call onSelectionChange when hitting Space/Enter on the currently selected row', function () {
- let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight', onAction});
+ it('should announce the new row when moving with the keyboard after multi select', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'});
- let row = tree.getAllByRole('row')[1];
- expect(row).toHaveAttribute('aria-selected', 'false');
- act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true}));
+ let rows = tree.getAllByRole('row');
+ userEvent.click(rows[0], {pointerType: 'mouse'});
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+ checkSelection(onSelectionChange, ['foo']);
+ onSelectionChange.mockClear();
+
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true});
+ fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true});
+ expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+ checkSelection(onSelectionChange, ['foo', 'bar']);
+ onSelectionChange.mockClear();
+
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'});
+ fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'});
+ expect(announce).toHaveBeenLastCalledWith('Baz selected. 1 item selected.');
+ checkSelection(onSelectionChange, ['baz']);
+ });
- checkSelection(onSelectionChange, ['bar']);
- expect(row).toHaveAttribute('aria-selected', 'true');
- expect(onAction).toHaveBeenCalledTimes(0);
+ it('should support non-contiguous selection with the keyboard', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'});
- fireEvent.keyDown(row, {key: 'Space'});
- fireEvent.keyUp(row, {key: 'Space'});
- expect(onSelectionChange).toHaveBeenCalledTimes(1);
- expect(onAction).toHaveBeenCalledTimes(0);
+ let rows = tree.getAllByRole('row');
+ userEvent.click(rows[0], {pointerType: 'mouse'});
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+ checkSelection(onSelectionChange, ['foo']);
+ onSelectionChange.mockClear();
+
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', ctrlKey: true});
+ fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', ctrlKey: true});
+ expect(announce).toHaveBeenCalledTimes(1);
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(document.activeElement).toBe(getRow(tree, 'Bar'));
- fireEvent.keyDown(row, {key: 'Enter'});
- fireEvent.keyUp(row, {key: 'Enter'});
- expect(onSelectionChange).toHaveBeenCalledTimes(1);
- expect(onAction).toHaveBeenCalledTimes(1);
- expect(onAction).toHaveBeenCalledWith('bar');
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', ctrlKey: true});
+ fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', ctrlKey: true});
+ expect(announce).toHaveBeenCalledTimes(1);
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(document.activeElement).toBe(getRow(tree, 'Baz'));
+
+ fireEvent.keyDown(document.activeElement, {key: ' ', ctrlKey: true});
+ fireEvent.keyUp(document.activeElement, {key: ' ', ctrlKey: true});
+ expect(announce).toHaveBeenCalledWith('Baz selected. 2 items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+ checkSelection(onSelectionChange, ['foo', 'baz']);
+ onSelectionChange.mockClear();
+
+ fireEvent.keyDown(document.activeElement, {key: ' '});
+ fireEvent.keyUp(document.activeElement, {key: ' '});
+ expect(announce).toHaveBeenCalledWith('Baz selected. 1 item selected.');
+ expect(announce).toHaveBeenCalledTimes(3);
+ checkSelection(onSelectionChange, ['baz']);
+ });
+
+ it('should announce the current selection when moving from all to one item', function () {
+ let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', onAction, selectionMode: 'multiple'});
+
+ let rows = tree.getAllByRole('row');
+ userEvent.click(rows[0], {pointerType: 'mouse'});
+ checkSelection(onSelectionChange, ['foo']);
+ onSelectionChange.mockClear();
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+
+ fireEvent.keyDown(rows[0], {key: 'a', ctrlKey: true});
+ fireEvent.keyUp(rows[0], {key: 'a', ctrlKey: true});
+ checkSelection(onSelectionChange, 'all');
+ onSelectionChange.mockClear();
+ expect(announce).toHaveBeenLastCalledWith('All items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'});
+ fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'});
+ expect(announce).toHaveBeenLastCalledWith('Bar selected. 1 item selected.');
+ expect(announce).toHaveBeenCalledTimes(3);
+ checkSelection(onSelectionChange, ['bar']);
+ });
});
+ describe('long press', () => {
+ installPointerEvent();
+ beforeEach(() => {
+ window.ontouchstart = jest.fn();
+ });
+
+ afterEach(() => {
+ delete window.ontouchstart;
+ });
+
+ it('should support long press to enter selection mode on touch', function () {
+ window.ontouchstart = jest.fn();
+ let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', onAction, selectionMode: 'multiple'});
+ let rows = tree.getAllByRole('row');
+ userEvent.click(document.body);
+
+ fireEvent.pointerDown(rows[0], {pointerType: 'touch'});
+ let description = tree.getByText('Long press to enter selection mode.');
+ expect(tree.getByRole('grid')).toHaveAttribute('aria-describedby', expect.stringContaining(description.id));
+ expect(announce).not.toHaveBeenCalled();
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(onAction).not.toHaveBeenCalled();
+
+ act(() => jest.advanceTimersByTime(800));
+
+ expect(announce).toHaveBeenLastCalledWith('Foo selected.');
+ expect(announce).toHaveBeenCalledTimes(1);
+ checkSelection(onSelectionChange, ['foo']);
+ onSelectionChange.mockClear();
+ expect(onAction).not.toHaveBeenCalled();
+ expect(within(rows[0]).getByRole('checkbox')).toBeTruthy();
+
+ fireEvent.pointerUp(rows[0], {pointerType: 'touch'});
+
+ userEvent.click(rows[1], {pointerType: 'touch'});
+ expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.');
+ expect(announce).toHaveBeenCalledTimes(2);
+ checkSelection(onSelectionChange, ['foo', 'bar']);
+ onSelectionChange.mockClear();
+
+ // Deselect all to exit selection mode
+ userEvent.click(rows[0], {pointerType: 'touch'});
+ expect(announce).toHaveBeenLastCalledWith('Foo not selected. 1 item selected.');
+ expect(announce).toHaveBeenCalledTimes(3);
+ checkSelection(onSelectionChange, ['bar']);
+ onSelectionChange.mockClear();
+ userEvent.click(rows[1], {pointerType: 'touch'});
+ expect(announce).toHaveBeenLastCalledWith('Bar not selected.');
+ expect(announce).toHaveBeenCalledTimes(4);
+
+ act(() => jest.runAllTimers());
+ checkSelection(onSelectionChange, []);
+ expect(onAction).not.toHaveBeenCalled();
+ expect(within(rows[0]).queryByRole('checkbox')).toBeNull();
+ });
+ });
});
describe('scrolling', function () {
@@ -1294,7 +1595,6 @@ describe('ListView', function () {
});
it('should make row selection happen on pressUp if list is draggable', function () {
-
let {getAllByRole} = render(
);
diff --git a/packages/@react-types/list/README.md b/packages/@react-types/list/README.md
new file mode 100644
index 00000000000..82e22bad671
--- /dev/null
+++ b/packages/@react-types/list/README.md
@@ -0,0 +1,3 @@
+# @react-types/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-types/list/package.json b/packages/@react-types/list/package.json
new file mode 100644
index 00000000000..c5c6038a6e5
--- /dev/null
+++ b/packages/@react-types/list/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@react-types/list",
+ "version": "3.0.0-alpha.1",
+ "description": "Spectrum UI components in React",
+ "license": "Apache-2.0",
+ "types": "src/index.d.ts",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/adobe/react-spectrum"
+ },
+ "dependencies": {
+ "@react-spectrum/dnd": "3.0.0-alpha.2",
+ "@react-types/shared": "^3.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/@react-types/list/src/index.d.ts b/packages/@react-types/list/src/index.d.ts
new file mode 100644
index 00000000000..4a7dce96a2b
--- /dev/null
+++ b/packages/@react-types/list/src/index.d.ts
@@ -0,0 +1,66 @@
+/*
+ * 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 {
+ AriaLabelingProps,
+ AsyncLoadable,
+ CollectionBase,
+ DOMProps,
+ LoadingState,
+ MultipleSelection,
+ SpectrumSelectionProps,
+ StyleProps
+} from '@react-types/shared';
+import {DragHooks, DropHooks} from '@react-spectrum/dnd';
+
+export interface ListProps extends CollectionBase, MultipleSelection {
+ /**
+ * Handler that is called when a user performs an action on an item. The exact user event depends on
+ * the collection's `selectionBehavior` prop and the interaction modality.
+ */
+ onAction?: (key: string) => void
+}
+
+export interface AriaListProps extends ListProps, DOMProps, AriaLabelingProps {}
+
+export interface SpectrumListProps extends AriaListProps, StyleProps, SpectrumSelectionProps, Omit {
+ /**
+ * Sets the amount of vertical padding within each cell.
+ * @default 'regular'
+ */
+ density?: 'compact' | 'regular' | 'spacious',
+ /** Whether the ListView should be displayed with a quiet style. */
+ isQuiet?: boolean,
+ /** The current loading state of the ListView. Determines whether or not the progress circle should be shown. */
+ loadingState?: LoadingState,
+ /**
+ * Sets the text behavior for the row contents.
+ * @default 'truncate'
+ */
+ overflowMode?: 'truncate' | 'wrap',
+ /** Sets what the ListView should render when there is no content to display. */
+ renderEmptyState?: () => JSX.Element,
+ /**
+ * Handler that is called when a user performs an action on an item. The exact user event depends on
+ * the collection's `selectionBehavior` prop and the interaction modality.
+ */
+ onAction?: (key: string) => void,
+ /**
+ * The drag hooks returned by `useDragHooks` used to enable drag and drop behavior for the ListView. See the
+ * [docs](https://react-spectrum.adobe.com/react-spectrum/useDragHooks.html) for more info.
+ */
+ dragHooks?: DragHooks,
+ /**
+ * The drag hooks returned by `useDragHooks` used to enable drag and drop behavior for the ListView.
+ */
+ dropHooks?: DropHooks
+}