From 3580959940c7efacf17c73e9d8e9712da5daf9a7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 10 Mar 2022 17:14:27 -0800 Subject: [PATCH 01/36] initial work to focus the row instead of the cell --- .../grid/src/GridKeyboardDelegate.ts | 23 +++++++++++++++---- packages/@react-aria/grid/src/useGridCell.ts | 7 ++++-- .../selection/src/useSelectableCollection.ts | 4 ++++ .../@react-spectrum/list/src/ListView.tsx | 6 ++--- .../@react-spectrum/list/src/ListViewItem.tsx | 15 +++++++----- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index cd9aa9cb089..6aa8d978a70 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -22,7 +22,10 @@ export interface GridKeyboardDelegateOptions { direction: Direction, collator?: Intl.Collator, layout?: Layout>, - focusMode?: 'row' | 'cell' + focusMode?: 'row' | 'cell', + // TODO: whether to prioritize focusing the row over the cell? Really this mode is for ListView where we + // either want to focus the row or the child elements of the cell, but never the cell itself + skipCell?: boolean } export class GridKeyboardDelegate> implements KeyboardDelegate { @@ -42,8 +45,13 @@ export class GridKeyboardDelegate> implements Key this.collator = options.collator; this.layout = options.layout; this.focusMode = options.focusMode || 'row'; + // TODO: temp hack to make up/down arrow focus the row instead of trying to focus + // the same cell contents of the above/below row + this.skipCell = true; } + // TODO: Fix Home, End, Page Down, and Page Up for ListView. It should work if you are focused on the row + // or within the cell protected isCell(node: Node) { return node.type === 'cell'; } @@ -97,7 +105,7 @@ export class GridKeyboardDelegate> implements Key key = this.findNextKey(key); if (key != null) { // If focus was on a cell, focus the cell with the same index in the next row. - if (this.isCell(startItem)) { + if (!this.skipCell && this.isCell(startItem)) { let item = this.collection.getItem(key); return [...item.childNodes][startItem.index].key; } @@ -124,7 +132,7 @@ export class GridKeyboardDelegate> implements Key key = this.findPreviousKey(key); if (key != null) { // If focus was on a cell, focus the cell with the same index in the previous row. - if (this.isCell(startItem)) { + if (!this.skipCell && this.isCell(startItem)) { let item = this.collection.getItem(key); return [...item.childNodes][startItem.index].key; } @@ -247,7 +255,7 @@ export class GridKeyboardDelegate> implements Key // If global flag is not set, and a cell is currently focused, // move focus to the last cell in the parent row. - if (this.isCell(item) && !global) { + if (this.isCell(item) && !global && !this.skipCell) { let parent = this.collection.getItem(item.parentKey); let children = [...parent.childNodes]; return children[children.length - 1].key; @@ -299,7 +307,10 @@ export class GridKeyboardDelegate> implements Key return this.ref?.current?.scrollHeight; } + // TODO: check if the key is a cell key or a row key. Should always use the + // row key? Need to debug getKeyPageAbove(key: Key) { + console.log('key in getPageAbovec', key) let itemRect = this.getItemRect(key); if (!itemRect) { return null; @@ -315,7 +326,10 @@ export class GridKeyboardDelegate> implements Key return key; } + // TODO: check if the key is a cell key or a row key. Should always use the + // row key? Need to debug getKeyPageBelow(key: Key) { + console.log('key in getPageBelow', key) let itemRect = this.getItemRect(key); if (!itemRect) { @@ -381,4 +395,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..a0d4447b739 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -64,13 +64,15 @@ export function useGridCell>(props: GridCellProps // Handles focusing the cell. If there is a focusable child, // it is focused, otherwise the cell itself is focused. + // TODO: this focus logic will coerce focus to the first focusable child when clicking on a listview's row child element let focus = () => { let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { let focusable = state.selectionManager.childFocusStrategy === 'last' ? last(treeWalker) : treeWalker.firstChild() as HTMLElement; - if (focusable) { + // TODO: only move focus to the child within the cell if focus isn't already within the cell (e.g. clicking on a buton within a listview row) + if (focusable && !ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { focusSafely(focusable); return; } @@ -98,7 +100,7 @@ export function useGridCell>(props: GridCellProps let walker = getFocusableTreeWalker(ref.current); walker.currentNode = document.activeElement; - + console.log('gwaegaweg') switch (e.key) { case 'ArrowLeft': { // Find the next focusable element within the cell. @@ -213,6 +215,7 @@ export function useGridCell>(props: GridCellProps // up to the tree, and move focus to a focusable child if possible. requestAnimationFrame(() => { if (focusMode === 'child' && document.activeElement === ref.current) { + console.log('blah') focus(); } }); diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index c78b28b5be6..787864727e9 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -169,10 +169,12 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowRight': { + console.log('arrow right') if (delegate.getKeyRightOf) { e.preventDefault(); let nextKey = delegate.getKeyRightOf(manager.focusedKey); navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); + console.log('next key arrow right', nextKey) } break; } @@ -201,10 +203,12 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'PageDown': + console.log('page down') if (delegate.getKeyPageBelow) { e.preventDefault(); let nextKey = delegate.getKeyPageBelow(manager.focusedKey); navigateToKey(nextKey); + console.log('nextkey', nextKey) } break; case 'PageUp': diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index 2fe11fd306a..6673c8ea3e4 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -141,7 +141,7 @@ function ListView(props: ListViewProps, ref: DOMRef(props: ListViewProps, ref: DOMRef + {...mergedProps} + ref={rowRef} + aria-label={item.textValue}>
+ {...gridCellProps}> {isListDraggable &&
From e51bd3f37599e9757ec06fd419643259b5feffd0 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 29 Mar 2022 17:01:23 -0700 Subject: [PATCH 02/36] cleanup --- .../grid/src/GridKeyboardDelegate.ts | 17 ++++------------- packages/@react-aria/grid/src/useGridCell.ts | 2 -- .../selection/src/useSelectableCollection.ts | 4 ---- packages/@react-spectrum/list/src/ListView.tsx | 3 ++- 4 files changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index 6aa8d978a70..e7ccfa4348b 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -23,8 +23,8 @@ export interface GridKeyboardDelegateOptions { collator?: Intl.Collator, layout?: Layout>, focusMode?: 'row' | 'cell', - // TODO: whether to prioritize focusing the row over the cell? Really this mode is for ListView where we - // either want to focus the row or the child elements of the cell, but never the cell itself + // TODO: whether to prioritize focusing the row over the cell? Specifically for Up/Down where we always want to move focus + // to the row skipCell?: boolean } @@ -36,6 +36,7 @@ export class GridKeyboardDelegate> implements Key protected collator: Intl.Collator; protected layout: Layout>; protected focusMode; + protected skipCell; constructor(options: GridKeyboardDelegateOptions) { this.collection = options.collection; @@ -45,13 +46,9 @@ export class GridKeyboardDelegate> implements Key this.collator = options.collator; this.layout = options.layout; this.focusMode = options.focusMode || 'row'; - // TODO: temp hack to make up/down arrow focus the row instead of trying to focus - // the same cell contents of the above/below row - this.skipCell = true; + this.skipCell = options.skipCell || false; } - // TODO: Fix Home, End, Page Down, and Page Up for ListView. It should work if you are focused on the row - // or within the cell protected isCell(node: Node) { return node.type === 'cell'; } @@ -307,10 +304,7 @@ export class GridKeyboardDelegate> implements Key return this.ref?.current?.scrollHeight; } - // TODO: check if the key is a cell key or a row key. Should always use the - // row key? Need to debug getKeyPageAbove(key: Key) { - console.log('key in getPageAbovec', key) let itemRect = this.getItemRect(key); if (!itemRect) { return null; @@ -326,10 +320,7 @@ export class GridKeyboardDelegate> implements Key return key; } - // TODO: check if the key is a cell key or a row key. Should always use the - // row key? Need to debug getKeyPageBelow(key: Key) { - console.log('key in getPageBelow', key) let itemRect = this.getItemRect(key); if (!itemRect) { diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index a0d4447b739..816b6a8567d 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -100,7 +100,6 @@ export function useGridCell>(props: GridCellProps let walker = getFocusableTreeWalker(ref.current); walker.currentNode = document.activeElement; - console.log('gwaegaweg') switch (e.key) { case 'ArrowLeft': { // Find the next focusable element within the cell. @@ -215,7 +214,6 @@ export function useGridCell>(props: GridCellProps // up to the tree, and move focus to a focusable child if possible. requestAnimationFrame(() => { if (focusMode === 'child' && document.activeElement === ref.current) { - console.log('blah') focus(); } }); diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 787864727e9..c78b28b5be6 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -169,12 +169,10 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S break; } case 'ArrowRight': { - console.log('arrow right') if (delegate.getKeyRightOf) { e.preventDefault(); let nextKey = delegate.getKeyRightOf(manager.focusedKey); navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); - console.log('next key arrow right', nextKey) } break; } @@ -203,12 +201,10 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S } break; case 'PageDown': - console.log('page down') if (delegate.getKeyPageBelow) { e.preventDefault(); let nextKey = delegate.getKeyPageBelow(manager.focusedKey); navigateToKey(nextKey); - console.log('nextkey', nextKey) } break; case 'PageUp': diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index 6673c8ea3e4..15ae86dc023 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -151,7 +151,8 @@ function ListView(props: ListViewProps, ref: DOMRef Date: Tue, 29 Mar 2022 17:44:49 -0700 Subject: [PATCH 03/36] tentative approach to always skip focusing the cell --- .../grid/src/GridKeyboardDelegate.ts | 1 + packages/@react-aria/grid/src/useGridCell.ts | 17 +++++++++++++---- .../@react-spectrum/list/src/ListViewItem.tsx | 3 ++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index e7ccfa4348b..c1d3654738c 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -25,6 +25,7 @@ export interface GridKeyboardDelegateOptions { focusMode?: 'row' | 'cell', // TODO: whether to prioritize focusing the row over the cell? Specifically for Up/Down where we always want to move focus // to the row + // TODO: check naming convention skipCell?: boolean } diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index 816b6a8567d..04d99a0294d 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,7 +58,8 @@ export function useGridCell>(props: GridCellProps isVirtualized, focusMode = 'child', shouldSelectOnPressUp, - onAction + onAction, + skipCell } = props; let {direction} = useLocale(); @@ -67,7 +70,7 @@ export function useGridCell>(props: GridCellProps // TODO: this focus logic will coerce focus to the first focusable child when clicking on a listview's row child element let focus = () => { let treeWalker = getFocusableTreeWalker(ref.current); - if (focusMode === 'child') { + if (focusMode === 'child' || skipCell) { let focusable = state.selectionManager.childFocusStrategy === 'last' ? last(treeWalker) : treeWalker.firstChild() as HTMLElement; @@ -78,8 +81,14 @@ export function useGridCell>(props: GridCellProps } } - if (!ref.current.contains(document.activeElement)) { + if (!ref.current.contains(document.activeElement) && !skipCell) { focusSafely(ref.current); + return; + } + + // TODO: If parent key exists and skipCell is true, set the focusedKey to be the parentKey + if (node.parentKey != null && skipCell) { + state.selectionManager.setFocusedKey(node.parentKey); } }; diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index b2d3e6f96cd..ab0eb2ed0ac 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -56,7 +56,8 @@ export function ListViewItem(props) { shouldSelectOnPressUp: isListDraggable }, state, rowRef); let {gridCellProps} = useGridCell({ - node: cellNode + node: cellNode, + skipCell: true }, state, cellRef); let draggableItem: DraggableItemResult; if (isListDraggable) { From 2216e982032e45f0cc59ad32d56188e0026418fb Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 30 Mar 2022 15:53:58 -0700 Subject: [PATCH 04/36] retaining focus on Listview focusable child when it is clicked --- packages/@react-aria/grid/src/useGridCell.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index 04d99a0294d..7a489f403a1 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -66,16 +66,22 @@ export function useGridCell>(props: GridCellProps 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. - // TODO: this focus logic will coerce focus to the first focusable child when clicking on a listview's row child element + // 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' || 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; - // TODO: only move focus to the child within the cell if focus isn't already within the cell (e.g. clicking on a buton within a listview row) - if (focusable && !ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { + + if (focusable) { focusSafely(focusable); return; } @@ -86,7 +92,6 @@ export function useGridCell>(props: GridCellProps return; } - // TODO: If parent key exists and skipCell is true, set the focusedKey to be the parentKey if (node.parentKey != null && skipCell) { state.selectionManager.setFocusedKey(node.parentKey); } From dac22a85c8608c8248db63f72041812c8b4b9edd Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 30 Mar 2022 16:27:46 -0700 Subject: [PATCH 05/36] making ListView specific keyboard delegate Felt that the previous modifications to GridKeyboardDelegate were too specific to ListView so split out the changes. Also fixes pageUp/Down/Home/End when you are focused on the ListView row (we forgot to pass the layout to the delegate previously) --- .../grid/src/GridKeyboardDelegate.ts | 13 ++--- packages/@react-aria/list/README.md | 3 + packages/@react-aria/list/index.ts | 13 +++++ packages/@react-aria/list/package.json | 31 ++++++++++ .../list/src/ListGridKeyboardDelegate.ts | 56 +++++++++++++++++++ packages/@react-aria/list/src/index.ts | 13 +++++ packages/@react-spectrum/list/package.json | 1 + .../@react-spectrum/list/src/ListView.tsx | 10 ++-- 8 files changed, 126 insertions(+), 14 deletions(-) create mode 100644 packages/@react-aria/list/README.md create mode 100644 packages/@react-aria/list/index.ts create mode 100644 packages/@react-aria/list/package.json create mode 100644 packages/@react-aria/list/src/ListGridKeyboardDelegate.ts create mode 100644 packages/@react-aria/list/src/index.ts diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index c1d3654738c..65c51e7888e 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -22,11 +22,7 @@ export interface GridKeyboardDelegateOptions { direction: Direction, collator?: Intl.Collator, layout?: Layout>, - focusMode?: 'row' | 'cell', - // TODO: whether to prioritize focusing the row over the cell? Specifically for Up/Down where we always want to move focus - // to the row - // TODO: check naming convention - skipCell?: boolean + focusMode?: 'row' | 'cell' } export class GridKeyboardDelegate> implements KeyboardDelegate { @@ -47,7 +43,6 @@ export class GridKeyboardDelegate> implements Key this.collator = options.collator; this.layout = options.layout; this.focusMode = options.focusMode || 'row'; - this.skipCell = options.skipCell || false; } protected isCell(node: Node) { @@ -103,7 +98,7 @@ export class GridKeyboardDelegate> implements Key key = this.findNextKey(key); if (key != null) { // If focus was on a cell, focus the cell with the same index in the next row. - if (!this.skipCell && this.isCell(startItem)) { + if (this.isCell(startItem)) { let item = this.collection.getItem(key); return [...item.childNodes][startItem.index].key; } @@ -130,7 +125,7 @@ export class GridKeyboardDelegate> implements Key key = this.findPreviousKey(key); if (key != null) { // If focus was on a cell, focus the cell with the same index in the previous row. - if (!this.skipCell && this.isCell(startItem)) { + if (this.isCell(startItem)) { let item = this.collection.getItem(key); return [...item.childNodes][startItem.index].key; } @@ -253,7 +248,7 @@ export class GridKeyboardDelegate> implements Key // If global flag is not set, and a cell is currently focused, // move focus to the last cell in the parent row. - if (this.isCell(item) && !global && !this.skipCell) { + if (this.isCell(item) && !global) { let parent = this.collection.getItem(item.parentKey); let children = [...parent.childNodes]; return children[children.length - 1].key; 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..3ab2c006b86 --- /dev/null +++ b/packages/@react-aria/list/package.json @@ -0,0 +1,31 @@ +{ + "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-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..998f78fdb0f --- /dev/null +++ b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts @@ -0,0 +1,56 @@ +/* + * 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'; + +// TODO: Open to feedback about name, ListKeyboardDelegate already exists +export class ListGridKeyboardDelegate extends GridKeyboardDelegate> { + constructor(options: Omit>, 'focusMode'>) { + super({...options, focusMode: 'row'}); + } + + // 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) { + let startItem = this.collection.getItem(key); + if (!startItem) { + return; + } + + // If focus was on a cell, start searching from the parent row + if (this.isCell(startItem)) { + key = startItem.parentKey; + } + + return this.findNextKey(key); + } + + getKeyAbove(key: Key) { + let startItem = this.collection.getItem(key); + if (!startItem) { + return; + } + + // If focus was on a cell, start searching from the parent row + if (this.isCell(startItem)) { + key = startItem.parentKey; + } + + return this.findPreviousKey(key); + } + + + // TODO: double check if getLastKey needs to be overridden here as well +} 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 15ae86dc023..daf317ca225 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 @@ -145,14 +146,13 @@ function ListView(props: ListViewProps, ref: DOMRef new GridKeyboardDelegate({ + let keyboardDelegate = useMemo(() => new ListGridKeyboardDelegate({ collection: state.collection, disabledKeys: state.disabledKeys, ref: domRef, direction, collator, - focusMode: 'row', - skipCell: true + layout }), [state, domRef, direction, collator]); let provider = useProvider(); From b540f23da03f09d00b26c0bc333d4ff926837e7e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 30 Mar 2022 17:12:44 -0700 Subject: [PATCH 06/36] fixing pageUp/Down operations when within the ListView row needed to override getItemRect for the ListView keyboard delegate so that it always uses the row key, namely due to the gridcell keys being unavailable in the listlayout. Changed getItem and getItemRect to protected funcs in GridKeyboardDelegate so I could do that --- .../grid/src/GridKeyboardDelegate.ts | 4 +- packages/@react-aria/list/package.json | 1 + .../list/src/ListGridKeyboardDelegate.ts | 48 +++++++++++++------ .../@react-spectrum/list/src/ListView.tsx | 4 +- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index 65c51e7888e..89380b252e3 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -269,11 +269,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; } diff --git a/packages/@react-aria/list/package.json b/packages/@react-aria/list/package.json index 3ab2c006b86..29ec3a72e86 100644 --- a/packages/@react-aria/list/package.json +++ b/packages/@react-aria/list/package.json @@ -20,6 +20,7 @@ "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": { diff --git a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts index 998f78fdb0f..5467e8c90db 100644 --- a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts +++ b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts @@ -13,6 +13,7 @@ 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> { @@ -20,11 +21,8 @@ export class ListGridKeyboardDelegate extends GridKeyboardDelegate extends GridKeyboardDelegate>, - keyboardDelegate: ListGridKeyboardDelegate>, + keyboardDelegate: ListGridKeyboardDelegate, dragState: DraggableCollectionState, onAction:(key: string) => void, isListDraggable: boolean @@ -146,7 +146,7 @@ function ListView(props: ListViewProps, ref: DOMRef new ListGridKeyboardDelegate({ + let keyboardDelegate = useMemo(() => new ListGridKeyboardDelegate({ collection: state.collection, disabledKeys: state.disabledKeys, ref: domRef, From 2ebabe1c4e95dbebdaabc51a1db27dce485231cc Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 31 Mar 2022 13:56:41 -0700 Subject: [PATCH 07/36] adding tests --- .../grid/src/GridKeyboardDelegate.ts | 1 - packages/@react-aria/grid/src/useGridCell.ts | 2 +- .../list/test/ListView.test.js | 261 +++++++++++++----- .../@react-spectrum/table/test/Table.test.js | 7 + 4 files changed, 202 insertions(+), 69 deletions(-) diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index 89380b252e3..ca1c40827b9 100644 --- a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts +++ b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts @@ -33,7 +33,6 @@ export class GridKeyboardDelegate> implements Key protected collator: Intl.Collator; protected layout: Layout>; protected focusMode; - protected skipCell; constructor(options: GridKeyboardDelegateOptions) { this.collection = options.collection; diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index 7a489f403a1..30acd58c321 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -80,7 +80,6 @@ export function useGridCell>(props: GridCellProps let focusable = state.selectionManager.childFocusStrategy === 'last' ? last(treeWalker) : treeWalker.firstChild() as HTMLElement; - if (focusable) { focusSafely(focusable); return; @@ -114,6 +113,7 @@ export function useGridCell>(props: GridCellProps let walker = getFocusableTreeWalker(ref.current); walker.currentNode = document.activeElement; + switch (e.key) { case 'ArrowLeft': { // Find the next focusable element within the cell. diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 7cc95a77879..aed5444ed5f 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 () { @@ -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'); From 2a92a70441f948207f2910d96f88c2e90979ecc6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 31 Mar 2022 15:01:21 -0700 Subject: [PATCH 08/36] fixing tests after rebase --- .../@react-spectrum/list/test/ListView.test.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index aed5444ed5f..b8f6baf6666 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -588,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'); @@ -610,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'); @@ -632,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'); @@ -700,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'); From 312aaed646e3b18d96b2c7554ead3955cc737da6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 1 Apr 2022 15:19:25 -0700 Subject: [PATCH 09/36] refactor to have outer div behave as both gridcell and grid row still need to fix pageUp/down --- packages/@react-aria/grid/src/useGridCell.ts | 11 +++------ .../list/src/ListGridKeyboardDelegate.ts | 24 ++----------------- .../@react-spectrum/list/src/ListView.tsx | 4 ++-- .../@react-spectrum/list/src/ListViewItem.tsx | 15 ++++++++---- 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index 30acd58c321..2dd47b1dff5 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -58,8 +58,7 @@ export function useGridCell>(props: GridCellProps isVirtualized, focusMode = 'child', shouldSelectOnPressUp, - onAction, - skipCell + onAction } = props; let {direction} = useLocale(); @@ -71,7 +70,7 @@ export function useGridCell>(props: GridCellProps // row. let focus = () => { let treeWalker = getFocusableTreeWalker(ref.current); - if (focusMode === 'child' || skipCell) { + if (focusMode === 'child') { // 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; @@ -86,14 +85,10 @@ export function useGridCell>(props: GridCellProps } } - if (!ref.current.contains(document.activeElement) && !skipCell) { + if (!ref.current.contains(document.activeElement)) { focusSafely(ref.current); return; } - - if (node.parentKey != null && skipCell) { - state.selectionManager.setFocusedKey(node.parentKey); - } }; let {itemProps, isPressed} = useSelectableItem({ diff --git a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts index 5467e8c90db..5dc4a689d8b 100644 --- a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts +++ b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts @@ -18,7 +18,7 @@ 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'}); + super({...options, focusMode: 'cell'}); } private getRowKey(key: Key) { @@ -35,28 +35,8 @@ export class ListGridKeyboardDelegate extends GridKeyboardDelegate(props: ListViewProps, ref: DOMRef onAction(item.key) : undefined, shouldSelectOnPressUp: isListDraggable }, state, rowRef); + + // We don't want the rowProps.tabIndex or rowProps.onFocus because useGridCell's props + // wil handle these for us. Our strategy is to treat the outer div as the "gridcell" + // so we can cleanly navigate to the focusable children while skipping the inner "gridcell" div. + delete rowProps.tabIndex; + delete rowProps.onFocus; + let {gridCellProps} = useGridCell({ node: cellNode, - skipCell: true - }, state, cellRef); + focusMode: 'cell' + }, state, rowRef); let draggableItem: DraggableItemResult; if (isListDraggable) { // eslint-disable-next-line react-hooks/rules-of-hooks draggableItem = dragHooks.useDraggableItem({key: item.key}, dragState); } const mergedProps = mergeProps( + gridCellProps, rowProps, pressProps, isDraggable && draggableItem?.dragProps, @@ -122,8 +130,7 @@ export function ListViewItem(props) { } ) } - ref={cellRef} - {...gridCellProps}> + role="gridcell"> {isListDraggable &&
From dd9dac5119ba3fe6370b5a41d5266f46551de899 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 1 Apr 2022 15:20:54 -0700 Subject: [PATCH 10/36] fixing Home/End needed to override getKeyLeftOf/getKeyRightOf so they dont use getFirstKey or getLastKey or else focus would jump to the last row instead of wrapping inside the cell --- .../list/src/ListGridKeyboardDelegate.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts index 5dc4a689d8b..4ae5e5b1865 100644 --- a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts +++ b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts @@ -35,6 +35,24 @@ export class ListGridKeyboardDelegate extends GridKeyboardDelegate Date: Fri, 1 Apr 2022 15:26:30 -0700 Subject: [PATCH 11/36] fixing test and getting rid of erroneous data id --- packages/@react-spectrum/list/src/ListViewItem.tsx | 3 ++- packages/@react-spectrum/list/test/ListView.test.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index a596bc7020f..c2e5850f065 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -64,7 +64,8 @@ export function ListViewItem(props) { let {gridCellProps} = useGridCell({ node: cellNode, - focusMode: 'cell' + focusMode: 'cell', + isVirtualized: true }, state, rowRef); let draggableItem: DraggableItemResult; if (isListDraggable) { diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index b8f6baf6666..e6fd0a3b0e5 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -226,10 +226,10 @@ describe('ListView', function () { }); it('should focus the row if the cell is pressed', function () { - let tree = renderList(); + let tree = renderList({selectionMode: 'single'}); let cell = within(getRow(tree, 'Bar')).getByRole('gridcell'); act(() => { - cell.focus(); + triggerPress(cell); jest.runAllTimers(); }); expect(document.activeElement).toBe(getRow(tree, 'Bar')); From d9eb366fa2f3ea6db5e83bc2518370be7c18b3ef Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 1 Apr 2022 15:37:47 -0700 Subject: [PATCH 12/36] cleanup --- packages/@react-aria/grid/src/useGridCell.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index 2dd47b1dff5..edeb40f08b8 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -35,9 +35,7 @@ interface GridCellProps { * Please use onCellAction at the collection level instead. * @deprecated **/ - onAction?: () => void, - // TODO: check naming convention - skipCell?: boolean + onAction?: () => void } interface GridCellAria { @@ -65,9 +63,7 @@ export function useGridCell>(props: GridCellProps 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. If skipCell is - // true, always attempt to put focus on a focusable child if any or back on the parent - // row. + // it is focused, otherwise the cell itself is focused. let focus = () => { let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { @@ -87,7 +83,6 @@ export function useGridCell>(props: GridCellProps if (!ref.current.contains(document.activeElement)) { focusSafely(ref.current); - return; } }; From 303304cca919e48a820c52aab7d324cfdf897bce Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 1 Apr 2022 16:06:19 -0700 Subject: [PATCH 13/36] lint --- packages/@react-spectrum/list/src/ListView.tsx | 4 ++-- packages/@react-spectrum/list/src/ListViewItem.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index 2b565794e0a..c610a23f98c 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -38,7 +38,7 @@ 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 {GridKeyboardDelegate, useGrid, useGridSelectionCheckbox} from '@react-aria/grid'; +import {useGrid, useGridSelectionCheckbox} from '@react-aria/grid'; import {Virtualizer} from '@react-aria/virtualizer'; interface ListViewContextValue { @@ -153,7 +153,7 @@ function ListView(props: ListViewProps, ref: DOMRef(); - let cellRef = useRef(); let { isFocusVisible: isFocusVisibleWithin, focusProps: focusWithinProps From 0fe13e6114f7a52bfcb92302107fcdb6a20b708e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 4 Apr 2022 10:16:51 -0700 Subject: [PATCH 14/36] removing todo --- packages/@react-aria/list/src/ListGridKeyboardDelegate.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts index 4ae5e5b1865..b3b7019f35a 100644 --- a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts +++ b/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts @@ -15,7 +15,6 @@ import {GridKeyboardDelegate, GridKeyboardDelegateOptions} from '@react-aria/gri 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: 'cell'}); From b4a4f40d2b50527dc0ba3b99e7d53a1dd99780f4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 4 Apr 2022 12:12:54 -0700 Subject: [PATCH 15/36] updating lint so we check that a dep is actually needed before throwing --- scripts/lint-packages.js | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/scripts/lint-packages.js b/scripts/lint-packages.js index 964ac3a7e19..fccc80e5260 100644 --- a/scripts/lint-packages.js +++ b/scripts/lint-packages.js @@ -46,6 +46,22 @@ softAssert.equal = function (val, val2, message) { } }; +// Checks if a dependency is actually being imported somewhere +function isDepUsed(dep, src) { + let depRegex = new RegExp(`import .* from '${dep}'`); + let files = glob.sync(src, { + ignore: ['**/node_modules/**', '**/dist/**'] + }); + + for (let file of files) { + let contents = fs.readFileSync(file, 'utf8'); + if (depRegex.test(contents)) { + return true; + } + } + return false; +} + let pkgNames = {}; for (let pkg of packages) { let json = JSON.parse(fs.readFileSync(pkg)); @@ -106,29 +122,27 @@ for (let pkg of packages) { for (let pkg of packages) { + let globSrc = pkg.replace('package.json', '**/*.{js,ts,tsx}'); let json = JSON.parse(fs.readFileSync(pkg)); let [scope, basename] = json.name.split('/'); if (basename.includes('utils') || basename.includes('layout')) { continue; } - if (scope === '@react-spectrum') { - let aria = `@react-aria/${basename}`; - let stately = `@react-stately/${basename}`; - let types = `@react-types/${basename}`; + let aria = `@react-aria/${basename}`; + let stately = `@react-stately/${basename}`; + let types = `@react-types/${basename}`; + + if (scope === '@react-spectrum' && isDepUsed(aria, globSrc)) { softAssert(!pkgNames[aria] || json.dependencies[aria], `${pkg} is missing a dependency on ${aria}`); - softAssert(!pkgNames[stately] || json.dependencies[stately], `${pkg} is missing a dependency on ${stately}`); - softAssert(!pkgNames[types] || json.dependencies[types], `${pkg} is missing a dependency on ${types}`); - } else if (scope === '@react-aria') { - let stately = `@react-stately/${basename}`; - let types = `@react-types/${basename}`; + } + if ((scope === '@react-aria' || scope === '@react-spectrum') && isDepUsed(stately, globSrc)) { softAssert(!pkgNames[stately] || json.dependencies[stately], `${pkg} is missing a dependency on ${stately}`); - softAssert(!pkgNames[types] || json.dependencies[types], `${pkg} is missing a dependency on ${types}`); - } else if (scope === '@react-stately') { - let types = `@react-types/${basename}`; + } + if ((scope === '@react-aria' || scope === '@react-spectrum' || scope === '@react-stately') && isDepUsed(types, globSrc)) { softAssert(!pkgNames[types] || json.dependencies[types], `${pkg} is missing a dependency on ${types}`); } } From 11302a3013146bb0a1e3147c3a6e3df8d9fd37d6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 4 Apr 2022 12:19:51 -0700 Subject: [PATCH 16/36] removing private --- packages/@react-aria/list/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-aria/list/package.json b/packages/@react-aria/list/package.json index 29ec3a72e86..af0ecf57cd6 100644 --- a/packages/@react-aria/list/package.json +++ b/packages/@react-aria/list/package.json @@ -1,7 +1,6 @@ { "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", From c9ba9237f494f2cd348e89190cf50b608cb21469 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 4 Apr 2022 17:24:23 -0700 Subject: [PATCH 17/36] pulling triggerPress outside the act --- .../@react-spectrum/list/test/ListView.test.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index e6fd0a3b0e5..e173ef17ce5 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -221,15 +221,15 @@ describe('ListView', function () { it('should retain focus on the pressed child', function () { let tree = renderListWithFocusables(); let button = within(getRow(tree, 'Foo')).getAllByRole('button')[1]; - act(() => triggerPress(button)); + triggerPress(button); expect(document.activeElement).toBe(button); }); it('should focus the row if the cell is pressed', function () { let tree = renderList({selectionMode: 'single'}); let cell = within(getRow(tree, 'Bar')).getByRole('gridcell'); + triggerPress(cell); act(() => { - triggerPress(cell); jest.runAllTimers(); }); expect(document.activeElement).toBe(getRow(tree, 'Bar')); @@ -288,7 +288,7 @@ describe('ListView', function () { 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)); + triggerPress(start); expect(document.activeElement).toHaveTextContent('button2 Foo'); expect(document.activeElement).toBe(start); moveFocus('ArrowRight'); @@ -327,7 +327,7 @@ describe('ListView', function () { 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)); + triggerPress(start); expect(document.activeElement).toHaveTextContent('button1 Foo'); expect(document.activeElement).toBe(start); moveFocus('ArrowLeft'); @@ -388,7 +388,7 @@ describe('ListView', function () { let tree = renderListWithFocusables({items: manyItems}); let focusables = within(getRow(tree, 'Foo 25')).getAllByRole('button'); let start = focusables[0]; - act(() => triggerPress(start)); + triggerPress(start); expect(document.activeElement).toBe(start); moveFocus('PageUp'); expect(document.activeElement).toBe(getRow(tree, 'Foo 1')); @@ -410,7 +410,7 @@ describe('ListView', function () { let tree = renderListWithFocusables({items: manyItems}); let focusables = within(getRow(tree, 'Foo 1')).getAllByRole('button'); let start = focusables[0]; - act(() => triggerPress(start)); + triggerPress(start); expect(document.activeElement).toBe(start); moveFocus('PageDown'); expect(document.activeElement).toBe(getRow(tree, 'Foo 25')); @@ -432,7 +432,7 @@ describe('ListView', function () { let tree = renderListWithFocusables({items: manyItems}); let focusables = within(getRow(tree, 'Foo 15')).getAllByRole('button'); let start = focusables[0]; - act(() => triggerPress(start)); + triggerPress(start); expect(document.activeElement).toBe(start); moveFocus('Home'); expect(document.activeElement).toBe(getRow(tree, 'Foo 1')); @@ -452,7 +452,7 @@ describe('ListView', function () { let tree = renderListWithFocusables({items: manyItems}); let focusables = within(getRow(tree, 'Foo 1')).getAllByRole('button'); let start = focusables[0]; - act(() => triggerPress(start)); + triggerPress(start); expect(document.activeElement).toBe(start); moveFocus('End'); expect(document.activeElement).toBe(getRow(tree, 'Foo 100')); From f2ef4ef8e995f3e11f1520bfd35c9b450a74f86c Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 21 Apr 2022 14:57:21 -0700 Subject: [PATCH 18/36] tentative approach to use ListLayout instead of making a ListGrid keyboard delegate --- packages/@react-aria/list/README.md | 3 - packages/@react-aria/list/index.ts | 13 ---- packages/@react-aria/list/package.json | 31 -------- .../list/src/ListGridKeyboardDelegate.ts | 71 ------------------- packages/@react-aria/list/src/index.ts | 13 ---- .../@react-spectrum/list/src/ListView.tsx | 25 ++----- .../@react-spectrum/list/src/ListViewItem.tsx | 9 +-- .../@react-stately/grid/src/GridCollection.ts | 17 ++--- .../@react-stately/layout/src/ListLayout.ts | 21 ++++-- 9 files changed, 33 insertions(+), 170 deletions(-) delete mode 100644 packages/@react-aria/list/README.md delete mode 100644 packages/@react-aria/list/index.ts delete mode 100644 packages/@react-aria/list/package.json delete mode 100644 packages/@react-aria/list/src/ListGridKeyboardDelegate.ts delete mode 100644 packages/@react-aria/list/src/index.ts diff --git a/packages/@react-aria/list/README.md b/packages/@react-aria/list/README.md deleted file mode 100644 index 8c3905bdc98..00000000000 --- a/packages/@react-aria/list/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @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 deleted file mode 100644 index 4e9931530d8..00000000000 --- a/packages/@react-aria/list/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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 deleted file mode 100644 index af0ecf57cd6..00000000000 --- a/packages/@react-aria/list/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@react-aria/list", - "version": "3.0.0-alpha.1", - "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 deleted file mode 100644 index b3b7019f35a..00000000000 --- a/packages/@react-aria/list/src/ListGridKeyboardDelegate.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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'; - -export class ListGridKeyboardDelegate extends GridKeyboardDelegate> { - constructor(options: Omit>, 'focusMode'>) { - super({...options, focusMode: 'cell'}); - } - - 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; - } - - // Return the same key since the ListView is a single column grid and focusMode is 'cell', thus we will never - // leave the cell via left/right arrows. useGridCell will handle moving focus to any focusable children that exist as well as RTL behavior - getKeyLeftOf(key: Key) { - return key; - } - - getKeyRightOf(key: Key) { - return key; - } - - getFirstKey() { - return this.collection.getFirstKey(); - } - - getLastKey() { - return this.collection.getLastKey(); - } - - protected getItemRect(key: Key): Rect { - // Get row key since the list layout will only have the row keys, not cell keys - 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 deleted file mode 100644 index 505d9903d9a..00000000000 --- a/packages/@react-aria/list/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index c610a23f98c..6a5923fc483 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -28,7 +28,6 @@ import {DragHooks} from '@react-spectrum/dnd'; import {GridCollection, GridState, useGridState} from '@react-stately/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'; @@ -37,13 +36,12 @@ import {ListViewItem} from './ListViewItem'; 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 {useCollator, useMessageFormatter} from '@react-aria/i18n'; import {useGrid, useGridSelectionCheckbox} from '@react-aria/grid'; import {Virtualizer} from '@react-aria/virtualizer'; interface ListViewContextValue { state: GridState>, - keyboardDelegate: ListGridKeyboardDelegate, dragState: DraggableCollectionState, onAction:(key: string) => void, isListDraggable: boolean @@ -119,8 +117,6 @@ function ListView(props: ListViewProps, ref: DOMRef new GridCollection({ columnCount: 1, items: [...collection].map(item => ({ @@ -135,26 +131,19 @@ function ListView(props: ListViewProps, ref: DOMRef new ListGridKeyboardDelegate({ - collection: state.collection, - disabledKeys: state.disabledKeys, - ref: domRef, - direction, - collator, - layout - }), [state, domRef, direction, collator, layout]); - let provider = useProvider(); let {checkboxProps} = useGridSelectionCheckbox({key: null}, state); let dragState: DraggableCollectionState; @@ -213,7 +202,7 @@ function ListView(props: ListViewProps, ref: DOMRef(props: ListViewProps, ref: DOMRef + implements IGridCollection { child.parentKey = node.key; } childKeys.add(child.key); - - if (last) { - last.nextKey = child.key; - child.prevKey = last.key; - } else { - child.prevKey = null; + if (child.nextKey == null && child.prevKey == null) { + if (last) { + last.nextKey = child.key; + child.prevKey = last.key; + } else { + child.prevKey = null; + } } visit(child); last = child; } - if (last) { - last.nextKey = null; - } - // Remove deleted nodes and their children from the key map if (prevNode) { for (let child of prevNode.childNodes) { diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 75399edf6f7..d41ed0c5d69 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -25,7 +25,8 @@ export type ListLayoutOptions = { indentationForItem?: (collection: Collection>, key: Key) => number, collator?: Intl.Collator, loaderHeight?: number, - placeholderHeight?: number + placeholderHeight?: number, + allowDisabledKeyFocus?: boolean }; // A wrapper around LayoutInfo that supports hierarchy @@ -60,6 +61,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { protected contentSize: Size; collection: Collection>; disabledKeys: Set = new Set(); + allowDisabledKeyFocus: boolean; isLoading: boolean; protected lastWidth: number; protected lastCollection: Collection>; @@ -89,6 +91,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { this.rootNodes = []; this.lastWidth = 0; this.lastCollection = null; + this.allowDisabledKeyFocus = options.allowDisabledKeyFocus; } getLayoutInfo(key: Key) { @@ -350,7 +353,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { key = collection.getKeyBefore(key); while (key != null) { let item = collection.getItem(key); - if (item.type === 'item' && !this.disabledKeys.has(item.key)) { + if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { return key; } @@ -364,7 +367,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { key = collection.getKeyAfter(key); while (key != null) { let item = collection.getItem(key); - if (item.type === 'item' && !this.disabledKeys.has(item.key)) { + if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { return key; } @@ -372,6 +375,14 @@ export class ListLayout extends Layout> implements KeyboardDelegate { } } + getKeyLeftOf(key: Key): Key { + return key; + } + + getKeyRightOf(key: Key): Key { + return key; + } + getKeyPageAbove(key: Key) { let layoutInfo = this.getLayoutInfo(key); @@ -413,7 +424,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { let key = collection.getFirstKey(); while (key != null) { let item = collection.getItem(key); - if (item.type === 'item' && !this.disabledKeys.has(item.key)) { + if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { return key; } @@ -426,7 +437,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { let key = collection.getLastKey(); while (key != null) { let item = collection.getItem(key); - if (item.type === 'item' && !this.disabledKeys.has(item.key)) { + if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { return key; } From 0476f4fc8c83efb8ad8329162ca18a990fcbe6b4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 21 Apr 2022 16:36:31 -0700 Subject: [PATCH 19/36] fixing build --- packages/@react-spectrum/list/package.json | 1 - packages/@react-spectrum/list/test/ListView.test.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index b2140249000..f698114007a 100644 --- a/packages/@react-spectrum/list/package.json +++ b/packages/@react-spectrum/list/package.json @@ -37,7 +37,6 @@ "@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/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index e173ef17ce5..37cb6f50e82 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -406,7 +406,7 @@ describe('ListView', function () { 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 () { + it.skip('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]; From 2ea0c5a2bb20bc3bf54c356e53130d7003d9f141 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 25 Apr 2022 17:49:08 -0700 Subject: [PATCH 20/36] removing useGridRow to ensure the focused key will always be a row also reverts changes to other files that arent necessary anymore. Adds tests for disabled keys --- .../grid/src/GridKeyboardDelegate.ts | 4 +- .../@react-spectrum/list/src/ListView.tsx | 17 ++---- .../@react-spectrum/list/src/ListViewItem.tsx | 54 ++++++++++--------- .../list/test/ListView.test.js | 20 ++++++- .../@react-stately/grid/src/GridCollection.ts | 17 +++--- 5 files changed, 65 insertions(+), 47 deletions(-) diff --git a/packages/@react-aria/grid/src/GridKeyboardDelegate.ts b/packages/@react-aria/grid/src/GridKeyboardDelegate.ts index ca1c40827b9..5755a5214b4 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; } - protected getItem(key: Key): HTMLElement { + private getItem(key: Key): HTMLElement { return this.ref.current.querySelector(`[data-key="${key}"]`); } - protected getItemRect(key: Key): Rect { + private getItemRect(key: Key): Rect { if (this.layout) { return this.layout.getLayoutInfo(key)?.rect; } diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index 6a5923fc483..de36385877d 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -73,7 +73,8 @@ export function useListLayout(state: ListState, density: ListViewProps[ estimatedRowHeight: ROW_HEIGHTS[density][scale], padding: 0, collator, - loaderHeight: isEmpty ? null : ROW_HEIGHTS[density][scale] + loaderHeight: isEmpty ? null : ROW_HEIGHTS[density][scale], + allowDisabledKeyFocus: true }) , [collator, scale, density, isEmpty]); @@ -131,9 +132,7 @@ function ListView(props: ListViewProps, ref: DOMRef(props: ListViewProps, ref: DOMRef (props: ListViewProps, ref: DOMRef(); @@ -48,37 +47,26 @@ export function ListViewItem(props) { let isDraggable = dragState?.isDraggable(item.key) && !isDisabled; let {hoverProps, isHovered} = useHover({isDisabled}); let {pressProps, isPressed} = usePress({isDisabled}); - let {rowProps} = useGridRow({ - node: item, - isVirtualized: true, - onAction: onAction ? () => onAction(item.key) : undefined, - shouldSelectOnPressUp: isListDraggable - }, state, rowRef); + // We only make use of useGridCell here to allow for keyboard navigation to the focusable children of the row. + // The actual grid cell of the ListView is intert since we don't want to ever focus it to decrease screenreader + // verbosity, so we pretend the row node is the cell for interaction purposes. useGridRow is never used since + // it would conflict with useGridCell if applied to the same node. let {gridCellProps} = useGridCell({ - node: cellNode, + node: item, focusMode: 'cell', - isVirtualized: true + isVirtualized: true, + shouldSelectOnPressUp: isListDraggable }, state, rowRef); - - delete gridCellProps.tabIndex; + delete gridCellProps['aria-colindex']; let draggableItem: DraggableItemResult; if (isListDraggable) { // eslint-disable-next-line react-hooks/rules-of-hooks draggableItem = dragHooks.useDraggableItem({key: item.key}, dragState); } - const mergedProps = mergeProps( - gridCellProps, - rowProps, - pressProps, - isDraggable && draggableItem?.dragProps, - hoverProps, - focusWithinProps, - focusProps - ); - let {checkboxProps} = useGridSelectionCheckbox({...props, key: item.key}, state); + let {checkboxProps} = useGridSelectionCheckbox({...props, key: item.key}, state); let dragButtonRef = React.useRef(); let {buttonProps} = useButton({ ...draggableItem?.dragButtonProps, @@ -104,11 +92,26 @@ export function ListViewItem(props) { let isSelected = state.selectionManager.isSelected(item.key); let showDragHandle = isDraggable && (isFocusVisibleWithin || isHovered || isPressed); let {visuallyHiddenProps} = useVisuallyHidden(); + let rowProps = { + role: 'row', + 'aria-label': item.textValue, + 'aria-selected': state.selectionManager.selectionMode !== 'none' ? isSelected : undefined, + 'aria-rowindex': item.index + 1 + }; + + const mergedProps = mergeProps( + gridCellProps, + rowProps, + pressProps, + isDraggable && draggableItem?.dragProps, + hoverProps, + focusWithinProps, + focusProps + ); return (
+ ref={rowRef}>
+ role="gridcell" + aria-colindex={1}> {isListDraggable &&
diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 37cb6f50e82..fd2b4e6acb4 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -354,6 +354,15 @@ describe('ListView', function () { moveFocus('ArrowUp'); expect(document.activeElement).toBe(end); }); + + it('should allow focus on disabled rows', function () { + let tree = renderListWithFocusables({disabledKeys: ['foo']}); + let start = getRow(tree, 'Bar'); + let end = getRow(tree, 'Foo'); + act(() => start.focus()); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(end); + }); }); describe('ArrowDown', function () { @@ -373,6 +382,15 @@ describe('ListView', function () { moveFocus('ArrowDown'); expect(document.activeElement).toBe(end); }); + + it('should allow focus on disabled rows', function () { + let tree = renderListWithFocusables({disabledKeys: ['bar']}); + let start = getRow(tree, 'Foo'); + let end = getRow(tree, 'Bar'); + act(() => start.focus()); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(end); + }); }); describe('PageUp', function () { @@ -406,7 +424,7 @@ describe('ListView', function () { expect(document.activeElement).toBe(getRow(tree, 'Foo 49')); }); - it.skip('should move focus to a row a page below when focus starts in the row cell', function () { + 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]; diff --git a/packages/@react-stately/grid/src/GridCollection.ts b/packages/@react-stately/grid/src/GridCollection.ts index fa25e0229bf..12ef5f331f7 100644 --- a/packages/@react-stately/grid/src/GridCollection.ts +++ b/packages/@react-stately/grid/src/GridCollection.ts @@ -48,19 +48,22 @@ export class GridCollection implements IGridCollection { child.parentKey = node.key; } childKeys.add(child.key); - if (child.nextKey == null && child.prevKey == null) { - if (last) { - last.nextKey = child.key; - child.prevKey = last.key; - } else { - child.prevKey = null; - } + + if (last) { + last.nextKey = child.key; + child.prevKey = last.key; + } else { + child.prevKey = null; } visit(child); last = child; } + if (last) { + last.nextKey = null; + } + // Remove deleted nodes and their children from the key map if (prevNode) { for (let child of prevNode.childNodes) { From 53e8c7ab2be0caed822ce2e4f45ad09020693ecc Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 26 Apr 2022 10:15:30 -0700 Subject: [PATCH 21/36] cleanup --- packages/@react-spectrum/list/src/ListView.tsx | 7 +++---- packages/@react-spectrum/list/src/ListViewItem.tsx | 7 ++++--- packages/@react-stately/layout/src/ListLayout.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index bae81e015c6..c6bb18d0392 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -38,10 +38,9 @@ import {useGrid} from '@react-aria/grid'; import {useProvider} from '@react-spectrum/provider'; import {Virtualizer} from '@react-aria/virtualizer'; -interface ListViewContextValue { +interface ListViewContextValue { state: GridState>, dragState: DraggableCollectionState, - onAction:(key: string) => void, isListDraggable: boolean, layout: ListLayout } @@ -182,7 +181,7 @@ function ListView(props: ListViewProps, ref: DOMRef + (props: ListViewProps, ref: DOMRef { if (type === 'item') { return ( - + ); } else if (type === 'loader') { return ( diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index 94500f71a93..8060cf78e04 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -32,9 +32,10 @@ export function ListViewItem(props) { let { item, isEmphasized, - dragHooks + dragHooks, + hasActions } = props; - let {state, dragState, onAction, isListDraggable, layout} = useContext(ListViewContext); + let {state, dragState, isListDraggable, layout} = useContext(ListViewContext); let {direction} = useLocale(); let rowRef = useRef(); let { @@ -42,7 +43,7 @@ export function ListViewItem(props) { focusProps: focusWithinProps } = useFocusRing({within: true}); let {isFocusVisible, focusProps} = useFocusRing(); - let allowsInteraction = state.selectionManager.selectionMode !== 'none' || onAction; + let allowsInteraction = state.selectionManager.selectionMode !== 'none' || hasActions; let isDisabled = !allowsInteraction || state.disabledKeys.has(item.key); let isDraggable = dragState?.isDraggable(item.key) && !isDisabled; let {hoverProps, isHovered} = useHover({isDisabled}); diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index d41ed0c5d69..b1dd841024c 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -353,7 +353,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { key = collection.getKeyBefore(key); while (key != null) { let item = collection.getItem(key); - if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { + if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { return key; } @@ -424,7 +424,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { let key = collection.getFirstKey(); while (key != null) { let item = collection.getItem(key); - if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { + if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { return key; } @@ -437,7 +437,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { let key = collection.getLastKey(); while (key != null) { let item = collection.getItem(key); - if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { + if (item.type === 'item' && (this.allowDisabledKeyFocus || !this.disabledKeys.has(item.key))) { return key; } From 3f7f3d8b0e00cbac567b9b0d2680aeaa37962174 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 27 Apr 2022 17:14:05 -0700 Subject: [PATCH 22/36] adding listview story decorator for before and after focusable elements helps avoid weird test cases like https://github.com/adobe/react-spectrum/pull/3000#issuecomment-1111494915 --- .../list/stories/ListView.stories.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/@react-spectrum/list/stories/ListView.stories.tsx b/packages/@react-spectrum/list/stories/ListView.stories.tsx index fc0639edc50..61aef2778d7 100644 --- a/packages/@react-spectrum/list/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/list/stories/ListView.stories.tsx @@ -52,7 +52,23 @@ function renderEmptyState() { ); } +let decorator = (storyFn, context) => { + let omittedStories = ['draggable rows', 'dynamic items + renderEmptyState']; + return omittedStories.some(omittedName => context.name.includes(omittedName)) ? + storyFn() : + ( + <> + + + {storyFn()} + + + + ); +}; + storiesOf('ListView', module) + .addDecorator(decorator) .add('default', () => ( row 1 From a5aa6e37916798dad364c7323484e5a1b442c79e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 28 Apr 2022 14:38:19 -0700 Subject: [PATCH 23/36] adding tests for refactor --- .../@react-spectrum/list/src/ListView.tsx | 10 + .../@react-spectrum/list/src/ListViewItem.tsx | 7 + .../list/test/ListView.test.js | 497 ++++++++++++++---- 3 files changed, 407 insertions(+), 107 deletions(-) diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index ffb9295be05..f0cc63fa905 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -132,6 +132,8 @@ function ListView(props: ListViewProps, ref: DOMRef new GridCollection({ columnCount: 1, items: [...collection].map(item => ({ @@ -150,12 +152,16 @@ function ListView(props: ListViewProps, ref: DOMRef(props: ListViewProps, ref: DOMRef 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'}); @@ -538,30 +554,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]; + act(() => {row.focus();}); expect(row).toHaveAttribute('aria-selected', 'false'); fireEvent.keyDown(row, {key: ' '}); fireEvent.keyUp(row, {key: ' '}); 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 Enter key on a row', function () { let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); let row = tree.getAllByRole('row')[1]; + act(() => {row.focus();}); expect(row).toHaveAttribute('aria-selected', 'false'); fireEvent.keyDown(row, {key: 'Enter'}); fireEvent.keyUp(row, {key: 'Enter'}); checkSelection(onSelectionChange, ['bar']); expect(row).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); }); it('should only allow one item to be selected in single selection', function () { @@ -573,6 +597,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'))); @@ -590,151 +616,408 @@ 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 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'}); + it('should support range selection', function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); 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'), {ctrlKey: true})); - - checkSelection(onSelectionChange, ['bar']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - + triggerPress(rows[0]); + checkSelection(onSelectionChange, ['foo']); onSelectionChange.mockClear(); - 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'); + triggerPress(rows[2], {shiftKey: true}); + checkSelection(onSelectionChange, ['foo', 'bar', 'baz']); + onSelectionChange.mockClear(); + expect(announce).toHaveBeenLastCalledWith('3 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); - uaMock.mockRestore(); + triggerPress(rows[0], {shiftKey: true}); + checkSelection(onSelectionChange, ['foo']); + expect(announce).toHaveBeenLastCalledWith('1 item selected.'); + expect(announce).toHaveBeenCalledTimes(3); }); - 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 support select all and clear all via keyboard', function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); 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'), {ctrlKey: true})); - + triggerPress(rows[0]); checkSelection(onSelectionChange, ['foo']); - expect(rows[0]).toHaveAttribute('aria-selected', 'true'); + 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(); - 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'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('All items selected.'); + expect(announce).toHaveBeenCalledTimes(2); - uaMock.mockRestore(); + 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); }); - 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'}); + 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'), {ctrlKey: 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'), {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(); + }); - 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'), {metaKey: true})); + 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'), {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'), {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, ['bar']); - expect(rows[1]).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'}); + + 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'), {metaKey: 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'), {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); + + uaMock.mockRestore(); + }); - onSelectionChange.mockClear(); - 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'); + 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'}); - uaMock.mockRestore(); - }); + let rows = tree.getAllByRole('row'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - 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'}); + 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', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); + fireEvent(el, pointerEvent('pointerup', {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); + }); - let rows = tree.getAllByRole('row'); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + 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'); + 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', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); + fireEvent(el, pointerEvent('pointerup', {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}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + act(() => userEvent.click(getRow(tree, 'Bar'), {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); + }); - // 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', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); - fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + 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'); + userEvent.click(rows[0]); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('foo'); }); - 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 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]); + 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']); + }); - 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'); + it('should announce the new row when moving with the keyboard after multi select', function () { + let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'}); + + let rows = tree.getAllByRole('row'); + userEvent.click(rows[0]); + 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']); + }); - // 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', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); - fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + it('should support non-contiguous selection with the keyboard', function () { + let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'}); + + let rows = tree.getAllByRole('row'); + userEvent.click(rows[0]); + 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(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']); }); - 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 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]); + 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']); + }); + }); - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getRow(tree, 'Bar'), {ctrlKey: true})); + describe('long press', () => { + installPointerEvent(); + beforeEach(() => { + window.ontouchstart = jest.fn(); + }); - checkSelection(onSelectionChange, ['bar']); - expect(row).toHaveAttribute('aria-selected', 'true'); - expect(onAction).toHaveBeenCalledTimes(0); + afterEach(() => { + delete window.ontouchstart; + }); - fireEvent.keyDown(row, {key: 'Space'}); - fireEvent.keyUp(row, {key: 'Space'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledTimes(0); + 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); - fireEvent.keyDown(row, {key: 'Enter'}); - fireEvent.keyUp(row, {key: 'Enter'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('bar'); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, []); + expect(onAction).not.toHaveBeenCalled(); + expect(within(rows[0]).queryByRole('checkbox')).toBeNull(); + }); }); - }); describe('scrolling', function () { @@ -1183,7 +1466,7 @@ describe('ListView', function () { expect(draggableRow).toHaveAttribute('aria-selected', 'true'); checkSelection(onSelectionChange, ['a']); - onSelectionChange.mockReset(); + onSelectionChange.mockClear(); expect(nonDraggableRow).toHaveAttribute('aria-selected', 'false'); fireEvent.mouseDown(nonDraggableRow); expect(nonDraggableRow).toHaveAttribute('aria-selected', 'false'); From 46147e7fa497a8c6e5aece8118ed1b8b5de11b04 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 28 Apr 2022 17:24:26 -0700 Subject: [PATCH 24/36] creating list aria and types packages --- packages/@react-aria/list/README.md | 3 + .../list/docs/useList.placeholder.mdx | 90 ++++++++ packages/@react-aria/list/index.ts | 14 ++ packages/@react-aria/list/intl/ar-AE.json | 8 + packages/@react-aria/list/intl/bg-BG.json | 8 + packages/@react-aria/list/intl/cs-CZ.json | 8 + packages/@react-aria/list/intl/da-DK.json | 8 + packages/@react-aria/list/intl/de-DE.json | 8 + packages/@react-aria/list/intl/el-GR.json | 8 + packages/@react-aria/list/intl/en-US.json | 8 + packages/@react-aria/list/intl/es-ES.json | 8 + packages/@react-aria/list/intl/et-EE.json | 8 + packages/@react-aria/list/intl/fi-FI.json | 8 + packages/@react-aria/list/intl/fr-FR.json | 8 + packages/@react-aria/list/intl/he-IL.json | 8 + packages/@react-aria/list/intl/hr-HR.json | 8 + packages/@react-aria/list/intl/hu-HU.json | 8 + packages/@react-aria/list/intl/it-IT.json | 8 + packages/@react-aria/list/intl/ja-JP.json | 8 + packages/@react-aria/list/intl/ko-KR.json | 8 + packages/@react-aria/list/intl/lt-LT.json | 8 + packages/@react-aria/list/intl/lv-LV.json | 8 + packages/@react-aria/list/intl/nb-NO.json | 8 + packages/@react-aria/list/intl/nl-NL.json | 8 + packages/@react-aria/list/intl/pl-PL.json | 8 + packages/@react-aria/list/intl/pt-BR.json | 8 + packages/@react-aria/list/intl/pt-PT.json | 8 + packages/@react-aria/list/intl/ro-RO.json | 8 + packages/@react-aria/list/intl/ru-RU.json | 8 + packages/@react-aria/list/intl/sk-SK.json | 8 + packages/@react-aria/list/intl/sl-SI.json | 8 + packages/@react-aria/list/intl/sr-SP.json | 8 + packages/@react-aria/list/intl/sv-SE.json | 8 + packages/@react-aria/list/intl/tr-TR.json | 8 + packages/@react-aria/list/intl/uk-UA.json | 8 + packages/@react-aria/list/intl/zh-CN.json | 8 + packages/@react-aria/list/intl/zh-TW.json | 8 + packages/@react-aria/list/package.json | 38 ++++ packages/@react-aria/list/src/index.ts | 15 ++ packages/@react-aria/list/src/useList.ts | 210 ++++++++++++++++++ packages/@react-aria/list/src/useListItem.ts | 175 +++++++++++++++ packages/@react-aria/list/src/utils.ts | 31 +++ packages/@react-spectrum/list/package.json | 1 + .../@react-spectrum/list/src/ListView.tsx | 44 +--- .../@react-spectrum/list/src/ListViewItem.tsx | 40 ++-- packages/@react-types/list/README.md | 3 + packages/@react-types/list/package.json | 17 ++ packages/@react-types/list/src/index.d.ts | 48 ++++ 48 files changed, 951 insertions(+), 50 deletions(-) create mode 100644 packages/@react-aria/list/README.md create mode 100644 packages/@react-aria/list/docs/useList.placeholder.mdx create mode 100644 packages/@react-aria/list/index.ts create mode 100644 packages/@react-aria/list/intl/ar-AE.json create mode 100644 packages/@react-aria/list/intl/bg-BG.json create mode 100644 packages/@react-aria/list/intl/cs-CZ.json create mode 100644 packages/@react-aria/list/intl/da-DK.json create mode 100644 packages/@react-aria/list/intl/de-DE.json create mode 100644 packages/@react-aria/list/intl/el-GR.json create mode 100644 packages/@react-aria/list/intl/en-US.json create mode 100644 packages/@react-aria/list/intl/es-ES.json create mode 100644 packages/@react-aria/list/intl/et-EE.json create mode 100644 packages/@react-aria/list/intl/fi-FI.json create mode 100644 packages/@react-aria/list/intl/fr-FR.json create mode 100644 packages/@react-aria/list/intl/he-IL.json create mode 100644 packages/@react-aria/list/intl/hr-HR.json create mode 100644 packages/@react-aria/list/intl/hu-HU.json create mode 100644 packages/@react-aria/list/intl/it-IT.json create mode 100644 packages/@react-aria/list/intl/ja-JP.json create mode 100644 packages/@react-aria/list/intl/ko-KR.json create mode 100644 packages/@react-aria/list/intl/lt-LT.json create mode 100644 packages/@react-aria/list/intl/lv-LV.json create mode 100644 packages/@react-aria/list/intl/nb-NO.json create mode 100644 packages/@react-aria/list/intl/nl-NL.json create mode 100644 packages/@react-aria/list/intl/pl-PL.json create mode 100644 packages/@react-aria/list/intl/pt-BR.json create mode 100644 packages/@react-aria/list/intl/pt-PT.json create mode 100644 packages/@react-aria/list/intl/ro-RO.json create mode 100644 packages/@react-aria/list/intl/ru-RU.json create mode 100644 packages/@react-aria/list/intl/sk-SK.json create mode 100644 packages/@react-aria/list/intl/sl-SI.json create mode 100644 packages/@react-aria/list/intl/sr-SP.json create mode 100644 packages/@react-aria/list/intl/sv-SE.json create mode 100644 packages/@react-aria/list/intl/tr-TR.json create mode 100644 packages/@react-aria/list/intl/uk-UA.json create mode 100644 packages/@react-aria/list/intl/zh-CN.json create mode 100644 packages/@react-aria/list/intl/zh-TW.json create mode 100644 packages/@react-aria/list/package.json create mode 100644 packages/@react-aria/list/src/index.ts create mode 100644 packages/@react-aria/list/src/useList.ts create mode 100644 packages/@react-aria/list/src/useListItem.ts create mode 100644 packages/@react-aria/list/src/utils.ts create mode 100644 packages/@react-types/list/README.md create mode 100644 packages/@react-types/list/package.json create mode 100644 packages/@react-types/list/src/index.d.ts 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/docs/useList.placeholder.mdx b/packages/@react-aria/list/docs/useList.placeholder.mdx new file mode 100644 index 00000000000..f93241d4194 --- /dev/null +++ b/packages/@react-aria/list/docs/useList.placeholder.mdx @@ -0,0 +1,90 @@ +{/* 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 {Layout} from '@react-spectrum/docs'; +export default Layout; + +*Additional types can be imported via a direct path to the *.d.ts file if need be. See the useComboBox.mdx for an example.* +import docs from 'docs:@react-aria/list'; +import {HeaderInfo, FunctionAPI, TypeContext, InterfaceType, TypeLink, PageDescription} from '@react-spectrum/docs'; +import packageData from '@react-aria/list/package.json'; +import Anatomy from './listAnatomy.svg' + +*Include after_version if the docs shouldn't be published to the website until reaching a specific package version.* +--- +category: Category Name +keywords: [] +after_version: 3.0.0-alpha.0 +--- + +# useList + +{docs.exports.useList.description} + +*Be sure to update the W3C url below if applicable to your hook, otherwise omit the sourceData prop.* + + +## API + +*Include an additional FunctionAPI if multiple hooks are being documented in a single file. See useTabList.mdx for an example.* + + +## Features + +*Describe what the aria hook helps with/provides.* + +## Anatomy/Usage + +*For hooks that are meant to be used with specific elements/components, include an Anatomy section detailing the props the hook returns. See useColorField.mdx for an example.* +*If applicable, the anatomy diagram should be added as a local svg file, sourced from the Spectrum XD file (ask Devon for the file if you are unsure). Follow these steps after you obtain the XD file:* +*1. Open the XD file and find the anatomy diagram. Select it by double clicking its artboard.* +*2. Export it as an SVG via File -> Export -> Selected...* +*3a. Manually remove any extraneous Spectrum-only elements and labels from the SVG.* +*3b. Replace the colors in the SVG with their spectrum color variable equivalents. See docs.css .provider for a mapping of these colors.* +*3c. Add a `title` and `desc` to the SVG summarizing the contents of the diagram. See any of existing anatomy.svg for an example. * + + +*For hooks that are meant for more general use, include a Usage section instead detailing the props/params the hook accepts and returns. See useKeyboard.mdx for an example.* + +*If the below doesn't work see useFocusRing.mdx, useFocusWithin.mdx, and useHover.mdx for some alternative ways of using InterfaceType. * + + + + +*If you'd like to have a inline type link (e.g. referencing the type def of the stately hook), use the TypeLink component below and replace the links to the appropriate docs import.* + + +## Example + +*Add an example of the hook (being used with native elements, etc)* +*If you create an example component that will be reused else where in this doc, include export=true so that you can directly reuse the component and avoid copy pasting the same code.* +*See useComboBox.mdx for an example.* +```tsx example export=true +import {useList} from '@react-aria/list'; + +function Example(props) { + return ( +
test
+ ); +} +``` + +## Usage + +*For hooks that are meant to be used with specific elements/components, include this usage section detailing examples of how to use the hook. * +*This should roughly mirror the examples that the corresponding React Spectrum component docs have (e.g. Controlled/Uncontrolled, Disabled, change handlers, etc). * + +## Internationalization + +*Mention if RTL * diff --git a/packages/@react-aria/list/index.ts b/packages/@react-aria/list/index.ts new file mode 100644 index 00000000000..f24b2a4c8aa --- /dev/null +++ b/packages/@react-aria/list/index.ts @@ -0,0 +1,14 @@ +/* + * 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'; +// TODO: export useList, useListItem, and types for each diff --git a/packages/@react-aria/list/intl/ar-AE.json b/packages/@react-aria/list/intl/ar-AE.json new file mode 100644 index 00000000000..eb78c078009 --- /dev/null +++ b/packages/@react-aria/list/intl/ar-AE.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} غير المحدد", + "longPressToSelect": "اضغط مطولًا للدخول إلى وضع التحديد.", + "select": "تحديد", + "selectedAll": "جميع العناصر المحددة.", + "selectedCount": "{count, plural, =0 {لم يتم تحديد عناصر} one {# عنصر محدد} other {# عنصر محدد}}.", + "selectedItem": "{item} المحدد" +} diff --git a/packages/@react-aria/list/intl/bg-BG.json b/packages/@react-aria/list/intl/bg-BG.json new file mode 100644 index 00000000000..1af7894ca44 --- /dev/null +++ b/packages/@react-aria/list/intl/bg-BG.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} не е избран.", + "longPressToSelect": "Натиснете и задръжте за да влезете в избирателен режим.", + "select": "Изберете", + "selectedAll": "Всички елементи са избрани.", + "selectedCount": "{count, plural, =0 {Няма избрани елементи} one {# избран елемент} other {# избрани елементи}}.", + "selectedItem": "{item} избран." +} diff --git a/packages/@react-aria/list/intl/cs-CZ.json b/packages/@react-aria/list/intl/cs-CZ.json new file mode 100644 index 00000000000..4e6d2cf58c2 --- /dev/null +++ b/packages/@react-aria/list/intl/cs-CZ.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Položka {item} není vybrána.", + "longPressToSelect": "Dlouhým stisknutím přejdete do režimu výběru.", + "select": "Vybrat", + "selectedAll": "Vybrány všechny položky.", + "selectedCount": "{count, plural, =0 {Nevybrány žádné položky} one {Vybrána # položka} other {Vybráno # položek}}.", + "selectedItem": "Vybrána položka {item}." +} diff --git a/packages/@react-aria/list/intl/da-DK.json b/packages/@react-aria/list/intl/da-DK.json new file mode 100644 index 00000000000..6f9c9aaaf86 --- /dev/null +++ b/packages/@react-aria/list/intl/da-DK.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} ikke valgt.", + "longPressToSelect": "Lav et langt tryk for at aktivere valgtilstand.", + "select": "Vælg", + "selectedAll": "Alle elementer valgt.", + "selectedCount": "{count, plural, =0 {Ingen elementer valgt} one {# element valgt} other {# elementer valgt}}.", + "selectedItem": "{item} valgt." +} diff --git a/packages/@react-aria/list/intl/de-DE.json b/packages/@react-aria/list/intl/de-DE.json new file mode 100644 index 00000000000..dc735f4e174 --- /dev/null +++ b/packages/@react-aria/list/intl/de-DE.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} nicht ausgewählt.", + "longPressToSelect": "Gedrückt halten, um Auswahlmodus zu öffnen.", + "select": "Auswählen", + "selectedAll": "Alle Elemente ausgewählt.", + "selectedCount": "{count, plural, =0 {Keine Elemente ausgewählt} one {# Element ausgewählt} other {# Elemente ausgewählt}}.", + "selectedItem": "{item} ausgewählt." +} diff --git a/packages/@react-aria/list/intl/el-GR.json b/packages/@react-aria/list/intl/el-GR.json new file mode 100644 index 00000000000..74f9211c5a6 --- /dev/null +++ b/packages/@react-aria/list/intl/el-GR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Δεν επιλέχθηκε το στοιχείο {item}.", + "longPressToSelect": "Πατήστε παρατεταμένα για να μπείτε σε λειτουργία επιλογής.", + "select": "Επιλογή", + "selectedAll": "Επιλέχθηκαν όλα τα στοιχεία.", + "selectedCount": "{count, plural, =0 {Δεν επιλέχθηκαν στοιχεία} one {Επιλέχθηκε # στοιχείο} other {Επιλέχθηκαν # στοιχεία}}.", + "selectedItem": "Επιλέχθηκε το στοιχείο {item}." +} diff --git a/packages/@react-aria/list/intl/en-US.json b/packages/@react-aria/list/intl/en-US.json new file mode 100644 index 00000000000..b4fcc207f28 --- /dev/null +++ b/packages/@react-aria/list/intl/en-US.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} not selected.", + "select": "Select", + "selectedCount": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}.", + "selectedAll": "All items selected.", + "selectedItem": "{item} selected.", + "longPressToSelect": "Long press to enter selection mode." +} diff --git a/packages/@react-aria/list/intl/es-ES.json b/packages/@react-aria/list/intl/es-ES.json new file mode 100644 index 00000000000..9ac6b8a9730 --- /dev/null +++ b/packages/@react-aria/list/intl/es-ES.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} no seleccionado.", + "longPressToSelect": "Mantenga pulsado para abrir el modo de selección.", + "select": "Seleccionar", + "selectedAll": "Todos los elementos seleccionados.", + "selectedCount": "{count, plural, =0 {Ningún elemento seleccionado} one {# elemento seleccionado} other {# elementos seleccionados}}.", + "selectedItem": "{item} seleccionado." +} diff --git a/packages/@react-aria/list/intl/et-EE.json b/packages/@react-aria/list/intl/et-EE.json new file mode 100644 index 00000000000..57a2141b983 --- /dev/null +++ b/packages/@react-aria/list/intl/et-EE.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} pole valitud.", + "longPressToSelect": "Valikurežiimi sisenemiseks vajutage pikalt.", + "select": "Vali", + "selectedAll": "Kõik üksused valitud.", + "selectedCount": "{count, plural, =0 {Üksusi pole valitud} one {# üksus valitud} other {# üksust valitud}}.", + "selectedItem": "{item} valitud." +} diff --git a/packages/@react-aria/list/intl/fi-FI.json b/packages/@react-aria/list/intl/fi-FI.json new file mode 100644 index 00000000000..1b7375371ed --- /dev/null +++ b/packages/@react-aria/list/intl/fi-FI.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Kohdetta {item} ei valittu.", + "longPressToSelect": "Siirry valintatilaan painamalla pitkään.", + "select": "Valitse", + "selectedAll": "Kaikki kohteet valittu.", + "selectedCount": "{count, plural, =0 {Ei yhtään kohdetta valittu} one {# kohde valittu} other {# kohdetta valittu}}.", + "selectedItem": "{item} valittu." +} diff --git a/packages/@react-aria/list/intl/fr-FR.json b/packages/@react-aria/list/intl/fr-FR.json new file mode 100644 index 00000000000..c5db90e5f4c --- /dev/null +++ b/packages/@react-aria/list/intl/fr-FR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} non sélectionné.", + "longPressToSelect": "Appuyez de manière prolongée pour passer en mode de sélection.", + "select": "Sélectionner", + "selectedAll": "Tous les éléments sélectionnés.", + "selectedCount": "{count, plural, =0 {Aucun élément sélectionné} one {# élément sélectionné} other {# éléments sélectionnés}}.", + "selectedItem": "{item} sélectionné." +} diff --git a/packages/@react-aria/list/intl/he-IL.json b/packages/@react-aria/list/intl/he-IL.json new file mode 100644 index 00000000000..7bee1b9eed9 --- /dev/null +++ b/packages/@react-aria/list/intl/he-IL.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} לא נבחר.", + "longPressToSelect": "הקשה ארוכה לכניסה למצב בחירה.", + "select": "בחר", + "selectedAll": "כל הפריטים נבחרו.", + "selectedCount": "{count, plural, =0 {לא נבחרו פריטים} one {פריט # נבחר} other {# פריטים נבחרו}}.", + "selectedItem": "{item} נבחר." +} diff --git a/packages/@react-aria/list/intl/hr-HR.json b/packages/@react-aria/list/intl/hr-HR.json new file mode 100644 index 00000000000..b8f21698f6b --- /dev/null +++ b/packages/@react-aria/list/intl/hr-HR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Stavka {item} nije odabrana.", + "longPressToSelect": "Dugo pritisnite za ulazak u način odabira.", + "select": "Odaberite", + "selectedAll": "Odabrane su sve stavke.", + "selectedCount": "{count, plural, =0 {Nije odabrana nijedna stavka} one {Odabrana je # stavka} other {Odabrano je # stavki}}.", + "selectedItem": "Stavka {item} je odabrana." +} diff --git a/packages/@react-aria/list/intl/hu-HU.json b/packages/@react-aria/list/intl/hu-HU.json new file mode 100644 index 00000000000..2b51e1b6da8 --- /dev/null +++ b/packages/@react-aria/list/intl/hu-HU.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} nincs kijelölve.", + "longPressToSelect": "Nyomja hosszan a kijelöléshez.", + "select": "Kijelölés", + "selectedAll": "Az összes elem kijelölve.", + "selectedCount": "{count, plural, =0 {Egy elem sincs kijelölve} one {# elem kijelölve} other {# elem kijelölve}}.", + "selectedItem": "{item} kijelölve." +} diff --git a/packages/@react-aria/list/intl/it-IT.json b/packages/@react-aria/list/intl/it-IT.json new file mode 100644 index 00000000000..1d402d0c675 --- /dev/null +++ b/packages/@react-aria/list/intl/it-IT.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} non selezionato.", + "longPressToSelect": "Premi a lungo per passare alla modalità di selezione.", + "select": "Seleziona", + "selectedAll": "Tutti gli elementi selezionati.", + "selectedCount": "{count, plural, =0 {Nessun elemento selezionato} one {# elemento selezionato} other {# elementi selezionati}}.", + "selectedItem": "{item} selezionato." +} diff --git a/packages/@react-aria/list/intl/ja-JP.json b/packages/@react-aria/list/intl/ja-JP.json new file mode 100644 index 00000000000..1e5f8653c32 --- /dev/null +++ b/packages/@react-aria/list/intl/ja-JP.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} が選択されていません。", + "longPressToSelect": "長押しして選択モードを開きます。", + "select": "選択", + "selectedAll": "すべての項目を選択しました。", + "selectedCount": "{count, plural, =0 {項目が選択されていません} one {# 項目を選択しました} other {# 項目を選択しました}}。", + "selectedItem": "{item} を選択しました。" +} diff --git a/packages/@react-aria/list/intl/ko-KR.json b/packages/@react-aria/list/intl/ko-KR.json new file mode 100644 index 00000000000..96143803671 --- /dev/null +++ b/packages/@react-aria/list/intl/ko-KR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item}이(가) 선택되지 않았습니다.", + "longPressToSelect": "선택 모드로 들어가려면 길게 누르십시오.", + "select": "선택", + "selectedAll": "모든 항목이 선택되었습니다.", + "selectedCount": "{count, plural, =0 {선택된 항목이 없습니다} one {#개 항목이 선택되었습니다} other {#개 항목이 선택되었습니다}}.", + "selectedItem": "{item}이(가) 선택되었습니다." +} diff --git a/packages/@react-aria/list/intl/lt-LT.json b/packages/@react-aria/list/intl/lt-LT.json new file mode 100644 index 00000000000..59bde223e72 --- /dev/null +++ b/packages/@react-aria/list/intl/lt-LT.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} nepasirinkta.", + "longPressToSelect": "Norėdami įjungti pasirinkimo režimą, paspauskite ir palaikykite.", + "select": "Pasirinkti", + "selectedAll": "Pasirinkti visi elementai.", + "selectedCount": "{count, plural, =0 {Nepasirinktas nė vienas elementas} one {Pasirinktas # elementas} other {Pasirinkta elementų: #}}.", + "selectedItem": "Pasirinkta: {item}." +} diff --git a/packages/@react-aria/list/intl/lv-LV.json b/packages/@react-aria/list/intl/lv-LV.json new file mode 100644 index 00000000000..f1282ade995 --- /dev/null +++ b/packages/@react-aria/list/intl/lv-LV.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Vienums {item} nav atlasīts.", + "longPressToSelect": "Ilgi turiet nospiestu. lai ieslēgtu atlases režīmu.", + "select": "Atlasīt", + "selectedAll": "Atlasīti visi vienumi.", + "selectedCount": "{count, plural, =0 {Nav atlasīts neviens vienums} one {Atlasīto vienumu skaits: #} other {Atlasīto vienumu skaits: #}}.", + "selectedItem": "Atlasīts vienums {item}." +} diff --git a/packages/@react-aria/list/intl/nb-NO.json b/packages/@react-aria/list/intl/nb-NO.json new file mode 100644 index 00000000000..52d2c4fe603 --- /dev/null +++ b/packages/@react-aria/list/intl/nb-NO.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} er ikke valgt.", + "longPressToSelect": "Bruk et langt trykk for å gå inn i valgmodus.", + "select": "Velg", + "selectedAll": "Alle elementer er valgt.", + "selectedCount": "{count, plural, =0 {Ingen elementer er valgt} one {# element er valgt} other {# elementer er valgt}}.", + "selectedItem": "{item} er valgt." +} diff --git a/packages/@react-aria/list/intl/nl-NL.json b/packages/@react-aria/list/intl/nl-NL.json new file mode 100644 index 00000000000..61d80eb0725 --- /dev/null +++ b/packages/@react-aria/list/intl/nl-NL.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} niet geselecteerd.", + "longPressToSelect": "Druk lang om de selectiemodus te openen.", + "select": "Selecteren", + "selectedAll": "Alle items geselecteerd.", + "selectedCount": "{count, plural, =0 {Geen items geselecteerd} one {# item geselecteerd} other {# items geselecteerd}}.", + "selectedItem": "{item} geselecteerd." +} diff --git a/packages/@react-aria/list/intl/pl-PL.json b/packages/@react-aria/list/intl/pl-PL.json new file mode 100644 index 00000000000..cef60105f09 --- /dev/null +++ b/packages/@react-aria/list/intl/pl-PL.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Nie zaznaczono {item}.", + "longPressToSelect": "Naciśnij i przytrzymaj, aby wejść do trybu wyboru.", + "select": "Zaznacz", + "selectedAll": "Wszystkie zaznaczone elementy.", + "selectedCount": "{count, plural, =0 {Nie zaznaczono żadnych elementów} one {# zaznaczony element} other {# zaznaczonych elementów}}.", + "selectedItem": "Zaznaczono {item}." +} diff --git a/packages/@react-aria/list/intl/pt-BR.json b/packages/@react-aria/list/intl/pt-BR.json new file mode 100644 index 00000000000..87a2a14508a --- /dev/null +++ b/packages/@react-aria/list/intl/pt-BR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} não selecionado.", + "longPressToSelect": "Mantenha pressionado para entrar no modo de seleção.", + "select": "Selecionar", + "selectedAll": "Todos os itens selecionados.", + "selectedCount": "{count, plural, =0 {Nenhum item selecionado} one {# item selecionado} other {# itens selecionados}}.", + "selectedItem": "{item} selecionado." +} diff --git a/packages/@react-aria/list/intl/pt-PT.json b/packages/@react-aria/list/intl/pt-PT.json new file mode 100644 index 00000000000..e0af18f69f5 --- /dev/null +++ b/packages/@react-aria/list/intl/pt-PT.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} não selecionado.", + "longPressToSelect": "Prima continuamente para entrar no modo de seleção.", + "select": "Selecionar", + "selectedAll": "Todos os itens selecionados.", + "selectedCount": "{count, plural, =0 {Nenhum item selecionado} one {# item selecionado} other {# itens selecionados}}.", + "selectedItem": "{item} selecionado." +} diff --git a/packages/@react-aria/list/intl/ro-RO.json b/packages/@react-aria/list/intl/ro-RO.json new file mode 100644 index 00000000000..c2c749e47f1 --- /dev/null +++ b/packages/@react-aria/list/intl/ro-RO.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} neselectat.", + "longPressToSelect": "Apăsați lung pentru a intra în modul de selectare.", + "select": "Selectare", + "selectedAll": "Toate elementele selectate.", + "selectedCount": "{count, plural, =0 {Niciun element selectat} one {# element selectat} other {# elemente selectate}}.", + "selectedItem": "{item} selectat." +} diff --git a/packages/@react-aria/list/intl/ru-RU.json b/packages/@react-aria/list/intl/ru-RU.json new file mode 100644 index 00000000000..9e805fe6346 --- /dev/null +++ b/packages/@react-aria/list/intl/ru-RU.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} не выбрано.", + "longPressToSelect": "Нажмите и удерживайте для входа в режим выбора.", + "select": "Выбрать", + "selectedAll": "Выбраны все элементы.", + "selectedCount": "{count, plural, =0 {Нет выбранных элементов} one {# элемент выбран} other {# элементов выбрано}}.", + "selectedItem": "{item} выбрано." +} diff --git a/packages/@react-aria/list/intl/sk-SK.json b/packages/@react-aria/list/intl/sk-SK.json new file mode 100644 index 00000000000..91e6c7b6fd6 --- /dev/null +++ b/packages/@react-aria/list/intl/sk-SK.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Nevybraté položky: {item}.", + "longPressToSelect": "Dlhším stlačením prejdite do režimu výberu.", + "select": "Vybrať", + "selectedAll": "Všetky vybraté položky.", + "selectedCount": "{count, plural, =0 {Žiadne vybraté položky} one {# vybratá položka} other {Počet vybratých položiek:#}}.", + "selectedItem": "Vybraté položky: {item}." +} diff --git a/packages/@react-aria/list/intl/sl-SI.json b/packages/@react-aria/list/intl/sl-SI.json new file mode 100644 index 00000000000..dde6830c824 --- /dev/null +++ b/packages/@react-aria/list/intl/sl-SI.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Element {item} ni izbran.", + "longPressToSelect": "Za izbirni način pritisnite in dlje časa držite.", + "select": "Izberite", + "selectedAll": "Vsi elementi so izbrani.", + "selectedCount": "{count, plural, =0 {Noben element ni izbran} one {# element je izbran} other {# elementov je izbranih}}.", + "selectedItem": "Element {item} je izbran." +} diff --git a/packages/@react-aria/list/intl/sr-SP.json b/packages/@react-aria/list/intl/sr-SP.json new file mode 100644 index 00000000000..b2300ab5fb6 --- /dev/null +++ b/packages/@react-aria/list/intl/sr-SP.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} nije izabrano.", + "longPressToSelect": "Dugo pritisnite za ulazak u režim biranja.", + "select": "Izaberite", + "selectedAll": "Izabrane su sve stavke.", + "selectedCount": "{count, plural, =0 {Nije izabrana nijedna stavka} one {Izabrana je # stavka} other {Izabrano je # stavki}}.", + "selectedItem": "{item} je izabrano." +} diff --git a/packages/@react-aria/list/intl/sv-SE.json b/packages/@react-aria/list/intl/sv-SE.json new file mode 100644 index 00000000000..b4b58379beb --- /dev/null +++ b/packages/@react-aria/list/intl/sv-SE.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} ej markerat.", + "longPressToSelect": "Tryck länge när du vill öppna väljarläge.", + "select": "Markera", + "selectedAll": "Alla markerade objekt.", + "selectedCount": "{count, plural, =0 {Inga markerade objekt} one {# markerat objekt} other {# markerade objekt}}.", + "selectedItem": "{item} markerat." +} diff --git a/packages/@react-aria/list/intl/tr-TR.json b/packages/@react-aria/list/intl/tr-TR.json new file mode 100644 index 00000000000..0c441b800c8 --- /dev/null +++ b/packages/@react-aria/list/intl/tr-TR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} seçilmedi.", + "longPressToSelect": "Seçim moduna girmek için uzun basın.", + "select": "Seç", + "selectedAll": "Tüm ögeler seçildi.", + "selectedCount": "{count, plural, =0 {Hiçbir öge seçilmedi} one {# öge seçildi} other {# öge seçildi}}.", + "selectedItem": "{item} seçildi." +} diff --git a/packages/@react-aria/list/intl/uk-UA.json b/packages/@react-aria/list/intl/uk-UA.json new file mode 100644 index 00000000000..f113d5407bf --- /dev/null +++ b/packages/@react-aria/list/intl/uk-UA.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} не вибрано.", + "longPressToSelect": "Виконайте довге натиснення, щоб перейти в режим вибору.", + "select": "Вибрати", + "selectedAll": "Усі елементи вибрано.", + "selectedCount": "{count, plural, =0 {Жодних елементів не вибрано} one {# елемент вибрано} other {Вибрано елементів: #}}.", + "selectedItem": "{item} вибрано." +} diff --git a/packages/@react-aria/list/intl/zh-CN.json b/packages/@react-aria/list/intl/zh-CN.json new file mode 100644 index 00000000000..1b6644bd680 --- /dev/null +++ b/packages/@react-aria/list/intl/zh-CN.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "未选择 {item}。", + "longPressToSelect": "长按以进入选择模式。", + "select": "选择", + "selectedAll": "已选择所有项目。", + "selectedCount": "{count, plural, =0 {未选择项目} one {已选择 # 个项目} other {已选择 # 个项目}}。", + "selectedItem": "已选择 {item}。" +} diff --git a/packages/@react-aria/list/intl/zh-TW.json b/packages/@react-aria/list/intl/zh-TW.json new file mode 100644 index 00000000000..bfb55511765 --- /dev/null +++ b/packages/@react-aria/list/intl/zh-TW.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "未選取「{item}」。", + "longPressToSelect": "長按以進入選擇模式。", + "select": "選取", + "selectedAll": "已選取所有項目。", + "selectedCount": "{count, plural, =0 {未選取任何項目} one {已選取 # 個項目} other {已選取 # 個項目}}。", + "selectedItem": "已選取「{item}」。" +} diff --git a/packages/@react-aria/list/package.json b/packages/@react-aria/list/package.json new file mode 100644 index 00000000000..0e943561392 --- /dev/null +++ b/packages/@react-aria/list/package.json @@ -0,0 +1,38 @@ +{ + "name": "@react-aria/list", + "version": "3.0.0-alpha.1", + "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/focus": "^3.5.4", + "@react-aria/grid": "^3.2.5", + "@react-aria/i18n": "^3.3.8", + "@react-aria/interactions": "^3.8.3", + "@react-aria/live-announcer": "^3.0.5", + "@react-aria/selection": "^3.8.1", + "@react-aria/utils": "^3.11.3", + "@react-stately/list": "^3.4.4", + "@react-types/list": "3.0.0-alpha.1", + "@react-types/shared": "^3.11.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-aria/list/src/index.ts b/packages/@react-aria/list/src/index.ts new file mode 100644 index 00000000000..299eecbc8c1 --- /dev/null +++ b/packages/@react-aria/list/src/index.ts @@ -0,0 +1,15 @@ +/* + * 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 './useList'; +// TODO: export useList, useListItem, and types for each +// specifically the props passed in and the aria props returned diff --git a/packages/@react-aria/list/src/useList.ts b/packages/@react-aria/list/src/useList.ts new file mode 100644 index 00000000000..f27d6d74a2d --- /dev/null +++ b/packages/@react-aria/list/src/useList.ts @@ -0,0 +1,210 @@ +/* + * 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 {announce} from '@react-aria/live-announcer'; +import {AriaListProps} from '@react-types/list'; +import {filterDOMProps, mergeProps, useId, useUpdateEffect} from '@react-aria/utils'; +import {HTMLAttributes, Key, RefObject, useMemo, useRef} from 'react'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {KeyboardDelegate, Selection} from '@react-types/shared'; +import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection'; +import {listMap} from './utils'; +import {ListState} from '@react-stately/list'; +import {useCollator, useMessageFormatter} from '@react-aria/i18n'; +import {useDescription} from '@react-aria/utils'; +import {useInteractionModality} from '@react-aria/interactions'; + +interface AriaListOptions extends Omit, 'children'> { + /** Whether the list uses virtual scrolling. */ + isVirtualized?: boolean, + /** + * An optional keyboard delegate implementation for type to select, + * to override the default. + */ + keyboardDelegate?: KeyboardDelegate, + /** + * A function that returns the text that should be announced by assistive technology when a row is added or removed from selection. + * @default (key) => state.collection.getItem(key)?.textValue + */ + getRowText?: (key: Key) => string, + /** + * The ref attached to the scrollable body. Used to provided automatic scrolling on item focus for non-virtualized grids. + */ + scrollRef?: RefObject +} + +// TODO: maybe name it listProps instead of gridProps? +interface ListViewAria { + /** Props for the grid element. */ + gridProps: HTMLAttributes +} + +/** + * Provides the behavior and accessibility implementation for a list component. + * A list displays data in a single columns and enables a user to navigate its contents via directional navigation keys. + * @param props - Props for the list. + * @param state - State for the list, as returned by `useListState`. + * @param ref - The ref attached to the list element. + */ +export function useList(props: AriaListOptions, state: ListState, ref: RefObject): ListViewAria { + // Rough copy of useGrid, but modifications + things removed for ListView specific case + let { + isVirtualized, + keyboardDelegate, + getRowText = (key) => state.collection.getItem(key)?.textValue, + scrollRef, + onAction + } = props; + let formatMessage = useMessageFormatter(intlMessages); + + if (!props['aria-label'] && !props['aria-labelledby']) { + console.warn('An aria-label or aria-labelledby prop is required for accessibility.'); + } + + // TODO: perhaps refactor further and try to use useSelectableList? That will handle the ListKeyboardDelegate stuff, just need + // to make sure we only pass certain props to the hook + // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). + // When virtualized, the layout object will be passed in as a prop and override this. + let collator = useCollator({usage: 'search', sensitivity: 'base'}); + let delegate = useMemo(() => keyboardDelegate || new ListKeyboardDelegate( + state.collection, + state.disabledKeys, + ref, + collator + ), [keyboardDelegate, state.collection, state.disabledKeys, ref, collator]); + + let {collectionProps} = useSelectableCollection({ + ref, + selectionManager: state.selectionManager, + keyboardDelegate: delegate, + isVirtualized, + scrollRef + }); + + // TODO: confirm if this weak map is the prefered way of sending id and onAction to useListItem + // I figure it is because we don't want to have onAction in the useListItem props if people accidentatly don't include it? + let id = useId(); + listMap.set(state, {id, onAction}); + + // This is useHighlightSelectionDescription copy pasted, it isn't exposed by react-aria/grid. + let modality = useInteractionModality(); + // null is the default if the user hasn't interacted with the list at all yet or the rest of the page + let shouldLongPress = (modality === 'pointer' || modality === 'virtual' || modality == null) + && typeof window !== 'undefined' && 'ontouchstart' in window; + + let interactionDescription = useMemo(() => { + let selectionMode = state.selectionManager.selectionMode; + let selectionBehavior = state.selectionManager.selectionBehavior; + + let message = undefined; + if (shouldLongPress) { + message = formatMessage('longPressToSelect'); + } + + return selectionBehavior === 'replace' && selectionMode !== 'none' && onAction ? message : undefined; + }, [state.selectionManager.selectionMode, state.selectionManager.selectionBehavior, onAction, formatMessage, shouldLongPress]); + + let descriptionProps = useDescription(interactionDescription); + + let domProps = filterDOMProps(props, {labelable: true}); + let gridProps: HTMLAttributes = mergeProps( + domProps, + { + role: 'grid', + id, + 'aria-multiselectable': state.selectionManager.selectionMode === 'multiple' ? 'true' : undefined + }, + collectionProps, + descriptionProps + ); + + if (isVirtualized) { + gridProps['aria-rowcount'] = state.collection.size; + gridProps['aria-colcount'] = 1; + } + + // Many screen readers do not announce when items in a grid are selected/deselected. + // We do this using an ARIA live region. + let selection = state.selectionManager.rawSelection; + let lastSelection = useRef(selection); + useUpdateEffect(() => { + if (!state.selectionManager.isFocused) { + lastSelection.current = selection; + + return; + } + + let addedKeys = diffSelection(selection, lastSelection.current); + let removedKeys = diffSelection(lastSelection.current, selection); + + // If adding or removing a single row from the selection, announce the name of that item. + let isReplace = state.selectionManager.selectionBehavior === 'replace'; + let messages = []; + + if ((state.selectionManager.selectedKeys.size === 1 && isReplace)) { + if (state.collection.getItem(state.selectionManager.selectedKeys.keys().next().value)) { + let currentSelectionText = getRowText(state.selectionManager.selectedKeys.keys().next().value); + if (currentSelectionText) { + messages.push(formatMessage('selectedItem', {item: currentSelectionText})); + } + } + } else if (addedKeys.size === 1 && removedKeys.size === 0) { + let addedText = getRowText(addedKeys.keys().next().value); + if (addedText) { + messages.push(formatMessage('selectedItem', {item: addedText})); + } + } else if (removedKeys.size === 1 && addedKeys.size === 0) { + if (state.collection.getItem(removedKeys.keys().next().value)) { + let removedText = getRowText(removedKeys.keys().next().value); + if (removedText) { + messages.push(formatMessage('deselectedItem', {item: removedText})); + } + } + } + + // Announce how many items are selected, except when selecting the first item. + if (state.selectionManager.selectionMode === 'multiple') { + if (messages.length === 0 || selection === 'all' || selection.size > 1 || lastSelection.current === 'all' || lastSelection.current?.size > 1) { + messages.push(selection === 'all' + ? formatMessage('selectedAll') + : formatMessage('selectedCount', {count: selection.size}) + ); + } + } + + if (messages.length > 0) { + announce(messages.join(' ')); + } + + lastSelection.current = selection; + }, [selection]); + + return { + gridProps + }; +} + +function diffSelection(a: Selection, b: Selection): Set { + let res = new Set(); + if (a === 'all' || b === 'all') { + return res; + } + + for (let key of a.keys()) { + if (!b.has(key)) { + res.add(key); + } + } + + return res; +} diff --git a/packages/@react-aria/list/src/useListItem.ts b/packages/@react-aria/list/src/useListItem.ts new file mode 100644 index 00000000000..7ffdbf611d5 --- /dev/null +++ b/packages/@react-aria/list/src/useListItem.ts @@ -0,0 +1,175 @@ +/* + * 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 {AriaListViewProps} from '@react-types/list'; +import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; +import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, RefObject} from 'react'; +import {isFocusVisible} from '@react-aria/interactions'; +import {listMap, normalizeKey} from './utils'; +import type {ListState} from '@react-stately/list'; +import {mergeProps} from '@react-aria/utils'; +import {Node as RSNode} from '@react-types/shared'; +import {useLocale} from '@react-aria/i18n'; +import {useSelectableItem} from '@react-aria/selection'; + +// TODO move to react-types? +interface AriaListItemOptions { + /** An object representing the list item. Contains all the relevant information that makes up the list row. */ + node: RSNode, + /** Whether the list row is contained in a virtual scroller. */ + isVirtualized?: boolean, + /** Whether selection should occur on press up instead of press down. */ + shouldSelectOnPressUp?: boolean, + /** Whether the list item is disabled. */ + isDisabled?: boolean +} + +interface ListItemAria { + rowProps: HTMLAttributes, + gridCellProps: HTMLAttributes, + isPressed: boolean +} + +/** + * Provides the behavior and accessibility implementation for a row in a list. + * @param props - Props for the row. + * @param state - State of the parent list, as returned by `useListState`. + * @param ref - The ref attached to the row element. + */ +export function useListItem(props: AriaListItemOptions, state: ListState, ref: RefObject): ListItemAria { + // Copied from useGridCell + some modifications to make it not so grid specific + let { + node, + isVirtualized, + shouldSelectOnPressUp, + isDisabled + } = props; + + let {direction} = useLocale(); + // TODO: keyboardelegate unused? + let {id: listId, onAction} = listMap.get(state); + + let {itemProps, isPressed} = useSelectableItem({ + selectionManager: state.selectionManager, + key: node.key, + ref, + isVirtualized, + shouldSelectOnPressUp, + onAction: () => onAction(node.key), + // TODO: double check if isDisabled is appropriate here or if user should handle isPressed state externally + isDisabled + }); + + let onKeyDown = (e: ReactKeyboardEvent) => { + if (!e.currentTarget.contains(e.target as HTMLElement)) { + return; + } + + let walker = getFocusableTreeWalker(ref.current); + walker.currentNode = document.activeElement; + + switch (e.key) { + case 'ArrowLeft': { + // Find the next focusable element within the row. + let focusable = direction === 'rtl' + ? walker.nextNode() as HTMLElement + : walker.previousNode() as HTMLElement; + + if (focusable) { + e.preventDefault(); + e.stopPropagation(); + focusSafely(focusable); + } else { + // If there is no next focusable child, then return focus back to the row + e.preventDefault(); + e.stopPropagation(); + if (direction === 'rtl') { + focusSafely(ref.current); + } + } + break; + } + case 'ArrowRight': { + let focusable = direction === 'rtl' + ? walker.previousNode() as HTMLElement + : walker.nextNode() as HTMLElement; + + if (focusable) { + e.preventDefault(); + e.stopPropagation(); + focusSafely(focusable); + } else { + e.preventDefault(); + e.stopPropagation(); + if (direction === 'ltr') { + focusSafely(ref.current); + } + } + break; + } + case 'ArrowUp': + case 'ArrowDown': + // Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate + // to the row above/below instead. We need to re-dispatch the event from a higher parent so it still + // bubbles and gets handled by useSelectableCollection. + if (!e.altKey && ref.current.contains(e.target as HTMLElement)) { + e.stopPropagation(); + e.preventDefault(); + ref.current.parentElement.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ); + } + break; + } + }; + + // List rows can have focusable elements inside them. In this case, focus should + // be marshalled to that element rather than focusing the row itself. + let onFocus = (e) => { + if (e.target !== ref.current) { + // useSelectableItem only handles setting the focused key when + // the focused element is the row itself. We also want to + // set the focused key when a child element receives focus. + // If focus is currently visible (e.g. the user is navigating with the keyboard), + // then skip this. We want to restore focus to the previously focused row + // in that case since the list should act like a single tab stop. + if (!isFocusVisible()) { + state.selectionManager.setFocusedKey(node.key); + } + return; + } + }; + + let rowProps: HTMLAttributes = mergeProps(itemProps, { + role: 'row', + onKeyDownCapture: onKeyDown, + onFocus, + 'aria-label': node.textValue, + 'aria-selected': state.selectionManager.selectionMode !== 'none' ? state.selectionManager.isSelected(node.key) : undefined, + id: `${listId}-${normalizeKey(node.key)}` + }); + + if (isVirtualized) { + rowProps['aria-colindex'] = node.index + 1; + } + + let gridCellProps = { + role: 'gridcell', + 'aria-colindex': 1 + }; + + return { + rowProps, + gridCellProps, + isPressed + }; +} diff --git a/packages/@react-aria/list/src/utils.ts b/packages/@react-aria/list/src/utils.ts new file mode 100644 index 00000000000..2a31f3f321a --- /dev/null +++ b/packages/@react-aria/list/src/utils.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 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 {Key} from 'react'; +import type {ListState} from '@react-stately/list'; + +interface ListMapShared { + id: string, + onAction: (key: Key) => void +} + +// Used to share: +// id of the list and onAction between useList and useListItem +export const listMap = new WeakMap, ListMapShared>(); + +export function normalizeKey(key: Key): string { + if (typeof key === 'string') { + return key.replace(/\s*/g, ''); + } + + return '' + key; +} diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index 14ab733ec75..0feb88d3777 100644 --- a/packages/@react-spectrum/list/package.json +++ b/packages/@react-spectrum/list/package.json @@ -59,6 +59,7 @@ "@react-stately/virtualizer": "^3.1.8", "@react-types/button": "^3.4.4", "@react-types/grid": "^3.0.3", + "@react-types/list": "3.0.0-alpha.1", "@react-types/listbox": "^3.2.4", "@react-types/provider": "^3.4.2", "@react-types/shared": "^3.11.2", diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index f0cc63fa905..4c1fe0af541 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -9,18 +9,9 @@ * 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, - DOMRef, - LoadingState, - MultipleSelection, - SpectrumSelectionProps, - StyleProps -} from '@react-types/shared'; + import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; +import {DOMRef} from '@react-types/shared'; import type {DraggableCollectionState} from '@react-stately/dnd'; import {DragHooks} from '@react-spectrum/dnd'; import {DragPreview} from './DragPreview'; @@ -33,6 +24,7 @@ import listStyles from './listview.css'; import {ListViewItem} from './ListViewItem'; import {ProgressCircle} from '@react-spectrum/progress'; import React, {ReactElement, useContext, useMemo, useRef} from 'react'; +import {SpectrumListProps} from '@react-types/list'; import {useCollator, useLocale, useMessageFormatter} from '@react-aria/i18n'; import {useGrid} from '@react-aria/grid'; import {useProvider} from '@react-spectrum/provider'; @@ -81,28 +73,7 @@ export function useListLayout(state: ListState, density: ListViewProps[ return layout; } -interface ListViewProps extends CollectionBase, DOMProps, AriaLabelingProps, StyleProps, MultipleSelection, 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 what the ListView should render when there is no content to display. */ - renderEmptyState?: () => JSX.Element, - /** - * The duration of animated layout changes, in milliseconds. Used by the Virtualizer. - * @default 0 - */ - transitionDuration?: number, - /** - * 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, +interface SpectrumListViewProps extends SpectrumListProps { /** * 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. @@ -110,13 +81,12 @@ interface ListViewProps extends CollectionBase, DOMProps, AriaLabelingProp dragHooks?: DragHooks } -function ListView(props: ListViewProps, ref: DOMRef) { +function ListView(props: SpectrumListViewProps, ref: DOMRef) { let { density = 'regular', onLoadMore, loadingState, isQuiet, - transitionDuration = 0, onAction, dragHooks } = props; @@ -165,6 +135,7 @@ function ListView(props: ListViewProps, ref: DOMRef(props: ListViewProps, ref: DOMRef + transitionDuration={isLoading ? 160 : 220}> {(type, item) => { if (type === 'item') { return ( diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index 9da769e9808..8aca7aa3ed6 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -45,6 +45,7 @@ export function ListViewItem(props) { let {isFocusVisible, focusProps} = useFocusRing(); let allowsInteraction = state.selectionManager.selectionMode !== 'none' || hasActions; let isDisabled = !allowsInteraction || state.disabledKeys.has(item.key); + let isSelected = state.selectionManager.isSelected(item.key); let isDraggable = dragState?.isDraggable(item.key) && !isDisabled; let {hoverProps, isHovered} = useHover({isDisabled}); let {pressProps, isPressed} = usePress({isDisabled}); @@ -59,13 +60,35 @@ export function ListViewItem(props) { // The actual grid cell of the ListView is intert since we don't want to ever focus it to decrease screenreader // verbosity, so we pretend the row node is the cell for interaction purposes. useGridRow is never used since // it would conflict with useGridCell if applied to the same node. - let {gridCellProps} = useGridCell({ + let {gridCellProps: rowProps} = useGridCell({ node: item, focusMode: 'cell', isVirtualized: true, shouldSelectOnPressUp: isListDraggable }, state, rowRef); - delete gridCellProps['aria-colindex']; + delete rowProps['aria-colindex']; + + rowProps = { + ...rowProps, + role: 'row', + 'aria-label': item.textValue, + 'aria-selected': state.selectionManager.selectionMode !== 'none' ? isSelected : undefined, + 'aria-rowindex': item.index + 1 + }; + + let gridCellProps = { + role: 'gridcell', + 'aria-colindex': 1 + }; + + + // TODO make a useListItemCheckbox hook to mirror table checkbox hook. Will need to access the id of the row for aria-labelledby + let {checkboxProps} = useGridSelectionCheckbox({...props, key: item.key}, state); + + + + + let draggableItem: DraggableItemResult; if (isListDraggable) { @@ -73,8 +96,6 @@ export function ListViewItem(props) { draggableItem = dragHooks.useDraggableItem({key: item.key}, dragState); } - // TODO make a useListItemCheckbox hook to mirror table checkbox hook - let {checkboxProps} = useGridSelectionCheckbox({...props, key: item.key}, state); let dragButtonRef = React.useRef(); let {buttonProps} = useButton({ ...draggableItem?.dragButtonProps, @@ -97,18 +118,10 @@ export function ListViewItem(props) { } let showCheckbox = state.selectionManager.selectionMode !== 'none' && state.selectionManager.selectionBehavior === 'toggle'; - let isSelected = state.selectionManager.isSelected(item.key); let showDragHandle = isDraggable && isFocusVisibleWithin; let {visuallyHiddenProps} = useVisuallyHidden(); - let rowProps = { - role: 'row', - 'aria-label': item.textValue, - 'aria-selected': state.selectionManager.selectionMode !== 'none' ? isSelected : undefined, - 'aria-rowindex': item.index + 1 - }; const mergedProps = mergeProps( - gridCellProps, rowProps, pressProps, isDraggable && draggableItem?.dragProps, @@ -163,8 +176,7 @@ export function ListViewItem(props) { } ) } - role="gridcell" - aria-colindex={1}> + {...gridCellProps}> {isListDraggable &&
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..95837cb0f3b --- /dev/null +++ b/packages/@react-types/list/package.json @@ -0,0 +1,17 @@ +{ + "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-types/shared": "^3.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1" + } +} 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..dfe07229ed3 --- /dev/null +++ b/packages/@react-types/list/src/index.d.ts @@ -0,0 +1,48 @@ +/* + * 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'; + +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 what the ListView should render when there is no content to display. */ + renderEmptyState?: () => JSX.Element +} From 005c557f5069485080bdc2cade054a98b91b8262 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 29 Apr 2022 11:06:20 -0700 Subject: [PATCH 25/36] adding useListSelectionCheckbox and exporting types --- packages/@react-aria/list/package.json | 2 +- packages/@react-aria/list/src/index.ts | 10 +++-- packages/@react-aria/list/src/useList.ts | 4 +- packages/@react-aria/list/src/useListItem.ts | 40 ++++++++++++++--- .../list/src/useListSelectionCheckbox.ts | 44 +++++++++++++++++++ packages/@react-aria/list/src/utils.ts | 9 ++++ 6 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 packages/@react-aria/list/src/useListSelectionCheckbox.ts diff --git a/packages/@react-aria/list/package.json b/packages/@react-aria/list/package.json index 0e943561392..5d7050eaee8 100644 --- a/packages/@react-aria/list/package.json +++ b/packages/@react-aria/list/package.json @@ -25,7 +25,7 @@ "@react-aria/live-announcer": "^3.0.5", "@react-aria/selection": "^3.8.1", "@react-aria/utils": "^3.11.3", - "@react-stately/list": "^3.4.4", + "@react-types/checkbox": "^3.2.6", "@react-types/list": "3.0.0-alpha.1", "@react-types/shared": "^3.11.2" }, diff --git a/packages/@react-aria/list/src/index.ts b/packages/@react-aria/list/src/index.ts index 299eecbc8c1..27c64e7195a 100644 --- a/packages/@react-aria/list/src/index.ts +++ b/packages/@react-aria/list/src/index.ts @@ -10,6 +10,10 @@ * governing permissions and limitations under the License. */ -export * from './useList'; -// TODO: export useList, useListItem, and types for each -// specifically the props passed in and the aria props returned +export {useList} from './useList'; +export {useListItem} from './useListItem'; +export {useListSelectionCheckbox} from './useListSelectionCheckbox'; + +export type {AriaListOptions, ListViewAria} from './useList'; +export type {AriaListItemOptions, ListItemAria} from './useListItem'; +export type {SelectionCheckboxProps, SelectionCheckboxAria} from './useListSelectionCheckbox'; diff --git a/packages/@react-aria/list/src/useList.ts b/packages/@react-aria/list/src/useList.ts index f27d6d74a2d..996b72ea53f 100644 --- a/packages/@react-aria/list/src/useList.ts +++ b/packages/@react-aria/list/src/useList.ts @@ -24,7 +24,7 @@ import {useCollator, useMessageFormatter} from '@react-aria/i18n'; import {useDescription} from '@react-aria/utils'; import {useInteractionModality} from '@react-aria/interactions'; -interface AriaListOptions extends Omit, 'children'> { +export interface AriaListOptions extends Omit, 'children'> { /** Whether the list uses virtual scrolling. */ isVirtualized?: boolean, /** @@ -44,7 +44,7 @@ interface AriaListOptions extends Omit, 'children'> { } // TODO: maybe name it listProps instead of gridProps? -interface ListViewAria { +export interface ListViewAria { /** Props for the grid element. */ gridProps: HTMLAttributes } diff --git a/packages/@react-aria/list/src/useListItem.ts b/packages/@react-aria/list/src/useListItem.ts index 7ffdbf611d5..075628ecb84 100644 --- a/packages/@react-aria/list/src/useListItem.ts +++ b/packages/@react-aria/list/src/useListItem.ts @@ -10,19 +10,17 @@ * governing permissions and limitations under the License. */ -// import {AriaListViewProps} from '@react-types/list'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; +import {getRowId, listMap, normalizeKey} from './utils'; import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, RefObject} from 'react'; import {isFocusVisible} from '@react-aria/interactions'; -import {listMap, normalizeKey} from './utils'; import type {ListState} from '@react-stately/list'; import {mergeProps} from '@react-aria/utils'; import {Node as RSNode} from '@react-types/shared'; import {useLocale} from '@react-aria/i18n'; import {useSelectableItem} from '@react-aria/selection'; -// TODO move to react-types? -interface AriaListItemOptions { +export interface AriaListItemOptions { /** An object representing the list item. Contains all the relevant information that makes up the list row. */ node: RSNode, /** Whether the list row is contained in a virtual scroller. */ @@ -33,9 +31,12 @@ interface AriaListItemOptions { isDisabled?: boolean } -interface ListItemAria { +export interface ListItemAria { + /** Props for the list row element. */ rowProps: HTMLAttributes, + /** Props for the grid cell element within the list row. */ gridCellProps: HTMLAttributes, + /** Whether the row is currently pressed. */ isPressed: boolean } @@ -55,7 +56,7 @@ export function useListItem(props: AriaListItemOptions, state: ListState, } = props; let {direction} = useLocale(); - // TODO: keyboardelegate unused? + // TODO: keyboardelegate unused? Revert the additional changes made in ListLayout if so let {id: listId, onAction} = listMap.get(state); let {itemProps, isPressed} = useSelectableItem({ @@ -94,7 +95,14 @@ export function useListItem(props: AriaListItemOptions, state: ListState, e.stopPropagation(); if (direction === 'rtl') { focusSafely(ref.current); + } else { + walker.currentNode = ref.current; + let lastElement = last(walker); + if (lastElement) { + focusSafely(lastElement); + } } + // todo may have to handle wrapping, check code path } break; } @@ -112,6 +120,12 @@ export function useListItem(props: AriaListItemOptions, state: ListState, e.stopPropagation(); if (direction === 'ltr') { focusSafely(ref.current); + } else { + walker.currentNode = ref.current; + let lastElement = last(walker); + if (lastElement) { + focusSafely(lastElement); + } } } break; @@ -155,7 +169,7 @@ export function useListItem(props: AriaListItemOptions, state: ListState, onFocus, 'aria-label': node.textValue, 'aria-selected': state.selectionManager.selectionMode !== 'none' ? state.selectionManager.isSelected(node.key) : undefined, - id: `${listId}-${normalizeKey(node.key)}` + id: getRowId(state, node.key) }); if (isVirtualized) { @@ -173,3 +187,15 @@ export function useListItem(props: AriaListItemOptions, state: ListState, isPressed }; } + +function last(walker: TreeWalker) { + let next: HTMLElement; + let last: HTMLElement; + do { + last = walker.lastChild() as HTMLElement; + if (last) { + next = last; + } + } while (last); + return next; +} diff --git a/packages/@react-aria/list/src/useListSelectionCheckbox.ts b/packages/@react-aria/list/src/useListSelectionCheckbox.ts new file mode 100644 index 00000000000..08f91e05b8b --- /dev/null +++ b/packages/@react-aria/list/src/useListSelectionCheckbox.ts @@ -0,0 +1,44 @@ +/* + * 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 {AriaCheckboxProps} from '@react-types/checkbox'; +import {getRowId} from './utils'; +import {Key} from 'react'; +import type {ListState} from '@react-stately/list'; +import {useGridSelectionCheckbox} from '@react-aria/grid'; + +export interface SelectionCheckboxProps { + /** A unique key for the checkbox. */ + key: Key +} + +export interface SelectionCheckboxAria { + /** Props for the row selection checkbox element. */ + checkboxProps: AriaCheckboxProps +} + +/** + * Provides the behavior and accessibility implementation for a selection checkbox in a list. + * @param props - Props for the selection checkbox. + * @param state - State of the list, as returned by `useListState`. + */ +export function useListSelectionCheckbox(props: SelectionCheckboxProps, state: ListState): SelectionCheckboxAria { + let {key} = props; + const {checkboxProps} = useGridSelectionCheckbox(props, state as any); + + return { + checkboxProps: { + ...checkboxProps, + 'aria-labelledby': `${checkboxProps.id} ${getRowId(state, key)}` + } + }; +} diff --git a/packages/@react-aria/list/src/utils.ts b/packages/@react-aria/list/src/utils.ts index 2a31f3f321a..0b60ebbb3bc 100644 --- a/packages/@react-aria/list/src/utils.ts +++ b/packages/@react-aria/list/src/utils.ts @@ -22,6 +22,15 @@ interface ListMapShared { // id of the list and onAction between useList and useListItem export const listMap = new WeakMap, ListMapShared>(); +export function getRowId(state: ListState, key: Key) { + let {id} = listMap.get(state); + if (!id) { + throw new Error('Unknown list'); + } + + return `${id}-${normalizeKey(key)}`; +} + export function normalizeKey(key: Key): string { if (typeof key === 'string') { return key.replace(/\s*/g, ''); From d35af328f5a48d63c8194dc2dd4825bdf0614cd9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 29 Apr 2022 11:16:22 -0700 Subject: [PATCH 26/36] adding tests for rowindex and colindex --- packages/@react-aria/list/package.json | 1 + packages/@react-spectrum/list/test/ListView.test.js | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/@react-aria/list/package.json b/packages/@react-aria/list/package.json index 5d7050eaee8..c64deab732a 100644 --- a/packages/@react-aria/list/package.json +++ b/packages/@react-aria/list/package.json @@ -25,6 +25,7 @@ "@react-aria/live-announcer": "^3.0.5", "@react-aria/selection": "^3.8.1", "@react-aria/utils": "^3.11.3", + "@react-stately/list": "^3.4.4", "@react-types/checkbox": "^3.2.6", "@react-types/list": "3.0.0-alpha.1", "@react-types/shared": "^3.11.2" diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index e4b5fc39662..ac8ba37fce2 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -163,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 () { @@ -191,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 () { From 2e6181d5f51cb3b8cf56f6680f9119b133a1025e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 29 Apr 2022 12:13:44 -0700 Subject: [PATCH 27/36] replacing grid hooks with list hooks also clean up and fixes from testing --- packages/@react-aria/list/src/useList.ts | 2 - packages/@react-aria/list/src/useListItem.ts | 23 +++--- packages/@react-aria/list/src/utils.ts | 2 +- packages/@react-spectrum/list/package.json | 4 +- .../@react-spectrum/list/src/ListView.tsx | 70 ++++--------------- .../@react-spectrum/list/src/ListViewItem.tsx | 58 +++++---------- .../@react-stately/layout/src/ListLayout.ts | 8 --- packages/@react-types/list/package.json | 4 ++ packages/@react-types/list/src/index.d.ts | 12 ++-- 9 files changed, 59 insertions(+), 124 deletions(-) diff --git a/packages/@react-aria/list/src/useList.ts b/packages/@react-aria/list/src/useList.ts index 996b72ea53f..3c27aa748e1 100644 --- a/packages/@react-aria/list/src/useList.ts +++ b/packages/@react-aria/list/src/useList.ts @@ -91,8 +91,6 @@ export function useList(props: AriaListOptions, state: ListState, ref: scrollRef }); - // TODO: confirm if this weak map is the prefered way of sending id and onAction to useListItem - // I figure it is because we don't want to have onAction in the useListItem props if people accidentatly don't include it? let id = useId(); listMap.set(state, {id, onAction}); diff --git a/packages/@react-aria/list/src/useListItem.ts b/packages/@react-aria/list/src/useListItem.ts index 075628ecb84..3230b8f70f1 100644 --- a/packages/@react-aria/list/src/useListItem.ts +++ b/packages/@react-aria/list/src/useListItem.ts @@ -11,7 +11,7 @@ */ import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; -import {getRowId, listMap, normalizeKey} from './utils'; +import {getRowId, listMap} from './utils'; import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, RefObject} from 'react'; import {isFocusVisible} from '@react-aria/interactions'; import type {ListState} from '@react-stately/list'; @@ -56,8 +56,14 @@ export function useListItem(props: AriaListItemOptions, state: ListState, } = props; let {direction} = useLocale(); - // TODO: keyboardelegate unused? Revert the additional changes made in ListLayout if so - let {id: listId, onAction} = listMap.get(state); + let {onAction} = listMap.get(state); + let focus = () => { + // Don't shift focus to the row if the active element is a element within the row already + // (e.g. clicking on a row button) + if (!ref.current.contains(document.activeElement)) { + focusSafely(ref.current); + } + }; let {itemProps, isPressed} = useSelectableItem({ selectionManager: state.selectionManager, @@ -65,9 +71,9 @@ export function useListItem(props: AriaListItemOptions, state: ListState, ref, isVirtualized, shouldSelectOnPressUp, - onAction: () => onAction(node.key), - // TODO: double check if isDisabled is appropriate here or if user should handle isPressed state externally - isDisabled + onAction: onAction ? () => onAction(node.key) : undefined, + isDisabled, + focus }); let onKeyDown = (e: ReactKeyboardEvent) => { @@ -102,7 +108,6 @@ export function useListItem(props: AriaListItemOptions, state: ListState, focusSafely(lastElement); } } - // todo may have to handle wrapping, check code path } break; } @@ -146,8 +151,6 @@ export function useListItem(props: AriaListItemOptions, state: ListState, } }; - // List rows can have focusable elements inside them. In this case, focus should - // be marshalled to that element rather than focusing the row itself. let onFocus = (e) => { if (e.target !== ref.current) { // useSelectableItem only handles setting the focused key when @@ -173,7 +176,7 @@ export function useListItem(props: AriaListItemOptions, state: ListState, }); if (isVirtualized) { - rowProps['aria-colindex'] = node.index + 1; + rowProps['aria-rowindex'] = node.index + 1; } let gridCellProps = { diff --git a/packages/@react-aria/list/src/utils.ts b/packages/@react-aria/list/src/utils.ts index 0b60ebbb3bc..6204911f800 100644 --- a/packages/@react-aria/list/src/utils.ts +++ b/packages/@react-aria/list/src/utils.ts @@ -19,7 +19,7 @@ interface ListMapShared { } // Used to share: -// id of the list and onAction between useList and useListItem +// id of the list and onAction between useList, useListItem, and useListSelectionCheckbox export const listMap = new WeakMap, ListMapShared>(); export function getRowId(state: ListState, key: Key) { diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index 0feb88d3777..c7c5867d055 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", @@ -63,7 +64,8 @@ "@react-types/listbox": "^3.2.4", "@react-types/provider": "^3.4.2", "@react-types/shared": "^3.11.2", - "@spectrum-icons/ui": "^3.2.4" + "@spectrum-icons/ui": "^3.2.4", + "@spectrum-icons/workflow": "^3.2.4" }, "devDependencies": { "@adobe/spectrum-css-temp": "^3.0.0-alpha.1", diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index 4c1fe0af541..43e21140317 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -13,9 +13,7 @@ import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import {DOMRef} from '@react-types/shared'; import type {DraggableCollectionState} from '@react-stately/dnd'; -import {DragHooks} from '@react-spectrum/dnd'; import {DragPreview} from './DragPreview'; -import {GridCollection, GridState, useGridState} from '@react-stately/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; import {ListLayout} from '@react-stately/layout'; @@ -26,12 +24,12 @@ import {ProgressCircle} from '@react-spectrum/progress'; import React, {ReactElement, useContext, useMemo, useRef} from 'react'; import {SpectrumListProps} from '@react-types/list'; import {useCollator, useLocale, useMessageFormatter} from '@react-aria/i18n'; -import {useGrid} from '@react-aria/grid'; +import {useList} from '@react-aria/list'; import {useProvider} from '@react-spectrum/provider'; import {Virtualizer} from '@react-aria/virtualizer'; interface ListViewContextValue { - state: GridState>, + state: ListState, dragState: DraggableCollectionState, isListDraggable: boolean, layout: ListLayout @@ -54,7 +52,7 @@ const ROW_HEIGHTS = { } }; -export function useListLayout(state: ListState, density: ListViewProps['density']) { +export function useListLayout(state: ListState, density: SpectrumListProps['density']) { let {scale} = useProvider(); let collator = useCollator({usage: 'search', sensitivity: 'base'}); let isEmpty = state.collection.size === 0; @@ -73,15 +71,7 @@ export function useListLayout(state: ListState, density: ListViewProps[ return layout; } -interface SpectrumListViewProps extends SpectrumListProps { - /** - * 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 -} - -function ListView(props: SpectrumListViewProps, ref: DOMRef) { +function ListView(props: SpectrumListProps, ref: DOMRef) { let { density = 'regular', onLoadMore, @@ -96,46 +86,19 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef console.warn('Drag hooks were provided during one render, but not another. This should be avoided as it may produce unexpected behavior.'); } let domRef = useDOMRef(ref); - let {collection} = useListState(props); + let state = useListState({ + ...props, + selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' + }); let formatMessage = useMessageFormatter(intlMessages); let isLoading = loadingState === 'loading' || loadingState === 'loadingMore'; let {styleProps} = useStyleProps(props); let {locale} = useLocale(); - // TODO: remove if possible - let gridCollection = useMemo(() => new GridCollection({ - columnCount: 1, - items: [...collection].map(item => ({ - ...item, - hasChildNodes: true, - childNodes: [{ - key: `cell-${item.key}`, - type: 'cell', - index: 0, - value: null, - level: 0, - rendered: null, - textValue: item.textValue, - hasChildNodes: false, - childNodes: [] - }] - })) - }), [collection]); - - // TODO: Remove if possible - let state = useGridState({ - ...props, - collection: gridCollection, - focusMode: 'row', - selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' - }); - - // TODO attempt to replace grid state here with useListState, should be able to use useListState's stuff? let layout = useListLayout(state, props.density || 'regular'); let provider = useProvider(); let dragState: DraggableCollectionState; - // TODO replace state here with liststate if (isListDraggable) { dragState = dragHooks.useDraggableCollectionState({ collection: state.collection, @@ -149,15 +112,11 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef }); } - // TODO get rid of this if possible. Will need to port some stuff over - // Need to do the gridMap stuff? Prob can just tear that stuff out of useGridCell and make our own weak map - // make it so that useListItem (maybe name it useListRow?) doesn't accept onAction - // Bring in everthing else - let {gridProps} = useGrid({ + let {gridProps} = useList({ ...props, - onCellAction: onAction, isVirtualized: true, - keyboardDelegate: layout + keyboardDelegate: layout, + onAction }, state, domRef); // Sync loading state into the layout. @@ -187,13 +146,12 @@ function ListView(props: SpectrumListViewProps, ref: DOMRef ) } layout={layout} - // TODO change back to list collection - collection={gridCollection} + collection={state.collection} transitionDuration={isLoading ? 160 : 220}> {(type, item) => { if (type === 'item') { return ( - + ); } else if (type === 'loader') { return ( @@ -246,5 +204,5 @@ function CenteredWrapper({children}) { /** * Lists display a linear collection of data. They allow users to quickly scan, sort, compare, and take action on large amounts of data. */ -const _ListView = React.forwardRef(ListView) as (props: ListViewProps & {ref?: DOMRef}) => ReactElement; +const _ListView = React.forwardRef(ListView) as (props: SpectrumListProps & {ref?: DOMRef}) => ReactElement; export {_ListView as ListView}; diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index 8aca7aa3ed6..fbd1fec0dbe 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -15,20 +15,29 @@ import ChevronRightMedium from '@spectrum-icons/ui/ChevronRightMedium'; import {classNames, ClearSlots, SlotProvider} from '@react-spectrum/utils'; import {Content} from '@react-spectrum/view'; import type {DraggableItemResult} from '@react-aria/dnd'; +import type {DragHooks} from '@react-spectrum/dnd'; import {FocusRing, useFocusRing} from '@react-aria/focus'; import {Grid} from '@react-spectrum/layout'; import ListGripper from '@spectrum-icons/ui/ListGripper'; import listStyles from './listview.css'; import {ListViewContext} from './ListView'; import {mergeProps} from '@react-aria/utils'; +import {Node} from '@react-types/shared'; import React, {useContext, useRef} from 'react'; import {useButton} from '@react-aria/button'; -import {useGridCell, useGridSelectionCheckbox} from '@react-aria/grid'; -import {useHover, usePress} from '@react-aria/interactions'; +import {useHover} from '@react-aria/interactions'; +import {useListItem, useListSelectionCheckbox} from '@react-aria/list'; import {useLocale} from '@react-aria/i18n'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; -export function ListViewItem(props) { +interface ListViewItemProps { + item: Node, + isEmphasized: boolean, + dragHooks: DragHooks, + hasActions: boolean +} + +export function ListViewItem(props: ListViewItemProps) { let { item, isEmphasized, @@ -48,47 +57,13 @@ export function ListViewItem(props) { let isSelected = state.selectionManager.isSelected(item.key); let isDraggable = dragState?.isDraggable(item.key) && !isDisabled; let {hoverProps, isHovered} = useHover({isDisabled}); - let {pressProps, isPressed} = usePress({isDisabled}); - - - // TODO: Make useListItem hook that returns row and cell props. It will contain stuff ripped out of useGridCell - // Will need to keep a isVirtualized option but no need for focusMode. Keep drag stuff out of it for now. - // Don't need the usePress and useHover stuff since that is for visual styles - // Will need to bring in the rowProps from below and the inline applied grid cell props for the hook to return - - // We only make use of useGridCell here to allow for keyboard navigation to the focusable children of the row. - // The actual grid cell of the ListView is intert since we don't want to ever focus it to decrease screenreader - // verbosity, so we pretend the row node is the cell for interaction purposes. useGridRow is never used since - // it would conflict with useGridCell if applied to the same node. - let {gridCellProps: rowProps} = useGridCell({ + let {rowProps, gridCellProps, isPressed} = useListItem({ node: item, - focusMode: 'cell', isVirtualized: true, - shouldSelectOnPressUp: isListDraggable + shouldSelectOnPressUp: isListDraggable, + isDisabled }, state, rowRef); - delete rowProps['aria-colindex']; - - rowProps = { - ...rowProps, - role: 'row', - 'aria-label': item.textValue, - 'aria-selected': state.selectionManager.selectionMode !== 'none' ? isSelected : undefined, - 'aria-rowindex': item.index + 1 - }; - - let gridCellProps = { - role: 'gridcell', - 'aria-colindex': 1 - }; - - - // TODO make a useListItemCheckbox hook to mirror table checkbox hook. Will need to access the id of the row for aria-labelledby - let {checkboxProps} = useGridSelectionCheckbox({...props, key: item.key}, state); - - - - - + let {checkboxProps} = useListSelectionCheckbox({key: item.key}, state); let draggableItem: DraggableItemResult; if (isListDraggable) { @@ -123,7 +98,6 @@ export function ListViewItem(props) { const mergedProps = mergeProps( rowProps, - pressProps, isDraggable && draggableItem?.dragProps, hoverProps, focusWithinProps, diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index b1dd841024c..ae1714b43cc 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -375,14 +375,6 @@ export class ListLayout extends Layout> implements KeyboardDelegate { } } - getKeyLeftOf(key: Key): Key { - return key; - } - - getKeyRightOf(key: Key): Key { - return key; - } - getKeyPageAbove(key: Key) { let layoutInfo = this.getLayoutInfo(key); diff --git a/packages/@react-types/list/package.json b/packages/@react-types/list/package.json index 95837cb0f3b..a02db8dfecb 100644 --- a/packages/@react-types/list/package.json +++ b/packages/@react-types/list/package.json @@ -9,9 +9,13 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-spectrum/dnd": "3.0.0-alpha.1", "@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 index dfe07229ed3..60c874d0d04 100644 --- a/packages/@react-types/list/src/index.d.ts +++ b/packages/@react-types/list/src/index.d.ts @@ -20,6 +20,7 @@ import { SpectrumSelectionProps, StyleProps } from '@react-types/shared'; +import {DragHooks} from '@react-spectrum/dnd'; export interface ListProps extends CollectionBase, MultipleSelection { /** @@ -29,9 +30,7 @@ export interface ListProps extends CollectionBase, MultipleSelection { onAction?: (key: string) => void } -export interface AriaListProps extends ListProps, DOMProps, AriaLabelingProps { - -} +export interface AriaListProps extends ListProps, DOMProps, AriaLabelingProps {} export interface SpectrumListProps extends AriaListProps, StyleProps, SpectrumSelectionProps, Omit { /** @@ -44,5 +43,10 @@ export interface SpectrumListProps extends AriaListProps, StyleProps, Spec /** The current loading state of the ListView. Determines whether or not the progress circle should be shown. */ loadingState?: LoadingState, /** Sets what the ListView should render when there is no content to display. */ - renderEmptyState?: () => JSX.Element + renderEmptyState?: () => JSX.Element, + /** + * 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 } From 6945972e6cbc50ce86819607c3cf2c81be8b4cce Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 29 Apr 2022 14:07:37 -0700 Subject: [PATCH 28/36] replace useSelectableCollection call with useSelectableList --- packages/@react-aria/list/src/useList.ts | 36 ++++++------------- .../list/test/ListView.test.js | 9 +++++ 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/packages/@react-aria/list/src/useList.ts b/packages/@react-aria/list/src/useList.ts index 3c27aa748e1..41ad41aa3f9 100644 --- a/packages/@react-aria/list/src/useList.ts +++ b/packages/@react-aria/list/src/useList.ts @@ -17,12 +17,12 @@ import {HTMLAttributes, Key, RefObject, useMemo, useRef} from 'react'; // @ts-ignore import intlMessages from '../intl/*.json'; import {KeyboardDelegate, Selection} from '@react-types/shared'; -import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection'; import {listMap} from './utils'; import {ListState} from '@react-stately/list'; -import {useCollator, useMessageFormatter} from '@react-aria/i18n'; import {useDescription} from '@react-aria/utils'; import {useInteractionModality} from '@react-aria/interactions'; +import {useMessageFormatter} from '@react-aria/i18n'; +import {useSelectableList} from '@react-aria/selection'; export interface AriaListOptions extends Omit, 'children'> { /** Whether the list uses virtual scrolling. */ @@ -36,14 +36,9 @@ export interface AriaListOptions extends Omit, 'children'> { * A function that returns the text that should be announced by assistive technology when a row is added or removed from selection. * @default (key) => state.collection.getItem(key)?.textValue */ - getRowText?: (key: Key) => string, - /** - * The ref attached to the scrollable body. Used to provided automatic scrolling on item focus for non-virtualized grids. - */ - scrollRef?: RefObject + getRowText?: (key: Key) => string } -// TODO: maybe name it listProps instead of gridProps? export interface ListViewAria { /** Props for the grid element. */ gridProps: HTMLAttributes @@ -62,7 +57,6 @@ export function useList(props: AriaListOptions, state: ListState, ref: isVirtualized, keyboardDelegate, getRowText = (key) => state.collection.getItem(key)?.textValue, - scrollRef, onAction } = props; let formatMessage = useMessageFormatter(intlMessages); @@ -71,24 +65,14 @@ export function useList(props: AriaListOptions, state: ListState, ref: console.warn('An aria-label or aria-labelledby prop is required for accessibility.'); } - // TODO: perhaps refactor further and try to use useSelectableList? That will handle the ListKeyboardDelegate stuff, just need - // to make sure we only pass certain props to the hook - // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). - // When virtualized, the layout object will be passed in as a prop and override this. - let collator = useCollator({usage: 'search', sensitivity: 'base'}); - let delegate = useMemo(() => keyboardDelegate || new ListKeyboardDelegate( - state.collection, - state.disabledKeys, - ref, - collator - ), [keyboardDelegate, state.collection, state.disabledKeys, ref, collator]); - - let {collectionProps} = useSelectableCollection({ - ref, + let {listProps} = useSelectableList({ selectionManager: state.selectionManager, - keyboardDelegate: delegate, + collection: state.collection, + disabledKeys: state.disabledKeys, + ref, + keyboardDelegate: keyboardDelegate, isVirtualized, - scrollRef + selectOnFocus: state.selectionManager.selectionBehavior === 'replace' }); let id = useId(); @@ -122,7 +106,7 @@ export function useList(props: AriaListOptions, state: ListState, ref: id, 'aria-multiselectable': state.selectionManager.selectionMode === 'multiple' ? 'true' : undefined }, - collectionProps, + listProps, descriptionProps ); diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index ac8ba37fce2..4ba9d6db194 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -253,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 () { From 161298f5f06df15c7f69a06857283fdbc9dd2c60 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 29 Apr 2022 14:09:38 -0700 Subject: [PATCH 29/36] fixing docs build? --- packages/@react-aria/list/docs/useList.placeholder.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@react-aria/list/docs/useList.placeholder.mdx b/packages/@react-aria/list/docs/useList.placeholder.mdx index f93241d4194..c71e3607230 100644 --- a/packages/@react-aria/list/docs/useList.placeholder.mdx +++ b/packages/@react-aria/list/docs/useList.placeholder.mdx @@ -14,7 +14,6 @@ export default Layout; import docs from 'docs:@react-aria/list'; import {HeaderInfo, FunctionAPI, TypeContext, InterfaceType, TypeLink, PageDescription} from '@react-spectrum/docs'; import packageData from '@react-aria/list/package.json'; -import Anatomy from './listAnatomy.svg' *Include after_version if the docs shouldn't be published to the website until reaching a specific package version.* --- @@ -53,7 +52,6 @@ after_version: 3.0.0-alpha.0 *3a. Manually remove any extraneous Spectrum-only elements and labels from the SVG.* *3b. Replace the colors in the SVG with their spectrum color variable equivalents. See docs.css .provider for a mapping of these colors.* *3c. Add a `title` and `desc` to the SVG summarizing the contents of the diagram. See any of existing anatomy.svg for an example. * - *For hooks that are meant for more general use, include a Usage section instead detailing the props/params the hook accepts and returns. See useKeyboard.mdx for an example.* From 5f17b371d1fc832f4370b1f4b38d72399a17bf03 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 29 Apr 2022 14:39:24 -0700 Subject: [PATCH 30/36] cleanup and fixing docs build again --- .../list/docs/useList.placeholder.mdx | 19 ------------------- packages/@react-aria/list/index.ts | 1 - packages/@react-spectrum/list/package.json | 3 +-- 3 files changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/@react-aria/list/docs/useList.placeholder.mdx b/packages/@react-aria/list/docs/useList.placeholder.mdx index c71e3607230..03780773cf0 100644 --- a/packages/@react-aria/list/docs/useList.placeholder.mdx +++ b/packages/@react-aria/list/docs/useList.placeholder.mdx @@ -10,7 +10,6 @@ governing permissions and limitations under the License. */} import {Layout} from '@react-spectrum/docs'; export default Layout; -*Additional types can be imported via a direct path to the *.d.ts file if need be. See the useComboBox.mdx for an example.* import docs from 'docs:@react-aria/list'; import {HeaderInfo, FunctionAPI, TypeContext, InterfaceType, TypeLink, PageDescription} from '@react-spectrum/docs'; import packageData from '@react-aria/list/package.json'; @@ -37,8 +36,6 @@ after_version: 3.0.0-alpha.0 ## API *Include an additional FunctionAPI if multiple hooks are being documented in a single file. See useTabList.mdx for an example.* - - ## Features *Describe what the aria hook helps with/provides.* @@ -55,28 +52,12 @@ after_version: 3.0.0-alpha.0 *For hooks that are meant for more general use, include a Usage section instead detailing the props/params the hook accepts and returns. See useKeyboard.mdx for an example.* -*If the below doesn't work see useFocusRing.mdx, useFocusWithin.mdx, and useHover.mdx for some alternative ways of using InterfaceType. * - - - - -*If you'd like to have a inline type link (e.g. referencing the type def of the stately hook), use the TypeLink component below and replace the links to the appropriate docs import.* - ## Example *Add an example of the hook (being used with native elements, etc)* *If you create an example component that will be reused else where in this doc, include export=true so that you can directly reuse the component and avoid copy pasting the same code.* *See useComboBox.mdx for an example.* -```tsx example export=true -import {useList} from '@react-aria/list'; - -function Example(props) { - return ( -
test
- ); -} -``` ## Usage diff --git a/packages/@react-aria/list/index.ts b/packages/@react-aria/list/index.ts index f24b2a4c8aa..4e9931530d8 100644 --- a/packages/@react-aria/list/index.ts +++ b/packages/@react-aria/list/index.ts @@ -11,4 +11,3 @@ */ export * from './src'; -// TODO: export useList, useListItem, and types for each diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index c7c5867d055..f184e3d19e6 100644 --- a/packages/@react-spectrum/list/package.json +++ b/packages/@react-spectrum/list/package.json @@ -64,8 +64,7 @@ "@react-types/listbox": "^3.2.4", "@react-types/provider": "^3.4.2", "@react-types/shared": "^3.11.2", - "@spectrum-icons/ui": "^3.2.4", - "@spectrum-icons/workflow": "^3.2.4" + "@spectrum-icons/ui": "^3.2.4" }, "devDependencies": { "@adobe/spectrum-css-temp": "^3.0.0-alpha.1", From 6c02faf14ef51898452a25efd3ed7a8af7054661 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 29 Apr 2022 14:51:25 -0700 Subject: [PATCH 31/36] making the story input fields only appear at certain screen widths this makes it so the listview doesnt get squished on mobile --- packages/@react-spectrum/list/stories/ListView.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/list/stories/ListView.stories.tsx b/packages/@react-spectrum/list/stories/ListView.stories.tsx index 61aef2778d7..c7ba859be40 100644 --- a/packages/@react-spectrum/list/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/list/stories/ListView.stories.tsx @@ -54,7 +54,7 @@ function renderEmptyState() { let decorator = (storyFn, context) => { let omittedStories = ['draggable rows', 'dynamic items + renderEmptyState']; - return omittedStories.some(omittedName => context.name.includes(omittedName)) ? + return window.screen.width <= 700 || omittedStories.some(omittedName => context.name.includes(omittedName)) ? storyFn() : ( <> From e4086bf81a366e13351f512cb106abe7bc19aa6e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 4 May 2022 11:39:31 -0700 Subject: [PATCH 32/36] addressing review comments --- .../@react-spectrum/list/src/ListViewItem.tsx | 2 +- .../list/test/ListView.test.js | 64 ++++++++++--------- .../@react-stately/layout/src/ListLayout.ts | 2 +- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index 8060cf78e04..971c567c16a 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -50,7 +50,7 @@ export function ListViewItem(props) { let {pressProps, isPressed} = usePress({isDisabled}); // We only make use of useGridCell here to allow for keyboard navigation to the focusable children of the row. - // The actual grid cell of the ListView is intert since we don't want to ever focus it to decrease screenreader + // The actual grid cell of the ListView is inert since we don't want to ever focus it to decrease screenreader // verbosity, so we pretend the row node is the cell for interaction purposes. useGridRow is never used since // it would conflict with useGridCell if applied to the same node. let {gridCellProps} = useGridCell({ diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 2e4c4bedeed..f5b135000df 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -248,7 +248,7 @@ describe('ListView', function () { let tree = renderList(); let target = getRow(tree, 'Baz'); let grid = tree.getByRole('grid'); - act(() => grid.focus()); + userEvent.tab(); fireEvent.keyDown(grid, {key: 'B'}); fireEvent.keyUp(grid, {key: 'Enter'}); fireEvent.keyDown(grid, {key: 'A'}); @@ -263,7 +263,7 @@ describe('ListView', function () { it('should not move focus if no focusables present', function () { let tree = renderList(); let start = getRow(tree, 'Foo'); - act(() => start.focus()); + userEvent.tab(); moveFocus('ArrowRight'); expect(document.activeElement).toBe(start); }); @@ -273,7 +273,7 @@ describe('ListView', function () { let tree = renderListWithFocusables(); let start = getRow(tree, 'Foo'); let focusables = within(start).getAllByRole('button'); - act(() => start.focus()); + userEvent.tab(); moveFocus('ArrowRight'); expect(document.activeElement).toBe(focusables[0]); moveFocus('ArrowRight'); @@ -302,7 +302,7 @@ describe('ListView', function () { it('should not move focus if no focusables present', function () { let tree = renderList(); let start = getRow(tree, 'Foo'); - act(() => start.focus()); + userEvent.tab(); moveFocus('ArrowLeft'); expect(document.activeElement).toBe(start); }); @@ -312,7 +312,7 @@ describe('ListView', function () { let tree = renderListWithFocusables(); let focusables = within(getRow(tree, 'Foo')).getAllByRole('button'); let start = getRow(tree, 'Foo'); - act(() => start.focus()); + userEvent.tab(); moveFocus('ArrowLeft'); expect(document.activeElement).toBe(focusables[1]); moveFocus('ArrowLeft'); @@ -341,25 +341,25 @@ describe('ListView', function () { it('should not wrap focus', function () { let tree = renderListWithFocusables(); let start = getRow(tree, 'Foo'); - act(() => start.focus()); + userEvent.tab(); moveFocus('ArrowUp'); expect(document.activeElement).toBe(start); }); it('should move focus to above row', function () { - let tree = renderListWithFocusables(); + let tree = renderListWithFocusables({selectionMode: 'single'}); let start = getRow(tree, 'Bar'); let end = getRow(tree, 'Foo'); - act(() => start.focus()); + triggerPress(start); moveFocus('ArrowUp'); expect(document.activeElement).toBe(end); }); it('should allow focus on disabled rows', function () { - let tree = renderListWithFocusables({disabledKeys: ['foo']}); + let tree = renderListWithFocusables({disabledKeys: ['foo'], selectionMode: 'single'}); let start = getRow(tree, 'Bar'); let end = getRow(tree, 'Foo'); - act(() => start.focus()); + triggerPress(start); moveFocus('ArrowUp'); expect(document.activeElement).toBe(end); }); @@ -367,27 +367,27 @@ describe('ListView', function () { describe('ArrowDown', function () { it('should not wrap focus', function () { - let tree = renderListWithFocusables(); + let tree = renderListWithFocusables({selectionMode: 'single'}); let start = getRow(tree, 'Baz'); - act(() => start.focus()); + triggerPress(start); moveFocus('ArrowDown'); expect(document.activeElement).toBe(start); }); it('should move focus to below row', function () { - let tree = renderListWithFocusables(); + let tree = renderListWithFocusables({selectionMode: 'single'}); let start = getRow(tree, 'Foo'); let end = getRow(tree, 'Bar'); - act(() => start.focus()); + triggerPress(start); moveFocus('ArrowDown'); expect(document.activeElement).toBe(end); }); it('should allow focus on disabled rows', function () { - let tree = renderListWithFocusables({disabledKeys: ['bar']}); + let tree = renderListWithFocusables({disabledKeys: ['bar'], selectionMode: 'single'}); let start = getRow(tree, 'Foo'); let end = getRow(tree, 'Bar'); - act(() => start.focus()); + triggerPress(start); moveFocus('ArrowDown'); expect(document.activeElement).toBe(end); }); @@ -395,9 +395,9 @@ describe('ListView', function () { 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 tree = renderListWithFocusables({items: manyItems, selectionMode: 'single'}); let start = getRow(tree, 'Foo 25'); - act(() => start.focus()); + triggerPress(start); moveFocus('PageUp'); expect(document.activeElement).toBe(getRow(tree, 'Foo 1')); }); @@ -415,9 +415,8 @@ describe('ListView', function () { 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()); + let tree = renderListWithFocusables({items: manyItems, selectionMode: 'single'}); + userEvent.tab(); moveFocus('PageDown'); expect(document.activeElement).toBe(getRow(tree, 'Foo 25')); moveFocus('PageDown'); @@ -439,9 +438,9 @@ describe('ListView', function () { describe('Home', function () { it('should move focus to the first row when focus starts on a row', function () { - let tree = renderListWithFocusables({items: manyItems}); + let tree = renderListWithFocusables({items: manyItems, selectionMode: 'single'}); let start = getRow(tree, 'Foo 15'); - act(() => start.focus()); + triggerPress(start); moveFocus('Home'); expect(document.activeElement).toBe(getRow(tree, 'Foo 1')); }); @@ -460,8 +459,7 @@ describe('ListView', function () { 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()); + userEvent.tab(); moveFocus('End'); expect(document.activeElement).toBe(getRow(tree, 'Foo 100')); }); @@ -1063,7 +1061,7 @@ describe('ListView', function () { expect(cell).toHaveTextContent('File a'); expect(row).toHaveAttribute('draggable', 'true'); - act(() => cell.focus()); + userEvent.tab(); let draghandle = within(cell).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); @@ -1121,7 +1119,7 @@ describe('ListView', function () { expect(cellD).toHaveTextContent('File d'); expect(rows[3]).toHaveAttribute('draggable', 'true'); - act(() => cellA.focus()); + userEvent.tab(); let draghandle = within(cellA).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); @@ -1209,26 +1207,32 @@ describe('ListView', function () { it('should only display the drag handle on keyboard focus for dragggable items', function () { let {getAllByRole} = render( - + ); let rows = getAllByRole('row'); let cellA = within(rows[0]).getByRole('gridcell'); + triggerPress(cellA); + expect(document.activeElement).toBe(rows[0]); let dragHandle = within(cellA).getAllByRole('button')[0]; // If the dragHandle has a style applied, it is visually hidden expect(dragHandle.style).toBeTruthy(); + expect(dragHandle.style.position).toBe('absolute'); fireEvent.pointerDown(rows[0], {button: 0, pointerId: 1}); dragHandle = within(cellA).getAllByRole('button')[0]; expect(dragHandle.style).toBeTruthy(); + expect(dragHandle.style.position).toBe('absolute'); fireEvent.pointerUp(rows[0], {button: 0, pointerId: 1}); fireEvent.pointerEnter(rows[0]); dragHandle = within(cellA).getAllByRole('button')[0]; expect(dragHandle.style).toBeTruthy(); + expect(dragHandle.style.position).toBe('absolute'); // If dragHandle doesn't have a position applied, it isn't visually hidden - act(() => rows[0].focus()); + fireEvent.keyDown(rows[0], {key: 'Enter'}); + fireEvent.keyUp(rows[0], {key: 'Enter'}); dragHandle = within(cellA).getAllByRole('button')[0]; expect(dragHandle.style.position).toBe(''); }); @@ -1255,7 +1259,7 @@ describe('ListView', function () { let cellA = within(rows[0]).getByRole('gridcell'); let cellB = within(rows[1]).getByRole('gridcell'); - act(() => cellA.focus()); + userEvent.tab(); expect(hasDragHandle(cellA)).toBeFalsy(); moveFocus('ArrowDown'); expect(hasDragHandle(cellB)).toBeFalsy(); diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index b1dd841024c..34f2fd1a11b 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -61,7 +61,7 @@ export class ListLayout extends Layout> implements KeyboardDelegate { protected contentSize: Size; collection: Collection>; disabledKeys: Set = new Set(); - allowDisabledKeyFocus: boolean; + allowDisabledKeyFocus: boolean = false; isLoading: boolean; protected lastWidth: number; protected lastCollection: Collection>; From e750bf8b63c9b21412114f43fa60ba33023973d6 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 4 May 2022 13:20:54 -0700 Subject: [PATCH 33/36] fixing dep version --- packages/@react-types/list/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-types/list/package.json b/packages/@react-types/list/package.json index a02db8dfecb..c5c6038a6e5 100644 --- a/packages/@react-types/list/package.json +++ b/packages/@react-types/list/package.json @@ -9,7 +9,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-spectrum/dnd": "3.0.0-alpha.1", + "@react-spectrum/dnd": "3.0.0-alpha.2", "@react-types/shared": "^3.0.0" }, "peerDependencies": { From b0043030ea5954ef9e0c3d0de8f1a97747622313 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 9 May 2022 15:41:05 -0700 Subject: [PATCH 34/36] fixing test failures --- .../@react-spectrum/list/test/ListView.test.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 57ad6e8b7b9..dc8dcf7da1c 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -703,6 +703,7 @@ describe('ListView', function () { }); describe('onAction', function () { + installPointerEvent(); it('should trigger onAction when clicking items with the mouse', function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); @@ -843,7 +844,7 @@ describe('ListView', function () { expect(announce).toHaveBeenCalledTimes(1); onSelectionChange.mockClear(); - act(() => userEvent.click(getRow(tree, 'Foo'), {pointerType: 'mouse', ctrlKey: true})); + 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'); @@ -869,7 +870,7 @@ describe('ListView', function () { expect(announce).toHaveBeenCalledTimes(1); onSelectionChange.mockClear(); - act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', metaKey: true})); + 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'); @@ -964,7 +965,7 @@ describe('ListView', function () { let tree = renderSelectionList({onSelectionChange, selectionMode: 'none', selectionStyle: 'highlight', onAction}); let rows = tree.getAllByRole('row'); - userEvent.click(rows[0]); + userEvent.click(rows[0], {pointerType: 'mouse'}); expect(announce).not.toHaveBeenCalled(); expect(onSelectionChange).not.toHaveBeenCalled(); expect(onAction).toHaveBeenCalledTimes(1); @@ -975,7 +976,7 @@ describe('ListView', function () { let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'}); let rows = tree.getAllByRole('row'); - userEvent.click(rows[0]); + userEvent.click(rows[0], {pointerType: 'mouse'}); expect(announce).toHaveBeenLastCalledWith('Foo selected.'); expect(announce).toHaveBeenCalledTimes(1); checkSelection(onSelectionChange, ['foo']); @@ -1006,7 +1007,7 @@ describe('ListView', function () { let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'}); let rows = tree.getAllByRole('row'); - userEvent.click(rows[0]); + userEvent.click(rows[0], {pointerType: 'mouse'}); expect(announce).toHaveBeenLastCalledWith('Foo selected.'); expect(announce).toHaveBeenCalledTimes(1); checkSelection(onSelectionChange, ['foo']); @@ -1029,7 +1030,7 @@ describe('ListView', function () { let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'}); let rows = tree.getAllByRole('row'); - userEvent.click(rows[0]); + userEvent.click(rows[0], {pointerType: 'mouse'}); expect(announce).toHaveBeenLastCalledWith('Foo selected.'); expect(announce).toHaveBeenCalledTimes(1); checkSelection(onSelectionChange, ['foo']); @@ -1065,7 +1066,7 @@ describe('ListView', function () { let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', onAction, selectionMode: 'multiple'}); let rows = tree.getAllByRole('row'); - userEvent.click(rows[0]); + userEvent.click(rows[0], {pointerType: 'mouse'}); checkSelection(onSelectionChange, ['foo']); onSelectionChange.mockClear(); expect(announce).toHaveBeenLastCalledWith('Foo selected.'); From c675212f744c68fdf0cfc206c3eeb2ffa980a103 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 11 May 2022 11:47:31 -0700 Subject: [PATCH 35/36] ignoring type for collection node for now --- packages/@react-spectrum/list/src/ListView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index b8ca956f372..bd5b5e178dd 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -173,6 +173,8 @@ function ListView(props: SpectrumListProps, ref: DOMRef= r.y + 10 && y <= r.maxY - 10 && collection.getItem(closest.key).value.type === 'folder') { closestDir = 'on'; } From 515b22653e162fed89ffec8cb1c8bb73f32b59d3 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 11 May 2022 17:12:38 -0700 Subject: [PATCH 36/36] small story fix found when testing screen reader announcements --- .../list/stories/ListView.stories.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/@react-spectrum/list/stories/ListView.stories.tsx b/packages/@react-spectrum/list/stories/ListView.stories.tsx index 11b3a713d34..b5733ce7bd7 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')) {