Skip to content

chore: Pass through more DOM events and attributes #8327

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
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
7 changes: 7 additions & 0 deletions packages/@react-aria/autocomplete/src/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
};
Expand Down
17 changes: 2 additions & 15 deletions packages/@react-aria/link/src/useLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -72,20 +72,7 @@ export function useLink(props: AriaLinkOptions, ref: RefObject<FocusableElement
'aria-current': props['aria-current'],
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => {
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);
}
})
};
Expand Down
57 changes: 29 additions & 28 deletions packages/@react-aria/menu/src/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -112,9 +113,10 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
'aria-haspopup': hasPopup,
onPressStart: pressStartProp,
onPressUp: pressUpProp,
onPress: pressProp,
onPressChange,
onPress,
onPressChange: pressChangeProp,
onPressEnd,
onClick: onClickProp,
onHoverStart: hoverStartProp,
onHoverChange,
onHoverEnd,
Expand All @@ -134,7 +136,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, 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;
}
Expand All @@ -150,10 +152,6 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, 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';
Expand Down Expand Up @@ -191,39 +189,41 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, 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<FocusableElement>) => {
onClickProp?.(e);
performAction();
handleLinkClick(e, router, item!.props.href, item?.props.routerOptions);
};

let {itemProps, isFocused} = useSelectableItem({
Expand Down Expand Up @@ -315,7 +315,8 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, 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
Expand Down
22 changes: 20 additions & 2 deletions packages/@react-aria/radio/src/useRadio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down
16 changes: 12 additions & 4 deletions packages/@react-aria/selection/src/useSelectableItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -220,15 +220,15 @@ 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) {
onAction();
}

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);
}
};

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down
22 changes: 20 additions & 2 deletions packages/@react-aria/toggle/src/useToggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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();
},
Expand Down
57 changes: 54 additions & 3 deletions packages/@react-aria/utils/src/filterDOMProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -34,13 +34,62 @@ 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.
*/
labelable?: boolean,
/** Whether the element is a link and should include DOM props for <a> 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.
*/
Expand All @@ -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) {
Expand All @@ -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)
)
Expand Down
Loading