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