Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@react-aria/interactions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
57 changes: 57 additions & 0 deletions packages/@react-aria/interactions/src/useLongPress.ts
Original file line number Diff line number Diff line change
@@ -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;
}
96 changes: 65 additions & 31 deletions packages/@react-aria/menu/src/useMenuTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -36,50 +39,81 @@ interface MenuTriggerAria {
*/
export function useMenuTrigger(props: MenuTriggerAriaProps, state: MenuTriggerState, ref: RefObject<HTMLElement>): 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();
Copy link
Contributor Author

@intergalacticspacehighway intergalacticspacehighway Aug 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recent changes for fixing #925 updated the behaviour of useOverlay. Earlier, onBlurWithin used to get called which calls onClose but now the toggle is only handled by onPressStart from pressProps. (For trigger buttons)
To replicate a similar behavior, I added state.close here. Let me know if it can be done in a better way.

},
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
Expand Down
5 changes: 3 additions & 2 deletions packages/@react-spectrum/menu/src/MenuTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,14 @@ function MenuTrigger(props: SpectrumMenuTriggerProps, ref: DOMRef<HTMLElement>)
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({
Expand Down
18 changes: 18 additions & 0 deletions packages/@react-spectrum/menu/stories/MenuTrigger.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,24 @@ storiesOf('MenuTrigger', module)
<Item key="3">Three</Item>
</Menu>
)
)
.add(
'MenuTrigger with trigger="longPress"',
() => (
<>
<div style={{display: 'flex', width: 'auto', margin: '250px 0'}}>
<MenuTrigger onOpenChange={action('onOpenChange')} trigger="longPress">
<ActionButton
onPress={action('press')}
onPressStart={action('pressstart')}
onPressEnd={action('pressend')}>
Menu Button
</ActionButton>
{defaultMenu}
</MenuTrigger>
</div>
</>
)
);

let customMenuItem = (item) => {
Expand Down
147 changes: 146 additions & 1 deletion packages/@react-spectrum/menu/test/MenuTrigger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
});
});
Loading