diff --git a/.circleci/config.yml b/.circleci/config.yml
index 344a6934521..8e0309e3364 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -20,14 +20,14 @@ orbs:
executors:
rsp:
docker:
- - image: cimg/node:22.21.1
+ - image: cimg/node:24.13.0
environment:
CACHE_VERSION: v1
working_directory: ~/react-spectrum
rsp-large:
docker:
- - image: cimg/node:22.21.1
+ - image: cimg/node:24.13.0
resource_class: large
environment:
CACHE_VERSION: v1
@@ -35,7 +35,7 @@ executors:
rsp-xlarge:
docker:
- - image: cimg/node:22.21.1
+ - image: cimg/node:24.13.0
resource_class: xlarge
environment:
CACHE_VERSION: v1
@@ -43,7 +43,7 @@ executors:
rsp-2xlarge:
docker:
- - image: cimg/node:22.21.1
+ - image: cimg/node:24.13.0
resource_class: 2xlarge
environment:
CACHE_VERSION: v1
diff --git a/.github/actions/branch/action.yml b/.github/actions/branch/action.yml
index 2ff8098f580..1d1f39c72da 100644
--- a/.github/actions/branch/action.yml
+++ b/.github/actions/branch/action.yml
@@ -1,5 +1,5 @@
name: 'Branch from fork'
description: 'creates a branch based off PR from fork'
runs:
- using: 'node22'
+ using: 'node24'
main: 'index.js'
diff --git a/.github/actions/comment/action.yml b/.github/actions/comment/action.yml
index 72aea6ed745..af0b32e476d 100644
--- a/.github/actions/comment/action.yml
+++ b/.github/actions/comment/action.yml
@@ -1,5 +1,5 @@
name: 'PR Comment'
description: 'Comment on the PR attached to a commit'
runs:
- using: 'node22'
+ using: 'node24'
main: 'index.js'
diff --git a/.github/actions/permissions/action.yml b/.github/actions/permissions/action.yml
index 44b9510de02..dc79862cbd6 100644
--- a/.github/actions/permissions/action.yml
+++ b/.github/actions/permissions/action.yml
@@ -1,5 +1,5 @@
name: 'Check permissions'
description: 'Checks if commentor has write access or above'
runs:
- using: 'node22'
+ using: 'node24'
main: 'index.js'
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 47a61392c8a..ffb2b9212f6 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -1,3 +1,16 @@
needs translations:
- changed-files:
- any-glob-to-any-file: ['**/intl/*.json']
+
+S2:
+- changed-files:
+ - any-glob-to-any-file: ['**/s2/**']
+
+RAC:
+- changed-files:
+ - any-glob-to-any-file: ['**/react-aria-components/**', '**/@react-aria/**']
+
+V3:
+- changed-files:
+ - any-glob-to-any-file: ['**/@react-spectrum/**']
+
diff --git a/.nvmrc b/.nvmrc
index 2bd5a0a98a3..a45fd52cc58 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-22
+24
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 057cd3ee4e1..4bada46eab9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -58,7 +58,7 @@ If you are looking for place to start, consider the following options:
## Developing
When you are ready to start developing you can clone the repo and start storybook.
-Make sure you have the following requirements installed: [node](https://nodejs.org/) (v14.15.0+) and [yarn](https://yarnpkg.com/en/) (v1.22.0+)
+Make sure you have the following requirements installed: [node](https://nodejs.org/) (v24.13.0+) and [yarn](https://yarnpkg.com/en/) (v1.22.0+)
Fork the repo first using [this guide](https://help.github.com/articles/fork-a-repo), then clone it locally.
```
@@ -176,7 +176,7 @@ parcel build packages/@react-{spectrum,aria,stately}/*/ packages/@internationali
make: *** [build] Segmentation fault: 11
```
-It's likely that you are using a different version of Node.js. Please use Node.js 18. When changing the node version, delete `node_modules` and re-run `yarn install`
+It's likely that you are using a different version of Node.js. Please use Node.js 24. When changing the node version, delete `node_modules` and re-run `yarn install`
> `yarn start` fails.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 23244f929ba..d7c27a8dbee 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -250,6 +250,8 @@ export default [{
"rsp-rules/no-react-key": [ERROR],
"rsp-rules/sort-imports": [ERROR],
"rsp-rules/no-non-shadow-contains": [ERROR],
+ "rsp-rules/safe-event-target": [ERROR],
+ "rsp-rules/shadow-safe-active-element": [ERROR],
"rsp-rules/faster-node-contains": [ERROR],
"rulesdir/imports": [ERROR],
"rulesdir/useLayoutEffectRule": [ERROR],
@@ -431,6 +433,8 @@ export default [{
"rsp-rules/act-events-test": ERROR,
"rsp-rules/no-getByRole-toThrow": ERROR,
"rsp-rules/no-non-shadow-contains": OFF,
+ "rsp-rules/safe-event-target": OFF,
+ "rsp-rules/shadow-safe-active-element": OFF,
"rsp-rules/faster-node-contains": OFF,
"rulesdir/imports": OFF,
"monorepo/no-internal-import": OFF,
@@ -472,6 +476,7 @@ export default [{
rules: {
"jsdoc/require-jsdoc": OFF,
"jsdoc/require-description": OFF,
+ "rsp-rules/safe-event-target": OFF,
},
}, {
files: [
@@ -512,6 +517,7 @@ export default [{
rules: {
"rsp-rules/faster-node-contains": OFF,
"rsp-rules/no-non-shadow-contains": OFF,
+ "rsp-rules/shadow-safe-active-element": OFF,
},
}, {
files: ["packages/@react-spectrum/s2/**", "packages/dev/s2-docs/**"],
diff --git a/examples/remix/package.json b/examples/remix/package.json
index 851f7a83d51..44323ad2810 100644
--- a/examples/remix/package.json
+++ b/examples/remix/package.json
@@ -30,7 +30,7 @@
"vite-tsconfig-paths": "^4.2.1"
},
"engines": {
- "node": ">=22.0.0"
+ "node": ">=24.0.0"
},
"workspaces": [
"../../packages/react-aria-components",
diff --git a/package.json b/package.json
index a66edf13f43..80eedfdee2d 100644
--- a/package.json
+++ b/package.json
@@ -178,6 +178,7 @@
"lerna": "^3.13.2",
"lucide-react": "^0.517.0",
"md5": "^2.2.1",
+ "mdast-util-to-string": "^4.0.0",
"motion": "^12.23.6",
"npm-cli-login": "^1.0.0",
"parcel": "^2.16.3",
@@ -231,9 +232,9 @@
"ast-types": "0.16.1",
"svgo": "^3",
"@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch",
- "@types/node@npm:*": "^22",
- "@types/node@npm:^18.0.0": "^22",
- "@types/node@npm:>= 8": "^22",
+ "@types/node@npm:*": "^24",
+ "@types/node@npm:^18.0.0": "^24",
+ "@types/node@npm:>= 8": "^24",
"@storybook/addon-docs@npm:8.6.14": "patch:@storybook/addon-docs@npm%3A8.6.14#~/.yarn/patches/@storybook-addon-docs-npm-8.6.14-12ab3f55f8.patch",
"@storybook/react": "patch:@storybook/react@npm%3A8.6.14#~/.yarn/patches/@storybook-react-npm-8.6.14-bc3fc2208a.patch",
"micromark-extension-mdxjs": "patch:micromark-extension-mdxjs@npm%3A1.0.0#~/.yarn/patches/micromark-extension-mdxjs-npm-1.0.0-d2b6b69e4a.patch",
diff --git a/packages/@internationalized/date/docs/ZonedDateTime.mdx b/packages/@internationalized/date/docs/ZonedDateTime.mdx
index 5b7e8ca9940..009b1fdb2e8 100644
--- a/packages/@internationalized/date/docs/ZonedDateTime.mdx
+++ b/packages/@internationalized/date/docs/ZonedDateTime.mdx
@@ -64,12 +64,14 @@ let date = parseAbsoluteToLocal('2021-11-07T07:45:00Z');
You can also create a `ZonedDateTime` using a `Date` object or epoch time (milliseconds) using one of the following functions:
* β This function creates a `ZonedDateTime` from a `Date` object. A time zone identifier, e.g. `America/Los_Angeles`, must be passed, and the result will be converted into that time zone.
+* β This function creates a `ZonedDateTime` from a `Date` object. The time zone identifier is automatically resolved from the current userβs environment, and the result is converted into that local time zone.
* β This function creates a `ZonedDateTime` from a Unix epoch (e.g. `1688023843144`, representing milliseconds since 1970). A time zone identifier, e.g. `America/Los_Angeles`, must be provided, and the result will be converted into that time zone.
```tsx
-import {fromDate, fromAbsolute} from '@internationalized/date';
+import {fromDate, fromDateToLocal, fromAbsolute} from '@internationalized/date';
let date = fromDate(new Date(), 'America/Los_Angeles');
+let date = fromDateToLocal(new Date());
let date = fromAbsolute(1688023843144, 'America/Los_Angeles');
```
diff --git a/packages/@internationalized/date/src/conversion.ts b/packages/@internationalized/date/src/conversion.ts
index 7b1ae398d1b..d34b256ae0e 100644
--- a/packages/@internationalized/date/src/conversion.ts
+++ b/packages/@internationalized/date/src/conversion.ts
@@ -197,6 +197,9 @@ export function fromDate(date: Date, timeZone: string): ZonedDateTime {
return fromAbsolute(date.getTime(), timeZone);
}
+/**
+ * Takes a `Date` object and converts it to the time zone identifier for the current user.
+ */
export function fromDateToLocal(date: Date): ZonedDateTime {
return fromDate(date, getLocalTimeZone());
}
diff --git a/packages/@internationalized/date/src/index.ts b/packages/@internationalized/date/src/index.ts
index 22e703f66f5..5d373c842f1 100644
--- a/packages/@internationalized/date/src/index.ts
+++ b/packages/@internationalized/date/src/index.ts
@@ -48,6 +48,7 @@ export {
toTimeZone,
toLocalTimeZone,
fromDate,
+ fromDateToLocal,
fromAbsolute
} from './conversion';
export {
diff --git a/packages/@react-aria/actiongroup/src/useActionGroup.ts b/packages/@react-aria/actiongroup/src/useActionGroup.ts
index c2afb86c9a7..e06a3123776 100644
--- a/packages/@react-aria/actiongroup/src/useActionGroup.ts
+++ b/packages/@react-aria/actiongroup/src/useActionGroup.ts
@@ -13,10 +13,10 @@
import {AriaActionGroupProps} from '@react-types/actiongroup';
import {createFocusManager} from '@react-aria/focus';
import {DOMAttributes, FocusableElement, Orientation, RefObject} from '@react-types/shared';
-import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils';
+import {filterDOMProps, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils';
+import {KeyboardEventHandler, useState} from 'react';
import {ListState} from '@react-stately/list';
import {useLocale} from '@react-aria/i18n';
-import {useState} from 'react';
const BUTTON_GROUP_ROLES = {
'none': 'toolbar',
@@ -47,8 +47,8 @@ export function useActionGroup(props: AriaActionGroupProps, state: ListSta
let {direction} = useLocale();
let focusManager = createFocusManager(ref);
let flipDirection = direction === 'rtl' && orientation === 'horizontal';
- let onKeyDown = (e) => {
- if (!nodeContains(e.currentTarget, e.target)) {
+ let onKeyDown: KeyboardEventHandler = (e) => {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts
index 291cd9065bb..e826dac7dcc 100644
--- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts
+++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts
@@ -13,7 +13,7 @@
import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, RefObject, ValueBase} from '@react-types/shared';
import {AriaTextFieldProps} from '@react-aria/textfield';
import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete';
-import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useLayoutEffect, useObjectRef} from '@react-aria/utils';
+import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getEventTarget, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useLayoutEffect, useObjectRef} from '@react-aria/utils';
import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus';
import {getInteractionModality, getPointerType} from '@react-aria/interactions';
// @ts-ignore
@@ -112,7 +112,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut
inputRef.current.focus();
}
- let target = e.target as Element | null;
+ let target = getEventTarget(e) as Element | null;
if (e.isTrusted || !target || queuedActiveDescendant.current === target.id) {
return;
}
@@ -225,7 +225,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut
let keyDownTarget = useRef(null);
// For textfield specific keydown operations
let onKeyDown = (e: BaseEvent>) => {
- keyDownTarget.current = e.target as Element;
+ keyDownTarget.current = getEventTarget(e) as Element;
if (e.nativeEvent.isComposing) {
return;
}
@@ -329,7 +329,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut
// Dispatch simulated key up events for things like triggering links in listbox
// Make sure to stop the propagation of the input keyup event so that the simulated keyup/down pair
// is detected by usePress instead of the original keyup originating from the input
- if (e.target === keyDownTarget.current) {
+ if (getEventTarget(e) === keyDownTarget.current) {
e.stopImmediatePropagation();
let focusedNodeId = queuedActiveDescendant.current;
if (focusedNodeId == null) {
@@ -386,7 +386,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut
let curFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null;
if (curFocusedNode) {
- let target = e.target;
+ let target = getEventTarget(e);
queueMicrotask(() => {
// instead of focusing the last focused node, just focus the collection instead and have the collection handle what item to focus via useSelectableCollection/Item
dispatchVirtualBlur(target, collectionRef.current);
diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts
index 6b059764bec..82f44a07697 100644
--- a/packages/@react-aria/calendar/src/useCalendarCell.ts
+++ b/packages/@react-aria/calendar/src/useCalendarCell.ts
@@ -13,7 +13,7 @@
import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date';
import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
import {DOMAttributes, RefObject} from '@react-types/shared';
-import {focusWithoutScrolling, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils';
+import {focusWithoutScrolling, getActiveElement, getEventTarget, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils';
import {getEraFormat, hookData} from './utils';
import {getInteractionModality, usePress} from '@react-aria/interactions';
// @ts-ignore
@@ -300,7 +300,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
// Also only scroll into view if the cell actually got focused.
// There are some cases where the cell might be disabled or inside,
// an inert container and we don't want to scroll then.
- if (getInteractionModality() !== 'pointer' && document.activeElement === ref.current) {
+ if (getInteractionModality() !== 'pointer' && getActiveElement() === ref.current) {
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});
}
}
@@ -343,17 +343,18 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
state.highlightDate(date);
}
},
- onPointerDown(e) {
+ onPointerDown(e: PointerEvent) {
// This is necessary on touch devices to allow dragging
// outside the original pressed element.
// (JSDOM does not support this)
- if ('releasePointerCapture' in e.target) {
- if ('hasPointerCapture' in e.target) {
- if (e.target.hasPointerCapture(e.pointerId)) {
- e.target.releasePointerCapture(e.pointerId);
+ let target = getEventTarget(e);
+ if (target instanceof HTMLElement && 'releasePointerCapture' in target) {
+ if ('hasPointerCapture' in target) {
+ if (target.hasPointerCapture(e.pointerId)) {
+ target.releasePointerCapture(e.pointerId);
}
} else {
- e.target.releasePointerCapture(e.pointerId);
+ (target as HTMLElement).releasePointerCapture(e.pointerId);
}
}
},
diff --git a/packages/@react-aria/color/src/useColorWheel.ts b/packages/@react-aria/color/src/useColorWheel.ts
index bcfa244cda8..789e9312784 100644
--- a/packages/@react-aria/color/src/useColorWheel.ts
+++ b/packages/@react-aria/color/src/useColorWheel.ts
@@ -13,7 +13,7 @@
import {AriaColorWheelProps} from '@react-types/color';
import {ColorWheelState} from '@react-stately/color';
import {DOMAttributes, RefObject} from '@react-types/shared';
-import {focusWithoutScrolling, mergeProps, useFormReset, useGlobalListeners, useLabels} from '@react-aria/utils';
+import {focusWithoutScrolling, getEventTarget, mergeProps, useFormReset, useGlobalListeners, useLabels} from '@react-aria/utils';
import React, {ChangeEvent, InputHTMLAttributes, useCallback, useRef} from 'react';
import {useKeyboard, useMove} from '@react-aria/interactions';
import {useLocale} from '@react-aria/i18n';
@@ -328,7 +328,7 @@ export function useColorWheel(props: AriaColorWheelOptions, state: ColorWheelSta
name,
form,
onChange: (e: ChangeEvent) => {
- state.setHue(parseFloat(e.target.value));
+ state.setHue(parseFloat(getEventTarget(e).value));
},
style: visuallyHiddenProps.style,
'aria-errormessage': props['aria-errormessage'],
diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts
index c8db04c19cc..e41da6f4b7c 100644
--- a/packages/@react-aria/combobox/src/useComboBox.ts
+++ b/packages/@react-aria/combobox/src/useComboBox.ts
@@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox';
import {ariaHideOutside} from '@react-aria/overlays';
import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox';
import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared';
-import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils';
+import {chain, getActiveElement, getEventTarget, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils';
import {ComboBoxState} from '@react-stately/combobox';
import {dispatchVirtualFocus} from '@react-aria/focus';
import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react';
@@ -264,7 +264,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta
return;
}
- let rect = (e.target as Element).getBoundingClientRect();
+ let rect = (getEventTarget(e) as Element).getBoundingClientRect();
let touch = e.changedTouches[0];
let centerX = Math.ceil(rect.left + .5 * rect.width);
diff --git a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts
index b8431acc549..fff29ad078d 100644
--- a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts
+++ b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts
@@ -1,7 +1,7 @@
import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus';
import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker';
import {DOMAttributes, FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared';
-import {mergeProps, nodeContains} from '@react-aria/utils';
+import {getEventTarget, mergeProps, nodeContains} from '@react-aria/utils';
import {useLocale} from '@react-aria/i18n';
import {useMemo} from 'react';
import {usePress} from '@react-aria/interactions';
@@ -12,7 +12,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
// Open the popover on alt + arrow down
let onKeyDown = (e: KeyboardEvent) => {
- if (!nodeContains(e.currentTarget, e.target as Element)) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e) as Element)) {
return;
}
@@ -32,7 +32,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
e.stopPropagation();
if (direction === 'rtl') {
if (ref.current) {
- let target = e.target as FocusableElement;
+ let target = getEventTarget(e) as FocusableElement;
let prev = findNextSegment(ref.current, target.getBoundingClientRect().left, -1);
if (prev) {
@@ -48,7 +48,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
e.stopPropagation();
if (direction === 'rtl') {
if (ref.current) {
- let target = e.target as FocusableElement;
+ let target = getEventTarget(e) as FocusableElement;
let next = findNextSegment(ref.current, target.getBoundingClientRect().left, 1);
if (next) {
@@ -68,7 +68,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
return;
}
// Try to find the segment prior to the element that was clicked on.
- let target = window.event?.target as FocusableElement;
+ let target = window.event ? getEventTarget(window.event) as FocusableElement : null;
let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
if (target) {
walker.currentNode = target;
diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts
index 9e77f840f51..4ed65b82fdb 100644
--- a/packages/@react-aria/datepicker/src/useDateSegment.ts
+++ b/packages/@react-aria/datepicker/src/useDateSegment.ts
@@ -12,7 +12,7 @@
import {CalendarDate, toCalendar} from '@internationalized/date';
import {DateFieldState, DateSegment} from '@react-stately/datepicker';
-import {getScrollParent, isIOS, isMac, mergeProps, nodeContains, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils';
+import {getActiveElement, getScrollParent, isIOS, isMac, mergeProps, nodeContains, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils';
import {hookData} from './useDateField';
import {NumberParser} from '@internationalized/number';
import React, {CSSProperties, useMemo, useRef} from 'react';
@@ -311,7 +311,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
let element = ref.current;
return () => {
// If the focused segment is removed, focus the previous one, or the next one if there was no previous one.
- if (document.activeElement === element) {
+ if (getActiveElement() === element) {
let prev = focusManager.focusPrevious();
if (!prev) {
focusManager.focusNext();
diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts
index 3094022f80f..01431b86990 100644
--- a/packages/@react-aria/dialog/src/useDialog.ts
+++ b/packages/@react-aria/dialog/src/useDialog.ts
@@ -12,7 +12,7 @@
import {AriaDialogProps} from '@react-types/dialog';
import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared';
-import {filterDOMProps, isFocusWithin, useSlotId} from '@react-aria/utils';
+import {filterDOMProps, getActiveElement, isFocusWithin, useSlotId} from '@react-aria/utils';
import {focusSafely} from '@react-aria/interactions';
import {useEffect, useRef} from 'react';
import {useOverlayFocusContain} from '@react-aria/overlays';
@@ -48,7 +48,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject {
// Check that the dialog is still focused, or focused was lost to the body.
- if (document.activeElement === ref.current || document.activeElement === document.body) {
+ if (getActiveElement() === ref.current || getActiveElement() === document.body) {
isRefocusing.current = true;
if (ref.current) {
ref.current.blur();
diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts
index 9895aaa818a..3b947be4d84 100644
--- a/packages/@react-aria/dnd/src/DragManager.ts
+++ b/packages/@react-aria/dnd/src/DragManager.ts
@@ -13,8 +13,8 @@
import {announce} from '@react-aria/live-announcer';
import {ariaHideOutside} from '@react-aria/overlays';
import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared';
+import {getActiveElement, getEventTarget, isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils';
import {getDragModality, getTypes} from './utils';
-import {isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils';
import type {LocalizedStringFormatter} from '@internationalized/string';
import {RefObject, useEffect, useState} from 'react';
@@ -243,7 +243,7 @@ class DragSession {
this.cancelEvent(e);
if (e.key === 'Enter') {
- if (e.altKey || nodeContains(this.getCurrentActivateButton(), e.target as Node)) {
+ if (e.altKey || nodeContains(this.getCurrentActivateButton(), getEventTarget(e) as Node)) {
this.activate(this.currentDropTarget, this.currentDropItem);
} else {
this.drop();
@@ -257,25 +257,26 @@ class DragSession {
onFocus(e: FocusEvent): void {
let activateButton = this.getCurrentActivateButton();
- if (e.target === activateButton) {
+ let eventTarget = getEventTarget(e);
+ if (eventTarget === activateButton) {
// TODO: canceling this breaks the focus ring. Revisit when we support tabbing.
this.cancelEvent(e);
return;
}
// Prevent focus events, except to the original drag target.
- if (e.target !== this.dragTarget.element) {
+ if (eventTarget !== this.dragTarget.element) {
this.cancelEvent(e);
}
// Ignore focus events on the window/document (JSDOM). Will be handled in onBlur, below.
- if (!(e.target instanceof HTMLElement) || e.target === this.dragTarget.element) {
+ if (!(eventTarget instanceof HTMLElement) || eventTarget === this.dragTarget.element) {
return;
}
let dropTarget =
- this.validDropTargets.find(target => target.element === e.target as HTMLElement) ||
- this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement));
+ this.validDropTargets.find(target => target.element === eventTarget) ||
+ this.validDropTargets.find(target => nodeContains(target.element, eventTarget));
if (!dropTarget) {
// if (e.target === activateButton) {
@@ -289,7 +290,7 @@ class DragSession {
return;
}
- let item = dropItems.get(e.target as HTMLElement);
+ let item = dropItems.get(eventTarget);
if (dropTarget) {
this.setCurrentDropTarget(dropTarget, item);
}
@@ -302,7 +303,7 @@ class DragSession {
return;
}
- if (e.target !== this.dragTarget.element) {
+ if (getEventTarget(e) !== this.dragTarget.element) {
this.cancelEvent(e);
}
@@ -321,15 +322,16 @@ class DragSession {
this.cancelEvent(e);
if (isVirtualClick(e) || this.isVirtualClick) {
let dropElements = dropItems.values();
- let item = [...dropElements].find(item => item.element === e.target as HTMLElement || nodeContains(item.activateButtonRef?.current, e.target as HTMLElement));
- let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement));
+ let eventTarget = getEventTarget(e) as HTMLElement;
+ let item = [...dropElements].find(item => item.element === eventTarget || nodeContains(item.activateButtonRef?.current, eventTarget));
+ let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, eventTarget));
let activateButton = item?.activateButtonRef?.current ?? dropTarget?.activateButtonRef?.current;
- if (nodeContains(activateButton, e.target as HTMLElement) && dropTarget) {
+ if (nodeContains(activateButton, eventTarget) && dropTarget) {
this.activate(dropTarget, item);
return;
}
- if (e.target === this.dragTarget.element) {
+ if (getEventTarget(e) === this.dragTarget.element) {
this.cancel();
return;
}
@@ -350,7 +352,8 @@ class DragSession {
cancelEvent(e: Event): void {
// Allow focusin and focusout on the drag target so focus ring works properly.
- if ((e.type === 'focusin' || e.type === 'focusout') && (e.target === this.dragTarget?.element || e.target === this.getCurrentActivateButton())) {
+ let eventTarget = getEventTarget(e);
+ if ((e.type === 'focusin' || e.type === 'focusout') && (eventTarget === this.dragTarget?.element || eventTarget === this.getCurrentActivateButton())) {
return;
}
@@ -570,7 +573,7 @@ class DragSession {
// Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent).
// This corrects state such as whether focus ring should appear.
// useDroppableCollection handles this itself, so this is only for standalone drop zones.
- document.activeElement?.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
+ getActiveElement()?.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
}
this.setCurrentDropTarget(null);
@@ -584,7 +587,7 @@ class DragSession {
}
// Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent).
- document.activeElement?.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
+ getActiveElement()?.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
announce(this.stringFormatter.format('dropCanceled'));
}
diff --git a/packages/@react-aria/dnd/src/useDrag.ts b/packages/@react-aria/dnd/src/useDrag.ts
index 6cfbb9be7b1..d91c5b2dc6c 100644
--- a/packages/@react-aria/dnd/src/useDrag.ts
+++ b/packages/@react-aria/dnd/src/useDrag.ts
@@ -15,10 +15,10 @@ import {DragEndEvent, DragItem, DragMoveEvent, DragPreviewRenderer, DragStartEve
import {DragEvent, HTMLAttributes, version as ReactVersion, useEffect, useRef, useState} from 'react';
import * as DragManager from './DragManager';
import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './constants';
+import {getEventTarget, isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils';
import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, useDragModality, writeToDataTransfer} from './utils';
// @ts-ignore
import intlMessages from '../intl/*.json';
-import {isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';
export interface DragOptions {
@@ -102,7 +102,7 @@ export function useDrag(options: DragOptions): DragResult {
// If this drag was initiated by a mobile screen reader (e.g. VoiceOver or TalkBack), enter virtual dragging mode.
if (modalityOnPointerDown.current === 'virtual') {
e.preventDefault();
- startDragging(e.target as HTMLElement);
+ startDragging(getEventTarget(e) as HTMLElement);
modalityOnPointerDown.current = null;
return;
}
@@ -188,9 +188,9 @@ export function useDrag(options: DragOptions): DragResult {
// Wait a frame before we set dragging to true so that the browser has time to
// render the preview image before we update the element that has been dragged.
- let target = e.target;
+ let target = getEventTarget(e);
requestAnimationFrame(() => {
- setDragging(target as Element);
+ setDragging(target);
});
};
@@ -340,16 +340,16 @@ export function useDrag(options: DragOptions): DragResult {
}
},
onKeyDownCapture(e) {
- if (e.target === e.currentTarget && e.key === 'Enter') {
+ if (getEventTarget(e) === e.currentTarget && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
}
},
onKeyUpCapture(e) {
- if (e.target === e.currentTarget && e.key === 'Enter') {
+ if (getEventTarget(e) === e.currentTarget && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
- startDragging(e.target as HTMLElement);
+ startDragging(getEventTarget(e));
}
},
onClick(e) {
@@ -357,7 +357,7 @@ export function useDrag(options: DragOptions): DragResult {
if (isVirtualClick(e.nativeEvent) || modalityOnPointerDown.current === 'virtual') {
e.preventDefault();
e.stopPropagation();
- startDragging(e.target as HTMLElement);
+ startDragging(getEventTarget(e));
}
}
};
diff --git a/packages/@react-aria/dnd/src/useDrop.ts b/packages/@react-aria/dnd/src/useDrop.ts
index 03d0fc92f2a..c48388f1dfc 100644
--- a/packages/@react-aria/dnd/src/useDrop.ts
+++ b/packages/@react-aria/dnd/src/useDrop.ts
@@ -16,7 +16,7 @@ import {DragEvent, useRef, useState} from 'react';
import * as DragManager from './DragManager';
import {DragTypes, globalAllowedDropOperations, globalDndState, readFromDataTransfer, setGlobalDnDState, setGlobalDropEffect} from './utils';
import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, DROP_OPERATION_ALLOWED, DROP_OPERATION_TO_DROP_EFFECT} from './constants';
-import {isIPad, isMac, nodeContains, useEffectEvent, useLayoutEffect} from '@react-aria/utils';
+import {getEventTarget, isIPad, isMac, nodeContains, useEffectEvent, useLayoutEffect} from '@react-aria/utils';
import {useVirtualDrop} from './useVirtualDrop';
export interface DropOptions {
@@ -186,7 +186,7 @@ export function useDrop(options: DropOptions): DropResult {
let onDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
- state.dragOverElements.add(e.target as Element);
+ state.dragOverElements.add(getEventTarget(e));
if (state.dragOverElements.size > 1) {
return;
}
@@ -232,7 +232,7 @@ export function useDrop(options: DropOptions): DropResult {
// events will never be fired for these. This can happen, for example, with drop
// indicators between items, which disappear when the drop target changes.
- state.dragOverElements.delete(e.target as Element);
+ state.dragOverElements.delete(getEventTarget(e));
for (let element of state.dragOverElements) {
if (!nodeContains(e.currentTarget, element)) {
state.dragOverElements.delete(element);
diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts
index 707984cbdac..60b8ad67a50 100644
--- a/packages/@react-aria/form/src/useFormValidation.ts
+++ b/packages/@react-aria/form/src/useFormValidation.ts
@@ -11,10 +11,10 @@
*/
import {FormValidationState} from '@react-stately/form';
+import {getEventTarget, useEffectEvent, useLayoutEffect} from '@react-aria/utils';
import {RefObject, Validation, ValidationResult} from '@react-types/shared';
import {setInteractionModality} from '@react-aria/interactions';
import {useEffect, useRef} from 'react';
-import {useEffectEvent, useLayoutEffect} from '@react-aria/utils';
type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
@@ -94,7 +94,7 @@ export function useFormValidation(props: FormValidationProps, state: FormV
// This is best-effort. There may be false positives, e.g. setTimeout.
form.reset = () => {
// React uses MessageChannel for scheduling, so ignore 'message' events.
- isIgnoredReset.current = !window.event || (window.event.type === 'message' && window.event.target instanceof MessagePort);
+ isIgnoredReset.current = !window.event || (window.event.type === 'message' && getEventTarget(window.event) instanceof MessagePort);
reset?.call(form);
isIgnoredReset.current = false;
};
diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts
index 5c6ba84eec0..80a3c834899 100644
--- a/packages/@react-aria/grid/src/useGrid.ts
+++ b/packages/@react-aria/grid/src/useGrid.ts
@@ -11,12 +11,12 @@
*/
import {AriaLabelingProps, DOMAttributes, DOMProps, Key, KeyboardDelegate, RefObject} from '@react-types/shared';
-import {filterDOMProps, mergeProps, nodeContains, useId} from '@react-aria/utils';
+import {filterDOMProps, getEventTarget, mergeProps, nodeContains, useId} from '@react-aria/utils';
+import {FocusEventHandler, useCallback, useMemo} from 'react';
import {GridCollection} from '@react-types/grid';
import {GridKeyboardDelegate} from './GridKeyboardDelegate';
import {gridMap} from './utils';
import {GridState} from '@react-stately/grid';
-import {useCallback, useMemo} from 'react';
import {useCollator, useLocale} from '@react-aria/i18n';
import {useGridSelectionAnnouncement} from './useGridSelectionAnnouncement';
import {useHasTabbableChild} from '@react-aria/focus';
@@ -133,10 +133,10 @@ export function useGrid(props: GridProps, state: GridState {
+ let onFocus: FocusEventHandler = useCallback((e) => {
if (manager.isFocused) {
// If a focus event bubbled through a portal, reset focus state.
- if (!nodeContains(e.currentTarget, e.target)) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
manager.setFocused(false);
}
@@ -144,7 +144,7 @@ export function useGrid(props: GridProps, state: GridState>(props: GridCellProps
let treeWalker = getFocusableTreeWalker(ref.current);
if (focusMode === 'child') {
// If focus is already on a focusable child within the cell, early return so we don't shift focus
- if (isFocusWithin(ref.current) && ref.current !== document.activeElement) {
+ if (isFocusWithin(ref.current) && ref.current !== getActiveElement()) {
return;
}
@@ -109,12 +109,13 @@ export function useGridCell>(props: GridCellProps
});
let onKeyDownCapture = (e: ReactKeyboardEvent) => {
- if (!nodeContains(e.currentTarget, e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) {
+ let activeElement = getActiveElement();
+ if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || state.isKeyboardNavigationDisabled || !ref.current || !activeElement) {
return;
}
let walker = getFocusableTreeWalker(ref.current);
- walker.currentNode = document.activeElement;
+ walker.currentNode = activeElement;
switch (e.key) {
case 'ArrowLeft': {
@@ -213,7 +214,7 @@ export function useGridCell>(props: GridCellProps
// Prevent this event from reaching cell children, e.g. menu buttons. We want arrow keys to navigate
// to the cell above/below instead. We need to re-dispatch the event from a higher parent so it still
// bubbles and gets handled by useSelectableCollection.
- if (!e.altKey && nodeContains(ref.current, e.target as Element)) {
+ if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) {
e.stopPropagation();
e.preventDefault();
ref.current.parentElement?.dispatchEvent(
@@ -228,7 +229,7 @@ export function useGridCell>(props: GridCellProps
// be marshalled to that element rather than focusing the cell itself.
let onFocus = (e) => {
keyWhenFocused.current = node.key;
- if (e.target !== ref.current) {
+ if (getEventTarget(e) !== ref.current) {
// useSelectableItem only handles setting the focused key when
// the focused element is the gridcell itself. We also want to
// set the focused key when a child element receives focus.
@@ -244,7 +245,7 @@ export function useGridCell>(props: GridCellProps
// If the cell itself is focused, wait a frame so that focus finishes propagatating
// up to the tree, and move focus to a focusable child if possible.
requestAnimationFrame(() => {
- if (focusMode === 'child' && document.activeElement === ref.current) {
+ if (focusMode === 'child' && getActiveElement() === ref.current) {
focus();
}
});
diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts
index 9cae838885c..d6280b13874 100644
--- a/packages/@react-aria/gridlist/src/useGridListItem.ts
+++ b/packages/@react-aria/gridlist/src/useGridListItem.ts
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {chain, getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils';
+import {chain, getActiveElement, getEventTarget, getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils';
import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared';
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
import {getRowId, listMap} from './utils';
@@ -131,14 +131,15 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt
});
let onKeyDownCapture = (e: ReactKeyboardEvent) => {
- if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) {
+ let activeElement = getActiveElement();
+ if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !activeElement) {
return;
}
let walker = getFocusableTreeWalker(ref.current);
- walker.currentNode = document.activeElement;
+ walker.currentNode = activeElement;
- if ('expandedKeys' in state && document.activeElement === ref.current) {
+ if ('expandedKeys' in state && activeElement === ref.current) {
if ((e.key === EXPANSION_KEYS['expand'][direction]) && state.selectionManager.focusedKey === node.key && hasChildRows && !state.expandedKeys.has(node.key)) {
state.toggleKey(node.key);
e.stopPropagation();
@@ -216,7 +217,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt
// Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate
// to the row above/below instead. We need to re-dispatch the event from a higher parent so it still
// bubbles and gets handled by useSelectableCollection.
- if (!e.altKey && nodeContains(ref.current, e.target as Element)) {
+ if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) {
e.stopPropagation();
e.preventDefault();
ref.current.parentElement?.dispatchEvent(
@@ -229,7 +230,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt
let onFocus = (e) => {
keyWhenFocused.current = node.key;
- if (e.target !== ref.current) {
+ if (getEventTarget(e) !== ref.current) {
// useSelectableItem only handles setting the focused key when
// the focused element is the row itself. We also want to
// set the focused key when a child element receives focus.
@@ -244,7 +245,8 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt
};
let onKeyDown = (e) => {
- if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) {
+ let activeElement = getActiveElement();
+ if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !activeElement) {
return;
}
@@ -254,7 +256,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt
// If there is another focusable element within this item, stop propagation so the tab key
// is handled by the browser and not by useSelectableCollection (which would take us out of the list).
let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
- walker.currentNode = document.activeElement;
+ walker.currentNode = activeElement;
let next = e.shiftKey ? walker.previousNode() : walker.nextNode();
if (next) {
diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts
index d2c910ecedd..5cb574d662f 100644
--- a/packages/@react-aria/interactions/src/useFocus.ts
+++ b/packages/@react-aria/interactions/src/useFocus.ts
@@ -43,7 +43,7 @@ export function useFocus(pro
} = props;
const onBlur: FocusProps['onBlur'] = useCallback((e: FocusEvent) => {
- if (e.target === e.currentTarget) {
+ if (getEventTarget(e) === e.currentTarget) {
if (onBlurProp) {
onBlurProp(e);
}
@@ -63,9 +63,10 @@ export function useFocus(pro
// Double check that document.activeElement actually matches e.target in case a previously chained
// focus handler already moved focus somewhere else.
- const ownerDocument = getOwnerDocument(e.target);
+ let eventTarget = getEventTarget(e);
+ const ownerDocument = getOwnerDocument(eventTarget);
const activeElement = ownerDocument ? getActiveElement(ownerDocument) : getActiveElement();
- if (e.target === e.currentTarget && activeElement === getEventTarget(e.nativeEvent)) {
+ if (eventTarget === e.currentTarget && eventTarget === activeElement) {
if (onFocusProp) {
onFocusProp(e);
}
diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts
index 07626782dea..ba6912ff239 100644
--- a/packages/@react-aria/interactions/src/useFocusVisible.ts
+++ b/packages/@react-aria/interactions/src/useFocusVisible.ts
@@ -15,7 +15,7 @@
// NOTICE file in the root directory of this source tree.
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
-import {getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, openLink} from '@react-aria/utils';
+import {getActiveElement, getEventTarget, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, openLink} from '@react-aria/utils';
import {ignoreFocusEvent} from './utils';
import {PointerType} from '@react-types/shared';
import {useEffect, useState} from 'react';
@@ -98,7 +98,7 @@ function handleFocusEvent(e: FocusEvent) {
// Firefox fires two extra focus events when the user first clicks into an iframe:
// first on the window, then on the document. We ignore these events so they don't
// cause keyboard focus rings to appear.
- if (e.target === window || e.target === document || ignoreFocusEvent || !e.isTrusted) {
+ if (getEventTarget(e) === window || getEventTarget(e) === document || ignoreFocusEvent || !e.isTrusted) {
return;
}
@@ -302,18 +302,20 @@ const nonTextInputTypes = new Set([
* focus visible style can be properly set.
*/
function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) {
- let document = getOwnerDocument(e?.target as Element);
- const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLInputElement : HTMLInputElement;
- const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLTextAreaElement : HTMLTextAreaElement;
- const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLElement : HTMLElement;
- const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).KeyboardEvent : KeyboardEvent;
+ let document = getOwnerDocument(e ? getEventTarget(e) as Element : undefined);
+ let eventTarget = e ? getEventTarget(e) as Element : undefined;
+ const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLInputElement : HTMLInputElement;
+ const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLTextAreaElement : HTMLTextAreaElement;
+ const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLElement : HTMLElement;
+ const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).KeyboardEvent : KeyboardEvent;
// For keyboard events that occur on a non-input element that will move focus into input element (aka ArrowLeft going from Datepicker button to the main input group)
// we need to rely on the user passing isTextInput into here. This way we can skip toggling focus visiblity for said input element
+ let activeElement = getActiveElement(document);
isTextInput = isTextInput ||
- (document.activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(document.activeElement.type)) ||
- document.activeElement instanceof IHTMLTextAreaElement ||
- (document.activeElement instanceof IHTMLElement && document.activeElement.isContentEditable);
+ (activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(activeElement.type)) ||
+ activeElement instanceof IHTMLTextAreaElement ||
+ (activeElement instanceof IHTMLElement && activeElement.isContentEditable);
return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]);
}
diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts
index 1faf3127c32..5f32dba0a11 100644
--- a/packages/@react-aria/interactions/src/useFocusWithin.ts
+++ b/packages/@react-aria/interactions/src/useFocusWithin.ts
@@ -54,7 +54,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
let onBlur = useCallback((e: FocusEvent) => {
// Ignore events bubbling through portals.
- if (!nodeContains(e.currentTarget, e.target)) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -78,15 +78,16 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
let onSyntheticFocus = useSyntheticBlurEvent(onBlur);
let onFocus = useCallback((e: FocusEvent) => {
// Ignore events bubbling through portals.
- if (!nodeContains(e.currentTarget, e.target)) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
// Double check that document.activeElement actually matches e.target in case a previously chained
// focus handler already moved focus somewhere else.
- const ownerDocument = getOwnerDocument(e.target);
+ let eventTarget = getEventTarget(e);
+ const ownerDocument = getOwnerDocument(eventTarget);
const activeElement = getActiveElement(ownerDocument);
- if (!state.current.isFocusWithin && activeElement === getEventTarget(e.nativeEvent)) {
+ if (!state.current.isFocusWithin && activeElement === eventTarget) {
if (onFocusWithin) {
onFocusWithin(e);
}
@@ -103,8 +104,9 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
// can manually fire onBlur.
let currentTarget = e.currentTarget;
addGlobalListener(ownerDocument, 'focus', e => {
- if (state.current.isFocusWithin && !nodeContains(currentTarget, e.target as Element)) {
- let nativeEvent = new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: e.target});
+ let eventTarget = getEventTarget(e);
+ if (state.current.isFocusWithin && !nodeContains(currentTarget, eventTarget as Element)) {
+ let nativeEvent = new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: eventTarget});
setEventTarget(nativeEvent, currentTarget);
let event = createSyntheticEvent(nativeEvent);
onBlur(event);
diff --git a/packages/@react-aria/interactions/src/useHover.ts b/packages/@react-aria/interactions/src/useHover.ts
index cde3c286128..5a1d94c4eee 100644
--- a/packages/@react-aria/interactions/src/useHover.ts
+++ b/packages/@react-aria/interactions/src/useHover.ts
@@ -16,7 +16,7 @@
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
import {DOMAttributes, HoverEvents} from '@react-types/shared';
-import {getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils';
+import {getEventTarget, getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils';
import {useEffect, useMemo, useRef, useState} from 'react';
export interface HoverProps extends HoverEvents {
@@ -108,7 +108,7 @@ export function useHover(props: HoverProps): HoverResult {
let {hoverProps, triggerHoverEnd} = useMemo(() => {
let triggerHoverStart = (event, pointerType) => {
state.pointerType = pointerType;
- if (isDisabled || pointerType === 'touch' || state.isHovered || !nodeContains(event.currentTarget, event.target)) {
+ if (isDisabled || pointerType === 'touch' || state.isHovered || !nodeContains(event.currentTarget, getEventTarget(event) as Element)) {
return;
}
@@ -120,8 +120,8 @@ export function useHover(props: HoverProps): HoverResult {
// even though the originally hovered target may have shrunk in size so it is no longer hovered.
// However, a pointerover event will be fired on the new target the mouse is over.
// In Chrome this happens immediately. In Safari and Firefox, it happens upon moving the mouse one pixel.
- addGlobalListener(getOwnerDocument(event.target), 'pointerover', e => {
- if (state.isHovered && state.target && !nodeContains(state.target, e.target as Element)) {
+ addGlobalListener(getOwnerDocument(getEventTarget(event) as Element), 'pointerover', e => {
+ if (state.isHovered && state.target && !nodeContains(state.target, getEventTarget(e) as Element)) {
triggerHoverEnd(e, e.pointerType);
}
}, {capture: true});
@@ -180,7 +180,7 @@ export function useHover(props: HoverProps): HoverResult {
};
hoverProps.onPointerLeave = (e) => {
- if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) {
+ if (!isDisabled && nodeContains(e.currentTarget, getEventTarget(e) as Element)) {
triggerHoverEnd(e, e.pointerType);
}
};
@@ -198,7 +198,7 @@ export function useHover(props: HoverProps): HoverResult {
};
hoverProps.onMouseLeave = (e) => {
- if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) {
+ if (!isDisabled && nodeContains(e.currentTarget, getEventTarget(e) as Element)) {
triggerHoverEnd(e, 'mouse');
}
};
diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts
index b9580fabc1d..309899d9a92 100644
--- a/packages/@react-aria/interactions/src/useInteractOutside.ts
+++ b/packages/@react-aria/interactions/src/useInteractOutside.ts
@@ -15,7 +15,7 @@
// NOTICE file in the root directory of this source tree.
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
-import {getOwnerDocument, nodeContains, useEffectEvent} from '@react-aria/utils';
+import {getEventTarget, getOwnerDocument, nodeContains, useEffectEvent} from '@react-aria/utils';
import {RefObject} from '@react-types/shared';
import {useEffect, useRef} from 'react';
@@ -118,14 +118,15 @@ function isValidEvent(event, ref) {
if (event.button > 0) {
return false;
}
- if (event.target) {
+ let target = getEventTarget(event) as Element;
+ if (target) {
// if the event target is no longer in the document, ignore
- const ownerDocument = event.target.ownerDocument;
- if (!ownerDocument || !nodeContains(ownerDocument.documentElement, event.target)) {
+ const ownerDocument = target.ownerDocument;
+ if (!ownerDocument || !nodeContains(ownerDocument.documentElement, target)) {
return false;
}
// If the target is within a top layer element (e.g. toasts), ignore.
- if (event.target.closest('[data-react-aria-top-layer]')) {
+ if (target.closest('[data-react-aria-top-layer]')) {
return false;
}
}
diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts
index 22c91966655..1ef7824fd61 100644
--- a/packages/@react-aria/interactions/src/usePress.ts
+++ b/packages/@react-aria/interactions/src/usePress.ts
@@ -72,7 +72,7 @@ interface PressState {
isOverTarget: boolean,
pointerType: PointerType | null,
userSelect?: string,
- metaKeyEvents?: Map,
+ metaKeyEvents?: Map,
disposables: Array<() => void>
}
@@ -339,12 +339,12 @@ export function usePress(props: PressHookProps): PressResult {
if (isElemKeyPressed) {
let onKeyUp = (e: KeyboardEvent) => {
if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) {
- if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) {
+ if (shouldPreventDefaultKeyboard(getEventTarget(e) as Element, e.key)) {
e.preventDefault();
}
- let target = getEventTarget(e);
- let wasPressed = nodeContains(state.target, getEventTarget(e));
+ let target = getEventTarget(e) as Element;
+ let wasPressed = nodeContains(state.target, target);
triggerPressEndEvent(createEvent(state.target, e), 'keyboard', wasPressed);
if (wasPressed) {
triggerSyntheticClickEvent(e, state.target);
@@ -379,8 +379,8 @@ export function usePress(props: PressHookProps): PressResult {
// instead of the same element where the key down event occurred. Make it capturing so that it will trigger
// before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element.
let originalTarget = state.target;
- let pressUp = (e) => {
- if (originalTarget && isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) {
+ let pressUp = (e: KeyboardEvent) => {
+ if (originalTarget && isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e) as Element) && state.target) {
triggerPressUpEvent(createEvent(state.target, e), 'keyboard');
}
};
@@ -398,7 +398,7 @@ export function usePress(props: PressHookProps): PressResult {
if (isPointerPressed === 'pointer') {
let onPointerUp = (e: PointerEvent) => {
if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) {
- if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) {
+ if (nodeContains(state.target, getEventTarget(e) as Element) && state.pointerType != null) {
// Wait for onClick to fire onPress. This avoids browser issues when the DOM
// is mutated between onPointerUp and onClick, and is more compatible with third party libraries.
// https://github.com/adobe/react-spectrum/issues/1513
@@ -422,7 +422,9 @@ export function usePress(props: PressHookProps): PressResult {
}, 80);
// Use a capturing listener to track if a click occurred.
// If stopPropagation is called it may never reach our handler.
- addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true);
+ if (e.currentTarget) {
+ addGlobalListener(e.currentTarget, 'click', () => clicked = true, true);
+ }
state.disposables.push(() => clearTimeout(timeout));
} else {
cancelEvent(e);
@@ -471,7 +473,7 @@ export function usePress(props: PressHookProps): PressResult {
};
} else if (isPointerPressed === 'touch' && process.env.NODE_ENV === 'test') {
let onScroll = (e: Event) => {
- if (state.isPressed && nodeContains(getEventTarget(e), state.target)) {
+ if (state.isPressed && nodeContains(getEventTarget(e) as Element, state.target)) {
cancelEvent({
currentTarget: state.target,
shiftKey: false,
@@ -493,8 +495,8 @@ export function usePress(props: PressHookProps): PressResult {
let state = ref.current;
let pressProps: DOMAttributes = {
onKeyDown(e) {
- if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
- if (shouldPreventDefaultKeyboard(getEventTarget(e.nativeEvent), e.key)) {
+ if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget as Element) && nodeContains(e.currentTarget as Element, getEventTarget(e) as Element)) {
+ if (shouldPreventDefaultKeyboard(getEventTarget(e) as Element, e.key)) {
e.preventDefault();
}
@@ -529,7 +531,7 @@ export function usePress(props: PressHookProps): PressResult {
}
},
onClick(e) {
- if (e && !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (e && !nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -568,7 +570,7 @@ export function usePress(props: PressHookProps): PressResult {
if (typeof PointerEvent !== 'undefined') {
pressProps.onPointerDown = (e) => {
// Only handle left clicks, and ignore events that bubbled through portals.
- if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -599,7 +601,7 @@ export function usePress(props: PressHookProps): PressResult {
// Release pointer capture so that touch interactions can leave the original target.
// This enables onPointerLeave and onPointerEnter to fire.
- let target = getEventTarget(e.nativeEvent);
+ let target = getEventTarget(e);
if ('releasePointerCapture' in target) {
if ('hasPointerCapture' in target) {
if (target.hasPointerCapture(e.pointerId)) {
@@ -617,7 +619,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onMouseDown = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -635,7 +637,7 @@ export function usePress(props: PressHookProps): PressResult {
pressProps.onPointerUp = (e) => {
// iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown.
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent)) || state.pointerType === 'virtual') {
+ if (!nodeContains(e.currentTarget, getEventTarget(e)) || state.pointerType === 'virtual') {
return;
}
@@ -662,7 +664,7 @@ export function usePress(props: PressHookProps): PressResult {
pressProps.onDragStart = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -675,7 +677,7 @@ export function usePress(props: PressHookProps): PressResult {
pressProps.onMouseDown = (e) => {
// Only handle left clicks
- if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -705,7 +707,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onMouseEnter = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -721,7 +723,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onMouseLeave = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -738,7 +740,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onMouseUp = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -748,7 +750,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onTouchStart = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -775,7 +777,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onTouchMove = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -803,7 +805,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onTouchEnd = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -838,7 +840,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onTouchCancel = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -849,7 +851,7 @@ export function usePress(props: PressHookProps): PressResult {
};
pressProps.onDragStart = (e) => {
- if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -922,7 +924,7 @@ function isHTMLAnchorLink(target: Element): target is HTMLAnchorElement {
return target.tagName === 'A' && target.hasAttribute('href');
}
-function isValidKeyboardEvent(event: KeyboardEvent, currentTarget: Element): boolean {
+function isValidKeyboardEvent(event: KeyboardEvent | globalThis.KeyboardEvent, currentTarget: Element): boolean {
const {key, code} = event;
const element = currentTarget as HTMLElement;
const role = element.getAttribute('role');
diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts
index 46321a1d4a8..10eeca42bf5 100644
--- a/packages/@react-aria/interactions/src/utils.ts
+++ b/packages/@react-aria/interactions/src/utils.ts
@@ -11,7 +11,7 @@
*/
import {FocusableElement} from '@react-types/shared';
-import {focusWithoutScrolling, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils';
+import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils';
import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react';
// Turn a native event into a React synthetic event.
@@ -54,15 +54,16 @@ export function useSyntheticBlurEvent(onBlur:
// Most browsers fire a native focusout event in this case, except for Firefox. In that case, we use a
// MutationObserver to watch for the disabled attribute, and dispatch these events ourselves.
// For browsers that do, focusout fires before the MutationObserver, so onBlur should not fire twice.
+ let eventTarget = getEventTarget(e);
if (
- e.target instanceof HTMLButtonElement ||
- e.target instanceof HTMLInputElement ||
- e.target instanceof HTMLTextAreaElement ||
- e.target instanceof HTMLSelectElement
+ eventTarget instanceof HTMLButtonElement ||
+ eventTarget instanceof HTMLInputElement ||
+ eventTarget instanceof HTMLTextAreaElement ||
+ eventTarget instanceof HTMLSelectElement
) {
stateRef.current.isFocused = true;
- let target = e.target;
+ let target = eventTarget;
let onBlurHandler: EventListenerOrEventListenerObject | null = (e) => {
stateRef.current.isFocused = false;
@@ -84,7 +85,7 @@ export function useSyntheticBlurEvent(onBlur:
stateRef.current.observer = new MutationObserver(() => {
if (stateRef.current.isFocused && target.disabled) {
stateRef.current.observer?.disconnect();
- let relatedTargetEl = target === document.activeElement ? null : document.activeElement;
+ let relatedTargetEl = target === getActiveElement() ? null : getActiveElement();
target.dispatchEvent(new FocusEvent('blur', {relatedTarget: relatedTargetEl}));
target.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: relatedTargetEl}));
}
@@ -117,13 +118,13 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un
ignoreFocusEvent = true;
let isRefocusing = false;
let onBlur = (e: FocusEvent) => {
- if (e.target === activeElement || isRefocusing) {
+ if (getEventTarget(e) === activeElement || isRefocusing) {
e.stopImmediatePropagation();
}
};
let onFocusOut = (e: FocusEvent) => {
- if (e.target === activeElement || isRefocusing) {
+ if (getEventTarget(e) === activeElement || isRefocusing) {
e.stopImmediatePropagation();
// If there was no focusable ancestor, we don't expect a focus event.
@@ -137,13 +138,13 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un
};
let onFocus = (e: FocusEvent) => {
- if (e.target === target || isRefocusing) {
+ if (getEventTarget(e) === target || isRefocusing) {
e.stopImmediatePropagation();
}
};
let onFocusIn = (e: FocusEvent) => {
- if (e.target === target || isRefocusing) {
+ if (getEventTarget(e) === target || isRefocusing) {
e.stopImmediatePropagation();
if (!isRefocusing) {
diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts
index 34001d640c1..ce701232693 100644
--- a/packages/@react-aria/landmark/src/useLandmark.ts
+++ b/packages/@react-aria/landmark/src/useLandmark.ts
@@ -11,7 +11,7 @@
*/
import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared';
-import {nodeContains, useLayoutEffect} from '@react-aria/utils';
+import {getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils';
import {useCallback, useEffect, useState} from 'react';
import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js';
@@ -315,7 +315,7 @@ class LandmarkManager implements LandmarkManagerApi {
private f6Handler(e: KeyboardEvent) {
if (e.key === 'F6') {
// If alt key pressed, focus main landmark, otherwise navigate forward or backward based on shift key.
- let handled = e.altKey ? this.focusMain() : this.navigate(e.target as FocusableElement, e.shiftKey);
+ let handled = e.altKey ? this.focusMain() : this.navigate(getEventTarget(e) as FocusableElement, e.shiftKey);
if (handled) {
e.preventDefault();
e.stopPropagation();
@@ -365,9 +365,9 @@ class LandmarkManager implements LandmarkManagerApi {
* Lets the last focused landmark know it was blurred if something else is focused.
*/
private focusinHandler(e: FocusEvent) {
- let currentLandmark = this.closestLandmark(e.target as FocusableElement);
- if (currentLandmark && currentLandmark.ref.current !== e.target) {
- this.updateLandmark({ref: currentLandmark.ref, lastFocused: e.target as FocusableElement});
+ let currentLandmark = this.closestLandmark(getEventTarget(e) as FocusableElement);
+ if (currentLandmark && currentLandmark.ref.current !== getEventTarget(e)) {
+ this.updateLandmark({ref: currentLandmark.ref, lastFocused: getEventTarget(e) as FocusableElement});
}
let previousFocusedElement = e.relatedTarget as FocusableElement;
if (previousFocusedElement) {
@@ -382,7 +382,7 @@ class LandmarkManager implements LandmarkManagerApi {
* Track if the focus is lost to the body. If it is, do cleanup on the landmark that last had focus.
*/
private focusoutHandler(e: FocusEvent) {
- let previousFocusedElement = e.target as FocusableElement;
+ let previousFocusedElement = getEventTarget(e) as FocusableElement;
let nextFocusedElement = e.relatedTarget;
// the === document seems to be a jest thing for focus to go there on generic blur event such as landmark.blur();
// browsers appear to send focus instead to document.body and the relatedTarget is null when that happens
diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts
index 7e161eec2b6..231f2ae73f6 100644
--- a/packages/@react-aria/menu/src/useMenuItem.ts
+++ b/packages/@react-aria/menu/src/useMenuItem.ts
@@ -11,7 +11,7 @@
*/
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 {filterDOMProps, getEventTarget, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils';
import {getItemCount} from '@react-stately/collections';
import {isFocusVisible, useFocusable, useHover, useKeyboard, usePress} from '@react-aria/interactions';
import {menuData} from './utils';
@@ -286,14 +286,14 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re
switch (e.key) {
case ' ':
interaction.current = {pointerType: 'keyboard', key: ' '};
- (e.target as HTMLElement).click();
+ (getEventTarget(e) as HTMLElement).click();
break;
case 'Enter':
interaction.current = {pointerType: 'keyboard', key: 'Enter'};
// Trigger click unless this is a link. Links trigger click natively.
- if ((e.target as HTMLElement).tagName !== 'A') {
- (e.target as HTMLElement).click();
+ if ((getEventTarget(e) as HTMLElement).tagName !== 'A') {
+ (getEventTarget(e) as HTMLElement).click();
}
break;
default:
diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts
index 1be44f8a931..65b98a3c3ca 100644
--- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts
+++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts
@@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem';
import {AriaMenuOptions} from './useMenu';
import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays';
import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared';
-import {focusWithoutScrolling, isFocusWithin, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils';
+import {focusWithoutScrolling, getActiveElement, getEventTarget, isFocusWithin, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils';
import type {SubmenuTriggerState} from '@react-stately/menu';
import {useCallback, useRef} from 'react';
import {useLocale} from '@react-aria/i18n';
@@ -106,7 +106,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm
switch (e.key) {
case 'ArrowLeft':
- if (direction === 'ltr' && nodeContains(e.currentTarget, e.target as Element)) {
+ if (direction === 'ltr' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) {
e.preventDefault();
e.stopPropagation();
onSubmenuClose();
@@ -116,7 +116,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm
}
break;
case 'ArrowRight':
- if (direction === 'rtl' && nodeContains(e.currentTarget, e.target as Element)) {
+ if (direction === 'rtl' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) {
e.preventDefault();
e.stopPropagation();
onSubmenuClose();
@@ -127,7 +127,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm
break;
case 'Escape':
// TODO: can remove this when we fix collection event leaks
- if (nodeContains(submenuRef.current, e.target as Element)) {
+ if (nodeContains(submenuRef.current, getEventTarget(e) as Element)) {
e.stopPropagation();
onSubmenuClose();
if (!shouldUseVirtualFocus && ref.current) {
@@ -159,7 +159,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm
onSubmenuOpen('first');
}
- if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) {
+ if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) {
focusWithoutScrolling(submenuRef.current);
}
} else if (state.isOpen) {
@@ -178,7 +178,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm
onSubmenuOpen('first');
}
- if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) {
+ if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) {
focusWithoutScrolling(submenuRef.current);
}
} else if (state.isOpen) {
@@ -226,7 +226,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm
useEvent(parentMenuRef, 'focusin', (e) => {
// If we detect focus moved to a different item in the same menu that the currently open submenu trigger is in
// then close the submenu. This is for a case where the user hovers a root menu item when multiple submenus are open
- if (state.isOpen && (nodeContains(parentMenuRef.current, e.target as HTMLElement) && e.target !== ref.current)) {
+ if (state.isOpen && (nodeContains(parentMenuRef.current, getEventTarget(e) as HTMLElement) && getEventTarget(e) !== ref.current)) {
onSubmenuClose();
}
});
diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts
index 5c117bc7767..4d5f607ea31 100644
--- a/packages/@react-aria/numberfield/src/useNumberField.ts
+++ b/packages/@react-aria/numberfield/src/useNumberField.ts
@@ -13,7 +13,7 @@
import {announce} from '@react-aria/live-announcer';
import {AriaButtonProps} from '@react-types/button';
import {AriaNumberFieldProps} from '@react-types/numberfield';
-import {chain, filterDOMProps, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils';
+import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils';
import {
type ClipboardEvent,
type ClipboardEventHandler,
@@ -197,7 +197,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
let onPaste: ClipboardEventHandler = (e: ClipboardEvent) => {
props.onPaste?.(e);
- let inputElement = e.target as HTMLInputElement;
+ let inputElement = getEventTarget(e) as HTMLInputElement;
// we can only handle the case where the paste takes over the entire input, otherwise things get very complicated
// trying to calculate the new string based on what the paste is replacing and where in the source string it is
if (inputElement &&
@@ -286,7 +286,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
let onButtonPressStart = (e) => {
// If focus is already on the input, keep it there so we don't hide the
// software keyboard when tapping the increment/decrement buttons.
- if (document.activeElement === inputRef.current) {
+ if (getActiveElement() === inputRef.current) {
return;
}
diff --git a/packages/@react-aria/overlays/src/useCloseOnScroll.ts b/packages/@react-aria/overlays/src/useCloseOnScroll.ts
index 64f54860947..539df203212 100644
--- a/packages/@react-aria/overlays/src/useCloseOnScroll.ts
+++ b/packages/@react-aria/overlays/src/useCloseOnScroll.ts
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {nodeContains} from '@react-aria/utils';
+import {getEventTarget, nodeContains} from '@react-aria/utils';
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
@@ -38,7 +38,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
let onScroll = (e: Event) => {
// Ignore if scrolling an scrollable region outside the trigger's tree.
- let target = e.target;
+ let target = getEventTarget(e);
// window is not a Node and doesn't have contain, but window contains everything
if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) {
return;
@@ -47,7 +47,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
// Ignore scroll events on any input or textarea as the cursor position can cause it to scroll
// such as in a combobox. Clicking the dropdown button places focus on the input, and if the
// text inside the input extends beyond the 'end', then it will scroll so the cursor is visible at the end.
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
return;
}
diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts
index 06792f54c07..46497cf2729 100644
--- a/packages/@react-aria/overlays/src/useOverlay.ts
+++ b/packages/@react-aria/overlays/src/useOverlay.ts
@@ -11,6 +11,7 @@
*/
import {DOMAttributes, RefObject} from '@react-types/shared';
+import {getEventTarget} from '@react-aria/utils';
import {isElementInChildOfActiveScope} from '@react-aria/focus';
import {useEffect, useRef} from 'react';
import {useFocusWithin, useInteractOutside} from '@react-aria/interactions';
@@ -95,7 +96,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject {
const topMostOverlay = visibleOverlays[visibleOverlays.length - 1];
lastVisibleOverlay.current = topMostOverlay;
- if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) {
+ if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e) as Element)) {
if (topMostOverlay === ref) {
e.stopPropagation();
e.preventDefault();
@@ -104,7 +105,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject {
- if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) {
+ if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e) as Element)) {
if (visibleOverlays[visibleOverlays.length - 1] === ref) {
e.stopPropagation();
e.preventDefault();
@@ -152,7 +153,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject {
// fixes a firefox issue that starts text selection https://bugzilla.mozilla.org/show_bug.cgi?id=1675846
- if (e.target === e.currentTarget) {
+ if (getEventTarget(e) === e.currentTarget) {
e.preventDefault();
}
};
diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts
index 7980f406e3f..de180f94c9e 100644
--- a/packages/@react-aria/overlays/src/useOverlayPosition.ts
+++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts
@@ -12,7 +12,7 @@
import {calculatePosition, getRect, PositionResult} from './calculatePosition';
import {DOMAttributes, RefObject} from '@react-types/shared';
-import {isFocusWithin, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
+import {getActiveElement, isFocusWithin, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCloseOnScroll} from './useCloseOnScroll';
@@ -155,7 +155,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
// changes, the focused element appears to stay in the same position.
let anchor: ScrollAnchor | null = null;
if (scrollRef.current && isFocusWithin(scrollRef.current)) {
- let anchorRect = document.activeElement?.getBoundingClientRect();
+ let anchorRect = getActiveElement()?.getBoundingClientRect();
let scrollRect = scrollRef.current.getBoundingClientRect();
// Anchor from the top if the offset is in the top half of the scrollable element,
// otherwise anchor from the bottom.
@@ -208,8 +208,9 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria {
overlay.style.maxHeight = position.maxHeight != null ? position.maxHeight + 'px' : '';
// Restore scroll position relative to anchor element.
- if (anchor && document.activeElement && scrollRef.current) {
- let anchorRect = document.activeElement.getBoundingClientRect();
+ let activeElement = getActiveElement();
+ if (anchor && activeElement && scrollRef.current) {
+ let anchorRect = activeElement.getBoundingClientRect();
let scrollRect = scrollRef.current.getBoundingClientRect();
let newOffset = anchorRect[anchor.type] - scrollRect[anchor.type];
scrollRef.current.scrollTop += newOffset - anchor.offset;
diff --git a/packages/@react-aria/overlays/src/usePreventScroll.ts b/packages/@react-aria/overlays/src/usePreventScroll.ts
index c8687d33b97..4c1698093b2 100644
--- a/packages/@react-aria/overlays/src/usePreventScroll.ts
+++ b/packages/@react-aria/overlays/src/usePreventScroll.ts
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {chain, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
+import {chain, getActiveElement, getEventTarget, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
interface PreventScrollOptions {
/** Whether the scroll lock is disabled. */
@@ -88,7 +88,7 @@ function preventScrollStandard() {
// by preventing default in a `touchmove` event. This is best effort: we can't prevent default when pinch
// zooming or when an element contains text selection, which may allow scrolling in some cases.
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
-// 4. When focus moves to an input, create an off screen input and focus that temporarily. This prevents
+// 4. When focus moves to an input, create an off screen input and focus that temporarily. This prevents
// Safari from scrolling the page. After a small delay, focus the real input and scroll it into view
// ourselves, without scrolling the whole page.
function preventScrollMobileSafari() {
@@ -96,10 +96,10 @@ function preventScrollMobileSafari() {
let allowTouchMove = false;
let onTouchStart = (e: TouchEvent) => {
// Store the nearest scrollable parent element from the element that the user touched.
- let target = e.target as Element;
+ let target = getEventTarget(e) as Element;
scrollable = isScrollable(target) ? target : getScrollParent(target, true);
allowTouchMove = false;
-
+
// If the target is selected, don't preventDefault in touchmove to allow user to adjust selection.
let selection = target.ownerDocument.defaultView!.getSelection();
if (selection && !selection.isCollapsed && selection.containsNode(target, true)) {
@@ -116,7 +116,7 @@ function preventScrollMobileSafari() {
// If this is a focused input element with a selected range, allow user to drag the selection handles.
if (
- 'selectionStart' in target &&
+ 'selectionStart' in target &&
'selectionEnd' in target &&
(target.selectionStart as number) < (target.selectionEnd as number) &&
target.ownerDocument.activeElement === target
@@ -162,7 +162,7 @@ function preventScrollMobileSafari() {
};
let onBlur = (e: FocusEvent) => {
- let target = e.target as HTMLElement;
+ let target = getEventTarget(e) as HTMLElement;
let relatedTarget = e.relatedTarget as HTMLElement | null;
if (relatedTarget && willOpenKeyboard(relatedTarget)) {
// Focus without scrolling the whole page, and then scroll into view manually.
@@ -183,7 +183,8 @@ function preventScrollMobileSafari() {
let focus = HTMLElement.prototype.focus;
HTMLElement.prototype.focus = function (opts) {
// Track whether the keyboard was already visible before.
- let wasKeyboardVisible = document.activeElement != null && willOpenKeyboard(document.activeElement);
+ let activeElement = getActiveElement();
+ let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement);
// Focus the element without scrolling the page.
focus.call(this, {...opts, preventScroll: true});
diff --git a/packages/@react-aria/radio/src/useRadioGroup.ts b/packages/@react-aria/radio/src/useRadioGroup.ts
index df09b13fe1d..47034b23d8f 100644
--- a/packages/@react-aria/radio/src/useRadioGroup.ts
+++ b/packages/@react-aria/radio/src/useRadioGroup.ts
@@ -12,7 +12,7 @@
import {AriaRadioGroupProps} from '@react-types/radio';
import {DOMAttributes, ValidationResult} from '@react-types/shared';
-import {filterDOMProps, getOwnerWindow, mergeProps, useId} from '@react-aria/utils';
+import {filterDOMProps, getEventTarget, getOwnerWindow, mergeProps, useId} from '@react-aria/utils';
import {getFocusableTreeWalker} from '@react-aria/focus';
import {radioGroupData} from './utils';
import {RadioGroupState} from '@react-stately/radio';
@@ -103,7 +103,7 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState
}
e.preventDefault();
let walker = getFocusableTreeWalker(e.currentTarget, {
- from: e.target,
+ from: getEventTarget(e) as Element,
accept: (node) => node instanceof getOwnerWindow(node).HTMLInputElement && node.type === 'radio'
});
let nextElem;
diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx
index 1029f53eb8e..07efe254bc5 100644
--- a/packages/@react-aria/select/src/HiddenSelect.tsx
+++ b/packages/@react-aria/select/src/HiddenSelect.tsx
@@ -11,11 +11,11 @@
*/
import {FocusableElement, Key, RefObject} from '@react-types/shared';
+import {getEventTarget, useFormReset} from '@react-aria/utils';
import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react';
import {selectData} from './useSelect';
import {SelectionMode} from '@react-types/select';
import {SelectState} from '@react-stately/select';
-import {useFormReset} from '@react-aria/utils';
import {useFormValidation} from '@react-aria/form';
import {useVisuallyHidden} from '@react-aria/visually-hidden';
@@ -92,9 +92,10 @@ export function useHiddenSelect(props: Ar
let setValue = state.setValue;
let onChange = useCallback((e: React.ChangeEvent) => {
- if (e.target.multiple) {
+ let eventTarget = getEventTarget(e);
+ if (eventTarget.multiple) {
setValue(Array.from(
- e.target.selectedOptions,
+ eventTarget.selectedOptions,
(option) => option.value
) as any);
} else {
diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts
index e27dc3f71ac..796d7dcaa60 100644
--- a/packages/@react-aria/selection/src/useSelectableCollection.ts
+++ b/packages/@react-aria/selection/src/useSelectableCollection.ts
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isFocusWithin, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils';
+import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, getEventTarget, isCtrlKeyPressed, isFocusWithin, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils';
import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus';
import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared';
import {flushSync} from 'react-dom';
@@ -133,7 +133,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
// Keyboard events bubble through portals. Don't handle keyboard events
// for elements outside the collection (e.g. menus).
- if (!ref.current || !nodeContains(ref.current, e.target as Element)) {
+ if (!ref.current || !nodeContains(ref.current, getEventTarget(e) as Element)) {
return;
}
@@ -314,7 +314,8 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
// If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to
// the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between
// focusing the containing cell and back to the non-tabbable child element
- if (next && (!isFocusWithin(next) || (document.activeElement && !isTabbable(document.activeElement)))) {
+ let activeElement = getActiveElement();
+ if (next && (!isFocusWithin(next) || (activeElement && !isTabbable(activeElement)))) {
focusWithoutScrolling(next);
}
}
@@ -337,7 +338,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
let onFocus = (e: FocusEvent) => {
if (manager.isFocused) {
// If a focus event bubbled through a portal, reset focus state.
- if (!nodeContains(e.currentTarget, e.target)) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
manager.setFocused(false);
}
@@ -345,7 +346,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
}
// Focus events can bubble through portals. Ignore these events.
- if (!nodeContains(e.currentTarget, e.target)) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e))) {
return;
}
@@ -560,7 +561,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
onBlur,
onMouseDown(e) {
// Ignore events that bubbled through portals.
- if (scrollRef.current === e.target) {
+ if (scrollRef.current === getEventTarget(e)) {
// Prevent focus going to the collection when clicking on the scrollbar.
e.preventDefault();
}
diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts
index 207a72e07ba..2eb6d3211af 100644
--- a/packages/@react-aria/selection/src/useSelectableItem.ts
+++ b/packages/@react-aria/selection/src/useSelectableItem.ts
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {chain, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils';
+import {chain, getActiveElement, getEventTarget, 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';
@@ -169,7 +169,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
if (!shouldUseVirtualFocus) {
if (focus) {
focus();
- } else if (document.activeElement !== ref.current && ref.current) {
+ } else if (getActiveElement() !== ref.current && ref.current) {
focusSafely(ref.current);
}
} else {
@@ -188,7 +188,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
itemProps = {
tabIndex: key === manager.focusedKey ? 0 : -1,
onFocus(e) {
- if (e.target === ref.current) {
+ if (getEventTarget(e) === ref.current) {
manager.setFocusedKey(key);
}
}
diff --git a/packages/@react-aria/selection/src/useTypeSelect.ts b/packages/@react-aria/selection/src/useTypeSelect.ts
index 2be01de8b3b..5d40d747d7f 100644
--- a/packages/@react-aria/selection/src/useTypeSelect.ts
+++ b/packages/@react-aria/selection/src/useTypeSelect.ts
@@ -11,9 +11,9 @@
*/
import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared';
+import {getEventTarget, nodeContains} from '@react-aria/utils';
import {KeyboardEvent, useRef} from 'react';
import {MultipleSelectionManager} from '@react-stately/selection';
-import {nodeContains} from '@react-aria/utils';
/**
* Controls how long to wait before clearing the typeahead buffer.
@@ -54,7 +54,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria {
let onKeyDown = (e: KeyboardEvent) => {
let character = getStringForKey(e.key);
- if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, e.target as HTMLElement) || (state.search.length === 0 && character === ' ')) {
+ if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement) || (state.search.length === 0 && character === ' ')) {
return;
}
diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts
index 5a609e4f78b..28f61055999 100644
--- a/packages/@react-aria/slider/src/useSliderThumb.ts
+++ b/packages/@react-aria/slider/src/useSliderThumb.ts
@@ -1,5 +1,5 @@
import {AriaSliderThumbProps} from '@react-types/slider';
-import {clamp, focusWithoutScrolling, mergeProps, useFormReset, useGlobalListeners} from '@react-aria/utils';
+import {clamp, focusWithoutScrolling, getEventTarget, mergeProps, useFormReset, useGlobalListeners} from '@react-aria/utils';
import {DOMAttributes, RefObject} from '@react-types/shared';
import {getSliderThumbId, sliderData} from './utils';
import React, {ChangeEvent, InputHTMLAttributes, LabelHTMLAttributes, useCallback, useEffect, useRef} from 'react';
@@ -255,7 +255,7 @@ export function useSliderThumb(
'aria-describedby': [data['aria-describedby'], opts['aria-describedby']].filter(Boolean).join(' '),
'aria-details': [data['aria-details'], opts['aria-details']].filter(Boolean).join(' '),
onChange: (e: ChangeEvent) => {
- state.setThumbValue(index, parseFloat(e.target.value));
+ state.setThumbValue(index, parseFloat(getEventTarget(e).value));
}
}),
thumbProps: {
diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts
index 7d02274c946..21e462e7937 100644
--- a/packages/@react-aria/table/src/useTableColumnResize.ts
+++ b/packages/@react-aria/table/src/useTableColumnResize.ts
@@ -14,11 +14,11 @@ import {ChangeEvent, useCallback, useEffect, useRef} from 'react';
import {ColumnSize} from '@react-types/table';
import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared';
import {focusSafely, useInteractionModality, useKeyboard, useMove, usePress} from '@react-aria/interactions';
+import {getActiveElement, getEventTarget, mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils';
import {getColumnHeaderId} from './utils';
import {GridNode} from '@react-types/grid';
// @ts-ignore
import intlMessages from '../intl/*.json';
-import {mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils';
import {TableColumnResizeState} from '@react-stately/table';
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
import {useVisuallyHidden} from '@react-aria/visually-hidden';
@@ -198,7 +198,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st
let startResizeEvent = useEffectEvent(startResize);
useEffect(() => {
if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === item.key) {
- wasFocusedOnResizeStart.current = document.activeElement === ref.current;
+ wasFocusedOnResizeStart.current = getActiveElement() === ref.current;
startResizeEvent(item);
// Delay focusing input until Android Chrome's delayed click after touchend happens: https://bugs.chromium.org/p/chromium/issues/detail?id=1150073
let timeout = setTimeout(() => focusInput(), 0);
@@ -214,7 +214,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st
let onChange = (e: ChangeEvent) => {
let currentWidth = state.getColumnWidth(item.key);
- let nextValue = parseFloat(e.target.value);
+ let nextValue = parseFloat(getEventTarget(e).value);
if (nextValue > currentWidth) {
nextValue = currentWidth + 10;
diff --git a/packages/@react-aria/textfield/src/useFormattedTextField.ts b/packages/@react-aria/textfield/src/useFormattedTextField.ts
index e0d866fd016..30325c5c9e3 100644
--- a/packages/@react-aria/textfield/src/useFormattedTextField.ts
+++ b/packages/@react-aria/textfield/src/useFormattedTextField.ts
@@ -11,10 +11,10 @@
*/
import {AriaTextFieldProps} from '@react-types/textfield';
-import {mergeProps, useEffectEvent} from '@react-aria/utils';
+import {getEventTarget, mergeProps, useEffectEvent} from '@react-aria/utils';
+import {InputEventHandler, useEffect, useRef} from 'react';
import {RefObject} from '@react-types/shared';
import {TextFieldAria, useTextField} from './useTextField';
-import {useEffect, useRef} from 'react';
interface FormattedTextFieldState {
validate: (val: string) => boolean,
@@ -106,12 +106,12 @@ export function useFormattedTextField(props: AriaTextFieldProps, state: Formatte
};
}, [inputRef]);
- let onBeforeInput = !supportsNativeBeforeInputEvent()
+ let onBeforeInput: InputEventHandler | null = !supportsNativeBeforeInputEvent()
? e => {
let nextValue =
- e.target.value.slice(0, e.target.selectionStart) +
+ getEventTarget(e).value.slice(0, getEventTarget(e).selectionStart!) +
e.data +
- e.target.value.slice(e.target.selectionEnd);
+ getEventTarget(e).value.slice(getEventTarget(e).selectionEnd!);
if (!state.validate(nextValue)) {
e.preventDefault();
diff --git a/packages/@react-aria/textfield/src/useTextField.ts b/packages/@react-aria/textfield/src/useTextField.ts
index 18f2942a90c..e82f0293238 100644
--- a/packages/@react-aria/textfield/src/useTextField.ts
+++ b/packages/@react-aria/textfield/src/useTextField.ts
@@ -12,7 +12,7 @@
import {AriaTextFieldProps} from '@react-types/textfield';
import {DOMAttributes, ValidationResult} from '@react-types/shared';
-import {filterDOMProps, mergeProps, useFormReset} from '@react-aria/utils';
+import {filterDOMProps, getEventTarget, mergeProps, useFormReset} from '@react-aria/utils';
import React, {
ChangeEvent,
HTMLAttributes,
@@ -163,7 +163,7 @@ export function useTextField) => setValue(e.target.value),
+ onChange: (e: ChangeEvent) => setValue(getEventTarget(e).value),
autoComplete: props.autoComplete,
autoCapitalize: props.autoCapitalize,
maxLength: props.maxLength,
diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts
index 1c6c3dbaf84..16adbd38e0f 100644
--- a/packages/@react-aria/toast/src/useToastRegion.ts
+++ b/packages/@react-aria/toast/src/useToastRegion.ts
@@ -11,7 +11,7 @@
*/
import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared';
-import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils';
+import {focusWithoutScrolling, getEventTarget, mergeProps, useLayoutEffect} from '@react-aria/utils';
import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions';
// @ts-ignore
import intlMessages from '../intl/*.json';
@@ -190,7 +190,7 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState
// listen to focus events separate from focuswithin because that will only fire once
// and we need to follow all focus changes
onFocus: (e) => {
- let target = e.target.closest('[role="alertdialog"]');
+ let target = (getEventTarget(e) as Element).closest('[role="alertdialog"]');
focusedToast.current = toasts.current.findIndex(t => t === target);
},
onBlur: () => {
diff --git a/packages/@react-aria/toggle/src/useToggle.ts b/packages/@react-aria/toggle/src/useToggle.ts
index 788c1b5e00f..511d9abea53 100644
--- a/packages/@react-aria/toggle/src/useToggle.ts
+++ b/packages/@react-aria/toggle/src/useToggle.ts
@@ -11,8 +11,8 @@
*/
import {AriaToggleProps} from '@react-types/checkbox';
-import {filterDOMProps, mergeProps, useFormReset} from '@react-aria/utils';
-import {InputHTMLAttributes, LabelHTMLAttributes} from 'react';
+import {ChangeEventHandler, InputHTMLAttributes, LabelHTMLAttributes} from 'react';
+import {filterDOMProps, getEventTarget, mergeProps, useFormReset} from '@react-aria/utils';
import {RefObject} from '@react-types/shared';
import {ToggleState} from '@react-stately/toggle';
import {useFocusable, usePress} from '@react-aria/interactions';
@@ -57,11 +57,11 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb
onClick
} = props;
- let onChange = (e) => {
+ let onChange: ChangeEventHandler = (e) => {
// since we spread props on label, onChange will end up there as well as in here.
// so we have to stop propagation at the lowest level that we care about
e.stopPropagation();
- state.setSelected(e.target.checked);
+ state.setSelected(getEventTarget(e).checked);
};
let hasChildren = children != null;
diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts
index 7331973d8f9..28f249b9a02 100644
--- a/packages/@react-aria/toolbar/src/useToolbar.ts
+++ b/packages/@react-aria/toolbar/src/useToolbar.ts
@@ -12,8 +12,8 @@
import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared';
import {createFocusManager} from '@react-aria/focus';
-import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils';
-import {HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react';
+import {filterDOMProps, getActiveElement, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils';
+import {FocusEventHandler, HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react';
import {useLocale} from '@react-aria/i18n';
export interface AriaToolbarProps extends AriaLabelingProps {
@@ -56,7 +56,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject {
// don't handle portalled events
- if (!nodeContains(e.currentTarget, e.target as HTMLElement)) {
+ if (!nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement)) {
return;
}
if (
@@ -81,7 +81,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject(null);
- const onBlur = (e) => {
+ const onBlur: FocusEventHandler = (e) => {
if (!nodeContains(e.currentTarget, e.relatedTarget) && !lastFocused.current) {
- lastFocused.current = e.target;
+ lastFocused.current = getEventTarget(e);
}
};
// Restore focus to the last focused child when focus returns into the toolbar.
// If the element was removed, do nothing, either the first item in the first group,
// or the last item in the last group will be focused, depending on direction.
- const onFocus = (e) => {
- if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, e.target)) {
+ const onFocus: FocusEventHandler = (e) => {
+ if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, getEventTarget(e))) {
lastFocused.current?.focus();
lastFocused.current = null;
}
diff --git a/packages/@react-aria/utils/src/getScrollParents.ts b/packages/@react-aria/utils/src/getScrollParents.ts
index 0eb32f0f5d1..7266229339a 100644
--- a/packages/@react-aria/utils/src/getScrollParents.ts
+++ b/packages/@react-aria/utils/src/getScrollParents.ts
@@ -13,14 +13,15 @@
import {isScrollable} from './isScrollable';
export function getScrollParents(node: Element, checkForOverflow?: boolean): Element[] {
- const scrollParents: Element[] = [];
+ let parentElements: Element[] = [];
+ let root = document.scrollingElement || document.documentElement;
- while (node && node !== document.documentElement) {
+ do {
if (isScrollable(node, checkForOverflow)) {
- scrollParents.push(node);
+ parentElements.push(node);
}
node = node.parentElement as Element;
- }
+ } while (node && node !== root);
- return scrollParents;
+ return parentElements;
}
diff --git a/packages/@react-aria/utils/src/isScrollable.ts b/packages/@react-aria/utils/src/isScrollable.ts
index c62ca8ed44e..352a5780399 100644
--- a/packages/@react-aria/utils/src/isScrollable.ts
+++ b/packages/@react-aria/utils/src/isScrollable.ts
@@ -15,8 +15,14 @@ export function isScrollable(node: Element | null, checkForOverflow?: boolean):
return false;
}
let style = window.getComputedStyle(node);
+ let root = document.scrollingElement || document.documentElement;
let isScrollable = /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY);
+ // Root element has `visible` overflow by default, but is scrollable nonetheless.
+ if (node === root && style.overflow !== 'hidden') {
+ isScrollable = true;
+ }
+
if (isScrollable && checkForOverflow) {
isScrollable = node.scrollHeight !== node.clientHeight || node.scrollWidth !== node.clientWidth;
}
diff --git a/packages/@react-aria/utils/src/runAfterTransition.ts b/packages/@react-aria/utils/src/runAfterTransition.ts
index 3004d2313df..80e31fc112f 100644
--- a/packages/@react-aria/utils/src/runAfterTransition.ts
+++ b/packages/@react-aria/utils/src/runAfterTransition.ts
@@ -16,6 +16,7 @@
// bugs, e.g. Chrome sometimes fires both transitionend and transitioncancel rather
// than one or the other. So we need to track what's actually transitioning so that
// we can ignore these duplicate events.
+import {getEventTarget} from './shadowdom/DOMFunctions';
let transitionsByElement = new Map>();
// A list of callbacks to call once there are no transitioning elements.
@@ -31,19 +32,20 @@ function setupGlobalEvents() {
}
let onTransitionStart = (e: Event) => {
- if (!isTransitionEvent(e) || !e.target) {
+ let eventTarget = getEventTarget(e);
+ if (!isTransitionEvent(e) || !eventTarget) {
return;
}
// Add the transitioning property to the list for this element.
- let transitions = transitionsByElement.get(e.target);
+ let transitions = transitionsByElement.get(eventTarget);
if (!transitions) {
transitions = new Set();
- transitionsByElement.set(e.target, transitions);
+ transitionsByElement.set(eventTarget, transitions);
// The transitioncancel event must be registered on the element itself, rather than as a global
// event. This enables us to handle when the node is deleted from the document while it is transitioning.
// In that case, the cancel event would have nowhere to bubble to so we need to handle it directly.
- e.target.addEventListener('transitioncancel', onTransitionEnd, {
+ eventTarget.addEventListener('transitioncancel', onTransitionEnd, {
once: true
});
}
@@ -52,11 +54,12 @@ function setupGlobalEvents() {
};
let onTransitionEnd = (e: Event) => {
- if (!isTransitionEvent(e) || !e.target) {
+ let eventTarget = getEventTarget(e);
+ if (!isTransitionEvent(e) || !eventTarget) {
return;
}
// Remove property from list of transitioning properties.
- let properties = transitionsByElement.get(e.target);
+ let properties = transitionsByElement.get(eventTarget);
if (!properties) {
return;
}
@@ -65,8 +68,8 @@ function setupGlobalEvents() {
// If empty, remove transitioncancel event, and remove the element from the list of transitioning elements.
if (properties.size === 0) {
- e.target.removeEventListener('transitioncancel', onTransitionEnd);
- transitionsByElement.delete(e.target);
+ eventTarget.removeEventListener('transitioncancel', onTransitionEnd);
+ transitionsByElement.delete(eventTarget);
}
// If no transitioning elements, call all of the queued callbacks.
diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts
index f20aa9c9759..7a297118e2e 100644
--- a/packages/@react-aria/utils/src/scrollIntoView.ts
+++ b/packages/@react-aria/utils/src/scrollIntoView.ts
@@ -11,7 +11,15 @@
*/
import {getScrollParents} from './getScrollParents';
-import {nodeContains} from './shadowdom/DOMFunctions';
+import {isChrome} from './platform';
+
+interface ScrollIntoViewOpts {
+ /** The position to align items along the block axis in. */
+ block?: ScrollLogicalPosition,
+ /** The position to align items along the inline axis in. */
+ inline?: ScrollLogicalPosition
+}
+
interface ScrollIntoViewportOpts {
/** The optional containing element of the target to be centered in the viewport. */
@@ -23,74 +31,86 @@ interface ScrollIntoViewportOpts {
* Similar to `element.scrollIntoView({block: 'nearest'})` (not supported in Edge),
* but doesn't affect parents above `scrollView`.
*/
-export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): void {
- let offsetX = relativeOffset(scrollView, element, 'left');
- let offsetY = relativeOffset(scrollView, element, 'top');
- let width = element.offsetWidth;
- let height = element.offsetHeight;
- let x = scrollView.scrollLeft;
+export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement, opts: ScrollIntoViewOpts = {}): void {
+ let {block = 'nearest', inline = 'nearest'} = opts;
+
+ if (scrollView === element) { return; }
+
let y = scrollView.scrollTop;
+ let x = scrollView.scrollLeft;
- // Account for top/left border offsetting the scroll top/Left + scroll padding
- let {
- borderTopWidth,
- borderLeftWidth,
- scrollPaddingTop,
- scrollPaddingRight,
- scrollPaddingBottom,
- scrollPaddingLeft
- } = getComputedStyle(scrollView);
-
- let borderAdjustedX = x + parseInt(borderLeftWidth, 10);
- let borderAdjustedY = y + parseInt(borderTopWidth, 10);
- // Ignore end/bottom border via clientHeight/Width instead of offsetHeight/Width
- let maxX = borderAdjustedX + scrollView.clientWidth;
- let maxY = borderAdjustedY + scrollView.clientHeight;
-
- // Get scroll padding values as pixels - defaults to 0 if no scroll padding
- // is used.
- let scrollPaddingTopNumber = parseInt(scrollPaddingTop, 10) || 0;
- let scrollPaddingBottomNumber = parseInt(scrollPaddingBottom, 10) || 0;
- let scrollPaddingRightNumber = parseInt(scrollPaddingRight, 10) || 0;
- let scrollPaddingLeftNumber = parseInt(scrollPaddingLeft, 10) || 0;
-
- if (offsetX <= x + scrollPaddingLeftNumber) {
- x = offsetX - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber;
- } else if (offsetX + width > maxX - scrollPaddingRightNumber) {
- x += offsetX + width - maxX + scrollPaddingRightNumber;
- }
- if (offsetY <= borderAdjustedY + scrollPaddingTopNumber) {
- y = offsetY - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber;
- } else if (offsetY + height > maxY - scrollPaddingBottomNumber) {
- y += offsetY + height - maxY + scrollPaddingBottomNumber;
+ let target = element.getBoundingClientRect();
+ let view = scrollView.getBoundingClientRect();
+ let itemStyle = window.getComputedStyle(element);
+ let viewStyle = window.getComputedStyle(scrollView);
+ let root = document.scrollingElement || document.documentElement;
+ let scrollbarWidth = view.width - scrollView.clientWidth;
+ let scrollbarHeight = view.height - scrollView.clientHeight;
+
+ let viewTop = scrollView === root ? 0 : view.top;
+ let viewBottom = scrollView === root ? scrollView.clientHeight : view.bottom;
+ let viewLeft = scrollView === root ? 0 : view.left;
+ let viewRight = scrollView === root ? scrollView.clientWidth : view.right;
+
+ let scrollMarginTop = parseInt(itemStyle.scrollMarginTop, 10) || 0;
+ let scrollMarginBottom = parseInt(itemStyle.scrollMarginBottom, 10) || 0;
+ let scrollMarginLeft = parseInt(itemStyle.scrollMarginLeft, 10) || 0;
+ let scrollMarginRight = parseInt(itemStyle.scrollMarginRight, 10) || 0;
+
+ let scrollPaddingTop = parseInt(viewStyle.scrollPaddingTop, 10) || 0;
+ let scrollPaddingBottom = parseInt(viewStyle.scrollPaddingBottom, 10) || 0;
+ let scrollPaddingLeft = parseInt(viewStyle.scrollPaddingLeft, 10) || 0;
+ let scrollPaddingRight = parseInt(viewStyle.scrollPaddingRight, 10) || 0;
+
+ let borderTopWidth = parseInt(viewStyle.borderTopWidth, 10) || 0;
+ let borderBottomWidth = parseInt(viewStyle.borderBottomWidth, 10) || 0;
+ let borderLeftWidth = parseInt(viewStyle.borderLeftWidth, 10) || 0;
+ let borderRightWidth = parseInt(viewStyle.borderRightWidth, 10) || 0;
+
+ let scrollAreaTop = target.top - scrollMarginTop;
+ let scrollAreaBottom = target.bottom + scrollMarginBottom;
+ let scrollAreaLeft = target.left - scrollMarginLeft;
+ let scrollAreaRight = target.right + scrollMarginRight;
+
+ let scrollPortTop = viewTop + borderTopWidth + scrollPaddingTop;
+ let scrollPortBottom = viewBottom - borderBottomWidth - scrollPaddingBottom - scrollbarHeight;
+ let scrollPortLeft = viewLeft + borderLeftWidth + scrollPaddingLeft;
+ let scrollPortRight = viewRight - borderRightWidth - scrollPaddingRight - scrollbarWidth;
+
+ let shouldScrollBlock = scrollAreaTop < scrollPortTop || scrollAreaBottom > scrollPortBottom;
+ let shouldScrollInline = scrollAreaLeft < scrollPortLeft || scrollAreaRight > scrollPortRight;
+
+ if (shouldScrollBlock && block === 'start') {
+ y += scrollAreaTop - scrollPortTop;
+ } else if (shouldScrollBlock && block === 'center') {
+ y += (scrollAreaTop + scrollAreaBottom) / 2 - (scrollPortTop + scrollPortBottom) / 2;
+ } else if (shouldScrollBlock && block === 'end') {
+ y += scrollAreaBottom - scrollPortBottom;
+ } else if (shouldScrollBlock && block === 'nearest') {
+ let start = scrollAreaTop - scrollPortTop;
+ let end = scrollAreaBottom - scrollPortBottom;
+ y += Math.abs(start) <= Math.abs(end) ? start : end;
}
- scrollView.scrollLeft = x;
- scrollView.scrollTop = y;
-}
+ if (shouldScrollInline && inline === 'start') {
+ x += scrollAreaLeft - scrollPortLeft;
+ } else if (shouldScrollInline && inline === 'center') {
+ x += (scrollAreaLeft + scrollAreaRight) / 2 - (scrollPortLeft + scrollPortRight) / 2;
+ } else if (shouldScrollInline && inline === 'end') {
+ x += scrollAreaRight - scrollPortRight;
+ } else if (shouldScrollInline && inline === 'nearest') {
+ let start = scrollAreaLeft - scrollPortLeft;
+ let end = scrollAreaRight - scrollPortRight;
+ x += Math.abs(start) <= Math.abs(end) ? start : end;
+ }
-/**
- * Computes the offset left or top from child to ancestor by accumulating
- * offsetLeft or offsetTop through intervening offsetParents.
- */
-function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|'top') {
- const prop = axis === 'left' ? 'offsetLeft' : 'offsetTop';
- let sum = 0;
- while (child.offsetParent) {
- sum += child[prop];
- if (child.offsetParent === ancestor) {
- // Stop once we have found the ancestor we are interested in.
- break;
- } else if (nodeContains(child.offsetParent, ancestor)) {
- // If the ancestor is not `position:relative`, then we stop at
- // _its_ offset parent, and we subtract off _its_ offset, so that
- // we end up with the proper offset from child to ancestor.
- sum -= ancestor[prop];
- break;
- }
- child = child.offsetParent as HTMLElement;
+ if (process.env.NODE_ENV === 'test') {
+ scrollView.scrollLeft = x;
+ scrollView.scrollTop = y;
+ return;
}
- return sum;
+
+ scrollView.scrollTo({left: x, top: y});
}
/**
@@ -98,12 +118,14 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|
* that will be centered in the viewport prior to scrolling the targetElement into view. If scrolling is prevented on
* the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself.
*/
-export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts): void {
+export function scrollIntoViewport(targetElement: Element | null, opts: ScrollIntoViewportOpts = {}): void {
+ let {containingElement} = opts;
if (targetElement && targetElement.isConnected) {
let root = document.scrollingElement || document.documentElement;
let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden';
- // If scrolling is not currently prevented then we arenβt in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
- if (!isScrollPrevented) {
+ // If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
+ // Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749
+ if (!isScrollPrevented && !isChrome()) {
let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect();
// use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus()
@@ -112,18 +134,25 @@ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollI
let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect();
// Account for sub pixel differences from rounding
if ((Math.abs(originalLeft - newLeft) > 1) || (Math.abs(originalTop - newTop) > 1)) {
- opts?.containingElement?.scrollIntoView?.({block: 'center', inline: 'center'});
+ containingElement?.scrollIntoView?.({block: 'center', inline: 'center'});
targetElement.scrollIntoView?.({block: 'nearest'});
}
} else {
- let scrollParents = getScrollParents(targetElement);
+ let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect();
+
// If scrolling is prevented, we don't want to scroll the body since it might move the overlay partially offscreen and the user can't scroll it back into view.
- if (!isScrollPrevented) {
- scrollParents.push(root);
- }
+ let scrollParents = getScrollParents(targetElement, true);
for (let scrollParent of scrollParents) {
scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement);
}
+ let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect();
+ // Account for sub pixel differences from rounding
+ if ((Math.abs(originalLeft - newLeft) > 1) || (Math.abs(originalTop - newTop) > 1)) {
+ scrollParents = containingElement ? getScrollParents(containingElement, true) : [];
+ for (let scrollParent of scrollParents) {
+ scrollIntoView(scrollParent as HTMLElement, containingElement as HTMLElement, {block: 'center', inline: 'center'});
+ }
+ }
}
}
}
diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts
index 12c7322e0fa..ba0a25b611b 100644
--- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts
+++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts
@@ -3,13 +3,14 @@
import {getOwnerWindow, isShadowRoot} from '../domHelpers';
import {shadowDOM} from '@react-stately/flags';
+import type {SyntheticEvent} from 'react';
/**
* ShadowDOM safe version of Node.contains.
*/
export function nodeContains(
- node: Node | null | undefined,
- otherNode: Node | null | undefined
+ node: Node | Element | null | undefined,
+ otherNode: Node | Element | null | undefined
): boolean {
if (!shadowDOM()) {
return otherNode && node ? node.contains(otherNode) : false;
@@ -58,16 +59,23 @@ export const getActiveElement = (doc: Document = document): Element | null => {
return activeElement;
};
+// Type helper to extract the target element type from an event
+type EventTargetType = T extends SyntheticEvent ? E : EventTarget;
+
/**
* ShadowDOM safe version of event.target.
*/
-export function getEventTarget(event: T): Element {
- if (shadowDOM() && (event.target as HTMLElement).shadowRoot) {
- if (event.composedPath) {
- return event.composedPath()[0] as Element;
+export function getEventTarget(event: T): EventTargetType {
+ // For React synthetic events, use the native event
+ let nativeEvent: Event = 'nativeEvent' in event ? (event as SyntheticEvent).nativeEvent : event as Event;
+ let target = nativeEvent.target!;
+
+ if (shadowDOM() && (target as HTMLElement).shadowRoot) {
+ if (nativeEvent.composedPath) {
+ return nativeEvent.composedPath()[0] as EventTargetType;
}
}
- return event.target as Element;
+ return target as EventTargetType;
}
/**
diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts
index 41fe28abac8..285a9231cfd 100644
--- a/packages/@react-aria/utils/src/useDrag1D.ts
+++ b/packages/@react-aria/utils/src/useDrag1D.ts
@@ -12,8 +12,8 @@
/* eslint-disable rulesdir/pure-render */
+import {getEventTarget, nodeContains} from './shadowdom/DOMFunctions';
import {getOffset} from './getOffset';
-import {nodeContains} from './shadowdom/DOMFunctions';
import {Orientation} from '@react-types/shared';
import React, {HTMLAttributes, MutableRefObject, useRef} from 'react';
@@ -81,7 +81,7 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes {
};
let onMouseUp = (e: MouseEvent) => {
- const target = e.target as HTMLElement;
+ let target = getEventTarget(e) as HTMLElement;
dragging.current = false;
let nextOffset = getNextOffset(e);
if (handlers.current.onDrag) {
diff --git a/packages/@react-aria/utils/src/useViewportSize.ts b/packages/@react-aria/utils/src/useViewportSize.ts
index 3c362b43efb..fa27847c032 100644
--- a/packages/@react-aria/utils/src/useViewportSize.ts
+++ b/packages/@react-aria/utils/src/useViewportSize.ts
@@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/
+import {getActiveElement, getEventTarget} from './shadowdom/DOMFunctions';
import {isIOS} from './platform';
import {useEffect, useState} from 'react';
import {useIsSSR} from '@react-aria/ssr';
@@ -54,10 +55,11 @@ export function useViewportSize(): ViewportSize {
return;
}
- if (willOpenKeyboard(e.target as Element)) {
+ if (willOpenKeyboard(getEventTarget(e) as Element)) {
// Wait one frame to see if a new element gets focused.
frame = requestAnimationFrame(() => {
- if (!document.activeElement || !willOpenKeyboard(document.activeElement)) {
+ let activeElement = getActiveElement();
+ if (!activeElement || !willOpenKeyboard(activeElement)) {
updateSize({width: document.documentElement.clientWidth, height: document.documentElement.clientHeight});
}
});
diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx
index b57efc4f19e..ba6c460104f 100644
--- a/packages/@react-aria/virtualizer/src/ScrollView.tsx
+++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx
@@ -12,6 +12,7 @@
// @ts-ignore
import {flushSync} from 'react-dom';
+import {getEventTarget, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {getScrollLeft} from './utils';
import React, {
CSSProperties,
@@ -25,7 +26,6 @@ import React, {
useState
} from 'react';
import {Rect, Size} from '@react-stately/virtualizer';
-import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {useLocale} from '@react-aria/i18n';
interface ScrollViewProps extends HTMLAttributes {
@@ -87,7 +87,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject {
- if (e.target !== e.currentTarget) {
+ if (getEventTarget(e) !== e.currentTarget) {
return;
}
diff --git a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx
index 242a1e0c91b..d27c1567655 100644
--- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx
+++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx
@@ -21,11 +21,11 @@ import {Field} from '@react-spectrum/label';
import {FocusableRef, ValidationState} from '@react-types/shared';
import {focusSafely, setInteractionModality, useHover} from '@react-aria/interactions';
import {FocusScope, useFocusRing} from '@react-aria/focus';
+import {getActiveElement, mergeProps, useFormReset, useId} from '@react-aria/utils';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {ListBoxBase, useListBoxLayout} from '@react-spectrum/listbox';
import Magnifier from '@spectrum-icons/ui/Magnifier';
-import {mergeProps, useFormReset, useId} from '@react-aria/utils';
import {ProgressCircle} from '@react-spectrum/progress';
import React, {
HTMLAttributes,
@@ -482,7 +482,7 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) {
};
let onScroll = useCallback(() => {
- if (!inputRef.current || document.activeElement !== inputRef.current || !isTouchDown.current) {
+ if (!inputRef.current || getActiveElement() !== inputRef.current || !isTouchDown.current) {
return;
}
diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx
index 5954ab301a3..804fe66d52a 100644
--- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx
+++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx
@@ -24,11 +24,11 @@ import {Field} from '@react-spectrum/label';
import {FocusableRef, FocusableRefValue, ValidationState} from '@react-types/shared';
import {FocusRing, FocusScope} from '@react-aria/focus';
import {focusSafely, setInteractionModality, useHover} from '@react-aria/interactions';
+import {getActiveElement, mergeProps, useFormReset, useId, useObjectRef} from '@react-aria/utils';
// @ts-ignore
import intlMessages from '../intl/*.json';
import labelStyles from '@adobe/spectrum-css-temp/components/fieldlabel/vars.css';
import {ListBoxBase, useListBoxLayout} from '@react-spectrum/listbox';
-import {mergeProps, useFormReset, useId, useObjectRef} from '@react-aria/utils';
import {ProgressCircle} from '@react-spectrum/progress';
import React, {ForwardedRef, HTMLAttributes, InputHTMLAttributes, ReactElement, ReactNode, useCallback, useEffect, useRef, useState} from 'react';
import searchStyles from '@adobe/spectrum-css-temp/components/search/vars.css';
@@ -436,7 +436,7 @@ function ComboBoxTray(props: ComboBoxTrayProps) {
};
let onScroll = useCallback(() => {
- if (!inputRef.current || document.activeElement !== inputRef.current || !isTouchDown.current) {
+ if (!inputRef.current || getActiveElement() !== inputRef.current || !isTouchDown.current) {
return;
}
diff --git a/packages/@react-spectrum/menu/src/useCloseOnScroll.ts b/packages/@react-spectrum/menu/src/useCloseOnScroll.ts
index 64f54860947..fff810607f7 100644
--- a/packages/@react-spectrum/menu/src/useCloseOnScroll.ts
+++ b/packages/@react-spectrum/menu/src/useCloseOnScroll.ts
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {nodeContains} from '@react-aria/utils';
+import {getEventTarget, nodeContains} from '@react-aria/utils';
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
@@ -38,7 +38,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
let onScroll = (e: Event) => {
// Ignore if scrolling an scrollable region outside the trigger's tree.
- let target = e.target;
+ let target = getEventTarget(e);
// window is not a Node and doesn't have contain, but window contains everything
if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) {
return;
@@ -47,7 +47,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
// Ignore scroll events on any input or textarea as the cursor position can cause it to scroll
// such as in a combobox. Clicking the dropdown button places focus on the input, and if the
// text inside the input extends beyond the 'end', then it will scroll so the cursor is visible at the end.
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
+ if (getEventTarget(e) instanceof HTMLInputElement || getEventTarget(e) instanceof HTMLTextAreaElement) {
return;
}
diff --git a/packages/@react-spectrum/s2/src/Field.tsx b/packages/@react-spectrum/s2/src/Field.tsx
index a2144071c59..ee114374f56 100644
--- a/packages/@react-spectrum/s2/src/Field.tsx
+++ b/packages/@react-spectrum/s2/src/Field.tsx
@@ -19,13 +19,13 @@ import {composeRenderProps, FieldError, FieldErrorProps, Group, GroupProps, Labe
import {ContextualHelpContext} from './ContextualHelp';
import {control, controlFont, fieldInput, fieldLabel, StyleProps, UnsafeStyles} from './style-utils' with {type: 'macro'};
import {ForwardedRef, forwardRef, ReactNode} from 'react';
+import {getEventTarget, useId} from '@react-aria/utils';
import {IconContext} from './Icon';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {mergeStyles} from '../style/runtime';
import {StyleString} from '../style/types';
import {useDOMRef} from '@react-spectrum/utils';
-import {useId} from '@react-aria/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';
interface FieldLabelProps extends Omit, StyleProps {
@@ -207,13 +207,13 @@ export const FieldGroup = forwardRef(function FieldGroup(props: FieldGroupProps,
{...otherProps}
onPointerDown={(e) => {
// Forward focus to input element when clicking on a non-interactive child (e.g. icon or padding)
- if (e.pointerType === 'mouse' && !(e.target as Element).closest('button,input,textarea,[role="button"]')) {
+ if (e.pointerType === 'mouse' && !(getEventTarget(e) as Element).closest('button,input,textarea,[role="button"]')) {
e.preventDefault();
(e.currentTarget.querySelector('input, textarea') as HTMLElement)?.focus();
}
}}
onTouchEnd={e => {
- let target = e.target as HTMLElement;
+ let target = getEventTarget(e) as HTMLElement;
if (!target.isContentEditable && !target.closest('button,input,textarea,[role="button"]')) {
e.preventDefault();
(e.currentTarget.querySelector('input, textarea') as HTMLElement)?.focus();
diff --git a/packages/@react-spectrum/s2/src/Toast.tsx b/packages/@react-spectrum/s2/src/Toast.tsx
index 572eb2389ea..399691e8450 100644
--- a/packages/@react-spectrum/s2/src/Toast.tsx
+++ b/packages/@react-spectrum/s2/src/Toast.tsx
@@ -19,7 +19,7 @@ import Chevron from '../s2wf-icons/S2_Icon_ChevronDown_20_N.svg';
import {CloseButton} from './CloseButton';
import {createContext, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
import {DOMProps} from '@react-types/shared';
-import {filterDOMProps, isWebKit, useEvent} from '@react-aria/utils';
+import {filterDOMProps, getEventTarget, isWebKit, useEvent} from '@react-aria/utils';
import {flushSync} from 'react-dom';
import {focusRing, style} from '../style' with {type: 'macro'};
import {FocusScope, useModalOverlay} from 'react-aria';
@@ -446,7 +446,7 @@ function SpectrumToastList({placement, align, reduceMotion}) {
let toastListRef = useRef(null);
useEvent(toastListRef, 'click', (e) => {
// Have to check if this is a button because stopPropagation in react events doesn't affect native events.
- if (!isExpanded && !(e.target as Element)?.closest('button')) {
+ if (!isExpanded && !(getEventTarget(e) as Element)?.closest('button')) {
toggleExpanded();
}
});
diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx
index 3778641017a..371e288f4aa 100644
--- a/packages/@react-spectrum/table/src/TableViewBase.tsx
+++ b/packages/@react-spectrum/table/src/TableViewBase.tsx
@@ -28,12 +28,12 @@ import type {DragAndDropHooks} from '@react-spectrum/dnd';
import type {DraggableCollectionState, DroppableCollectionState} from '@react-stately/dnd';
import type {DraggableItemResult, DropIndicatorAria, DroppableCollectionResult} from '@react-aria/dnd';
import {FocusRing, FocusScope, useFocusRing} from '@react-aria/focus';
+import {getActiveElement, isAndroid, isFocusWithin, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils';
import {getInteractionModality, HoverProps, isFocusVisible, useHover, usePress} from '@react-aria/interactions';
import {GridNode} from '@react-types/grid';
import {InsertionIndicator} from './InsertionIndicator';
// @ts-ignore
import intlMessages from '../intl/*.json';
-import {isAndroid, isFocusWithin, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils';
import {Item, Menu, MenuTrigger} from '@react-spectrum/menu';
import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer';
import {layoutInfoToStyle, ScrollView, setScrollLeft, VirtualizerItem} from '@react-aria/virtualizer';
@@ -607,8 +607,9 @@ function TableVirtualizer(props: TableVirtualizerProps) {
// header scroll position
useEffect(() => {
if (getInteractionModality() === 'keyboard' && headerRef.current && isFocusWithin(headerRef.current) && bodyRef.current) {
- scrollIntoView(headerRef.current, document.activeElement as HTMLElement);
- scrollIntoViewport(document.activeElement, {containingElement: domRef.current});
+ let activeElement = getActiveElement() as HTMLElement;
+ scrollIntoView(headerRef.current, activeElement);
+ scrollIntoViewport(activeElement, {containingElement: domRef.current});
bodyRef.current.scrollLeft = headerRef.current.scrollLeft;
}
}, [state.contentSize, headerRef, bodyRef, domRef]);
diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts
index 9a8b69ca8c2..2c898c743f5 100644
--- a/packages/@react-types/table/src/index.d.ts
+++ b/packages/@react-types/table/src/index.d.ts
@@ -82,7 +82,7 @@ export interface SpectrumTableProps extends TableProps, SpectrumSelectionP
export interface TableHeaderProps {
/** A list of table columns. */
- columns?: T[],
+ columns?: readonly T[],
/** A list of `Column(s)` or a function. If the latter, a list of columns must be provided using the `columns` prop. */
children: ColumnElement | ColumnElement[] | ColumnRenderer
}
diff --git a/packages/@react-types/tabs/src/index.d.ts b/packages/@react-types/tabs/src/index.d.ts
index 9a20acc7ef9..4f17abe0e3c 100644
--- a/packages/@react-types/tabs/src/index.d.ts
+++ b/packages/@react-types/tabs/src/index.d.ts
@@ -30,12 +30,14 @@ export interface AriaTabProps extends AriaLabelingProps {
shouldSelectOnPressUp?: boolean
}
-export interface TabListProps extends CollectionBase, Omit {
+export interface TabListProps extends CollectionBase, Omit {
/**
* Whether the TabList is disabled.
* Shows that a selection exists, but is not available in that circumstance.
*/
isDisabled?: boolean,
+ /** The currently selected key in the collection (controlled). */
+ selectedKey?: Key,
/** Handler that is called when the selection changes. */
onSelectionChange?: (key: Key) => void
}
@@ -60,7 +62,7 @@ export interface AriaTabPanelProps extends Omit, AriaLabelingPro
id?: Key
}
-export interface SpectrumTabsProps extends AriaTabListBase, Omit, DOMProps, StyleProps {
+export interface SpectrumTabsProps extends AriaTabListBase, Omit, DOMProps, StyleProps {
/** The children of the `` element. Should include `` and `` elements. */
children: ReactNode,
/** The item objects for each tab, for dynamic collections. */
@@ -75,6 +77,8 @@ export interface SpectrumTabsProps extends AriaTabListBase, Omit void
}
diff --git a/packages/dev/codemods/package.json b/packages/dev/codemods/package.json
index 0551c4c995b..434acaf918c 100644
--- a/packages/dev/codemods/package.json
+++ b/packages/dev/codemods/package.json
@@ -26,7 +26,7 @@
"@babel/types": "^7.24.5",
"@react-spectrum/s2": "^1.1.0",
"@react-types/shared": "^3.33.0",
- "@types/node": "^22",
+ "@types/node": "^24",
"boxen": "^5.1.2",
"chalk": "^4.0.0",
"execa": "^5.1.1",
diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/styleProps.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/styleProps.ts
index f6fcd0aaba8..91a0324c59e 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/styleProps.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/styleProps.ts
@@ -677,7 +677,7 @@ export default function transformStyleProps(path: NodePath, elemen
if (isDOMElement) {
let index = path.node.openingElement.attributes?.findIndex(a => a.type === 'JSXAttribute' && a.name.name === 'className');
if (index != null && index >= 0) {
- classNameAttribute = path.get('openingElement').get('attributes').at(index);
+ classNameAttribute = path.get('openingElement').get('attributes')[index];
}
}
diff --git a/packages/dev/eslint-plugin-rsp-rules/index.js b/packages/dev/eslint-plugin-rsp-rules/index.js
index aba60c2f759..e3f40b7b70c 100644
--- a/packages/dev/eslint-plugin-rsp-rules/index.js
+++ b/packages/dev/eslint-plugin-rsp-rules/index.js
@@ -15,6 +15,8 @@ import fasterNodeContains from './rules/faster-node-contains.js';
import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js';
import noNonShadowContains from './rules/no-non-shadow-contains.js';
import noReactKey from './rules/no-react-key.js';
+import safeEventTarget from './rules/safe-event-target.js';
+import shadowSafeActiveElement from './rules/shadow-safe-active-element.js';
import sortImports from './rules/sort-imports.js';
const rules = {
@@ -23,6 +25,8 @@ const rules = {
'no-react-key': noReactKey,
'sort-imports': sortImports,
'no-non-shadow-contains': noNonShadowContains,
+ 'safe-event-target': safeEventTarget,
+ 'shadow-safe-active-element': shadowSafeActiveElement,
'faster-node-contains': fasterNodeContains
};
diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/safe-event-target.js b/packages/dev/eslint-plugin-rsp-rules/rules/safe-event-target.js
new file mode 100644
index 00000000000..e9b2662875e
--- /dev/null
+++ b/packages/dev/eslint-plugin-rsp-rules/rules/safe-event-target.js
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2023 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.
+ */
+
+const plugin = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'Disallow using event.target in favor of getEventTarget for shadow DOM compatibility',
+ recommended: true
+ },
+ fixable: 'code',
+ messages: {
+ useGetEventTarget: 'Use getEventTarget() instead of .target for shadow DOM compatibility.',
+ unnecessaryNativeEvent: 'getEventTarget() already handles nativeEvent unwrapping. Pass the event directly.'
+ }
+ },
+ create: (context) => {
+ let hasGetEventTargetImport = false;
+ let getEventTargetLocalName = 'getEventTarget';
+ let existingReactAriaUtilsImport = null;
+
+ return {
+ // Track imports from @react-aria/utils
+ ImportDeclaration(node) {
+ if (
+ node.source &&
+ node.source.type === 'Literal' &&
+ node.source.value === '@react-aria/utils'
+ ) {
+ existingReactAriaUtilsImport = node;
+ // Check if getEventTarget is already imported
+ const hasGetEventTarget = node.specifiers.some(
+ spec => spec.type === 'ImportSpecifier' &&
+ spec.imported.type === 'Identifier' &&
+ spec.imported.name === 'getEventTarget'
+ );
+ if (hasGetEventTarget) {
+ hasGetEventTargetImport = true;
+ const getEventTargetSpec = node.specifiers.find(
+ spec => spec.type === 'ImportSpecifier' &&
+ spec.imported.type === 'Identifier' &&
+ spec.imported.name === 'getEventTarget'
+ );
+ getEventTargetLocalName = getEventTargetSpec.local.name;
+ }
+ }
+ },
+
+ // Detect getEventTarget(event.nativeEvent) calls
+ CallExpression(node) {
+ // Check if this is a getEventTarget call
+ if (node.callee.type !== 'Identifier' || node.callee.name !== getEventTargetLocalName) {
+ return;
+ }
+
+ // Check if it has exactly one argument
+ if (node.arguments.length !== 1) {
+ return;
+ }
+
+ const arg = node.arguments[0];
+
+ // Check if the argument is event.nativeEvent
+ if (arg.type === 'MemberExpression' &&
+ arg.property.type === 'Identifier' &&
+ arg.property.name === 'nativeEvent' &&
+ arg.object.type === 'Identifier') {
+
+ // Only match common event parameter names
+ const commonEventNames = /^(e|event|evt)$/i;
+ if (!commonEventNames.test(arg.object.name)) {
+ return;
+ }
+
+ context.report({
+ node,
+ messageId: 'unnecessaryNativeEvent',
+ fix: (fixer) => {
+ const sourceCode = context.sourceCode;
+ // Get the event object without .nativeEvent (e.g., 'event' from 'event.nativeEvent')
+ const eventText = sourceCode.getText(arg.object);
+ // Replace the entire argument with just the event
+ return fixer.replaceText(arg, eventText);
+ }
+ });
+ }
+ },
+
+ // Detect .target property access
+ ['MemberExpression[property.name=\'target\']'](node) {
+ // Skip if it's already a getEventTarget call result
+ if (node.object.type === 'CallExpression' &&
+ node.object.callee.type === 'Identifier' &&
+ node.object.callee.name === getEventTargetLocalName) {
+ return;
+ }
+
+ // Only match common event parameter names
+ const commonEventNames = /^(e|event|evt)$/i;
+ let isEventTarget = false;
+ let eventExpression = null;
+
+ if (node.object.type === 'Identifier') {
+ // Check if the identifier matches common event names (e.target, event.target, evt.target)
+ isEventTarget = commonEventNames.test(node.object.name);
+ if (isEventTarget) {
+ eventExpression = node.object;
+ }
+ } else if (node.object.type === 'MemberExpression' || node.object.type === 'ChainExpression') {
+ // Handle *.event.target or *.event?.target patterns (e.g., window.event, getOwnerWindow(foo).event)
+ let objectToCheck = node.object;
+
+ // Unwrap ChainExpression to get the MemberExpression
+ if (objectToCheck.type === 'ChainExpression') {
+ objectToCheck = objectToCheck.expression;
+ }
+
+ // Check for *.event pattern (any expression followed by .event)
+ if (objectToCheck.type === 'MemberExpression' &&
+ objectToCheck.property.type === 'Identifier' &&
+ objectToCheck.property.name === 'event') {
+ isEventTarget = true;
+ eventExpression = node.object;
+ }
+ }
+
+ // Skip if this doesn't look like an event target access
+ if (!isEventTarget || !eventExpression) {
+ return;
+ }
+
+ // Skip if we're inside a press or drag/drop event handler
+ // These handlers use custom event types that don't extend DOM Events
+ let currentNode = node;
+ while (currentNode) {
+ if (currentNode.type === 'FunctionDeclaration' ||
+ currentNode.type === 'FunctionExpression' ||
+ currentNode.type === 'ArrowFunctionExpression') {
+ // Check if the function has a name that indicates it's a press or drag/drop handler
+ let functionName = null;
+
+ if (currentNode.type === 'FunctionDeclaration' && currentNode.id) {
+ functionName = currentNode.id.name;
+ } else if (currentNode.parent) {
+ // Check if this is a named function expression or arrow function assigned to a variable
+ if (currentNode.parent.type === 'VariableDeclarator' && currentNode.parent.id) {
+ functionName = currentNode.parent.id.name;
+ } else if (currentNode.parent.type === 'Property' && currentNode.parent.key) {
+ functionName = currentNode.parent.key.name || currentNode.parent.key.value;
+ } else if (currentNode.parent.type === 'AssignmentExpression' &&
+ currentNode.parent.left.type === 'Identifier') {
+ functionName = currentNode.parent.left.name;
+ }
+ }
+
+ // Check if the function name indicates it's a press or drag/drop handler
+ // Press handlers: onPress, onPressStart, onPressEnd, onPressUp, onPressChange, handlePress, etc.
+ // Drag/drop handlers: onDrag, onDrop, onDropActivate, onDropEnter, onDropExit, onDragStart, onDragEnd, etc.
+ if (functionName && /(press|drag|drop)/i.test(functionName)) {
+ return;
+ }
+ }
+ currentNode = currentNode.parent;
+ }
+
+ context.report({
+ node,
+ messageId: 'useGetEventTarget',
+ fix: (fixer) => {
+ const fixes = [];
+ const sourceCode = context.sourceCode;
+
+ // Get the event expression text
+ let eventText = sourceCode.getText(eventExpression);
+
+ // If it's a ChainExpression (window.event?), unwrap it
+ if (eventExpression.type === 'ChainExpression') {
+ eventText = sourceCode.getText(eventExpression.expression);
+ }
+
+ // Replace event.target with getEventTarget(event)
+ fixes.push(fixer.replaceText(node, `${getEventTargetLocalName}(${eventText})`));
+
+ // Add import if not present
+ if (!hasGetEventTargetImport) {
+ if (existingReactAriaUtilsImport) {
+ // Add getEventTarget to existing @react-aria/utils import
+ const specifiers = existingReactAriaUtilsImport.specifiers;
+ if (specifiers.length > 0) {
+ fixes.push(fixer.insertTextAfter(
+ sourceCode.getFirstToken(existingReactAriaUtilsImport, token => token.value === '{'),
+ 'getEventTarget, '
+ ));
+ }
+ } else {
+ // No existing import from @react-aria/utils, create a new one
+ const programNode = context.sourceCode.ast;
+ const imports = programNode.body.filter(node => node.type === 'ImportDeclaration');
+
+ if (imports.length > 0) {
+ const lastImport = imports[imports.length - 1];
+ const importStatement = '\nimport {getEventTarget} from \'@react-aria/utils\';';
+ fixes.push(fixer.insertTextAfter(lastImport, importStatement));
+ } else {
+ // No imports, add at the beginning
+ const importStatement = 'import {getEventTarget} from \'@react-aria/utils\';\n';
+ fixes.push(fixer.insertTextBefore(programNode.body[0], importStatement));
+ }
+ }
+
+ // Mark as imported for subsequent fixes in the same file
+ hasGetEventTargetImport = true;
+ }
+
+ return fixes;
+ }
+ });
+ }
+ };
+ }
+};
+
+export default plugin;
diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-active-element.js b/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-active-element.js
new file mode 100644
index 00000000000..3ed4d96a34f
--- /dev/null
+++ b/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-active-element.js
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2023 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.
+ */
+
+const plugin = {
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description: 'Disallow using document.activeElement in favor of getActiveElement() for shadow DOM compatibility',
+ recommended: true
+ },
+ fixable: 'code',
+ messages: {
+ useGetActiveElement: 'Use getActiveElement() instead of document.activeElement for shadow DOM compatibility.'
+ }
+ },
+ create: (context) => {
+ let hasGetActiveElementImport = false;
+ let getActiveElementLocalName = 'getActiveElement';
+ let existingReactAriaUtilsImport = null;
+
+ return {
+ // Track imports from @react-aria/utils
+ ImportDeclaration(node) {
+ if (
+ node.source &&
+ node.source.type === 'Literal' &&
+ node.source.value === '@react-aria/utils'
+ ) {
+ existingReactAriaUtilsImport = node;
+ // Check if getActiveElement is already imported
+ const hasGetActiveElement = node.specifiers.some(
+ spec => spec.type === 'ImportSpecifier' &&
+ spec.imported.type === 'Identifier' &&
+ spec.imported.name === 'getActiveElement'
+ );
+ if (hasGetActiveElement) {
+ hasGetActiveElementImport = true;
+ const getActiveElementSpec = node.specifiers.find(
+ spec => spec.type === 'ImportSpecifier' &&
+ spec.imported.type === 'Identifier' &&
+ spec.imported.name === 'getActiveElement'
+ );
+ getActiveElementLocalName = getActiveElementSpec.local.name;
+ }
+ }
+ },
+
+ // Detect document.activeElement usage
+ ['MemberExpression[object.name=\'document\'][property.name=\'activeElement\']'](node) {
+ context.report({
+ node,
+ messageId: 'useGetActiveElement',
+ fix: (fixer) => {
+ const fixes = [];
+ const sourceCode = context.sourceCode;
+
+ // Replace document.activeElement with getActiveElement()
+ fixes.push(fixer.replaceText(node, `${getActiveElementLocalName}()`));
+
+ // Add import if not present
+ if (!hasGetActiveElementImport) {
+ if (existingReactAriaUtilsImport) {
+ // Add getActiveElement to existing @react-aria/utils import
+ const specifiers = existingReactAriaUtilsImport.specifiers;
+ if (specifiers.length > 0) {
+ fixes.push(fixer.insertTextAfter(
+ sourceCode.getFirstToken(existingReactAriaUtilsImport, token => token.value === '{'),
+ 'getActiveElement, '
+ ));
+ }
+ } else {
+ // No existing import from @react-aria/utils, create a new one
+ const programNode = context.sourceCode.ast;
+ const imports = programNode.body.filter(node => node.type === 'ImportDeclaration');
+
+ if (imports.length > 0) {
+ const lastImport = imports[imports.length - 1];
+ const importStatement = '\nimport {getActiveElement} from \'@react-aria/utils\';';
+ fixes.push(fixer.insertTextAfter(lastImport, importStatement));
+ } else {
+ // No imports, add at the beginning
+ const importStatement = 'import {getActiveElement} from \'@react-aria/utils\';\n';
+ fixes.push(fixer.insertTextBefore(programNode.body[0], importStatement));
+ }
+ }
+
+ // Mark as imported for subsequent fixes in the same file
+ hasGetActiveElementImport = true;
+ }
+
+ return fixes;
+ }
+ });
+ }
+ };
+ }
+};
+
+export default plugin;
diff --git a/packages/dev/eslint-plugin-rsp-rules/test/safe-event-target.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/safe-event-target.test-lint.js
new file mode 100644
index 00000000000..26c7aa7bfac
--- /dev/null
+++ b/packages/dev/eslint-plugin-rsp-rules/test/safe-event-target.test-lint.js
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2023 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 {RuleTester} from 'eslint';
+import safeEventTargetRule from '../rules/safe-event-target.js';
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2020,
+ sourceType: 'module'
+ }
+});
+
+// Throws error if the tests in ruleTester.run() do not pass
+ruleTester.run(
+ 'safe-event-target',
+ safeEventTargetRule,
+ {
+ // 'valid' checks cases that should pass
+ valid: [
+ {
+ code: `import {getEventTarget} from '@react-aria/utils';
+const target = getEventTarget(event);`
+ },
+ {
+ code: `import {getEventTarget} from '@react-aria/utils';
+function handleClick(e) {
+ const target = getEventTarget(e);
+ console.log(target);
+}`
+ },
+ {
+ code: `function checkTarget(props) {
+ return props.target;
+}`
+ },
+ {
+ code: 'const value = target.target;'
+ },
+ {
+ code: `function focusTarget(ref) {
+ ref.target.focus();
+}`
+ },
+ {
+ code: `const link = {target: '_blank'};
+console.log(link.target);`
+ },
+ {
+ code: `function onPress(event) {
+ if (event.target instanceof HTMLElement) {
+ event.target.focus();
+ }
+}`
+ },
+ {
+ code: `function onPressStart(event) {
+ if (event.target instanceof HTMLElement) {
+ event.target.focus();
+ }
+}`
+ },
+ {
+ code: `function onDropActivate(event) {
+ if (event.target instanceof HTMLElement) {
+ event.target.focus();
+ }
+}`
+ },
+ {
+ code: `function onDragStart(event) {
+ const element = event.target;
+ element.classList.add('dragging');
+}`
+ },
+ {
+ code: `function onDrop(e) {
+ e.target.appendChild(draggedItem);
+}`
+ },
+ {
+ code: `const handleDragEnd = (evt) => {
+ evt.target.style.opacity = '1';
+};`
+ }
+ ],
+ // 'invalid' checks cases that should not pass
+ invalid: [
+ {
+ code: `function handleClick(event) {
+ const target = event.target;
+}`,
+ output: `import {getEventTarget} from '@react-aria/utils';
+function handleClick(event) {
+ const target = getEventTarget(event);
+}`,
+ errors: 1
+ },
+ {
+ code: 'const element = e.target;',
+ output: `import {getEventTarget} from '@react-aria/utils';
+const element = getEventTarget(e);`,
+ errors: 1
+ },
+ {
+ code: `import {something} from '@react-aria/utils';
+function handleEvent(evt) {
+ console.log(evt.target);
+}`,
+ output: `import {getEventTarget, something} from '@react-aria/utils';
+function handleEvent(evt) {
+ console.log(getEventTarget(evt));
+}`,
+ errors: 1
+ },
+ {
+ code: `import {getEventTarget} from '@react-aria/utils';
+function handleClick(event) {
+ const target = event.target;
+}`,
+ output: `import {getEventTarget} from '@react-aria/utils';
+function handleClick(event) {
+ const target = getEventTarget(event);
+}`,
+ errors: 1
+ },
+ {
+ code: `import React from 'react';
+const onClick = (e) => {
+ const target = e.target;
+ const value = e.target.value;
+};`,
+ output: `import React from 'react';
+import {getEventTarget} from '@react-aria/utils';
+const onClick = (e) => {
+ const target = getEventTarget(e);
+ const value = getEventTarget(e).value;
+};`,
+ errors: 2
+ },
+ {
+ code: `function onKeyDown(event) {
+ if (event.target instanceof HTMLElement) {
+ event.target.focus();
+ }
+}`,
+ output: `import {getEventTarget} from '@react-aria/utils';
+function onKeyDown(event) {
+ if (getEventTarget(event) instanceof HTMLElement) {
+ getEventTarget(event).focus();
+ }
+}`,
+ errors: 2
+ },
+ {
+ code: `import {getEventTarget} from '@react-aria/utils';
+getEventTarget(event.nativeEvent)`,
+ output: `import {getEventTarget} from '@react-aria/utils';
+getEventTarget(event)`,
+ errors: 1
+ },
+ {
+ code: 'window.event.target;',
+ output: `import {getEventTarget} from '@react-aria/utils';
+getEventTarget(window.event);`,
+ errors: 1
+ },
+ {
+ code: 'window.event?.target;',
+ output: `import {getEventTarget} from '@react-aria/utils';
+getEventTarget(window.event);`,
+ errors: 1
+ },
+ {
+ code: 'getOwnerWindow(foo).event.target;',
+ output: `import {getEventTarget} from '@react-aria/utils';
+getEventTarget(getOwnerWindow(foo).event);`,
+ errors: 1
+ },
+ {
+ code: 'getOwnerWindow(foo).event?.target;',
+ output: `import {getEventTarget} from '@react-aria/utils';
+getEventTarget(getOwnerWindow(foo).event);`,
+ errors: 1
+ }
+ ]
+ }
+);
diff --git a/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-active-element.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-active-element.test-lint.js
new file mode 100644
index 00000000000..54a0b575df7
--- /dev/null
+++ b/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-active-element.test-lint.js
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2023 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 {RuleTester} from 'eslint';
+import shadowSafeActiveElementRule from '../rules/shadow-safe-active-element.js';
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2015,
+ sourceType: 'module'
+ }
+});
+
+// Throws error if the tests in ruleTester.run() do not pass
+ruleTester.run(
+ 'shadow-safe-active-element',
+ shadowSafeActiveElementRule,
+ {
+ // 'valid' checks cases that should pass
+ valid: [
+ {
+ code: `
+import {getActiveElement} from '@react-aria/utils';
+if (getActiveElement()) {
+ console.log('active element');
+}`
+ },
+ {
+ code: `
+import {getActiveElement} from '@react-aria/utils';
+if (getActiveElement(element)) {
+ console.log('active element');
+}`
+ }
+ ],
+ // 'invalid' checks cases that should not pass
+ invalid: [
+ {
+ code: `
+if (document.activeElement) {
+ console.log('active element');
+}`,
+ output: `
+import {getActiveElement} from '@react-aria/utils';
+if (getActiveElement()) {
+ console.log('active element');
+}`,
+ errors: 1
+ }
+ ]
+ }
+);
diff --git a/packages/dev/s2-docs/src/SearchMenuTrigger.tsx b/packages/dev/s2-docs/src/SearchMenuTrigger.tsx
index 2051efcb829..d83dfc91021 100644
--- a/packages/dev/s2-docs/src/SearchMenuTrigger.tsx
+++ b/packages/dev/s2-docs/src/SearchMenuTrigger.tsx
@@ -2,6 +2,7 @@
import {Button, ButtonProps, Modal, ModalOverlay} from 'react-aria-components';
import {fontRelative, lightDark, style} from '@react-spectrum/s2/style' with { type: 'macro' };
+import {getActiveElement} from '@react-aria/utils';
import {getLibraryFromPage, getLibraryLabel} from './library';
import {Provider, Button as S2Button, ButtonProps as S2ButtonProps} from '@react-spectrum/s2';
import React, {lazy, useCallback, useEffect, useRef, useState} from 'react';
@@ -103,7 +104,7 @@ export default function SearchMenuTrigger({onOpen, onClose, isSearchOpen, overla
} else if (((e.key === 'k' && (isMac ? e.metaKey : e.ctrlKey)))) {
e.preventDefault();
open('');
- } else if (e.key === '/' && !(isTextInputLike(e.target as Element | null) || isTextInputLike(document.activeElement))) {
+ } else if (e.key === '/' && !(isTextInputLike(e.target as Element | null) || isTextInputLike(getActiveElement()))) {
e.preventDefault();
open('');
}
diff --git a/packages/react-aria-components/src/DropZone.tsx b/packages/react-aria-components/src/DropZone.tsx
index d542fc1b641..dc32b11c422 100644
--- a/packages/react-aria-components/src/DropZone.tsx
+++ b/packages/react-aria-components/src/DropZone.tsx
@@ -22,7 +22,7 @@ import {
useRenderProps
} from './utils';
import {DropOptions, mergeProps, useButton, useClipboard, useDrop, useFocusRing, useHover, useLocalizedStringFormatter, VisuallyHidden} from 'react-aria';
-import {filterDOMProps, isFocusable, nodeContains, useLabels, useObjectRef, useSlotId} from '@react-aria/utils';
+import {filterDOMProps, getEventTarget, isFocusable, nodeContains, useLabels, useObjectRef, useSlotId} from '@react-aria/utils';
// @ts-ignore
import intlMessages from '../intl/*.json';
import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react';
@@ -116,7 +116,7 @@ export const DropZone = forwardRef(function DropZone(props: DropZoneProps, ref:
slot={props.slot || undefined}
ref={dropzoneRef}
onClick={(e) => {
- let target = e.target as HTMLElement | null;
+ let target = getEventTarget(e) as HTMLElement | null;
while (target && nodeContains(dropzoneRef.current, target)) {
if (isFocusable(target)) {
break;
diff --git a/packages/react-aria-components/src/FileTrigger.tsx b/packages/react-aria-components/src/FileTrigger.tsx
index 979580dc7dd..3ddbfcb09f7 100644
--- a/packages/react-aria-components/src/FileTrigger.tsx
+++ b/packages/react-aria-components/src/FileTrigger.tsx
@@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
-import {filterDOMProps, useObjectRef} from '@react-aria/utils';
+import {filterDOMProps, getEventTarget, useObjectRef} from '@react-aria/utils';
import {GlobalDOMAttributes} from '@react-types/shared';
import {Input} from './Input';
import {PressResponder} from '@react-aria/interactions';
@@ -69,7 +69,7 @@ export const FileTrigger = forwardRef(function FileTrigger(props: FileTriggerPro
ref={inputRef}
style={{display: 'none'}}
accept={acceptedFileTypes?.toString()}
- onChange={(e) => onSelect?.(e.target.files)}
+ onChange={(e) => onSelect?.(getEventTarget(e).files)}
capture={defaultCamera}
multiple={allowsMultiple}
// @ts-expect-error
diff --git a/packages/react-aria-components/src/HiddenDateInput.tsx b/packages/react-aria-components/src/HiddenDateInput.tsx
index a7eeeb9c4d9..b6962a3a7c0 100644
--- a/packages/react-aria-components/src/HiddenDateInput.tsx
+++ b/packages/react-aria-components/src/HiddenDateInput.tsx
@@ -13,6 +13,7 @@
import {CalendarDate, CalendarDateTime, parseDate, parseDateTime, toCalendarDate, toCalendarDateTime, toLocalTimeZone} from '@internationalized/date';
import {DateFieldState, DatePickerState, DateSegmentType} from 'react-stately';
+import {getEventTarget} from '@react-aria/utils';
import React, {ReactNode} from 'react';
import {useVisuallyHidden} from 'react-aria';
@@ -105,7 +106,7 @@ export function useHiddenDateInput(props: HiddenDateInputProps, state: DateField
step: inputStep,
value: dateValue,
onChange: (e) => {
- let targetString = e.target.value.toString();
+ let targetString = getEventTarget(e).value.toString();
if (targetString) {
try {
let targetValue: CalendarDateTime | CalendarDate = parseDateTime(targetString);
diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx
index 0eeeebd2f7c..0bf816351c1 100644
--- a/packages/react-aria-components/stories/ListBox.stories.tsx
+++ b/packages/react-aria-components/stories/ListBox.stories.tsx
@@ -743,6 +743,46 @@ export const AsyncListBoxVirtualized: StoryFn = (args
);
};
+export const ListBoxScrollMargin: ListBoxStory = (args) => {
+ let items: {id: number, name: string, description: string}[] = [];
+ for (let i = 0; i < 100; i++) {
+ items.push({id: i, name: `Item ${i}`, description: `Description ${i}`});
+ }
+ return (
+
+ {item => (
+
+ {item.name}
+ {item.description}
+
+ )}
+
+ );
+};
+
+export const ListBoxSmoothScroll: ListBoxStory = (args) => {
+ let items: {id: number, name: string}[] = [];
+ for (let i = 0; i < 100; i++) {
+ items.push({id: i, name: `Item ${i}`});
+ }
+ return (
+
+ {item => {item.name}}
+
+ );
+};
+
AsyncListBoxVirtualized.story = {
args: {
delay: 50
diff --git a/scripts/getCommitsForTesting.js b/scripts/getCommitsForTesting.js
deleted file mode 100644
index bae0c91c8d9..00000000000
--- a/scripts/getCommitsForTesting.js
+++ /dev/null
@@ -1,162 +0,0 @@
-const Octokit = require('@octokit/rest');
-const fs = require('fs');
-let {parseArgs} = require('util');
-
-const octokit = new Octokit();
-
-let options = {
- startDate: {
- type: 'string'
- },
- endDate: {
- type: 'string'
- }
-};
-
-writeTestingCSV();
-
-async function writeTestingCSV() {
- let data = await listCommits();
-
- let s2PRs = [];
- let racPRs = [];
- let v3PRs = [];
- let otherPRs = [];
-
- for (let d of data) {
- let row = [];
-
- // Get the PR Title from the commit
- let regex = /\(#(\d+)\)/g;
- let messages = d.commit.message.split('\n');
- let title = messages[0];
- row.push(title);
-
- // Get info about the PR using PR number
- if (regex.test(title)) {
- let num = title.match(regex)[0].replace(/[\(\)#]/g, '');
- let info = await getPR(num);
-
- // Get testing instructions if it exists
- let content = info.data.body;
- const match = content.match(/## π Test Instructions:\s*([\s\S]*?)(?=##|$)/);
- let testInstructions = '';
- if (match) {
- testInstructions = match[1];
- testInstructions = testInstructions.replace(//g, '');
- testInstructions = testInstructions.trim();
- testInstructions = escapeCSV(testInstructions);
- }
-
- if (testInstructions.length > 350) {
- row.push('See PR for testing instructions');
- } else {
- row.push(testInstructions);
- }
- row.push(info.data.html_url);
-
- if ((/\bs2\b/gi).test(title)) {
- s2PRs.push(row);
- } else if ((/\brac\b/gi).test(title)) {
- racPRs.push(row);
- } else if ((/\bv3\b/gi).test(title)) {
- v3PRs.push(row);
- } else {
- otherPRs.push(row);
- }
- }
- }
-
- let csvRows = '';
- csvRows += 'V3 \n';
- for (let v3 of v3PRs) {
- csvRows += v3.join() + '\n';
- }
-
- csvRows += '\nRainbow \n'
- for (let s2 of s2PRs) {
- csvRows += s2.join() + '\n';
- }
-
- csvRows += '\nRAC \n'
- for (let rac of racPRs) {
- csvRows += rac.join() + '\n';
- }
-
- csvRows += '\nOther \n'
- for (let other of otherPRs) {
- csvRows += other.join() + '\n';
- }
-
- fs.writeFileSync('output.csv', csvRows, 'utf-8');
-}
-
-async function listCommits() {
- let args = parseArgs({options, allowPositionals: true});
- if (args.positionals.length < 2) {
- console.error('Expected at least two arguments');
- process.exit(1);
- }
-
- let start = new Date(args.positionals[0]);
- let end = new Date(args.positionals[1]);
-
- if (isNaN(start.getTime()) || isNaN(end.getTime())) {
- console.error('Please verify that your date is correctly formatted')
- process.exit(1)
- }
-
- let startDate = new Date(start).toISOString();
- let endDate = new Date(end).toISOString();
-
- let res = await octokit.request(`GET /repos/adobe/react-spectrum/commits?sha=main&since=${startDate}&until=${endDate}`, {
- owner: 'adobe',
- repo: 'react-spectrum',
- headers: {
- 'X-GitHub-Api-Version': '2022-11-28'
- }
- });
-
- return res.data;
-}
-
-async function getPR(num) {
- let res = await octokit.request(`GET /repos/adobe/react-spectrum/pulls/${num}`, {
- owner: 'adobe',
- repo: 'react-spectrum',
- pull_number: `${num}`,
- headers: {
- 'X-GitHub-Api-Version': '2022-11-28'
- }
- });
- return res;
-}
-
-function escapeCSV(value) {
- if (!value) {
- return '';
- }
-
- // Normalize newlines for CSV compatibility
- let stringValue = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
-
- // Escape any internal double quotes
- let escaped = stringValue.replace(/"/g, '""');
-
- // Wrap in quotes so commas/newlines don't break the cell
- return `"${escaped}"`;
-}
-
-// We can bring this back if we start using the "needs testing" label
-// function isReadyForTesting(labels){
-// if (labels.length === 0) {
-// return false;
-// }
-// for (let label of labels) {
-// if (label.name === 'needs testing') {
-// return true;
-// }
-// }
-
-// return false;
-// }
diff --git a/scripts/getCommitsForTesting.mjs b/scripts/getCommitsForTesting.mjs
new file mode 100644
index 00000000000..5d8dce479e8
--- /dev/null
+++ b/scripts/getCommitsForTesting.mjs
@@ -0,0 +1,330 @@
+import Octokit from '@octokit/rest';
+import fs from 'fs';
+import {parseArgs} from 'node:util';
+import remarkParse from 'remark-parse';
+import {toString} from 'mdast-util-to-string';
+import {unified} from 'unified';
+
+/**
+ * Instructions:
+ *
+ * 1. Run the following script: node scripts/getCommitsForTesting.mjs 2026-10-07 2026-10-18
+ * 2. Go to output.csv, copy it to Google sheets, highlight the rows, go to "Data" in the toolbar -> split text to columns -> separator: comma
+ */
+
+const octokit = new Octokit({
+ auth: `token ${process.env.GITHUB_TOKEN}`
+});
+
+let options = {
+ startDate: {
+ type: 'string'
+ },
+ endDate: {
+ type: 'string'
+ }
+};
+
+writeTestingCSV();
+
+async function writeTestingCSV() {
+ let data = await listCommits();
+
+ let s2PRs = [];
+ let racPRs = [];
+ let v3PRs = [];
+ let otherPRs = [];
+
+ for (let d of data) {
+ let row = [];
+
+ // Get the PR Title from the commit
+ let regex = /\(#(\d+)\)/g;
+ let messages = d.commit.message.split('\n');
+ let title = messages[0];
+
+ // Get info about the PR using PR number
+ if (regex.test(title)) {
+ let num = title.match(regex)[0].replace(/[()#]/g, '');
+ let info = await getPR(num);
+ let labels = new Set(info.data.labels.map(label => label.name));
+
+ // Skip PR if it has the no testing label
+ if (labels.has('no testing')) {
+ continue;
+ }
+
+ let matches = [...validLabels].filter(name => labels.has(name));
+ if (matches.length > 0) {
+ let title = matches[0];
+
+ if (title === 'documentation') {
+ row.push('Docs');
+ } else {
+ row.push(matches[0]);
+ }
+ }
+
+ // If there is no component label, use the title of the PR
+ if (matches.length === 0) {
+ row.push(removePRNumber(title));
+ }
+
+ // Get testing instructions if it exists
+ let content = info.data.body;
+ let testInstructions = escapeCSV(extractTestInstructions(content));
+
+ if (testInstructions.length > 300) {
+ row.push('See PR for testing instructions');
+ } else {
+ row.push(testInstructions);
+ }
+
+ // Add PR url to the row
+ row.push(info.data.html_url);
+
+ // Add PR title for additional context
+ row.push(removePRNumber(title));
+
+ // Categorize commit into V3, RAC, S2, or other (utilizes labels on PR's to categorize)
+ if (!labels.has('S2') && !labels.has('RAC') && !labels.has('v3')) {
+ otherPRs.push(row);
+ } else {
+ if (labels.has('S2')) {
+ s2PRs.push(row);
+ } else if (labels.has('RAC')) {
+ racPRs.push(row);
+ } else if (labels.has('v3')) {
+ v3PRs.push(row);
+ }
+ }
+ }
+ }
+
+ // Prepare to write into CSV
+ let csvRows = '';
+ csvRows += 'V3 \n';
+ for (let v3 of v3PRs) {
+ csvRows += v3.join() + '\n';
+ }
+
+ csvRows += '\nRainbow \n';
+ for (let s2 of s2PRs) {
+ csvRows += s2.join() + '\n';
+ }
+
+ csvRows += '\nRAC \n';
+ for (let rac of racPRs) {
+ csvRows += rac.join() + '\n';
+ }
+
+ csvRows += '\nOther \n';
+ for (let other of otherPRs) {
+ csvRows += other.join() + '\n';
+ }
+
+ fs.writeFileSync('output.csv', csvRows, 'utf-8');
+}
+
+async function listCommits() {
+ let args = parseArgs({options, allowPositionals: true});
+ if (args.positionals.length < 2) {
+ console.error('Expected at least two arguments');
+ process.exit(1);
+ }
+
+ let start = new Date(args.positionals[0]);
+ let end = new Date(args.positionals[1]);
+
+ if (isNaN(start.getTime()) || isNaN(end.getTime())) {
+ console.error('Please verify that your date is correctly formatted');
+ process.exit(1);
+ }
+
+ let startDate = new Date(start).toISOString();
+ let endDate = new Date(end).toISOString();
+
+ let res = await octokit.request(`GET /repos/adobe/react-spectrum/commits?sha=main&since=${startDate}&until=${endDate}`, {
+ owner: 'adobe',
+ repo: 'react-spectrum',
+ headers: {
+ 'X-GitHub-Api-Version': '2022-11-28'
+ }
+ });
+
+ return res.data;
+}
+
+async function getPR(num) {
+ let res = await octokit.request(`GET /repos/adobe/react-spectrum/pulls/${num}`, {
+ owner: 'adobe',
+ repo: 'react-spectrum',
+ pull_number: `${num}`,
+ headers: {
+ 'X-GitHub-Api-Version': '2022-11-28'
+ }
+ });
+ return res;
+}
+
+function getHeadingText(node) {
+ return node.children
+ .map(child => child.value || '')
+ .join('')
+ .trim();
+}
+
+function extractTestInstructions(contents) {
+ if (!contents) {
+ return '';
+ }
+
+ let tree = unified().use(remarkParse).parse(contents);
+
+ let collecting = false;
+ let headingDepth = null;
+ let collected = [];
+
+ for (let node of tree.children) {
+ if (node.type === 'heading') {
+ let text = getHeadingText(node).toLowerCase();
+
+ if (text.includes('test instructions')) {
+ collecting = true;
+ headingDepth = node.depth;
+ continue;
+ }
+
+ // Stop when we reach another heading of same or higher level
+ if (collecting && node.depth <= headingDepth) {
+ break;
+ }
+ }
+
+ if (collecting) {
+ collected.push(node);
+ }
+
+ }
+
+ return collected.map(node => toString(node)).join(' ').replace(/\r\n/g, '\n').replace(/\s+/g, ' ').trim();
+}
+
+
+function escapeCSV(value) {
+ if (!value) {
+ return '';
+ }
+
+ // Normalize newlines for CSV compatibility
+ let stringValue = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+
+ // Escape any internal double quotes
+ let escaped = stringValue.replace(/"/g, '""');
+
+ // Wrap in quotes so commas/newlines don't break the cell
+ return `"${escaped}"`;
+}
+
+function removePRNumber(title) {
+ return title.replace(/\s*\(#\d+\)\s*$/, '');
+}
+
+let validLabels = new Set([
+ 'Accordion',
+ 'ActionBar',
+ 'ActionButton',
+ 'ActionButtonGroup',
+ 'ActionMenu',
+ 'Autocomplete',
+ 'Avatar',
+ 'AvatarGroup',
+ 'Badge',
+ 'Breadcrumbs',
+ 'Button',
+ 'ButtonGroup',
+ 'Calendar',
+ 'Card',
+ 'CardView',
+ 'Checkbox',
+ 'CheckboxGroup',
+ 'ColorArea',
+ 'ColorField',
+ 'ColorPicker',
+ 'ColorSlider',
+ 'ColorSwatch',
+ 'ColorSwatchPicker',
+ 'ColorWheel',
+ 'ComboBox',
+ 'ContextualHelp',
+ 'DateField',
+ 'DatePicker',
+ 'DateRangePicker',
+ 'Dialog',
+ 'Disclosure',
+ 'DisclosureGroup',
+ 'Divider',
+ 'DropZone',
+ 'FileTrigger',
+ 'FocusRing',
+ 'FocusScope',
+ 'Form',
+ 'GridList',
+ 'Group',
+ 'I18nProvider',
+ 'IllustratedMessage',
+ 'Image',
+ 'InlineAlert',
+ 'Link',
+ 'LinkButton',
+ 'ListBox',
+ 'Menu',
+ 'Meter',
+ 'Modal',
+ 'NumberField',
+ 'Picker',
+ 'Popover',
+ 'PortalProvider',
+ 'ProgressBar',
+ 'ProgressCircle',
+ 'Provider',
+ 'RadioGroup',
+ 'RangeCalendar',
+ 'RangeSlider',
+ 'SearchField',
+ 'SegmentedControl',
+ 'Select',
+ 'SelectBoxGroup',
+ 'Separator',
+ 'Skeleton',
+ 'Slider',
+ 'SSRProvider',
+ 'StatusLight',
+ 'Switch',
+ 'Table',
+ 'TableView',
+ 'Tabs',
+ 'TagGroup',
+ 'TextArea',
+ 'TextField',
+ 'TimeField',
+ 'Toast',
+ 'ToggleButton',
+ 'ToggleButtonGroup',
+ 'Toolbar',
+ 'Tooltip',
+ 'Tree',
+ 'TreeView',
+ 'Virtualizer',
+ 'VisuallyHidden',
+ 'documentation',
+ 'usePress',
+ 'scrollIntoView',
+ 'ResizeObserver',
+ 'FocusScope',
+ 'Focus',
+ 'Overlays',
+ 'Overlay Positioning',
+ 'drag and drop',
+ 'ssr'
+]);
diff --git a/yarn.lock b/yarn.lock
index 09f406000e1..f5e3e3e8455 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6647,7 +6647,7 @@ __metadata:
"@react-spectrum/s2": "npm:^1.1.0"
"@react-types/shared": "npm:^3.33.0"
"@types/jscodeshift": "npm:^0.11.11"
- "@types/node": "npm:^22"
+ "@types/node": "npm:^24"
boxen: "npm:^5.1.2"
chalk: "npm:^4.0.0"
execa: "npm:^5.1.1"
@@ -10595,12 +10595,12 @@ __metadata:
languageName: node
linkType: hard
-"@types/node@npm:^22":
- version: 22.15.18
- resolution: "@types/node@npm:22.15.18"
+"@types/node@npm:^24":
+ version: 24.10.9
+ resolution: "@types/node@npm:24.10.9"
dependencies:
- undici-types: "npm:~6.21.0"
- checksum: 10c0/e23178c568e2dc6b93b6aa3b8dfb45f9556e527918c947fe7406a4c92d2184c7396558912400c3b1b8d0fa952ec63819aca2b8e4d3545455fc6f1e9623e09ca6
+ undici-types: "npm:~7.16.0"
+ checksum: 10c0/e9e436fcd2136bddb1bbe3271a89f4653910bcf6ee8047c4117f544c7905a106c039e2720ee48f28505ef2560e22fb9ead719f28bf5e075fdde0c1120e38e3b2
languageName: node
linkType: hard
@@ -24939,6 +24939,7 @@ __metadata:
lerna: "npm:^3.13.2"
lucide-react: "npm:^0.517.0"
md5: "npm:^2.2.1"
+ mdast-util-to-string: "npm:^4.0.0"
motion: "npm:^12.23.6"
npm-cli-login: "npm:^1.0.0"
parcel: "npm:^2.16.3"
@@ -28383,10 +28384,10 @@ __metadata:
languageName: node
linkType: hard
-"undici-types@npm:~6.21.0":
- version: 6.21.0
- resolution: "undici-types@npm:6.21.0"
- checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04
+"undici-types@npm:~7.16.0":
+ version: 7.16.0
+ resolution: "undici-types@npm:7.16.0"
+ checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard