diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 6e848fb51f0..5a8c9935efd 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -62,7 +62,9 @@ export interface GridProps extends DOMProps, AriaLabelingProps { * trigger selection clearing contextually. * @default 'clearSelection' */ - escapeKeyBehavior?: 'clearSelection' | 'none' + escapeKeyBehavior?: 'clearSelection' | 'none', + /** Whether selection should occur on press up instead of press down. */ + shouldSelectOnPressUp?: boolean } export interface GridAria { @@ -87,7 +89,8 @@ export function useGrid(props: GridProps, state: GridState(props: GridProps, state: GridState, S extends GridState actions.onRowAction?.(node.key) : onAction; let {itemProps, ...states} = useSelectableItem({ selectionManager: state.selectionManager, key: node.key, ref, isVirtualized, - shouldSelectOnPressUp, + shouldSelectOnPressUp: gridShouldSelectOnPressUp || shouldSelectOnPressUp, onAction: onRowAction || node?.props?.onAction ? chain(node?.props?.onAction, onRowAction) : undefined, isDisabled: state.collection.size === 0 }); diff --git a/packages/@react-aria/grid/src/utils.ts b/packages/@react-aria/grid/src/utils.ts index 65ffeee0939..1b93f26d370 100644 --- a/packages/@react-aria/grid/src/utils.ts +++ b/packages/@react-aria/grid/src/utils.ts @@ -19,7 +19,8 @@ interface GridMapShared { actions: { onRowAction?: (key: Key) => void, onCellAction?: (key: Key) => void - } + }, + shouldSelectOnPressUp?: boolean } // Used to share: diff --git a/packages/@react-aria/gridlist/src/useGridList.ts b/packages/@react-aria/gridlist/src/useGridList.ts index 3acbb242264..09e1c0cf368 100644 --- a/packages/@react-aria/gridlist/src/useGridList.ts +++ b/packages/@react-aria/gridlist/src/useGridList.ts @@ -39,7 +39,9 @@ export interface GridListProps extends CollectionBase, MultipleSelection { */ onAction?: (key: Key) => void, /** Whether `disabledKeys` applies to all interactions, or only selection. */ - disabledBehavior?: DisabledBehavior + disabledBehavior?: DisabledBehavior, + /** Whether selection should occur on press up instead of press down. */ + shouldSelectOnPressUp?: boolean } export interface AriaGridListProps extends GridListProps, DOMProps, AriaLabelingProps { @@ -115,7 +117,8 @@ export function useGridList(props: AriaGridListOptions, state: ListState(props: AriaGridListOptions, state: ListState(props: AriaGridListItemOptions, state: ListSt // Copied from useGridCell + some modifications to make it not so grid specific let { node, - isVirtualized, - shouldSelectOnPressUp + isVirtualized } = props; // let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist'); let {direction} = useLocale(); - let {onAction, linkBehavior, keyboardNavigationBehavior} = listMap.get(state)!; + let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} = listMap.get(state)!; let descriptionId = useSlotId(); // We need to track the key of the item at the time it was last focused so that we force @@ -125,7 +124,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt key: node.key, ref, isVirtualized, - shouldSelectOnPressUp, + shouldSelectOnPressUp: props.shouldSelectOnPressUp || shouldSelectOnPressUp, onAction: onAction || node.props?.onAction ? chain(node.props?.onAction, onAction ? () => onAction(node.key) : undefined) : undefined, focus, linkBehavior diff --git a/packages/@react-aria/gridlist/src/utils.ts b/packages/@react-aria/gridlist/src/utils.ts index 0829badb166..c5fd134eb58 100644 --- a/packages/@react-aria/gridlist/src/utils.ts +++ b/packages/@react-aria/gridlist/src/utils.ts @@ -17,7 +17,8 @@ interface ListMapShared { id: string, onAction?: (key: Key) => void, linkBehavior?: 'action' | 'selection' | 'override', - keyboardNavigationBehavior: 'arrow' | 'tab' + keyboardNavigationBehavior: 'arrow' | 'tab', + shouldSelectOnPressUp?: boolean } // Used to share: diff --git a/packages/@react-aria/listbox/src/useListBox.ts b/packages/@react-aria/listbox/src/useListBox.ts index c79278cd9dc..5023f8fc86d 100644 --- a/packages/@react-aria/listbox/src/useListBox.ts +++ b/packages/@react-aria/listbox/src/useListBox.ts @@ -48,9 +48,6 @@ export interface AriaListBoxOptions extends Omit, 'childr */ shouldUseVirtualFocus?: boolean, - /** Whether selection should occur on press up instead of press down. */ - shouldSelectOnPressUp?: boolean, - /** Whether options should be focused when the user hovers over them. */ shouldFocusOnHover?: boolean, diff --git a/packages/@react-aria/tag/src/useTagGroup.ts b/packages/@react-aria/tag/src/useTagGroup.ts index e7e9ada2651..77a1e92b8d0 100644 --- a/packages/@react-aria/tag/src/useTagGroup.ts +++ b/packages/@react-aria/tag/src/useTagGroup.ts @@ -34,6 +34,8 @@ export interface TagGroupAria { export interface AriaTagGroupProps extends CollectionBase, MultipleSelection, DOMProps, LabelableProps, AriaLabelingProps, Omit { /** How multiple selection should behave in the collection. */ selectionBehavior?: SelectionBehavior, + /** Whether selection should occur on press up instead of press down. */ + shouldSelectOnPressUp?: boolean, /** Handler that is called when a user deletes a tag. */ onRemove?: (keys: Set) => void, /** An error message for the field. */ diff --git a/packages/@react-types/listbox/src/index.d.ts b/packages/@react-types/listbox/src/index.d.ts index 082b44669f8..d5f42fc0876 100644 --- a/packages/@react-types/listbox/src/index.d.ts +++ b/packages/@react-types/listbox/src/index.d.ts @@ -38,6 +38,8 @@ export interface AriaListBoxProps extends AriaListBoxPropsBase { label?: ReactNode, /** How multiple selection should behave in the collection. */ selectionBehavior?: SelectionBehavior, + /** Whether selection should occur on press up instead of press down. */ + shouldSelectOnPressUp?: boolean, /** * 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. diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 656b82dd185..9a8b69ca8c2 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -38,7 +38,9 @@ export interface TableProps extends MultipleSelection, Sortable { * trigger selection clearing contextually. * @default 'clearSelection' */ - escapeKeyBehavior?: 'clearSelection' | 'none' + escapeKeyBehavior?: 'clearSelection' | 'none', + /** Whether selection should occur on press up instead of press down. */ + shouldSelectOnPressUp?: boolean } /** diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 86c29a79b97..a3a0fc78ea1 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -130,7 +130,8 @@ function GridListInner({props, collection, gridListRef: ref}: keyboardDelegate, // Only tab navigation is supported in grid layout. keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior, - isVirtualized + isVirtualized, + shouldSelectOnPressUp: props.shouldSelectOnPressUp }, state, ref); let selectionManager = state.selectionManager; diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 1435dad9967..a20e4edd669 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -11,11 +11,34 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Checkbox, CheckboxProps, DropIndicator, GridLayout, GridList, GridListItem, GridListItemProps, ListLayout, Size, Tag, TagGroup, TagList, useDragAndDrop, Virtualizer} from 'react-aria-components'; +import { + Button, + Checkbox, + CheckboxProps, + Dialog, + DialogTrigger, + DropIndicator, + GridLayout, + GridList, + GridListItem, + GridListItemProps, + Heading, + ListLayout, + Modal, + ModalOverlay, + Popover, + Size, + Tag, + TagGroup, + TagList, + useDragAndDrop, + Virtualizer +} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; -import React from 'react'; +import {Key, useListData} from 'react-stately'; +import React, {useState} from 'react'; import styles from '../example/index.css'; -import {useListData} from 'react-stately'; + export default { title: 'React Aria Components' }; @@ -66,7 +89,8 @@ const MyGridListItem = (props: GridListItemProps) => { GridListExample.story = { args: { layout: 'stack', - escapeKeyBehavior: 'clearSelection' + escapeKeyBehavior: 'clearSelection', + shouldSelectOnPressUp: false }, argTypes: { layout: { @@ -218,3 +242,100 @@ export function TagGroupInsideGridList() { ); } + +const GridListDropdown = () => { + const [isOpen, setIsOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState>(new Set([])); + + const handleSelectionChange = (e) => { + setSelectedItem(e); + setIsOpen(false); + }; + + return ( + + + +
+ + + Option 1 + + + Option 2 + + + Option 3 + + + Option 4 + + +
+
+
+ ); +}; + +function GridListInModalPickerRender(props) { + const [mainModalOpen, setMainModalOpen] = useState(true); + return ( + <> + + + + +
+ Open the GridList Picker + +
+
+
+
+ + ); +} + +export const GridListInModalPicker = { + render: (args) => , + parameters: { + docs: { + description: { + component: 'Selecting an option from the grid list over the backdrop should not result in the modal closing.' + } + } + } +}; diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index f423212cfa6..c3a437229c4 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -820,4 +820,42 @@ describe('GridList', () => { }); }); }); + + describe('shouldSelectOnPressUp', () => { + it('should select an item on pressing down when shouldSelectOnPressUp is not provided', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange}); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing down when shouldSelectOnPressUp is false', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing up when shouldSelectOnPressUp is true', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderGridList({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(0); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + }); }); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 17e7df8a62e..10b9370a4e7 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -1290,4 +1290,42 @@ describe('ListBox', () => { }); }); }); + + describe('shouldSelectOnPressUp', () => { + it('should select an item on pressing down when shouldSelectOnPressUp is not provided', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderListbox({selectionMode: 'single', onSelectionChange}); + let items = getAllByRole('option'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing down when shouldSelectOnPressUp is false', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderListbox({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}); + let items = getAllByRole('option'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing up when shouldSelectOnPressUp is true', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderListbox({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}); + let items = getAllByRole('option'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(0); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + }); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 1c1648c396e..31a2f7bed0f 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -2168,4 +2168,42 @@ describe('Table', () => { expect(rows[4]).toHaveTextContent('RatSat'); }); }); + + describe('shouldSelectOnPressUp', () => { + it('should select an item on pressing down when shouldSelectOnPressUp is not provided', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange}}); + let items = getAllByRole('row'); + + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing down when shouldSelectOnPressUp is false', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}}); + let items = getAllByRole('row'); + + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing up when shouldSelectOnPressUp is true', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderTable({tableProps: {selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}}); + let items = getAllByRole('row'); + + await user.pointer({target: items[1], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(0); + + await user.pointer({target: items[1], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + }); }); diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index 2442a96712c..4c0c33608d3 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -517,4 +517,42 @@ describe('TagGroup', () => { expect(onRemove).toHaveBeenCalledTimes(3); expect(onRemove).toHaveBeenLastCalledWith(new Set(['dog'])); }); + + describe('shouldSelectOnPressUp', () => { + it('should select an item on pressing down when shouldSelectOnPressUp is not provided', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderTagGroup({selectionMode: 'single', onSelectionChange}); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing down when shouldSelectOnPressUp is false', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderTagGroup({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: false}); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing up when shouldSelectOnPressUp is true', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderTagGroup({selectionMode: 'single', onSelectionChange, shouldSelectOnPressUp: true}); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(0); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + }); }); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 6d74c669547..6640907afd1 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -1198,6 +1198,44 @@ describe('Tree', () => { expect(rows[0]).toHaveTextContent('Nothing in tree'); }); }); + + describe('shouldSelectOnPressUp', () => { + it('should select an item on pressing down when shouldSelectOnPressUp is not provided', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = render(); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing down when shouldSelectOnPressUp is false', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = render(); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(1); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + + it('should select an item on pressing up when shouldSelectOnPressUp is true', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = render(); + let items = getAllByRole('row'); + + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); + expect(onSelectionChange).toBeCalledTimes(0); + + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); + expect(onSelectionChange).toBeCalledTimes(1); + }); + }); });