diff --git a/packages/@react-aria/interactions/src/index.ts b/packages/@react-aria/interactions/src/index.ts index 4fc5357977b..7a935b2e964 100644 --- a/packages/@react-aria/interactions/src/index.ts +++ b/packages/@react-aria/interactions/src/index.ts @@ -18,5 +18,6 @@ export * from './useFocusWithin'; export * from './useHover'; export * from './useInteractOutside'; export * from './useKeyboard'; +export * from './useLongPress'; export * from './useMove'; export * from './usePress'; diff --git a/packages/@react-aria/interactions/src/useLongPress.ts b/packages/@react-aria/interactions/src/useLongPress.ts new file mode 100644 index 00000000000..918f1a94dbe --- /dev/null +++ b/packages/@react-aria/interactions/src/useLongPress.ts @@ -0,0 +1,57 @@ +/* + * 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 {PressEvent} from '@react-types/shared'; +import {usePress} from './usePress'; +import {useRef} from 'react'; + +export interface LongPressHookProps { + onLongPress: (e: PressEvent) => void, + onPressStart?: (e: PressEvent) => void, + triggerThreshold?: number +} + +export const LONG_PRESS_DEFAULT_THRESHOLD_IN_MS = 500; + +export function useLongPress(props : LongPressHookProps) { + let { + onPressStart, + onLongPress, + triggerThreshold + } = props; + + triggerThreshold = triggerThreshold || LONG_PRESS_DEFAULT_THRESHOLD_IN_MS; + + const timeRef = useRef(null); + + let {pressProps} = usePress({ + onPressStart(e) { + if (e.pointerType === 'mouse' || e.pointerType === 'touch') { + if (onPressStart) { + onPressStart(e); + } + + timeRef.current = setTimeout(() => { + onLongPress(e); + timeRef.current = null; + }, triggerThreshold); + } + }, + onPressEnd() { + if (timeRef.current) { + clearTimeout(timeRef.current); + } + } + }); + + return pressProps; +} diff --git a/packages/@react-aria/menu/src/useMenuTrigger.ts b/packages/@react-aria/menu/src/useMenuTrigger.ts index 9870672e4db..1f04489ab7e 100644 --- a/packages/@react-aria/menu/src/useMenuTrigger.ts +++ b/packages/@react-aria/menu/src/useMenuTrigger.ts @@ -13,12 +13,15 @@ import {AriaButtonProps} from '@react-types/button'; import {HTMLAttributes, RefObject} from 'react'; import {MenuTriggerState} from '@react-stately/menu'; -import {useId} from '@react-aria/utils'; +import {MenuTriggerType} from '@react-types/menu'; +import {mergeProps, useId} from '@react-aria/utils'; +import {useLongPress} from '@react-aria/interactions'; import {useOverlayTrigger} from '@react-aria/overlays'; interface MenuTriggerAriaProps { /** The type of menu that the menu trigger opens. */ - type?: 'menu' | 'listbox' + type?: 'menu' | 'listbox', + trigger?: MenuTriggerType } interface MenuTriggerAria { @@ -36,50 +39,81 @@ interface MenuTriggerAria { */ export function useMenuTrigger(props: MenuTriggerAriaProps, state: MenuTriggerState, ref: RefObject): MenuTriggerAria { let { - type = 'menu' as MenuTriggerAriaProps['type'] + type = 'menu' as MenuTriggerAriaProps['type'], + trigger } = props; let menuTriggerId = useId(); let {triggerProps, overlayProps} = useOverlayTrigger({type}, state, ref); + + const handleArrowKeyBehaviour = (e) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + state.toggle('first'); + break; + case 'ArrowUp': + e.preventDefault(); + state.toggle('last'); + break; + } + }; - let onKeyDown = (e) => { + const handleLongPressAltBehaviour = (e) => { + if (e.altKey) { + handleArrowKeyBehaviour(e); + } + }; + + const onKeyDown = (e) => { if ((typeof e.isDefaultPrevented === 'function' && e.isDefaultPrevented()) || e.defaultPrevented) { return; } if (ref && ref.current) { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - state.toggle('first'); - break; - case 'ArrowUp': - e.preventDefault(); - state.toggle('last'); - break; + if (trigger === 'longPress') { + handleLongPressAltBehaviour(e); + } else { + handleArrowKeyBehaviour(e); } } }; - return { - menuTriggerProps: { - ...triggerProps, - id: menuTriggerId, - onPressStart(e) { - // For consistency with native, open the menu on mouse/key down, but touch up. - if (e.pointerType !== 'touch') { - // If opened with a keyboard or screen reader, auto focus the first item. - // Otherwise, the menu itself will be focused. - state.toggle(e.pointerType === 'keyboard' || e.pointerType === 'virtual' ? 'first' : null); - } - }, - onPress(e) { - if (e.pointerType === 'touch') { - state.toggle(); - } - }, - onKeyDown + const longPressProps = useLongPress({ + // Close on press start as menu can be in a open state after onLongPress. + onPressStart() { + state.close(); + }, + onLongPress() { + state.open('first'); + } + }); + + const pressProps = { + onPressStart(e) { + // For consistency with native, open the menu on mouse/key down, but touch up. + if (e.pointerType !== 'touch') { + // If opened with a keyboard or screen reader, auto focus the first item. + // Otherwise, the menu itself will be focused. + state.toggle(e.pointerType === 'keyboard' || e.pointerType === 'virtual' ? 'first' : null); + } }, + onPress(e) { + if (e.pointerType === 'touch') { + state.toggle(); + } + } + }; + + let menuTriggerProps = { + ...triggerProps, + id: menuTriggerId + }; + + menuTriggerProps = mergeProps(menuTriggerProps, trigger === 'longPress' ? longPressProps : pressProps, {onKeyDown}); + + return { + menuTriggerProps, menuProps: { ...overlayProps, 'aria-labelledby': menuTriggerId diff --git a/packages/@react-spectrum/menu/src/MenuTrigger.tsx b/packages/@react-spectrum/menu/src/MenuTrigger.tsx index b836ff1c692..0f50773c17a 100644 --- a/packages/@react-spectrum/menu/src/MenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/MenuTrigger.tsx @@ -35,13 +35,14 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef) align = 'start', shouldFlip = true, direction = 'bottom', - closeOnSelect = true + closeOnSelect = true, + trigger = 'press' } = props; let [menuTrigger, menu] = React.Children.toArray(children); let state = useMenuTriggerState(props); - let {menuTriggerProps, menuProps} = useMenuTrigger({}, state, menuTriggerRef); + let {menuTriggerProps, menuProps} = useMenuTrigger({trigger}, state, menuTriggerRef); let isMobile = useIsMobileDevice(); let {overlayProps: positionProps, placement} = useOverlayPosition({ diff --git a/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx b/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx index 5457808766e..ebc87c5d32d 100644 --- a/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx +++ b/packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx @@ -487,6 +487,24 @@ storiesOf('MenuTrigger', module) Three ) + ) + .add( + 'MenuTrigger with trigger="longPress"', + () => ( + <> +
+ + + Menu Button + + {defaultMenu} + +
+ + ) ); let customMenuItem = (item) => { diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index cdc4c1f0770..233b2ac3e96 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -13,10 +13,11 @@ import {act, fireEvent, render, within} from '@testing-library/react'; import {Button} from '@react-spectrum/button'; import {Item, Menu, MenuTrigger, Section} from '../'; +import {LONG_PRESS_DEFAULT_THRESHOLD_IN_MS} from '@react-aria/interactions'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; -import {triggerPress} from '@react-spectrum/test-utils'; +import {triggerLongPress, triggerPress, triggerTouchPress} from '@react-spectrum/test-utils'; import V2Button from '@react/react-spectrum/Button'; import V2Dropdown from '@react/react-spectrum/Dropdown'; import {Menu as V2Menu, MenuItem as V2MenuItem} from '@react/react-spectrum/Menu'; @@ -759,4 +760,148 @@ describe('MenuTrigger', function () { let checkmark = queryByRole('img', {hidden: true}); expect(checkmark).toBeNull(); }); + + describe('MenuTrigger trigger="longPress" behaviour', function () { + it('MenuTrigger toggles the menu display on mouse down longPress', function () { + const props = {onOpenChange, trigger: 'longPress'}; + verifyMenuToggle(MenuTrigger, props, {}, (button, menu) => { + if (menu) { + triggerPress(button); + } else { + triggerLongPress(button, 'mouse'); + } + }); + }); + + it('MenuTrigger toggles the menu display on touch longPress', function () { + const props = {onOpenChange, trigger: 'longPress'}; + verifyMenuToggle(MenuTrigger, props, {}, (button, menu) => { + if (menu) { + triggerTouchPress(button); + } else { + triggerLongPress(button, 'touch'); + } + }); + }); + + it('MenuTrigger toggles the menu display on Alt+ArrowDown key (LongPress Alternative)', function () { + const props = {onOpenChange, trigger: 'longPress'}; + verifyMenuToggle(MenuTrigger, props, {}, (button, menu) => { + if (menu) { + triggerPress(button); + } else { + fireEvent.keyDown(button, {key: 'ArrowDown', altKey: true}); + } + }); + }); + + it('MenuTrigger toggles the menu display on Alt+ArrowUp key (LongPress Alternative)', function () { + const props = {onOpenChange, trigger: 'longPress'}; + verifyMenuToggle(MenuTrigger, props, {}, (button, menu) => { + if (menu) { + triggerPress(button); + } else { + fireEvent.keyDown(button, {key: 'ArrowUp', altKey: true}); + } + }); + }); + + it(`Verifies long press duration behaviour (Set to ${LONG_PRESS_DEFAULT_THRESHOLD_IN_MS}ms)`, function () { + let menuNotFoundError = new Error('Menu not found'); + const props = {onOpenChange, trigger: 'longPress'}; + let tree = renderComponent(MenuTrigger, props, {}); + let triggerButton = tree.getByRole('button'); + + const callback = () => { + try { + let menu = tree.getByRole('menu'); + expect(menu).toBeTruthy(); + expect(menu).toHaveAttribute('aria-labelledby', triggerButton.id); + } catch (e) { + throw menuNotFoundError; + } + }; + + act(() => { + fireEvent.mouseDown(triggerButton, {detail: 1}); + + setTimeout(() => { + expect(callback).toThrowError(menuNotFoundError); + }, LONG_PRESS_DEFAULT_THRESHOLD_IN_MS / 2); + + setTimeout(() => { + expect(callback).not.toThrowError(menuNotFoundError); + }, LONG_PRESS_DEFAULT_THRESHOLD_IN_MS); + + jest.runAllTimers(); + }); + }); + + describe('MenuTrigger longPress focus behaviour', function () { + it('MenuTrigger autofocuses the selected item on menu open', function () { + let tree = renderComponent(MenuTrigger, {trigger: 'longPress'}, {selectedKeys: ['Bar']}); + let button = tree.getByRole('button'); + + act(() => { + fireEvent.mouseDown(button, {detail: 1}); + jest.runAllTimers(); + }); + + let menu = tree.getByRole('menu'); + expect(menu).toBeTruthy(); + let menuItems = within(menu).getAllByRole('menuitem'); + let selectedItem = menuItems[1]; + expect(selectedItem).toBe(document.activeElement); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + expect(menu).not.toBeInTheDocument(); + + // Opening menu via up alt+arrowUp still autofocuses the selected item + fireEvent.keyDown(button, {key: 'ArrowUp', altKey: true}); + menu = tree.getByRole('menu'); + menuItems = within(menu).getAllByRole('menuitem'); + selectedItem = menuItems[1]; + expect(selectedItem).toBe(document.activeElement); + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + expect(menu).not.toBeInTheDocument(); + + // Opening menu via up alt+arrowDown still autofocuses the selected item + fireEvent.keyDown(button, {key: 'ArrowDown', altKey: true}); + menu = tree.getByRole('menu'); + menuItems = within(menu).getAllByRole('menuitem'); + selectedItem = menuItems[1]; + expect(selectedItem).toBe(document.activeElement); + }); + + + it('MenuTrigger focuses the first item on Alt+ArrowDown if there isn\'t a selected item', function () { + let tree = renderComponent(MenuTrigger, {trigger: 'longPress'}, {}); + let button = tree.getByRole('button'); + fireEvent.keyDown(button, {key: 'ArrowDown', altKey: true}); + let menu = tree.getByRole('menu'); + expect(menu).toBeTruthy(); + let menuItems = within(menu).getAllByRole('menuitem'); + let selectedItem = menuItems[0]; + expect(selectedItem).toBe(document.activeElement); + }); + + it('MenuTrigger focuses the last item on Alt+ArrowUp if there isn\'t a selected item', function () { + let tree = renderComponent(MenuTrigger, {trigger: 'longPress'}, {}); + let button = tree.getByRole('button'); + fireEvent.keyDown(button, {key: 'ArrowUp', altKey: true}); + let menu = tree.getByRole('menu'); + expect(menu).toBeTruthy(); + let menuItems = within(menu).getAllByRole('menuitem'); + let selectedItem = menuItems[menuItems.length - 1]; + expect(selectedItem).toBe(document.activeElement); + }); + }); + }); }); diff --git a/packages/@react-spectrum/test-utils/src/events.ts b/packages/@react-spectrum/test-utils/src/events.ts index bb7b1e2973a..c295e412bf6 100644 --- a/packages/@react-spectrum/test-utils/src/events.ts +++ b/packages/@react-spectrum/test-utils/src/events.ts @@ -14,6 +14,7 @@ import {act, fireEvent} from '@testing-library/react'; import type {ITypeOpts} from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; +const LONG_PRESS_DEFAULT_THRESHOLD_IN_MS = 500; // Triggers a "press" event on an element. // TODO: move to somewhere more common export function triggerPress(element, opts = {}) { @@ -80,6 +81,14 @@ export function installPointerEvent() { }); } +export function triggerTouchPress(element) { + act(() => { + fireEvent.touchStart(element, {targetTouches: [{}]}); + }); + act(() => { + fireEvent.touchEnd(element, {targetTouches: [{}]}); + }); +} /** * Must **not** be called inside an `act` callback! * @@ -98,3 +107,26 @@ export function typeText(el: HTMLElement, value: string, opts?: ITypeOpts) { skipClick = true; } } + + +type pointerType = 'mouse' | 'touch' + +export function triggerLongPress(button: HTMLElement, pointerType: pointerType) { + act(() => { + if (pointerType === 'touch') { + fireEvent.touchStart(button, {targetTouches: [{}]}); + setTimeout(() => { + fireEvent.touchEnd(button, {targetTouches: [{}]}); + }, LONG_PRESS_DEFAULT_THRESHOLD_IN_MS); + + } else { + fireEvent.mouseDown(button, {detail: 1}); + setTimeout(() => { + fireEvent.mouseUp(button, {detail: 1}); + }, LONG_PRESS_DEFAULT_THRESHOLD_IN_MS); + } + + jest.advanceTimersByTime(LONG_PRESS_DEFAULT_THRESHOLD_IN_MS); + + }); +} diff --git a/packages/@react-types/menu/src/index.d.ts b/packages/@react-types/menu/src/index.d.ts index 2c5850a7bfc..23c95f06399 100644 --- a/packages/@react-types/menu/src/index.d.ts +++ b/packages/@react-types/menu/src/index.d.ts @@ -14,8 +14,14 @@ import {Alignment, AriaLabelingProps, CollectionBase, DOMProps, FocusStrategy, M import {Key, ReactElement} from 'react'; import {OverlayTriggerProps} from '@react-types/overlays'; +export type MenuTriggerType = 'press' | 'longPress' + export interface MenuTriggerProps extends OverlayTriggerProps { - // trigger?: 'press' | 'longPress', + /** + * Determines trigger behavior. When set to longPress, it opens the menu after a delay of 500ms and also enables using Alt+Arrow(Up/Down) as a long press gesture alternative. + * @default 'press' + */ + trigger?: MenuTriggerType, /** * Alignment of the menu relative to the trigger. * @default 'start' diff --git a/yarn.lock b/yarn.lock index 77c1d231726..704f199f9c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3703,6 +3703,15 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" +"@react-aria/interactions@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.1.0.tgz#2d9ea4d9751c0bb859f900c1a29aed74bc4b45a9" + integrity sha512-iu+O/qsRs7o7J0262eijbgyovNXXGHEZMtpWjJXz0NGKD870iAtuGu3irUewQJkf1t8w+qDGycATTkuV9FKhfw== + dependencies: + "@babel/runtime" "^7.6.2" + "@react-aria/utils" "^3.1.0" + "@react-types/shared" "^3.1.0" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"