From fbcdc1c592519d6a470e19ee8fdc23c69469adc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= Date: Thu, 22 Oct 2026 21:11:22 +0200 Subject: [PATCH] chore: add shadow-safe parent helpers and lint rules --- .oxlintrc.json | 8 +- .../src/actiongroup/ActionGroup.tsx | 3 +- .../src/buttongroup/ButtonGroup.tsx | 3 +- .../react-spectrum/src/datepicker/Input.tsx | 12 +- .../react-spectrum/src/provider/Provider.tsx | 3 +- .../react-spectrum/src/tag/TagGroup.tsx | 11 +- .../ai/src/PromptFieldContainer.tsx | 8 +- .../@react-spectrum/s2/src/Breadcrumbs.tsx | 3 +- .../@react-spectrum/s2/src/ButtonGroup.tsx | 3 +- packages/@react-spectrum/s2/src/TableView.tsx | 3 +- packages/@react-spectrum/s2/src/TagGroup.tsx | 6 +- packages/dev/eslint-plugin-rsp-rules/index.js | 4 + .../rules/shadow-safe-active-element.js | 26 ++-- .../rules/shadow-safe-parent-element.js | 126 ++++++++++++++++++ .../rules/shadow-safe-parent-node.js | 126 ++++++++++++++++++ .../shadow-safe-active-element.test-lint.js | 13 ++ .../shadow-safe-parent-element.test-lint.js | 63 +++++++++ .../test/shadow-safe-parent-node.test-lint.js | 63 +++++++++ packages/dev/s2-docs/src/CodePlatter.tsx | 3 +- .../dev/s2-docs/src/VisualExampleClient.tsx | 3 +- .../react-aria-components/src/DropZone.tsx | 8 +- packages/react-aria-components/src/Table.tsx | 3 +- .../private/utils/shadowdom/DOMFunctions.ts | 4 +- .../src/actiongroup/useActionGroup.ts | 3 +- .../react-aria/src/collections/Document.ts | 2 + packages/react-aria/src/focus/FocusScope.tsx | 9 +- packages/react-aria/src/grid/useGridCell.ts | 7 +- .../src/gridlist/useGridListItem.ts | 3 +- packages/react-aria/src/interactions/utils.ts | 9 +- .../react-aria/src/landmark/useLandmark.ts | 6 +- .../src/overlays/ariaHideOutside.ts | 9 +- .../src/overlays/calculatePosition.ts | 6 +- .../src/overlays/usePreventScroll.ts | 6 +- .../src/selection/useSelectableItem.ts | 4 +- .../react-aria/src/tokenfield/useToken.ts | 3 +- .../src/tokenfield/useTokenField.ts | 8 +- packages/react-aria/src/toolbar/useToolbar.ts | 9 +- packages/react-aria/src/utils/domHelpers.ts | 9 ++ .../react-aria/src/utils/getScrollParent.ts | 5 +- .../react-aria/src/utils/getScrollParents.ts | 3 +- .../react-aria/src/utils/isElementVisible.ts | 4 +- packages/react-aria/src/utils/isFocusable.ts | 3 +- .../src/utils/shadowdom/DOMFunctions.ts | 47 ++++++- .../src/utils/shadowdom/ShadowTreeWalker.ts | 1 + 44 files changed, 583 insertions(+), 78 deletions(-) create mode 100644 packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-parent-element.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-parent-node.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-parent-element.test-lint.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-parent-node.test-lint.js diff --git a/.oxlintrc.json b/.oxlintrc.json index 4686d5a4741..b7397063173 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -174,6 +174,8 @@ "rsp-rules/no-non-shadow-contains": ["error"], "rsp-rules/safe-event-target": ["error"], "rsp-rules/shadow-safe-active-element": ["error"], + "rsp-rules/shadow-safe-parent-element": ["error"], + "rsp-rules/shadow-safe-parent-node": ["error"], "rsp-rules/faster-node-contains": ["error"], "rsp-rules/imports": ["error"], "rsp-rules/use-layout-effect-rule": ["error"], @@ -277,6 +279,8 @@ "rsp-rules/no-non-shadow-contains": "off", "rsp-rules/safe-event-target": "off", "rsp-rules/shadow-safe-active-element": "off", + "rsp-rules/shadow-safe-parent-element": "off", + "rsp-rules/shadow-safe-parent-node": "off", "rsp-rules/faster-node-contains": "off", "rsp-rules/imports": "off", "monorepo/no-internal-import": "off", @@ -358,7 +362,9 @@ "rules": { "rsp-rules/faster-node-contains": "off", "rsp-rules/no-non-shadow-contains": "off", - "rsp-rules/shadow-safe-active-element": "off" + "rsp-rules/shadow-safe-active-element": "off", + "rsp-rules/shadow-safe-parent-element": "off", + "rsp-rules/shadow-safe-parent-node": "off" } }, { diff --git a/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx b/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx index 877afc6e041..11f8837a737 100644 --- a/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx +++ b/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx @@ -19,6 +19,7 @@ import {classNames} from '../utils/classNames'; import {ClearSlots, SlotProvider, useSlotProps} from '../utils/Slots'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {FocusScope} from 'react-aria/FocusScope'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import intlMessages from '../../intl/actiongroup/*.json'; import {Item} from 'react-stately/Item'; import {ListState, useListState} from 'react-stately/useListState'; @@ -257,7 +258,7 @@ export const ActionGroup = forwardRef(function ActionGroup( let parentRef = useMemo( () => ({ get current() { - return wrapperRef.current?.parentElement; + return getParentElement(wrapperRef.current); } }), // oxlint-disable-next-line react/react-compiler diff --git a/packages/@adobe/react-spectrum/src/buttongroup/ButtonGroup.tsx b/packages/@adobe/react-spectrum/src/buttongroup/ButtonGroup.tsx index 80042c6492a..2c298395be6 100644 --- a/packages/@adobe/react-spectrum/src/buttongroup/ButtonGroup.tsx +++ b/packages/@adobe/react-spectrum/src/buttongroup/ButtonGroup.tsx @@ -13,6 +13,7 @@ import {Alignment, DOMProps, DOMRef, Orientation, StyleProps} from '@react-types/shared'; import {classNames} from '../utils/classNames'; import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {Provider, useProvider, useProviderProps} from '../provider/Provider'; import React, {ReactNode, useCallback, useRef} from 'react'; import {SlotProvider, useSlotProps} from '../utils/Slots'; @@ -101,7 +102,7 @@ export const ButtonGroup = React.forwardRef(function ButtonGroup( // oxlint-disable react/react-compiler, react-hooks/exhaustive-deps useLayoutEffect(() => { if (domRef.current) { - parent.current = domRef.current.parentElement as HTMLElement; + parent.current = getParentElement(domRef.current) as HTMLElement; } }, [domRef.current]); // oxlint-enable react/react-compiler, react-hooks/exhaustive-deps diff --git a/packages/@adobe/react-spectrum/src/datepicker/Input.tsx b/packages/@adobe/react-spectrum/src/datepicker/Input.tsx index 0c53bd7d948..d9131b17adb 100644 --- a/packages/@adobe/react-spectrum/src/datepicker/Input.tsx +++ b/packages/@adobe/react-spectrum/src/datepicker/Input.tsx @@ -14,6 +14,7 @@ import Alert from '@spectrum-icons/ui/AlertMedium'; import Checkmark from '@spectrum-icons/ui/CheckmarkMedium'; import {classNames} from '../utils/classNames'; import datepickerStyles from './styles.css'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import React, {ReactElement, useCallback, useRef} from 'react'; @@ -45,26 +46,27 @@ export const Input = React.forwardRef(function Input(props: any, ref: any) { let onResize = useCallback( () => setReservePadding(function* (reservePadding) { - if (inputRef.current && inputRef.current.parentElement) { + let parentElement = getParentElement(inputRef.current) as HTMLElement | null; + if (inputRef.current && parentElement) { if (reservePadding) { // Try to collapse padding if the content is clipped. if (inputRef.current.scrollWidth > inputRef.current.offsetWidth) { - let width = inputRef.current.parentElement.offsetWidth; + let width = parentElement.offsetWidth; yield false; // If removing padding causes a layout shift, add it back. - if (inputRef.current.parentElement.offsetWidth !== width) { + if (parentElement.offsetWidth !== width) { yield true; } } } else { // Try to add padding if the content is not clipped. if (inputRef.current.offsetWidth >= inputRef.current.scrollWidth) { - let width = inputRef.current.parentElement.offsetWidth; + let width = parentElement.offsetWidth; yield true; // If adding padding does not change the width (i.e. width is constrained), remove it again. - if (inputRef.current.parentElement.offsetWidth === width) { + if (parentElement.offsetWidth === width) { yield false; } } diff --git a/packages/@adobe/react-spectrum/src/provider/Provider.tsx b/packages/@adobe/react-spectrum/src/provider/Provider.tsx index 9cdf57cbc99..634621714bc 100644 --- a/packages/@adobe/react-spectrum/src/provider/Provider.tsx +++ b/packages/@adobe/react-spectrum/src/provider/Provider.tsx @@ -16,6 +16,7 @@ import clsx from 'clsx'; import {Context} from './context'; import {DOMRef} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {getVersion} from './version-macro' with {type: 'macro'}; import {I18nProvider, useLocale} from 'react-aria/I18nProvider'; import {ModalProvider, useModalProvider} from 'react-aria/private/overlays/useModal'; @@ -181,7 +182,7 @@ const ProviderWrapper = React.forwardRef(function ProviderWrapper( let hasWarned = useRef(false); useEffect(() => { if (direction && domRef.current) { - let closestDir = domRef.current?.parentElement?.closest('[dir]'); + let closestDir = getParentElement(domRef.current)?.closest('[dir]'); let dir = closestDir && closestDir.getAttribute('dir'); if (dir && dir !== direction && !hasWarned.current && process.env.NODE_ENV !== 'production') { console.warn(`Language directions cannot be nested. ${direction} inside ${dir}.`); diff --git a/packages/@adobe/react-spectrum/src/tag/TagGroup.tsx b/packages/@adobe/react-spectrum/src/tag/TagGroup.tsx index 0facfdc677d..f68a715be36 100644 --- a/packages/@adobe/react-spectrum/src/tag/TagGroup.tsx +++ b/packages/@adobe/react-spectrum/src/tag/TagGroup.tsx @@ -24,6 +24,7 @@ import { import {Field} from '../label/Field'; import {FocusRing} from 'react-aria/FocusRing'; import {FocusScope} from 'react-aria/FocusScope'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import intlMessages from '../../intl/tag/*.json'; import {ListCollection} from 'react-stately/private/list/ListCollection'; import {ListKeyboardDelegate} from 'react-aria/ListKeyboardDelegate'; @@ -177,19 +178,15 @@ export const TagGroup = React.forwardRef(function TagGroup( // Remove tags until there is space for the collapse button and action button (if present) on the last row. let buttons = [...currActionsRef.children]; - if ( - maxRows && - buttons.length > 0 && - rowCount >= maxRows && - currContainerRef.parentElement - ) { + let parentElement = getParentElement(currContainerRef); + if (maxRows && buttons.length > 0 && rowCount >= maxRows && parentElement) { let buttonsWidth = buttons.reduce( (acc, curr) => (acc += curr.getBoundingClientRect().width), 0 ); buttonsWidth += TAG_STYLES[scale].margin * 2 * buttons.length; let end = direction === 'ltr' ? 'right' : 'left'; - let containerEnd = currContainerRef.parentElement.getBoundingClientRect()[end]; + let containerEnd = parentElement.getBoundingClientRect()[end]; let lastTagEnd = tags[index - 1]?.getBoundingClientRect()[end]; lastTagEnd += TAG_STYLES[scale].margin; let availableWidth = containerEnd - lastTagEnd; diff --git a/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx b/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx index 051e10084cb..4923ec09ab3 100644 --- a/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx +++ b/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx @@ -8,7 +8,11 @@ import { token } from './tokens.macro' with {type: 'macro'}; import {color, css, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {getEventTarget, nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import { + getEventTarget, + getParentElement, + nodeContains +} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {Group, GroupProps} from 'react-aria-components/Group'; import {isFocusable} from 'react-aria/private/utils/isFocusable'; import {keyframes} from './style/style-macro' with {type: 'macro'}; @@ -398,7 +402,7 @@ export function PromptFieldContainer(props: PropFieldContainerProps) { // If not clicking on something focusable within the prompt field, focus the input. let target = getEventTarget(e) as Element | null; while (target && target !== e.currentTarget && !isFocusable(target)) { - target = target.parentElement; + target = getParentElement(target); } if (target === e.currentTarget) { diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index 4c1c45e7829..a22749197b1 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -54,6 +54,7 @@ import { } from 'react'; import FolderIcon from '../s2wf-icons/S2_Icon_FolderBreadcrumb_20_N.svg'; import {forwardRefType} from './types'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {HeadingContext} from 'react-aria-components/Heading'; import {inertValue} from 'react-aria/private/utils/inertValue'; import intlMessages from '../intl/*.json'; @@ -467,7 +468,7 @@ let useCollectionRender = (collection: Collection>) => { setVisibleItems(collection.size); return; } - let container = currListRef.parentElement; + let container = getParentElement(currListRef) as HTMLElement | null; if (!container) { setVisibleItems(collection.size); return; diff --git a/packages/@react-spectrum/s2/src/ButtonGroup.tsx b/packages/@react-spectrum/s2/src/ButtonGroup.tsx index 07e86094ce2..bdff9537aaf 100644 --- a/packages/@react-spectrum/s2/src/ButtonGroup.tsx +++ b/packages/@react-spectrum/s2/src/ButtonGroup.tsx @@ -16,6 +16,7 @@ import {createContext, forwardRef, ReactNode, useCallback, useRef} from 'react'; import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {style} from '../style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; @@ -168,7 +169,7 @@ export const ButtonGroup = forwardRef(function ButtonGroup( // oxlint-disable react/react-compiler, react-hooks/exhaustive-deps useLayoutEffect(() => { if (domRef.current) { - parent.current = domRef.current.parentElement as HTMLElement; + parent.current = getParentElement(domRef.current) as HTMLElement; } }, [domRef.current]); // oxlint-enable react/react-compiler, react-hooks/exhaustive-deps diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 6ddb48a8f42..34be840a2c3 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -87,6 +87,7 @@ import {DragPreview} from './DragPreview'; import {Form} from 'react-aria-components/Form'; import { getActiveElement, + getParentElement, isFocusWithin, nodeContains } from 'react-aria/private/utils/shadowdom/DOMFunctions'; @@ -1690,7 +1691,7 @@ function EditableCellInner( } let width = cellRef.current?.clientWidth || 0; let cell = cellRef.current; - let boundingRect = cell?.parentElement?.getBoundingClientRect(); + let boundingRect = getParentElement(cell)?.getBoundingClientRect(); let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0); let tableWidth = cellRef.current?.closest('[role="grid"],[role="treegrid"]')?.clientWidth || 0; diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index 0f208512198..4bade0e49cb 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -53,6 +53,7 @@ import {FieldLabel, helpTextStyles} from './Field'; import {flushSync} from 'react-dom'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {IconContext} from './Icon'; import {ImageContext} from './Image'; import {inertValue} from 'react-aria/private/utils/inertValue'; @@ -214,7 +215,7 @@ function TagGroupInner({ !currContainerRef || !currTagsRef || collection.size === 0 || - currContainerRef.parentElement == null + getParentElement(currContainerRef) == null ) { return { visibleTagCount: 0, @@ -254,7 +255,8 @@ function TagGroupInner({ let margins = parseFloat(getComputedStyle(buttons[0]).marginInlineStart); buttonsWidth += margins * 2; let end = direction === 'ltr' ? 'right' : 'left'; - let containerEnd = currContainerRef.parentElement?.getBoundingClientRect()[end] - margins; + let containerEnd = + getParentElement(currContainerRef)?.getBoundingClientRect()[end] - margins; let lastTagEnd = tags[index - 1]?.getBoundingClientRect()[end]; lastTagEnd += margins; let availableWidth = containerEnd - lastTagEnd; diff --git a/packages/dev/eslint-plugin-rsp-rules/index.js b/packages/dev/eslint-plugin-rsp-rules/index.js index e9201d49257..262dd38b6cb 100644 --- a/packages/dev/eslint-plugin-rsp-rules/index.js +++ b/packages/dev/eslint-plugin-rsp-rules/index.js @@ -22,6 +22,8 @@ import noReactKey from './rules/no-react-key.js'; import pureRender from './rules/pure-render.js'; import safeEventTarget from './rules/safe-event-target.js'; import shadowSafeActiveElement from './rules/shadow-safe-active-element.js'; +import shadowSafeParentElement from './rules/shadow-safe-parent-element.js'; +import shadowSafeParentNode from './rules/shadow-safe-parent-node.js'; import sortImports from './rules/sort-imports.js'; import useLayoutEffectRule from './rules/use-layout-effect-rule.js'; @@ -36,6 +38,8 @@ const rules = { 'no-non-shadow-contains': noNonShadowContains, 'safe-event-target': safeEventTarget, 'shadow-safe-active-element': shadowSafeActiveElement, + 'shadow-safe-parent-element': shadowSafeParentElement, + 'shadow-safe-parent-node': shadowSafeParentNode, 'faster-node-contains': fasterNodeContains, imports, 'use-layout-effect-rule': useLayoutEffectRule, 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 index 69a62b90702..793e2ba4471 100644 --- 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 @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +const DOM_FUNCTIONS = 'react-aria/private/utils/shadowdom/DOMFunctions'; + const plugin = { meta: { type: 'suggestion', @@ -27,17 +29,19 @@ const plugin = { create: context => { let hasGetActiveElementImport = false; let getActiveElementLocalName = 'getActiveElement'; - let existingReactAriaUtilsImport = null; + let existingDOMFunctionsImport = null; return { - // Track imports from @react-aria/utils + // Track imports from DOMFunctions, and from the legacy @react-aria/utils package ImportDeclaration(node) { if ( node.source && node.source.type === 'Literal' && - node.source.value === '@react-aria/utils' + (node.source.value === DOM_FUNCTIONS || node.source.value === '@react-aria/utils') ) { - existingReactAriaUtilsImport = node; + if (node.source.value === DOM_FUNCTIONS) { + existingDOMFunctionsImport = node; + } // Check if getActiveElement is already imported const hasGetActiveElement = node.specifiers.some( spec => @@ -72,14 +76,14 @@ const plugin = { // Add import if not present if (!hasGetActiveElementImport) { - if (existingReactAriaUtilsImport) { - // Add getActiveElement to existing @react-aria/utils import - const specifiers = existingReactAriaUtilsImport.specifiers; + if (existingDOMFunctionsImport) { + // Add getActiveElement to existing DOMFunctions import + const specifiers = existingDOMFunctionsImport.specifiers; if (specifiers.length > 0) { fixes.push( fixer.insertTextAfter( sourceCode.getFirstToken( - existingReactAriaUtilsImport, + existingDOMFunctionsImport, token => token.value === '{' ), 'getActiveElement, ' @@ -87,17 +91,17 @@ const plugin = { ); } } else { - // No existing import from @react-aria/utils, create a new one + // No existing import from DOMFunctions, 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';"; + const importStatement = `\nimport {getActiveElement} from '${DOM_FUNCTIONS}';`; fixes.push(fixer.insertTextAfter(lastImport, importStatement)); } else { // No imports, add at the beginning - const importStatement = "import {getActiveElement} from '@react-aria/utils';\n"; + const importStatement = `import {getActiveElement} from '${DOM_FUNCTIONS}';\n`; fixes.push(fixer.insertTextBefore(programNode.body[0], importStatement)); } } diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-parent-element.js b/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-parent-element.js new file mode 100644 index 00000000000..820ec12d042 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-parent-element.js @@ -0,0 +1,126 @@ +/* + * Copyright 2026 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 DOM_FUNCTIONS = 'react-aria/private/utils/shadowdom/DOMFunctions'; + +const plugin = { + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow using node.parentElement in favor of getParentElement() for shadow DOM compatibility', + recommended: true + }, + fixable: 'code', + messages: { + useGetParentElement: + 'Use getParentElement() instead of node.parentElement for shadow DOM compatibility.' + } + }, + create: context => { + let hasGetParentElementImport = false; + let getParentElementLocalName = 'getParentElement'; + let existingDOMFunctionsImport = null; + + return { + // Track imports from DOMFunctions, and from the legacy @react-aria/utils package + ImportDeclaration(node) { + if ( + node.source && + node.source.type === 'Literal' && + (node.source.value === DOM_FUNCTIONS || node.source.value === '@react-aria/utils') + ) { + if (node.source.value === DOM_FUNCTIONS) { + existingDOMFunctionsImport = node; + } + // Check if getParentElement is already imported + const hasGetParentElement = node.specifiers.some( + spec => + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'getParentElement' + ); + if (hasGetParentElement) { + hasGetParentElementImport = true; + const getParentElementSpec = node.specifiers.find( + spec => + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'getParentElement' + ); + getParentElementLocalName = getParentElementSpec.local.name; + } + } + }, + + // Detect node.parentElement usage + ["MemberExpression[computed=false][property.name='parentElement']"](node) { + context.report({ + node, + messageId: 'useGetParentElement', + fix: fixer => { + const fixes = []; + const sourceCode = context.sourceCode; + + // Replace node.parentElement with getParentElement(node) + fixes.push( + fixer.replaceText( + node, + `${getParentElementLocalName}(${sourceCode.getText(node.object)})` + ) + ); + + // Add import if not present + if (!hasGetParentElementImport) { + if (existingDOMFunctionsImport) { + // Add getParentElement to existing DOMFunctions import + const specifiers = existingDOMFunctionsImport.specifiers; + if (specifiers.length > 0) { + fixes.push( + fixer.insertTextAfter( + sourceCode.getFirstToken( + existingDOMFunctionsImport, + token => token.value === '{' + ), + 'getParentElement, ' + ) + ); + } + } else { + // No existing import from DOMFunctions, 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 {getParentElement} from '${DOM_FUNCTIONS}';`; + fixes.push(fixer.insertTextAfter(lastImport, importStatement)); + } else { + // No imports, add at the beginning + const importStatement = `import {getParentElement} from '${DOM_FUNCTIONS}';\n`; + fixes.push(fixer.insertTextBefore(programNode.body[0], importStatement)); + } + } + + // Mark as imported for subsequent fixes in the same file + hasGetParentElementImport = true; + } + + return fixes; + } + }); + } + }; + } +}; + +export default plugin; diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-parent-node.js b/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-parent-node.js new file mode 100644 index 00000000000..251f956ce2c --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/rules/shadow-safe-parent-node.js @@ -0,0 +1,126 @@ +/* + * Copyright 2026 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 DOM_FUNCTIONS = 'react-aria/private/utils/shadowdom/DOMFunctions'; + +const plugin = { + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow using node.parentNode in favor of getParentNode() for shadow DOM compatibility', + recommended: true + }, + fixable: 'code', + messages: { + useGetParentNode: + 'Use getParentNode() instead of node.parentNode for shadow DOM compatibility.' + } + }, + create: context => { + let hasGetParentNodeImport = false; + let getParentNodeLocalName = 'getParentNode'; + let existingDOMFunctionsImport = null; + + return { + // Track imports from DOMFunctions, and from the legacy @react-aria/utils package + ImportDeclaration(node) { + if ( + node.source && + node.source.type === 'Literal' && + (node.source.value === DOM_FUNCTIONS || node.source.value === '@react-aria/utils') + ) { + if (node.source.value === DOM_FUNCTIONS) { + existingDOMFunctionsImport = node; + } + // Check if getParentNode is already imported + const hasGetParentNode = node.specifiers.some( + spec => + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'getParentNode' + ); + if (hasGetParentNode) { + hasGetParentNodeImport = true; + const getParentNodeSpec = node.specifiers.find( + spec => + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'getParentNode' + ); + getParentNodeLocalName = getParentNodeSpec.local.name; + } + } + }, + + // Detect node.parentNode usage + ["MemberExpression[computed=false][property.name='parentNode']"](node) { + context.report({ + node, + messageId: 'useGetParentNode', + fix: fixer => { + const fixes = []; + const sourceCode = context.sourceCode; + + // Replace node.parentNode with getParentNode(node) + fixes.push( + fixer.replaceText( + node, + `${getParentNodeLocalName}(${sourceCode.getText(node.object)})` + ) + ); + + // Add import if not present + if (!hasGetParentNodeImport) { + if (existingDOMFunctionsImport) { + // Add getParentNode to existing DOMFunctions import + const specifiers = existingDOMFunctionsImport.specifiers; + if (specifiers.length > 0) { + fixes.push( + fixer.insertTextAfter( + sourceCode.getFirstToken( + existingDOMFunctionsImport, + token => token.value === '{' + ), + 'getParentNode, ' + ) + ); + } + } else { + // No existing import from DOMFunctions, 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 {getParentNode} from '${DOM_FUNCTIONS}';`; + fixes.push(fixer.insertTextAfter(lastImport, importStatement)); + } else { + // No imports, add at the beginning + const importStatement = `import {getParentNode} from '${DOM_FUNCTIONS}';\n`; + fixes.push(fixer.insertTextBefore(programNode.body[0], importStatement)); + } + } + + // Mark as imported for subsequent fixes in the same file + hasGetParentNodeImport = true; + } + + return fixes; + } + }); + } + }; + } +}; + +export default plugin; 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 index 83381d82a07..1fe12366a1d 100644 --- 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 @@ -43,6 +43,19 @@ if (getActiveElement(element)) { invalid: [ { code: ` +if (document.activeElement) { + console.log('active element'); +}`, + output: ` +import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +if (getActiveElement()) { + console.log('active element'); +}`, + errors: 1 + }, + { + code: ` +import {getActiveElement} from '@react-aria/utils'; if (document.activeElement) { console.log('active element'); }`, diff --git a/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-parent-element.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-parent-element.test-lint.js new file mode 100644 index 00000000000..198ef8c6dd1 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-parent-element.test-lint.js @@ -0,0 +1,63 @@ +/* + * 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 shadowSafeParentElementRule from '../rules/shadow-safe-parent-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-parent-element', shadowSafeParentElementRule, { + // 'valid' checks cases that should pass + valid: [ + { + code: ` +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +if (getParentElement(document.body)) { + console.log('root element'); +}` + } + ], + // 'invalid' checks cases that should not pass + invalid: [ + { + code: ` +if (document.body.parentElement) { + console.log('root element'); +}`, + output: ` +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +if (getParentElement(document.body)) { + console.log('root element'); +}`, + errors: 1 + }, + { + code: ` +import {getParentElement} from '@react-aria/utils'; +if (document.body.parentElement) { + console.log('root element'); +}`, + output: ` +import {getParentElement} from '@react-aria/utils'; +if (getParentElement(document.body)) { + console.log('root element'); +}`, + errors: 1 + } + ] +}); diff --git a/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-parent-node.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-parent-node.test-lint.js new file mode 100644 index 00000000000..ebf0f953ee7 --- /dev/null +++ b/packages/dev/eslint-plugin-rsp-rules/test/shadow-safe-parent-node.test-lint.js @@ -0,0 +1,63 @@ +/* + * 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 shadowSafeParentNodeRule from '../rules/shadow-safe-parent-node.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-parent-node', shadowSafeParentNodeRule, { + // 'valid' checks cases that should pass + valid: [ + { + code: ` +import {getParentNode} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +if (getParentNode(document.body)) { + console.log('root node'); +}` + } + ], + // 'invalid' checks cases that should not pass + invalid: [ + { + code: ` +if (document.body.parentNode) { + console.log('root node'); +}`, + output: ` +import {getParentNode} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +if (getParentNode(document.body)) { + console.log('root node'); +}`, + errors: 1 + }, + { + code: ` +import {getParentNode} from '@react-aria/utils'; +if (document.body.parentNode) { + console.log('root node'); +}`, + output: ` +import {getParentNode} from '@react-aria/utils'; +if (getParentNode(document.body)) { + console.log('root node'); +}`, + errors: 1 + } + ] +}); diff --git a/packages/dev/s2-docs/src/CodePlatter.tsx b/packages/dev/s2-docs/src/CodePlatter.tsx index 831c2f8916e..47a577ccb43 100644 --- a/packages/dev/s2-docs/src/CodePlatter.tsx +++ b/packages/dev/s2-docs/src/CodePlatter.tsx @@ -24,6 +24,7 @@ import {createStackBlitz} from './StackBlitz'; import Download from '@react-spectrum/s2/icons/Download'; import type {DownloadFiles} from './CodeBlock'; import {getCodeSandboxFiles} from './CodeSandbox'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {keyframes} from '../../../@react-spectrum/s2/style/style-macro' with {type: 'macro'}; import {Library} from './library'; import LinkIcon from '@react-spectrum/s2/icons/Link'; @@ -163,7 +164,7 @@ export function CodePlatter({children, type, showCoachMark}: CodePlatterProps) { } // Move up to parent - node = node.parentElement; + node = getParentElement(node); } if (node instanceof HTMLHeadingElement && node.id) { diff --git a/packages/dev/s2-docs/src/VisualExampleClient.tsx b/packages/dev/s2-docs/src/VisualExampleClient.tsx index 15c48797de6..8af780e3b9a 100644 --- a/packages/dev/s2-docs/src/VisualExampleClient.tsx +++ b/packages/dev/s2-docs/src/VisualExampleClient.tsx @@ -38,6 +38,7 @@ import {ExampleOutput} from './ExampleOutput'; import {ExampleSwitcherContext} from './ExampleSwitcher'; import {flushSync} from 'react-dom'; import {getColorChannels, parseColor} from 'react-stately'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {ListBox, ListBoxItem, Size} from 'react-aria-components'; import {mergeStyles} from '../../../@react-spectrum/s2/style/runtime'; import {presetNames} from './PresetPicker'; @@ -154,7 +155,7 @@ export function VisualExampleClient({ } // Move up to parent - node = node.parentElement; + node = getParentElement(node); } let id = node instanceof HTMLHeadingElement ? node.id : null; diff --git a/packages/react-aria-components/src/DropZone.tsx b/packages/react-aria-components/src/DropZone.tsx index ec6829a9e6a..e3cefe9cdae 100644 --- a/packages/react-aria-components/src/DropZone.tsx +++ b/packages/react-aria-components/src/DropZone.tsx @@ -24,7 +24,11 @@ import { import {DropOptions, useDrop} from 'react-aria/useDrop'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {focusWithoutScrolling} from 'react-aria/private/utils/focusWithoutScrolling'; -import {getEventTarget, nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; +import { + getEventTarget, + getParentElement, + nodeContains +} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import intlMessages from '../intl/*.json'; import {isFocusable} from 'react-aria/private/utils/isFocusable'; import {mergeProps} from 'react-aria/mergeProps'; @@ -156,7 +160,7 @@ export const DropZone = forwardRef(function DropZone( break; } - target = target.parentElement; + target = getParentElement(target) as HTMLElement | null; } }} data-hovered={isHovered || undefined} diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 08ddd8b0cdc..c03716533a7 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -71,6 +71,7 @@ import { } from './Autocomplete'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {FocusScope} from 'react-aria/FocusScope'; +import {getParentElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {GridNode} from 'react-stately/private/grid/GridCollection'; import {inertValue} from 'react-aria/private/utils/inertValue'; import intlMessages from '../intl/*.json'; @@ -502,7 +503,7 @@ export const ResizableTableContainer = forwardRef(function ResizableTableContain // the Table itself for virtualized tables. let table = tableRef.current as HTMLElement | null; while (table && table !== containerRef.current && !isScrollable(table)) { - table = table.parentElement; + table = getParentElement(table) as HTMLElement | null; } scrollRef.current = table; }, [containerRef]); diff --git a/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts index 0af757fbf2b..64a12d1a773 100644 --- a/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts +++ b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts @@ -3,5 +3,7 @@ export { getPropagationTargets, nodeContains, isFocusWithin, - getActiveElement + getActiveElement, + getParentNode, + getParentElement } from '../../../../src/utils/shadowdom/DOMFunctions'; diff --git a/packages/react-aria/src/actiongroup/useActionGroup.ts b/packages/react-aria/src/actiongroup/useActionGroup.ts index 0febd0c210e..1a32a30ba33 100644 --- a/packages/react-aria/src/actiongroup/useActionGroup.ts +++ b/packages/react-aria/src/actiongroup/useActionGroup.ts @@ -24,6 +24,7 @@ import { } from '@react-types/shared'; import {createFocusManager} from '../focus/FocusScope'; import {filterDOMProps} from '../utils/filterDOMProps'; +import {getParentElement} from '../utils/shadowdom/DOMFunctions'; import {ListState} from 'react-stately/useListState'; import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; @@ -80,7 +81,7 @@ export function useActionGroup( let [isInToolbar, setInToolbar] = useState(false); useLayoutEffect(() => { - setInToolbar(!!(ref.current && ref.current.parentElement?.closest('[role="toolbar"]'))); + setInToolbar(!!(ref.current && getParentElement(ref.current)?.closest('[role="toolbar"]'))); }, [ref]); let allKeys = [...state.collection.getKeys()]; diff --git a/packages/react-aria/src/collections/Document.ts b/packages/react-aria/src/collections/Document.ts index 946e508e1c1..469f04fce5d 100644 --- a/packages/react-aria/src/collections/Document.ts +++ b/packages/react-aria/src/collections/Document.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +/* eslint-disable rsp-rules/shadow-safe-parent-node */ + import {BaseCollection, CollectionNode, Mutable} from './BaseCollection'; import {CollectionNodeClass} from './CollectionBuilder'; import {CSSProperties, ForwardedRef, ReactElement, ReactNode} from 'react'; diff --git a/packages/react-aria/src/focus/FocusScope.tsx b/packages/react-aria/src/focus/FocusScope.tsx index 41114eeadcf..1ca7457e99b 100644 --- a/packages/react-aria/src/focus/FocusScope.tsx +++ b/packages/react-aria/src/focus/FocusScope.tsx @@ -14,7 +14,12 @@ import {createShadowTreeWalker, ShadowTreeWalker} from '../utils/shadowdom/Shado import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from '../interactions/focusSafely'; -import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import { + getActiveElement, + getEventTarget, + getParentElement, + nodeContains +} from '../utils/shadowdom/DOMFunctions'; import {getInteractionModality} from '../interactions/useFocusVisible'; import {getOwnerDocument, getOwnerWindow} from '../utils/domHelpers'; import {isAndroid, isChrome} from '../utils/platform'; @@ -284,7 +289,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject) } function getScopeRoot(scope: Element[]) { - return scope[0].parentElement!; + return getParentElement(scope[0])!; } function shouldContainFocus(scopeRef: ScopeRef) { diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts index 93cd5729ec0..396a4af15d5 100644 --- a/packages/react-aria/src/grid/useGridCell.ts +++ b/packages/react-aria/src/grid/useGridCell.ts @@ -15,6 +15,7 @@ import {focusSafely} from '../interactions/focusSafely'; import { getActiveElement, getEventTarget, + getParentElement, isFocusWithin, nodeContains } from '../utils/shadowdom/DOMFunctions'; @@ -210,7 +211,7 @@ export function useGridCell>( // We prevent the capturing event from reaching children of the cell, e.g. pickers. // We want arrow keys to navigate to the next cell instead. We need to re-dispatch // the event from a higher parent so it still bubbles and gets handled by useSelectableCollection. - ref.current.parentElement?.dispatchEvent( + getParentElement(ref.current)?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); break; @@ -252,7 +253,7 @@ export function useGridCell>( // We prevent the capturing event from reaching children of the cell, e.g. pickers. // We want arrow keys to navigate to the next cell instead. We need to re-dispatch // the event from a higher parent so it still bubbles and gets handled by useSelectableCollection. - ref.current.parentElement?.dispatchEvent( + getParentElement(ref.current)?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); break; @@ -281,7 +282,7 @@ export function useGridCell>( if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { e.stopPropagation(); e.preventDefault(); - ref.current.parentElement?.dispatchEvent( + getParentElement(ref.current)?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); } diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index 106ab557fcf..aa81722e3f5 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -23,6 +23,7 @@ import {focusSafely} from '../interactions/focusSafely'; import { getActiveElement, getEventTarget, + getParentElement, isFocusWithin, nodeContains } from '../utils/shadowdom/DOMFunctions'; @@ -303,7 +304,7 @@ export function useGridListItem( if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { e.stopPropagation(); e.preventDefault(); - ref.current.parentElement?.dispatchEvent( + getParentElement(ref.current)?.dispatchEvent( new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) ); } diff --git a/packages/react-aria/src/interactions/utils.ts b/packages/react-aria/src/interactions/utils.ts index 429afde9cb4..779f7ad25d9 100644 --- a/packages/react-aria/src/interactions/utils.ts +++ b/packages/react-aria/src/interactions/utils.ts @@ -12,7 +12,12 @@ import {FocusableElement} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; -import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import { + getActiveElement, + getEventTarget, + getParentElement, + nodeContains +} from '../utils/shadowdom/DOMFunctions'; import {getOwnerWindow, isNode, isShadowRoot} from '../utils/domHelpers'; import {isFocusable} from '../utils/isFocusable'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; @@ -121,7 +126,7 @@ export let ignoreFocusEvent = false; export function preventFocus(target: FocusableElement | null): (() => void) | undefined { // The browser will focus the nearest focusable ancestor of our target. while (target && !isFocusable(target, {skipVisibilityCheck: true})) { - target = target.parentElement; + target = getParentElement(target) as FocusableElement | null; } let ownerWindow = getOwnerWindow(target); diff --git a/packages/react-aria/src/landmark/useLandmark.ts b/packages/react-aria/src/landmark/useLandmark.ts index 78384235c0d..57c13faefff 100644 --- a/packages/react-aria/src/landmark/useLandmark.ts +++ b/packages/react-aria/src/landmark/useLandmark.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {getEventTarget, getParentElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {useCallback, useEffect, useState} from 'react'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; @@ -270,9 +270,9 @@ class LandmarkManager implements LandmarkManagerApi { currentElement && !landmarkMap.has(currentElement) && currentElement !== document.body && - currentElement.parentElement + getParentElement(currentElement) ) { - currentElement = currentElement.parentElement; + currentElement = getParentElement(currentElement) as FocusableElement; } return landmarkMap.get(currentElement); } diff --git a/packages/react-aria/src/overlays/ariaHideOutside.ts b/packages/react-aria/src/overlays/ariaHideOutside.ts index 66cc828ad2c..ba1cdcf7b61 100644 --- a/packages/react-aria/src/overlays/ariaHideOutside.ts +++ b/packages/react-aria/src/overlays/ariaHideOutside.ts @@ -13,7 +13,7 @@ import {createShadowTreeWalker} from '../utils/shadowdom/ShadowTreeWalker'; import {getOwnerDocument, getOwnerWindow, isShadowRoot} from '../utils/domHelpers'; -import {nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {getParentElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {shadowDOM} from 'react-stately/private/flags/flags'; const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; @@ -105,12 +105,13 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt // Also skip children of already hidden nodes, as aria-hidden is recursive. An exception is // made for elements with role="row" since VoiceOver on iOS has issues hiding elements with role="row". // For that case we want to hide the cells inside as well (https://bugs.webkit.org/show_bug.cgi?id=222623). + let parentElement = getParentElement(node); if ( hiddenNodes.has(node) || visibleNodes.has(node) || - (node.parentElement && - hiddenNodes.has(node.parentElement) && - node.parentElement.getAttribute('role') !== 'row') + (parentElement && + hiddenNodes.has(parentElement) && + parentElement.getAttribute('role') !== 'row') ) { return NodeFilter.FILTER_REJECT; } diff --git a/packages/react-aria/src/overlays/calculatePosition.ts b/packages/react-aria/src/overlays/calculatePosition.ts index a815e993f49..3e1957be2d8 100644 --- a/packages/react-aria/src/overlays/calculatePosition.ts +++ b/packages/react-aria/src/overlays/calculatePosition.ts @@ -12,8 +12,8 @@ import {Axis, Placement, PlacementAxis, SizeAxis} from './useOverlayPosition'; import {clamp} from 'react-stately/private/utils/number'; +import {getParentElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {isWebKit} from '../utils/platform'; -import {nodeContains} from '../utils/shadowdom/DOMFunctions'; interface Position { top?: number; @@ -827,9 +827,9 @@ function getContainingBlock(node: HTMLElement): Element { // We have to walk up the tree manually in this case because fixed positioned elements // are still positioned relative to their containing block, which is not always the viewport. if (offsetParent == null) { - offsetParent = node.parentElement; + offsetParent = getParentElement(node); while (offsetParent && !isContainingBlock(offsetParent)) { - offsetParent = offsetParent.parentElement; + offsetParent = getParentElement(offsetParent); } } diff --git a/packages/react-aria/src/overlays/usePreventScroll.ts b/packages/react-aria/src/overlays/usePreventScroll.ts index d0bdf4b67b4..95421f55409 100644 --- a/packages/react-aria/src/overlays/usePreventScroll.ts +++ b/packages/react-aria/src/overlays/usePreventScroll.ts @@ -12,7 +12,7 @@ import {addEvent, setStyle} from '../utils/domHelpers'; import {chain} from '../utils/chain'; -import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, getEventTarget, getParentElement} from '../utils/shadowdom/DOMFunctions'; import {getNonce} from '../utils/getNonce'; import {getScrollParent} from '../utils/getScrollParent'; import {isIOS, isWebKit} from '../utils/platform'; @@ -212,7 +212,7 @@ function handleBlur() { // the same input again, it is already focused, so no blur event will fire, // resulting in the flow above never running and Safari's native scrolling occurring. // Instead, move focus to the parent focusable element (e.g. the dialog). - let focusable = target.parentElement?.closest('[tabindex]') as HTMLElement | null; + let focusable = getParentElement(target)?.closest('[tabindex]') as HTMLElement | null; focusable?.focus({preventScroll: true}); } }; @@ -296,6 +296,6 @@ function scrollIntoView(target: Element) { } } - nextTarget = scrollable.parentElement; + nextTarget = getParentElement(scrollable); } } diff --git a/packages/react-aria/src/selection/useSelectableItem.ts b/packages/react-aria/src/selection/useSelectableItem.ts index b45620fc071..d08a1188a66 100644 --- a/packages/react-aria/src/selection/useSelectableItem.ts +++ b/packages/react-aria/src/selection/useSelectableItem.ts @@ -23,7 +23,7 @@ import { RefObject } from '@react-types/shared'; import {focusSafely} from '../interactions/focusSafely'; -import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, getEventTarget, getParentElement} from '../utils/shadowdom/DOMFunctions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; import {isCtrlKeyPressed} from '../utils/keyboard'; import {isTabbable} from '../utils/isFocusable'; @@ -474,7 +474,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte if (elCollection != null) { return elCollection !== collectionId; } - el = el.parentElement; + el = getParentElement(el); } return isTabbable(target); }; diff --git a/packages/react-aria/src/tokenfield/useToken.ts b/packages/react-aria/src/tokenfield/useToken.ts index cd2572de546..262dc274db7 100644 --- a/packages/react-aria/src/tokenfield/useToken.ts +++ b/packages/react-aria/src/tokenfield/useToken.ts @@ -10,6 +10,7 @@ * governing */ +import {getParentElement} from '../utils/shadowdom/DOMFunctions'; import {HTMLAttributes, RefObject, useRef, useState} from 'react'; import {isVirtualClick} from '../utils/isVirtualEvent'; import {useEvent} from '../utils/useEvent'; @@ -64,7 +65,7 @@ export function useToken( return; } let selection = window.getSelection(); - let wrapper = ref.current?.parentElement; + let wrapper = getParentElement(ref.current); if (!selection || !wrapper) { return; } diff --git a/packages/react-aria/src/tokenfield/useTokenField.ts b/packages/react-aria/src/tokenfield/useTokenField.ts index 323991839ae..7476de8b86a 100644 --- a/packages/react-aria/src/tokenfield/useTokenField.ts +++ b/packages/react-aria/src/tokenfield/useTokenField.ts @@ -20,7 +20,7 @@ import { useMemo, useRef } from 'react'; -import {getActiveElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, getParentNode, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getInteractionModality, setInteractionModality} from '../interactions/useFocusVisible'; import {getOwnerDocument} from '../utils/domHelpers'; import {getScrollParents} from '../utils/getScrollParents'; @@ -665,8 +665,8 @@ function getPosition(container: Element, node: Node, offset: number, isRangeEnd } let originalNode = node; - while (node.parentNode !== container) { - node = node.parentNode!; + while (getParentNode(node) !== container) { + node = getParentNode(node)!; } let index = indexOfNode(node); @@ -775,7 +775,7 @@ function scrollCaretIntoView(root: HTMLElement): void { // Otherwise find the next sibling element (e.g. trailing
) and use its rect in this case. let nextSibling = node === root ? null : node.nextSibling; while (node && node !== root && !nextSibling) { - node = node.parentNode as Node | null; + node = getParentNode(node); nextSibling = node && node !== root ? node.nextSibling : null; } diff --git a/packages/react-aria/src/toolbar/useToolbar.ts b/packages/react-aria/src/toolbar/useToolbar.ts index 99d118c5b25..1dc26f1d64c 100644 --- a/packages/react-aria/src/toolbar/useToolbar.ts +++ b/packages/react-aria/src/toolbar/useToolbar.ts @@ -14,7 +14,12 @@ import {AriaLabelingProps, FocusableElement, Orientation, RefObject} from '@reac import {createFocusManager} from '../focus/FocusScope'; import {filterDOMProps} from '../utils/filterDOMProps'; import {FocusEventHandler, HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; -import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import { + getActiveElement, + getEventTarget, + getParentElement, + nodeContains +} from '../utils/shadowdom/DOMFunctions'; import {scrollIntoViewport} from '../utils/scrollIntoView'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; @@ -56,7 +61,7 @@ export function useToolbar( // this will allow us to react should a parent re-render and change its role though // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { - setInToolbar(!!(ref.current && ref.current.parentElement?.closest('[role="toolbar"]'))); + setInToolbar(!!(ref.current && getParentElement(ref.current)?.closest('[role="toolbar"]'))); }); const {direction} = useLocale(); const shouldReverse = direction === 'rtl' && orientation === 'horizontal'; diff --git a/packages/react-aria/src/utils/domHelpers.ts b/packages/react-aria/src/utils/domHelpers.ts index a1f7f8d7701..24d8282e46b 100644 --- a/packages/react-aria/src/utils/domHelpers.ts +++ b/packages/react-aria/src/utils/domHelpers.ts @@ -66,6 +66,15 @@ export function isShadowRoot(value: unknown): value is ShadowRoot { return isNode(value) && value.nodeType === 11 && 'host' in value; } +/** + * Type guard that checks if a value is an Element. Uses nodeType checks to distinguish Element + * from other values. + */ +export function isElement(value: unknown): value is Element { + // 1 = ELEMENT_NODE + return isNode(value) && value.nodeType === 1; +} + /** * Attaches an event listener on target(s) and returns a cleanup function. */ diff --git a/packages/react-aria/src/utils/getScrollParent.ts b/packages/react-aria/src/utils/getScrollParent.ts index be231aabae7..4547971e770 100644 --- a/packages/react-aria/src/utils/getScrollParent.ts +++ b/packages/react-aria/src/utils/getScrollParent.ts @@ -10,16 +10,17 @@ * governing permissions and limitations under the License. */ +import {getParentElement} from './shadowdom/DOMFunctions'; import {isScrollable} from './isScrollable'; export function getScrollParent(node: Element, checkForOverflow?: boolean): Element { let scrollableNode: Element | null = node; if (isScrollable(scrollableNode, checkForOverflow)) { - scrollableNode = scrollableNode.parentElement; + scrollableNode = getParentElement(scrollableNode); } while (scrollableNode && !isScrollable(scrollableNode, checkForOverflow)) { - scrollableNode = scrollableNode.parentElement; + scrollableNode = getParentElement(scrollableNode); } return scrollableNode || document.scrollingElement || document.documentElement; diff --git a/packages/react-aria/src/utils/getScrollParents.ts b/packages/react-aria/src/utils/getScrollParents.ts index 0b98228f817..9c81323aa16 100644 --- a/packages/react-aria/src/utils/getScrollParents.ts +++ b/packages/react-aria/src/utils/getScrollParents.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {getParentElement} from './shadowdom/DOMFunctions'; import {isScrollable} from './isScrollable'; export function getScrollParents(node: Element, checkForOverflow?: boolean): Element[] { @@ -23,7 +24,7 @@ export function getScrollParents(node: Element, checkForOverflow?: boolean): Ele if (node === root) { break; } - node = node.parentElement as Element; + node = getParentElement(node) as Element; } return parentElements; diff --git a/packages/react-aria/src/utils/isElementVisible.ts b/packages/react-aria/src/utils/isElementVisible.ts index 4ab7e4d967c..b0741ec756f 100644 --- a/packages/react-aria/src/utils/isElementVisible.ts +++ b/packages/react-aria/src/utils/isElementVisible.ts @@ -11,6 +11,7 @@ */ import {getOwnerWindow} from './domHelpers'; +import {getParentElement} from './shadowdom/DOMFunctions'; const supportsCheckVisibility = typeof Element !== 'undefined' && 'checkVisibility' in Element.prototype; @@ -67,10 +68,11 @@ export function isElementVisible(element: Element, childElement?: Element): bool ); } + let parentElement = getParentElement(element); return ( element.nodeName !== '#comment' && isStyleVisible(element) && isAttributeVisible(element, childElement) && - (!element.parentElement || isElementVisible(element.parentElement, element)) + (!parentElement || isElementVisible(parentElement, element)) ); } diff --git a/packages/react-aria/src/utils/isFocusable.ts b/packages/react-aria/src/utils/isFocusable.ts index ec3e6e76030..cae75e90cf1 100644 --- a/packages/react-aria/src/utils/isFocusable.ts +++ b/packages/react-aria/src/utils/isFocusable.ts @@ -11,6 +11,7 @@ */ import {getOwnerWindow} from './domHelpers'; +import {getParentElement} from './shadowdom/DOMFunctions'; import {isElementVisible} from './isElementVisible'; const focusableElements = [ @@ -57,7 +58,7 @@ function isInert(element: Element): boolean { return true; } - node = node.parentElement; + node = getParentElement(node); } return false; diff --git a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts index 09888fa52de..ec6ee4092b7 100644 --- a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts +++ b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts @@ -1,11 +1,41 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 -/* eslint-disable rsp-rules/no-non-shadow-contains, rsp-rules/safe-event-target */ +/* eslint-disable rsp-rules/no-non-shadow-contains, rsp-rules/safe-event-target, rsp-rules/shadow-safe-parent-node */ import {EventTargetType} from '@react-types/shared'; -import {getOwnerWindow, isShadowRoot} from '../domHelpers'; +import {getOwnerWindow, isElement, isShadowRoot} from '../domHelpers'; import {shadowDOM} from 'react-stately/private/flags/flags'; import type {SyntheticEvent} from 'react'; +/** + * ShadowDOM safe version of Node.parentNode. + */ +export function getParentNode(node: Node | Element | null | undefined): Node | null { + let currentNode: HTMLElement | Node | null | undefined = node; + + if (!shadowDOM()) { + return currentNode?.parentNode ?? null; + } + + if (!currentNode) { + return null; + } + + if ( + typeof (currentNode as HTMLSlotElement).assignedElements !== 'function' && + (currentNode as HTMLSlotElement).assignedSlot?.parentNode + ) { + // Element is slotted + currentNode = (currentNode as HTMLSlotElement).assignedSlot!.parentNode; + } else if (isShadowRoot(currentNode)) { + // Element is in shadow root + currentNode = currentNode.host; + } else { + currentNode = currentNode.parentNode; + } + + return currentNode; +} + /** * ShadowDOM safe version of Node.contains. */ @@ -45,6 +75,19 @@ export function nodeContains( return false; } +/** + * ShadowDOM safe version of element.parentElement. + */ +export function getParentElement(node: Node | Element | null | undefined): Element | null { + let parentNode = getParentNode(node); + + while (parentNode != null && !isElement(parentNode)) { + parentNode = getParentNode(parentNode); + } + + return parentNode; +} + /** * ShadowDOM safe version of document.activeElement. */ diff --git a/packages/react-aria/src/utils/shadowdom/ShadowTreeWalker.ts b/packages/react-aria/src/utils/shadowdom/ShadowTreeWalker.ts index 8ce91afe497..ccf68740645 100644 --- a/packages/react-aria/src/utils/shadowdom/ShadowTreeWalker.ts +++ b/packages/react-aria/src/utils/shadowdom/ShadowTreeWalker.ts @@ -90,6 +90,7 @@ export class ShadowTreeWalker implements TreeWalker { curNode = currentWalkerCurrentNode = shadowRoot.host; } else { + // eslint-disable-next-line rsp-rules/shadow-safe-parent-node curNode = curNode.parentNode; } }