diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 6f700db89f3..229bd697843 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -262,6 +262,13 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl clearVirtualFocus(); break; } + case 'Enter': + // Trigger click action on item when Enter key was pressed. + if (focusedNodeId != null) { + let item = document.getElementById(focusedNodeId); + item?.click(); + } + break; } } }; diff --git a/packages/@react-aria/link/src/useLink.ts b/packages/@react-aria/link/src/useLink.ts index 9a1fdd18959..f4098b25939 100644 --- a/packages/@react-aria/link/src/useLink.ts +++ b/packages/@react-aria/link/src/useLink.ts @@ -12,7 +12,7 @@ import {AriaLinkProps} from '@react-types/link'; import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {filterDOMProps, mergeProps, shouldClientNavigate, useLinkProps, useRouter} from '@react-aria/utils'; +import {filterDOMProps, handleLinkClick, mergeProps, useLinkProps, useRouter} from '@react-aria/utils'; import React from 'react'; import {useFocusable, usePress} from '@react-aria/interactions'; @@ -72,20 +72,7 @@ export function useLink(props: AriaLinkOptions, ref: RefObject) => { pressProps.onClick?.(e); - - // If a custom router is provided, prevent default and forward if this link should client navigate. - if ( - !router.isNative && - e.currentTarget instanceof HTMLAnchorElement && - e.currentTarget.href && - // If props are applied to a router Link component, it may have already prevented default. - !e.isDefaultPrevented() && - shouldClientNavigate(e.currentTarget, e) && - props.href - ) { - e.preventDefault(); - router.open(e.currentTarget, e, props.href, props.routerOptions); - } + handleLinkClick(e, router, props.href, props.routerOptions); } }) }; diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 83a1c8fbc31..498ba53209a 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -10,11 +10,12 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject, RouterOptions} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; +import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject} from '@react-types/shared'; +import {filterDOMProps, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; import {getItemCount} from '@react-stately/collections'; import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {menuData} from './utils'; +import {MouseEvent, useRef} from 'react'; import {SelectionManager} from '@react-stately/selection'; import {TreeState} from '@react-stately/tree'; import {useSelectableItem} from '@react-aria/selection'; @@ -112,9 +113,10 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re 'aria-haspopup': hasPopup, onPressStart: pressStartProp, onPressUp: pressUpProp, - onPress: pressProp, - onPressChange, + onPress, + onPressChange: pressChangeProp, onPressEnd, + onClick: onClickProp, onHoverStart: hoverStartProp, onHoverChange, onHoverEnd, @@ -134,7 +136,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re let item = state.collection.getItem(key); let onClose = props.onClose || data.onClose; let router = useRouter(); - let performAction = (e: PressEvent) => { + let performAction = () => { if (isTrigger) { return; } @@ -150,10 +152,6 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re let onAction = data.onAction; onAction(key); } - - if (e.target instanceof HTMLAnchorElement && item) { - router.open(e.target, e, item.props.href, item.props.routerOptions as RouterOptions); - } }; let role = 'menuitem'; @@ -191,39 +189,41 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re } let onPressStart = (e: PressEvent) => { - if (e.pointerType === 'keyboard') { - performAction(e); + // Trigger native click event on keydown unless this is a link (the browser will trigger onClick then). + if (e.pointerType === 'keyboard' && !selectionManager.isLink(key)) { + (e.target as HTMLElement).click(); } pressStartProp?.(e); }; - - let maybeClose = () => { - // Pressing a menu item should close by default in single selection mode but not multiple - // selection mode, except if overridden by the closeOnSelect prop. - if (!isTrigger && onClose && (closeOnSelect ?? (selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key)))) { - onClose(); - } + let isPressedRef = useRef(false); + let onPressChange = (isPressed: boolean) => { + pressChangeProp?.(isPressed); + isPressedRef.current = isPressed; }; let onPressUp = (e: PressEvent) => { // If interacting with mouse, allow the user to mouse down on the trigger button, // drag, and release over an item (matching native behavior). if (e.pointerType === 'mouse') { - performAction(e); - maybeClose(); + if (!isPressedRef.current) { + (e.target as HTMLElement).click(); + } + } + + // Pressing a menu item should close by default in single selection mode but not multiple + // selection mode, except if overridden by the closeOnSelect prop. + if (e.pointerType !== 'keyboard' && !isTrigger && onClose && (closeOnSelect ?? (selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key)))) { + onClose(); } pressUpProp?.(e); }; - let onPress = (e: PressEvent) => { - if (e.pointerType !== 'keyboard' && e.pointerType !== 'mouse') { - performAction(e); - maybeClose(); - } - - pressProp?.(e); + let onClick = (e: MouseEvent) => { + onClickProp?.(e); + performAction(); + handleLinkClick(e, router, item!.props.href, item?.props.routerOptions); }; let {itemProps, isFocused} = useSelectableItem({ @@ -315,7 +315,8 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re keyboardProps, focusProps, // Prevent DOM focus from moving on mouse down when using virtual focus or this is a submenu/subdialog trigger. - data.shouldUseVirtualFocus || isTrigger ? {onMouseDown: e => e.preventDefault()} : undefined + data.shouldUseVirtualFocus || isTrigger ? {onMouseDown: e => e.preventDefault()} : undefined, + isDisabled ? undefined : {onClick} ), // If a submenu is expanded, set the tabIndex to -1 so that shift tabbing goes out of the menu instead of the parent menu item. tabIndex: itemProps.tabIndex != null && isTriggerExpanded && !data.shouldUseVirtualFocus ? -1 : itemProps.tabIndex diff --git a/packages/@react-aria/radio/src/useRadio.ts b/packages/@react-aria/radio/src/useRadio.ts index 3c4f0220a22..e202c22677b 100644 --- a/packages/@react-aria/radio/src/useRadio.ts +++ b/packages/@react-aria/radio/src/useRadio.ts @@ -44,7 +44,13 @@ export function useRadio(props: AriaRadioProps, state: RadioGroupState, ref: Ref value, children, 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby + 'aria-labelledby': ariaLabelledby, + onPressStart, + onPressEnd, + onPressChange, + onPress, + onPressUp, + onClick } = props; const isDisabled = props.isDisabled || state.isDisabled; @@ -64,13 +70,25 @@ export function useRadio(props: AriaRadioProps, state: RadioGroupState, ref: Ref // Handle press state for keyboard interactions and cases where labelProps is not used. let {pressProps, isPressed} = usePress({ + onPressStart, + onPressEnd, + onPressChange, + onPress, + onPressUp, + onClick, isDisabled }); // Handle press state on the label. let {pressProps: labelProps, isPressed: isLabelPressed} = usePress({ + onPressStart, + onPressEnd, + onPressChange, + onPressUp, + onClick, isDisabled, - onPress() { + onPress(e) { + onPress?.(e); state.setSelectedValue(value); ref.current?.focus(); } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 84fb2662335..3a892960cf9 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ +import {chain, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared'; import {focusSafely, PressHookProps, useLongPress, usePress} from '@react-aria/interactions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; -import {isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; import {moveVirtualFocus} from '@react-aria/focus'; import {MultipleSelectionManager} from '@react-stately/selection'; import {useEffect, useRef} from 'react'; @@ -220,6 +220,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte let longPressEnabled = hasAction && allowsSelection; let longPressEnabledOnPressStart = useRef(false); let hadPrimaryActionOnPressStart = useRef(false); + let collectionItemProps = manager.getItemProps(key); let performAction = (e) => { if (onAction) { @@ -227,8 +228,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte } if (hasLinkAction && ref.current) { - let itemProps = manager.getItemProps(key); - router.open(ref.current, e, itemProps.href, itemProps.routerOptions); + router.open(ref.current, e, collectionItemProps.href, collectionItemProps.routerOptions); } }; @@ -337,6 +337,14 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte }); } + if (collectionItemProps) { + for (let key of ['onPressStart', 'onPressEnd', 'onPressChange', 'onPress', 'onPressUp', 'onClick']) { + if (collectionItemProps[key]) { + itemPressProps[key] = chain(itemPressProps[key], collectionItemProps[key]); + } + } + } + let {pressProps, isPressed} = usePress(itemPressProps); // Double clicking with a mouse with selectionBehavior = 'replace' performs an action. @@ -373,7 +381,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte // Prevent default on link clicks so that we control exactly // when they open (to match selection behavior). - let onClick = manager.isLink(key) ? e => { + let onClick = linkBehavior !== 'none' && manager.isLink(key) ? e => { if (!(openLink as any).isOpening) { e.preventDefault(); } diff --git a/packages/@react-aria/toggle/src/useToggle.ts b/packages/@react-aria/toggle/src/useToggle.ts index fee8b760286..3739590efea 100644 --- a/packages/@react-aria/toggle/src/useToggle.ts +++ b/packages/@react-aria/toggle/src/useToggle.ts @@ -47,7 +47,13 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, validationState = 'valid', - isInvalid + isInvalid, + onPressStart, + onPressEnd, + onPressChange, + onPress, + onPressUp, + onClick } = props; let onChange = (e) => { @@ -65,12 +71,24 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb // Handle press state for keyboard interactions and cases where labelProps is not used. let {pressProps, isPressed} = usePress({ + onPressStart, + onPressEnd, + onPressChange, + onPress, + onPressUp, + onClick, isDisabled }); // Handle press state on the label. let {pressProps: labelProps, isPressed: isLabelPressed} = usePress({ - onPress() { + onPressStart, + onPressEnd, + onPressChange, + onPressUp, + onClick, + onPress(e) { + onPress?.(e); state.toggle(); ref.current?.focus(); }, diff --git a/packages/@react-aria/utils/src/filterDOMProps.ts b/packages/@react-aria/utils/src/filterDOMProps.ts index 7912334f985..61918d421ca 100644 --- a/packages/@react-aria/utils/src/filterDOMProps.ts +++ b/packages/@react-aria/utils/src/filterDOMProps.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMProps, LinkDOMProps} from '@react-types/shared'; +import {AriaLabelingProps, DOMProps, GlobalDOMAttributes, LinkDOMProps} from '@react-types/shared'; const DOMPropNames = new Set([ 'id' @@ -34,6 +34,51 @@ const linkPropNames = new Set([ 'referrerPolicy' ]); +const globalAttrs = new Set([ + 'dir', + 'lang', + 'hidden', + 'inert', + 'translate' +]); + +const globalEvents = new Set([ + 'onClick', + 'onAuxClick', + 'onContextMenu', + 'onDoubleClick', + 'onMouseDown', + 'onMouseEnter', + 'onMouseLeave', + 'onMouseMove', + 'onMouseOut', + 'onMouseOver', + 'onMouseUp', + 'onTouchCancel', + 'onTouchEnd', + 'onTouchMove', + 'onTouchStart', + 'onPointerDown', + 'onPointerMove', + 'onPointerUp', + 'onPointerCancel', + 'onPointerEnter', + 'onPointerLeave', + 'onPointerOver', + 'onPointerOut', + 'onGotPointerCapture', + 'onLostPointerCapture', + 'onScroll', + 'onWheel', + 'onAnimationStart', + 'onAnimationEnd', + 'onAnimationIteration', + 'onTransitionCancel', + 'onTransitionEnd', + 'onTransitionRun', + 'onTransitionStart' +]); + interface Options { /** * If labelling associated aria properties should be included in the filter. @@ -41,6 +86,10 @@ interface Options { labelable?: boolean, /** Whether the element is a link and should include DOM props for elements. */ isLink?: boolean, + /** Whether to include global DOM attributes. */ + global?: boolean, + /** Whether to include DOM events. */ + events?: boolean, /** * A Set of other property names that should be included in the filter. */ @@ -54,8 +103,8 @@ const propRe = /^(data-.*)$/; * @param props - The component props to be filtered. * @param opts - Props to override. */ -export function filterDOMProps(props: DOMProps & AriaLabelingProps & LinkDOMProps, opts: Options = {}): DOMProps & AriaLabelingProps { - let {labelable, isLink, propNames} = opts; +export function filterDOMProps(props: DOMProps & AriaLabelingProps & LinkDOMProps & GlobalDOMAttributes, opts: Options = {}): DOMProps & AriaLabelingProps & GlobalDOMAttributes { + let {labelable, isLink, global, events = global, propNames} = opts; let filteredProps = {}; for (const prop in props) { @@ -64,6 +113,8 @@ export function filterDOMProps(props: DOMProps & AriaLabelingProps & LinkDOMProp DOMPropNames.has(prop) || (labelable && labelablePropNames.has(prop)) || (isLink && linkPropNames.has(prop)) || + (global && globalAttrs.has(prop)) || + (events && globalEvents.has(prop) || (prop.endsWith('Capture') && globalEvents.has(prop.slice(0, -7)))) || propNames?.has(prop) || propRe.test(prop) ) diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index a567ea89667..d846e80a379 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -19,7 +19,7 @@ export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; export {focusWithoutScrolling} from './focusWithoutScrolling'; export {getOffset} from './getOffset'; -export {openLink, getSyntheticLinkProps, useSyntheticLinkProps, RouterProvider, shouldClientNavigate, useRouter, useLinkProps} from './openLink'; +export {openLink, getSyntheticLinkProps, useSyntheticLinkProps, RouterProvider, shouldClientNavigate, useRouter, useLinkProps, handleLinkClick} from './openLink'; export {runAfterTransition} from './runAfterTransition'; export {useDrag1D} from './useDrag1D'; export {useGlobalListeners} from './useGlobalListeners'; diff --git a/packages/@react-aria/utils/src/openLink.tsx b/packages/@react-aria/utils/src/openLink.tsx index 79981f735c9..e3c4823f998 100644 --- a/packages/@react-aria/utils/src/openLink.tsx +++ b/packages/@react-aria/utils/src/openLink.tsx @@ -13,7 +13,7 @@ import {focusWithoutScrolling, isMac, isWebKit} from './index'; import {Href, LinkDOMProps, RouterOptions} from '@react-types/shared'; import {isFirefox, isIPad} from './platform'; -import React, {createContext, DOMAttributes, JSX, ReactNode, useContext, useMemo} from 'react'; +import React, {createContext, DOMAttributes, JSX, MouseEvent as ReactMouseEvent, ReactNode, useContext, useMemo} from 'react'; interface Router { isNative: boolean, @@ -183,3 +183,19 @@ export function useLinkProps(props?: LinkDOMProps): LinkDOMProps { referrerPolicy: props?.referrerPolicy }; } + +export function handleLinkClick(e: ReactMouseEvent, router: Router, href: Href | undefined, routerOptions: RouterOptions | undefined): void { + // If a custom router is provided, prevent default and forward if this link should client navigate. + if ( + !router.isNative && + e.currentTarget instanceof HTMLAnchorElement && + e.currentTarget.href && + // If props are applied to a router Link component, it may have already prevented default. + !e.isDefaultPrevented() && + shouldClientNavigate(e.currentTarget, e) && + href + ) { + e.preventDefault(); + router.open(e.currentTarget, e, href, routerOptions); + } +} diff --git a/packages/@react-spectrum/menu/test/Menu.test.js b/packages/@react-spectrum/menu/test/Menu.test.js index ec10ed3d328..1157ced12a6 100644 --- a/packages/@react-spectrum/menu/test/Menu.test.js +++ b/packages/@react-spectrum/menu/test/Menu.test.js @@ -496,8 +496,10 @@ describe('Menu', function () { let secondItem = menuItems[4]; let thirdItem = menuItems[1]; await user.click(firstItem); - fireEvent.keyDown(secondItem, {key: ' ', code: 32, charCode: 32}); - fireEvent.keyDown(thirdItem, {key: 'Enter', code: 13, charCode: 13}); + await user.keyboard('{ArrowDown}'); + await user.keyboard(' '); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); expect(firstItem).not.toHaveAttribute('aria-checked', 'true'); expect(secondItem).not.toHaveAttribute('aria-checked', 'true'); expect(thirdItem).not.toHaveAttribute('aria-checked', 'true'); @@ -813,12 +815,13 @@ describe('Menu', function () { expect(items[1].tagName).toBe('A'); expect(items[1]).toHaveAttribute('href', 'https://adobe.com'); - let onClick = mockClickDefault(); + let onClick = mockClickDefault({capture: true}); if (type === 'mouse') { await user.click(items[1]); } else { fireEvent.keyDown(items[1], {key: 'Enter'}); + fireEvent.click(items[1]); fireEvent.keyUp(items[1], {key: 'Enter'}); } expect(onAction).toHaveBeenCalledTimes(1); diff --git a/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx b/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx index f4dcef74377..11490c3494d 100644 --- a/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx +++ b/packages/@react-spectrum/menu/test/SubMenuTrigger.test.tsx @@ -686,6 +686,7 @@ describe('Submenu', function () { // Hit enter on the menu's submenu trigger await user.keyboard('[Enter]'); + act(() => jest.runAllTimers()); expect(onSelectionChange).not.toHaveBeenCalled(); let menus = tree.getAllByRole('menu', {hidden: true}); let submenu1Items = within(menus[1]).getAllByRole('menuitemradio'); diff --git a/packages/@react-types/checkbox/src/index.d.ts b/packages/@react-types/checkbox/src/index.d.ts index b44d614efb5..a2bcfb85327 100644 --- a/packages/@react-types/checkbox/src/index.d.ts +++ b/packages/@react-types/checkbox/src/index.d.ts @@ -22,6 +22,7 @@ import { InputDOMProps, LabelableProps, Orientation, + PressEvents, SpectrumHelpTextProps, SpectrumLabelableProps, StyleProps, @@ -56,7 +57,7 @@ export interface ToggleProps extends ToggleStateOptions, Validation, Fo value?: string } -export interface AriaToggleProps extends ToggleProps, FocusableDOMProps, AriaLabelingProps, AriaValidationProps, InputDOMProps { +export interface AriaToggleProps extends ToggleProps, FocusableDOMProps, AriaLabelingProps, AriaValidationProps, InputDOMProps, PressEvents { /** * Identifies the element (or elements) whose contents or presence are controlled by the current element. */ diff --git a/packages/@react-types/radio/src/index.d.ts b/packages/@react-types/radio/src/index.d.ts index e542dcfd65f..54ebb3c8bd0 100644 --- a/packages/@react-types/radio/src/index.d.ts +++ b/packages/@react-types/radio/src/index.d.ts @@ -21,6 +21,7 @@ import { InputDOMProps, LabelableProps, Orientation, + PressEvents, SpectrumHelpTextProps, SpectrumLabelableProps, StyleProps, @@ -67,5 +68,5 @@ export interface SpectrumRadioGroupProps extends AriaRadioGroupProps, SpectrumLa isEmphasized?: boolean } -export interface AriaRadioProps extends RadioProps, DOMProps, AriaLabelingProps {} +export interface AriaRadioProps extends RadioProps, DOMProps, AriaLabelingProps, PressEvents {} export interface SpectrumRadioProps extends AriaRadioProps, StyleProps {} diff --git a/packages/@react-types/shared/src/dom.d.ts b/packages/@react-types/shared/src/dom.d.ts index d6acd30ba68..9e49190f117 100644 --- a/packages/@react-types/shared/src/dom.d.ts +++ b/packages/@react-types/shared/src/dom.d.ts @@ -11,6 +11,7 @@ */ import { + AnimationEventHandler, AriaAttributes, AriaRole, ClipboardEventHandler, @@ -19,8 +20,14 @@ import { FormEventHandler, HTMLAttributeAnchorTarget, HTMLAttributeReferrerPolicy, + MouseEventHandler, + PointerEventHandler, DOMAttributes as ReactDOMAttributes, - ReactEventHandler + ReactEventHandler, + TouchEventHandler, + TransitionEventHandler, + UIEventHandler, + WheelEventHandler } from 'react'; export interface AriaLabelingProps { @@ -223,3 +230,107 @@ export interface DOMAttributes extends AriaAttributes, Rea export interface GroupDOMAttributes extends Omit, 'role'> { role?: 'group' | 'region' | 'presentation' } + +/** + * Global attributes that can be applied to any DOM element. + * @private + */ +// NOTE: id is handled elsewhere (DOMProps). +export interface GlobalDOMAttributes extends GlobalDOMEvents { + dir?: string | undefined, + lang?: string | undefined, + hidden?: boolean | undefined, + inert?: boolean | undefined, + translate?: 'yes' | 'no' | undefined +} + +/** + * Global DOM events that are supported on all DOM elements. + * @private + */ +// NOTES: +// - Drag and drop events are omitted for now. +// - Keyboard and focus events are supported directly on focusable elements (FocusableProps). +// - Text input events (e.g. onInput, onCompositionStart, onCopy) are +// supported only directly on input elements (TextInputDOMProps). +// We don't support contentEditable on our components. +// - Media events should be handled directly on the