diff --git a/.oxlintrc.json b/.oxlintrc.json
index c290fdafef1..4686d5a4741 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -168,6 +168,8 @@
"react/react-compiler": "error",
"rsp-rules/no-react-key": ["error"],
+ "rsp-rules/add-event-non-composing": ["error"],
+ "rsp-rules/no-non-composing-event-listener": ["error"],
"rsp-rules/sort-imports": ["error"],
"rsp-rules/no-non-shadow-contains": ["error"],
"rsp-rules/safe-event-target": ["error"],
@@ -270,6 +272,8 @@
"rsp-rules/no-react-key": ["error"],
"rsp-rules/act-events-test": "error",
"rsp-rules/no-getByRole-toThrow": "error",
+ "rsp-rules/add-event-non-composing": "off",
+ "rsp-rules/no-non-composing-event-listener": "off",
"rsp-rules/no-non-shadow-contains": "off",
"rsp-rules/safe-event-target": "off",
"rsp-rules/shadow-safe-active-element": "off",
@@ -303,7 +307,9 @@
{
"files": ["**/dev/**", "**/scripts/**"],
"rules": {
- "rsp-rules/safe-event-target": "off"
+ "rsp-rules/safe-event-target": "off",
+ "rsp-rules/add-event-non-composing": "off",
+ "rsp-rules/no-non-composing-event-listener": "off"
}
},
{
diff --git a/.storybook/custom-addons/shadowDOM/index.js b/.storybook/custom-addons/shadowDOM/index.js
new file mode 100644
index 00000000000..adc28982b96
--- /dev/null
+++ b/.storybook/custom-addons/shadowDOM/index.js
@@ -0,0 +1,14 @@
+import {enableShadowDOM} from 'react-stately/private/flags/flags';
+import React from 'react';
+
+// Read the URL param at module load so the one-way global flag is enabled before
+// any story (or react-aria code) reads it. Toggling is handled by a page reload in
+// the manager, so on each load we start from a clean state and re-enable if needed.
+let params = new URLSearchParams(document.location.search);
+if (params.get('shadowDOM') === 'true') {
+ enableShadowDOM();
+}
+
+export const withShadowDOMSwitcher = Story => {
+ return ;
+};
diff --git a/.storybook/custom-addons/shadowDOM/manager.js b/.storybook/custom-addons/shadowDOM/manager.js
new file mode 100644
index 00000000000..e24755ff532
--- /dev/null
+++ b/.storybook/custom-addons/shadowDOM/manager.js
@@ -0,0 +1,40 @@
+import {addons, types} from 'storybook/manager-api';
+import React, {useState} from 'react';
+
+const ShadowDOMToolBar = ({api}) => {
+ let shadowDOMParam = api.getQueryParam('shadowDOM');
+ let [isShadowDOM] = useState(shadowDOMParam === 'true');
+ let onChange = () => {
+ let params = new URLSearchParams(window.location.search);
+ params.set('shadowDOM', String(!isShadowDOM));
+ // The enableShadowDOM flag is global and can only be set True, so reload the page to
+ // sync it and so that false can be set.
+ window.location.search = params.toString();
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+addons.register('ShadowDOMSwitcher', api => {
+ addons.add('ShadowDOMSwitcher', {
+ title: 'Shadow DOM switcher',
+ type: types.TOOL,
+ match: ({viewMode}) => !!(viewMode && viewMode.match(/^(story|docs)$/)),
+ render: () =>
+ });
+});
diff --git a/.storybook/main.mjs b/.storybook/main.mjs
index 193ed5e9d83..5d6e4821f12 100644
--- a/.storybook/main.mjs
+++ b/.storybook/main.mjs
@@ -20,7 +20,8 @@ export default {
localAddon('./custom-addons/descriptions'),
localAddon('./custom-addons/theme'),
localAddon('./custom-addons/strictmode'),
- localAddon('./custom-addons/scrolling')
+ localAddon('./custom-addons/scrolling'),
+ localAddon('./custom-addons/shadowDOM')
],
typescript: {
diff --git a/.storybook/preview.js b/.storybook/preview.js
index 574739e70b4..fc662f57272 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -4,6 +4,7 @@ import {DARK_MODE_EVENT_NAME} from '@vueless/storybook-dark-mode';
import React from 'react';
import {withProviderSwitcher} from './custom-addons/provider';
import {withScrollingSwitcher} from './custom-addons/scrolling';
+import {withShadowDOMSwitcher} from './custom-addons/shadowDOM';
import {withStrictModeSwitcher} from './custom-addons/strictmode';
// decorator order matters, the last one will be the outer most
@@ -72,6 +73,7 @@ export const parameters = {
export const decorators = [
withScrollingSwitcher,
+ withShadowDOMSwitcher,
...(process.env.NODE_ENV !== 'production' ? [withStrictModeSwitcher] : []),
withProviderSwitcher
];
diff --git a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts
index 4470929976e..30ecb2965f6 100644
--- a/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts
+++ b/packages/@adobe/react-spectrum/src/menu/useCloseOnScroll.ts
@@ -10,7 +10,12 @@
* governing permissions and limitations under the License.
*/
-import {getEventTarget, nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions';
+import {addEvent} from 'react-aria/private/utils/domHelpers';
+import {
+ getEventTarget,
+ getPropagationTargets,
+ nodeContains
+} from 'react-aria/private/utils/shadowdom/DOMFunctions';
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
@@ -63,9 +68,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
}
};
- window.addEventListener('scroll', onScroll, true);
- return () => {
- window.removeEventListener('scroll', onScroll, true);
- };
+ return addEvent(getPropagationTargets(triggerRef.current), 'scroll', onScroll, true);
}, [isOpen, onClose, triggerRef]);
}
diff --git a/packages/@react-spectrum/ai/src/Chat.tsx b/packages/@react-spectrum/ai/src/Chat.tsx
index 13ef031b6fa..d0b11bde5be 100644
--- a/packages/@react-spectrum/ai/src/Chat.tsx
+++ b/packages/@react-spectrum/ai/src/Chat.tsx
@@ -123,6 +123,10 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat(
// TODO: will need some kind of api to programatically set the focused item to
// the newest item in the gridlist in the virtualizer case. this works for
// non-virtualized for now though
+ // 'scrollend' does not compose across shadow DOM boundaries, but this listener is intentionally
+ // scoped to this specific scroll container element (not a global target), so shadow root
+ // propagation does not apply here.
+ // oxlint-disable-next-line rsp-rules/no-non-composing-event-listener
el.addEventListener(
'scrollend',
() => {
diff --git a/packages/@react-types/shared/src/events.d.ts b/packages/@react-types/shared/src/events.d.ts
index 6b98068cd24..628ea1258b0 100644
--- a/packages/@react-types/shared/src/events.d.ts
+++ b/packages/@react-types/shared/src/events.d.ts
@@ -13,6 +13,20 @@
import {FocusableElement} from './dom';
import {FocusEvent, MouseEvent, KeyboardEvent as ReactKeyboardEvent, SyntheticEvent} from 'react';
+// Type helper to extract the target element type from an event
+export type EventTargetType = T extends SyntheticEvent ? E : EventTarget;
+
+// Type helper to extract the event map from a target
+export type EventMapType = T extends Window
+ ? WindowEventMap
+ : T extends Document
+ ? DocumentEventMap
+ : T extends Element
+ ? HTMLElementEventMap
+ : T extends VisualViewport
+ ? VisualViewportEventMap
+ : GlobalEventHandlersEventMap;
+
// Event bubbling can be problematic in real-world applications, so the default for React Spectrum components
// is not to propagate. This can be overridden by calling continuePropagation() on the event.
export type BaseEvent = T & {
diff --git a/packages/dev/eslint-plugin-rsp-rules/index.js b/packages/dev/eslint-plugin-rsp-rules/index.js
index dd1e17f1712..e9201d49257 100644
--- a/packages/dev/eslint-plugin-rsp-rules/index.js
+++ b/packages/dev/eslint-plugin-rsp-rules/index.js
@@ -11,9 +11,11 @@
*/
import actEventsTest from './rules/act-events-test.js';
+import addEventNonComposing from './rules/add-event-non-composing.js';
import fasterNodeContains from './rules/faster-node-contains.js';
import imports from './rules/imports.js';
import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js';
+import noNonComposingEventListener from './rules/no-non-composing-event-listener.js';
import noNonShadowContains from './rules/no-non-shadow-contains.js';
import noPackageRootImports from './rules/no-package-root-imports.js';
import noReactKey from './rules/no-react-key.js';
@@ -25,7 +27,9 @@ import useLayoutEffectRule from './rules/use-layout-effect-rule.js';
const rules = {
'act-events-test': actEventsTest,
+ 'add-event-non-composing': addEventNonComposing,
'no-getByRole-toThrow': noGetByRoleToThrow,
+ 'no-non-composing-event-listener': noNonComposingEventListener,
'no-package-root-imports': noPackageRootImports,
'no-react-key': noReactKey,
'sort-imports': sortImports,
diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/add-event-non-composing.js b/packages/dev/eslint-plugin-rsp-rules/rules/add-event-non-composing.js
new file mode 100644
index 00000000000..5cab551f644
--- /dev/null
+++ b/packages/dev/eslint-plugin-rsp-rules/rules/add-event-non-composing.js
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2025 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.
+ */
+
+// Events that do not compose across shadow DOM boundaries. A listener attached only to a global
+// target (e.g. window/document) will not observe these events when they are fired inside a shadow
+// root, so getPropagationTargets must be used to also attach listeners to the relevant shadow roots.
+const NON_COMPOSING_EVENTS = new Set([
+ 'scroll',
+ 'scrollend',
+ 'change',
+ 'submit',
+ 'reset',
+ 'select',
+ 'selectstart',
+ 'slotchange'
+]);
+
+const plugin = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Disallow calling addEvent with a non-composing event unless the target is getPropagationTargets(...), since these events do not cross shadow DOM boundaries',
+ recommended: true
+ },
+ schema: [],
+ messages: {
+ nonComposing:
+ "The '{{event}}' event does not compose across shadow DOM boundaries. Pass getPropagationTargets(...) as the target to addEvent so listeners are attached to the relevant shadow roots too."
+ }
+ },
+ create: context => {
+ return {
+ CallExpression(node) {
+ // Match a call to a function named `addEvent`.
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'addEvent') {
+ return;
+ }
+
+ // Second argument is the event type. Only statically-known string literals can be checked.
+ const eventArg = node.arguments[1];
+ if (
+ !eventArg ||
+ eventArg.type !== 'Literal' ||
+ typeof eventArg.value !== 'string' ||
+ !NON_COMPOSING_EVENTS.has(eventArg.value)
+ ) {
+ return;
+ }
+
+ // First argument is the target. It's fine if it is a getPropagationTargets(...) call.
+ let targetArg = node.arguments[0];
+ if (targetArg && targetArg.type === 'ChainExpression') {
+ targetArg = targetArg.expression;
+ }
+ if (
+ targetArg &&
+ targetArg.type === 'CallExpression' &&
+ targetArg.callee.type === 'Identifier' &&
+ targetArg.callee.name === 'getPropagationTargets'
+ ) {
+ return;
+ }
+
+ context.report({
+ node,
+ messageId: 'nonComposing',
+ data: {event: eventArg.value}
+ });
+ }
+ };
+ }
+};
+
+export default plugin;
diff --git a/packages/dev/eslint-plugin-rsp-rules/rules/no-non-composing-event-listener.js b/packages/dev/eslint-plugin-rsp-rules/rules/no-non-composing-event-listener.js
new file mode 100644
index 00000000000..4aacb182a8f
--- /dev/null
+++ b/packages/dev/eslint-plugin-rsp-rules/rules/no-non-composing-event-listener.js
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2025 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.
+ */
+
+// Events that do not compose across shadow DOM boundaries. A listener attached with
+// addEventListener will not observe these events when they are fired inside a shadow root the
+// listener's target does not contain, so addEvent(getPropagationTargets(...)) should be used instead
+// to also attach listeners to the relevant shadow roots.
+const NON_COMPOSING_EVENTS = new Set([
+ 'scroll',
+ 'scrollend',
+ 'change',
+ 'submit',
+ 'reset',
+ 'select',
+ 'selectstart',
+ 'slotchange'
+]);
+
+// Receivers that are not part of the shadow DOM tree, so getPropagationTargets does not apply to them
+// (e.g. visualViewport, or a MediaQueryList returned from matchMedia). Matched by common local names.
+const EXEMPT_RECEIVER_NAMES = new Set(['visualViewport', 'mq', 'm']);
+
+const plugin = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Disallow addEventListener with a non-composing event, since these events do not cross shadow DOM boundaries; use addEvent(getPropagationTargets(...)) instead',
+ recommended: true
+ },
+ schema: [],
+ messages: {
+ nonComposing:
+ "The '{{event}}' event does not compose across shadow DOM boundaries. Use addEvent(getPropagationTargets(...)) from @react-aria/utils instead of addEventListener so listeners are attached to the relevant shadow roots too."
+ }
+ },
+ create: context => {
+ return {
+ CallExpression(node) {
+ // Match `.addEventListener(...)`.
+ const callee = node.callee;
+ if (
+ callee.type !== 'MemberExpression' ||
+ callee.computed ||
+ callee.property.type !== 'Identifier' ||
+ callee.property.name !== 'addEventListener'
+ ) {
+ return;
+ }
+
+ // First argument is the event type. Only statically-known string literals can be checked.
+ const eventArg = node.arguments[0];
+ if (
+ !eventArg ||
+ eventArg.type !== 'Literal' ||
+ typeof eventArg.value !== 'string' ||
+ !NON_COMPOSING_EVENTS.has(eventArg.value)
+ ) {
+ return;
+ }
+
+ // Exempt receivers that are not part of the shadow DOM tree (visualViewport, MediaQueryList).
+ let receiver = callee.object;
+ if (receiver.type === 'ChainExpression') {
+ receiver = receiver.expression;
+ }
+ if (receiver.type === 'Identifier' && EXEMPT_RECEIVER_NAMES.has(receiver.name)) {
+ return;
+ }
+
+ context.report({
+ node,
+ messageId: 'nonComposing',
+ data: {event: eventArg.value}
+ });
+ }
+ };
+ }
+};
+
+export default plugin;
diff --git a/packages/dev/eslint-plugin-rsp-rules/test/add-event-non-composing.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/add-event-non-composing.test-lint.js
new file mode 100644
index 00000000000..cd69e5e35c7
--- /dev/null
+++ b/packages/dev/eslint-plugin-rsp-rules/test/add-event-non-composing.test-lint.js
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025 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 addEventNonComposingRule from '../rules/add-event-non-composing.js';
+import {RuleTester} from 'eslint';
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2020
+ }
+});
+
+ruleTester.run('add-event-non-composing', addEventNonComposingRule, {
+ valid: [
+ // Target is getPropagationTargets(...) — the correct pattern.
+ {code: "addEvent(getPropagationTargets(el), 'scroll', fn)"},
+ {
+ code: "addEvent(getPropagationTargets(ref.current, getOwnerDocument(ref.current)), 'scroll', fn)"
+ },
+ // Composing event, not in the list.
+ {code: "addEvent(document, 'touchstart', fn)"},
+ // Non-literal event — cannot be statically verified.
+ {code: 'addEvent(window, someVar, fn)'},
+ // Not a call to addEvent.
+ {code: "addSomethingElse(window, 'scroll', fn)"}
+ ],
+ invalid: [
+ {code: "addEvent(window, 'scroll', fn)", errors: 1},
+ {code: "addEvent(document, 'change', fn)", errors: 1},
+ {code: "addEvent(el, 'slotchange', fn)", errors: 1},
+ {code: "addEvent(target, 'selectstart', fn)", errors: 1},
+ {code: "addEvent(notPropagation(el), 'scroll', fn)", errors: 1}
+ ]
+});
diff --git a/packages/dev/eslint-plugin-rsp-rules/test/no-non-composing-event-listener.test-lint.js b/packages/dev/eslint-plugin-rsp-rules/test/no-non-composing-event-listener.test-lint.js
new file mode 100644
index 00000000000..b3531427c60
--- /dev/null
+++ b/packages/dev/eslint-plugin-rsp-rules/test/no-non-composing-event-listener.test-lint.js
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025 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 noNonComposingEventListenerRule from '../rules/no-non-composing-event-listener.js';
+import {RuleTester} from 'eslint';
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2020
+ }
+});
+
+ruleTester.run('no-non-composing-event-listener', noNonComposingEventListenerRule, {
+ valid: [
+ // Composing event, not in the list.
+ {code: "el.addEventListener('click', fn)"},
+ // Non-literal event — cannot be statically verified.
+ {code: 'el.addEventListener(evt, fn)'},
+ // Non-shadow-tree receivers where getPropagationTargets does not apply.
+ {code: "visualViewport.addEventListener('scroll', fn)"},
+ {code: "mq.addEventListener('change', fn)"},
+ {code: "m.addEventListener('change', fn)"},
+ // Not an addEventListener call.
+ {code: "el.addListener('scroll', fn)"}
+ ],
+ invalid: [
+ {code: "window.addEventListener('scroll', fn)", errors: 1},
+ {code: "document.addEventListener('change', fn)", errors: 1},
+ {code: "input.addEventListener('change', fn)", errors: 1},
+ {code: "form.addEventListener('reset', fn)", errors: 1},
+ {code: "el.addEventListener('slotchange', fn)", errors: 1},
+ {code: "getOwnerDocument(el).addEventListener('scroll', fn)", errors: 1}
+ ]
+});
diff --git a/packages/dev/s2-docs/assets/component-illustrations/dark/SideNav.avif b/packages/dev/s2-docs/assets/component-illustrations/dark/SideNav.avif
new file mode 100644
index 00000000000..2f749cf8c44
Binary files /dev/null and b/packages/dev/s2-docs/assets/component-illustrations/dark/SideNav.avif differ
diff --git a/packages/dev/s2-docs/assets/component-illustrations/dark/TokenField.avif b/packages/dev/s2-docs/assets/component-illustrations/dark/TokenField.avif
new file mode 100644
index 00000000000..63d7b3c52fd
Binary files /dev/null and b/packages/dev/s2-docs/assets/component-illustrations/dark/TokenField.avif differ
diff --git a/packages/dev/s2-docs/assets/component-illustrations/light/SideNav.avif b/packages/dev/s2-docs/assets/component-illustrations/light/SideNav.avif
new file mode 100644
index 00000000000..a44787ba1c0
Binary files /dev/null and b/packages/dev/s2-docs/assets/component-illustrations/light/SideNav.avif differ
diff --git a/packages/dev/s2-docs/assets/component-illustrations/light/TokenField.avif b/packages/dev/s2-docs/assets/component-illustrations/light/TokenField.avif
new file mode 100644
index 00000000000..730fd19a7eb
Binary files /dev/null and b/packages/dev/s2-docs/assets/component-illustrations/light/TokenField.avif differ
diff --git a/packages/dev/s2-docs/pages/s2/Card.mdx b/packages/dev/s2-docs/pages/s2/Card.mdx
index 8e2553eb008..7b5b857b9b1 100644
--- a/packages/dev/s2-docs/pages/s2/Card.mdx
+++ b/packages/dev/s2-docs/pages/s2/Card.mdx
@@ -270,7 +270,7 @@ import {Card, CardPreview, Image, Content, Text} from '@react-spectrum/s2/Card';
or
-
+
diff --git a/packages/dev/s2-docs/src/ComponentCard.tsx b/packages/dev/s2-docs/src/ComponentCard.tsx
index b6818f603d6..9e4bc0d261f 100644
--- a/packages/dev/s2-docs/src/ComponentCard.tsx
+++ b/packages/dev/s2-docs/src/ComponentCard.tsx
@@ -156,6 +156,8 @@ import SelectBoxGroupDark from 'url:../assets/component-illustrations/dark/Selec
import SelectBoxGroupLight from 'url:../assets/component-illustrations/light/SelectBoxGroup.avif';
import SelectionDark from 'url:../assets/component-illustrations/dark/Selection.avif';
import SelectionLight from 'url:../assets/component-illustrations/light/Selection.avif';
+import SideNavDark from 'url:../assets/component-illustrations/dark/SideNav.avif';
+import SideNavLight from 'url:../assets/component-illustrations/light/SideNav.avif';
import SkeletonDark from 'url:../assets/component-illustrations/dark/Skeleton.avif';
import SkeletonLight from 'url:../assets/component-illustrations/light/Skeleton.avif';
import SliderDark from 'url:../assets/component-illustrations/dark/Slider.avif';
@@ -189,6 +191,8 @@ import ToggleButtonDark from 'url:../assets/component-illustrations/dark/ToggleB
import ToggleButtonGroupDark from 'url:../assets/component-illustrations/dark/ToggleButtonGroup.avif';
import ToggleButtonGroupLight from 'url:../assets/component-illustrations/light/ToggleButtonGroup.avif';
import ToggleButtonLight from 'url:../assets/component-illustrations/light/ToggleButton.avif';
+import TokenFieldDark from 'url:../assets/component-illustrations/dark/TokenField.avif';
+import TokenFieldLight from 'url:../assets/component-illustrations/light/TokenField.avif';
import TooltipDark from 'url:../assets/component-illustrations/dark/Tooltip.avif';
import TooltipLight from 'url:../assets/component-illustrations/light/Tooltip.avif';
import TreeDark from 'url:../assets/component-illustrations/dark/Tree.avif';
@@ -205,6 +209,9 @@ export interface ComponentCardItem {
}
// Mapping from component names to their illustration [light, dark] tuple
+// to add new illustrations run `node scripts/processComponentImages.mjs ` where source dir needs the
+// "Light" and "Dark" folders containing the component illustrations (make sure they are the bluish gradient not gray)
+// then add imports above and entries below.
const componentIllustrations: Record = {
// Components
Accordion: [AccordionLight, AccordionDark],
@@ -276,6 +283,7 @@ const componentIllustrations: Record = {
Select: [PickerLight, PickerDark],
SelectBoxGroup: [SelectBoxGroupLight, SelectBoxGroupDark],
Separator: [DividerLight, DividerDark],
+ SideNav: [SideNavLight, SideNavDark],
Skeleton: [SkeletonLight, SkeletonDark],
Slider: [SliderLight, SliderDark],
StatusLight: [StatusLightLight, StatusLightDark],
@@ -290,6 +298,7 @@ const componentIllustrations: Record = {
Toast: [ToastLight, ToastDark],
ToggleButton: [ToggleButtonLight, ToggleButtonDark],
ToggleButtonGroup: [ToggleButtonGroupLight, ToggleButtonGroupDark],
+ TokenField: [TokenFieldLight, TokenFieldDark],
Toolbar: [ActionGroupLight, ActionGroupDark],
Tooltip: [TooltipLight, TooltipDark],
Tree: [TreeLight, TreeDark],
diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx
index b5920c692f6..8acea296766 100644
--- a/packages/react-aria-components/stories/Tree.stories.tsx
+++ b/packages/react-aria-components/stories/Tree.stories.tsx
@@ -16,6 +16,7 @@ import {Checkbox, CheckboxProps} from '../src/Checkbox';
import {classNames} from '@adobe/react-spectrum/private/utils/classNames';
import {Collection} from 'react-aria/Collection';
import {ComboBox} from '../src/ComboBox';
+import {createPortal} from 'react-dom';
import {DroppableCollectionReorderEvent, Key} from '@react-types/shared';
import {Input} from '../src/Input';
import {isTextDropItem, useDragAndDrop} from '../exports/useDragAndDrop';
@@ -25,7 +26,7 @@ import {Menu, MenuItem, MenuTrigger} from '../src/Menu';
import {Meta, StoryFn, StoryObj} from '@storybook/react';
import {MyListBoxItem, MyMenuItem} from './utils';
import {Popover} from '../src/Popover';
-import React, {JSX, ReactNode, useCallback, useState} from 'react';
+import React, {JSX, ReactNode, useCallback, useRef, useState} from 'react';
import styles from '../example/index.css';
import {Text} from '../src/Text';
import {TextField} from '../src/TextField';
@@ -49,7 +50,7 @@ import './styles.css';
export default {
title: 'React Aria Components/Tree',
component: Tree,
- excludeStories: ['TreeExampleStaticRender', 'TreeWithTextField']
+ excludeStories: ['TreeExampleStaticRender', 'TreeWithTextField', 'VirtualizedTreeInShadowDOM']
} as Meta;
export type TreeStory = StoryFn;
@@ -1958,3 +1959,89 @@ export const TreeWithTextFieldStory: StoryObj = {
},
name: 'Tree with Textfield'
};
+
+export function VirtualizedTreeInShadowDOM(props: TreeProps) {
+ const [portalNode] = useState(() => document.createElement('div'));
+ const onMountCleanup = useRef void)>(null);
+ const onMount = useCallback(
+ (mountPoint: HTMLDivElement | null) => {
+ onMountCleanup.current?.();
+ onMountCleanup.current = null;
+ if (mountPoint) {
+ /** ShadowRoot may already exist if React strict mode has run this callback twice. */
+ const shadowRoot = mountPoint.shadowRoot || mountPoint.attachShadow({mode: 'open'});
+
+ /**
+ * CSS does not cross the shadow boundary, so the styles Parcel injects into the
+ * document head never reach the portaled content. Copy the already-processed
+ * style nodes (with their hashed CSS-module selectors intact) into the shadow root
+ * so the tree renders with the same styling as the light-DOM stories.
+ */
+ const styleClones = Array.from(
+ document.head.querySelectorAll('style, link[rel="stylesheet"]')
+ ).map(node => node.cloneNode(true) as HTMLElement);
+ styleClones.forEach(clone => shadowRoot.appendChild(clone));
+
+ shadowRoot.appendChild(portalNode);
+
+ onMountCleanup.current = () => {
+ styleClones.forEach(clone => shadowRoot.removeChild(clone));
+ shadowRoot.removeChild(portalNode);
+ };
+ }
+ },
+ [portalNode]
+ );
+ return (
+ <>
+
+ {createPortal(
+
+
Rendered inside a shadow root.
+
+
+ +Tree + Virtualizer
+
+
+
+
+
,
+ portalNode
+ )}
+ >
+ );
+}
+export const VirtualizedTreeInShadowDOMStory: StoryObj = {
+ render: args => ,
+ args: {
+ selectionMode: 'none',
+ selectionBehavior: 'toggle',
+ disabledBehavior: 'selection',
+ items: treeData
+ },
+ argTypes: {
+ keyboardNavigationBehavior: {
+ control: 'radio',
+ options: ['arrow', 'tab']
+ },
+ selectionMode: {
+ control: 'radio',
+ options: ['none', 'single', 'multiple']
+ },
+ selectionBehavior: {
+ control: 'radio',
+ options: ['toggle', 'replace']
+ },
+ disabledBehavior: {
+ control: 'radio',
+ options: ['selection', 'all']
+ }
+ },
+ name: 'Virtualized Tree in Shadow DOM'
+};
diff --git a/packages/react-aria-components/test/ComboBox.browser.test.tsx b/packages/react-aria-components/test/ComboBox.browser.test.tsx
new file mode 100644
index 00000000000..9eeae701923
--- /dev/null
+++ b/packages/react-aria-components/test/ComboBox.browser.test.tsx
@@ -0,0 +1,122 @@
+/*
+ * 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.
+ */
+
+// Verifies that overlays close when a scrollable ancestor scrolls, both in
+// light DOM and inside a shadow DOM (where scroll events have composed: false).
+//
+// Uses ComboBox which sets isNonModal: true so its Popover registers a
+// document.addEventListener('scroll', ...) via useCloseOnScroll.
+
+import {Button} from '../src/Button';
+import {ComboBox} from '../src/ComboBox';
+import {createRoot} from 'react-dom/client';
+import {enableShadowDOM} from 'react-stately/private/flags/flags';
+import {expect, it} from 'vitest';
+import {Input} from '../src/Input';
+import {Label} from '../src/Label';
+import {ListBox, ListBoxItem} from '../src/ListBox';
+import {Popover} from '../src/Popover';
+import React from 'react';
+import {User} from '@react-aria/test-utils';
+
+function TestComboBox() {
+ return (
+
+
+
+
+
+
+ Cat
+ Dog
+ Kangaroo
+
+
+
+ );
+}
+
+function makeScrollableContainer() {
+ let scrollable = document.createElement('div');
+ scrollable.style.cssText = 'height: 100px; overflow-y: auto;';
+ let inner = document.createElement('div');
+ inner.style.height = '500px';
+ scrollable.appendChild(inner);
+ let mountPoint = document.createElement('div');
+ inner.appendChild(mountPoint);
+ return {scrollable, mountPoint};
+}
+
+it('overlay closes when a scrollable light DOM ancestor scrolls', async () => {
+ let testUtilUser = new User();
+ let {scrollable, mountPoint} = makeScrollableContainer();
+ document.body.appendChild(scrollable);
+
+ let root = createRoot(mountPoint);
+ root.render();
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: scrollable});
+ await comboboxTester.open();
+
+ // ComboBox listbox renders into document.body via portal.
+ expect(comboboxTester.getListbox()).not.toBeNull();
+
+ // Scroll the ancestor that contains the trigger — window capturing listener should close the overlay.
+ scrollable.dispatchEvent(new Event('scroll'));
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ expect(comboboxTester.getListbox()).toBeNull();
+
+ root.unmount();
+ document.body.removeChild(scrollable);
+});
+
+describe('Shadow DOM', () => {
+ /**
+ * EnableShadowDOM must be called before mounting.
+ *
+ * Cannot be turned off, so should be called after light-dom tests.
+ */
+ enableShadowDOM();
+
+ it('overlay closes when a scrollable shadow DOM ancestor scrolls', async () => {
+ let testUtilUser = new User();
+ let outerHost = document.createElement('div');
+ document.body.appendChild(outerHost);
+ let shadowRoot = outerHost.attachShadow({mode: 'open'});
+
+ let {scrollable, mountPoint} = makeScrollableContainer();
+ shadowRoot.appendChild(scrollable);
+
+ let root = createRoot(mountPoint);
+ root.render();
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ let comboboxTester = testUtilUser.createTester('ComboBox', {root: scrollable});
+ await comboboxTester.open();
+
+ // Listbox renders into document.body via portal even in shadow DOM mode.
+ expect(comboboxTester.getListbox()).not.toBeNull();
+
+ // Scroll inside the shadow root.
+ // Without the fix, document never sees this event (composed: false).
+ // With the fix (getEventTargets + addEvent), the shadow root listener closes the overlay.
+ scrollable.dispatchEvent(new Event('scroll'));
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ expect(comboboxTester.getListbox()).toBeNull();
+
+ root.unmount();
+ document.body.removeChild(outerHost);
+ });
+});
diff --git a/packages/react-aria-components/test/Modal.browser.test.tsx b/packages/react-aria-components/test/Modal.browser.test.tsx
new file mode 100644
index 00000000000..bb721dd5b93
--- /dev/null
+++ b/packages/react-aria-components/test/Modal.browser.test.tsx
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+import {Button} from '../src/Button';
+import {commands, page, userEvent} from 'vitest/browser';
+import {Dialog, DialogTrigger} from '../src/Dialog';
+import {expect, it} from 'vitest';
+import {Heading} from '../src/Heading';
+import {Modal, ModalOverlay} from '../src/Modal';
+import React from 'react';
+import {render} from 'vitest-browser-react';
+
+declare module 'vitest/browser' {
+ interface BrowserCommands {
+ mouseDownOnElement: (selector: string, offsetX?: number, offsetY?: number) => Promise;
+ mouseUp: () => Promise;
+ }
+}
+
+const OFFSET_VH = 5;
+
+function ScrollJumpExample() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+// mousedown on the backdrop moves focus to in Chrome/Safari; Firefox does not.
+// FocusScope containment must restore focus to the Dialog without scrolling,
+// otherwise the modal visibly jumps to the top of the screen.
+// Uses a trusted press so the native focus move actually happens. This cannot be
+// tested in a unit test nor in Chromatic play.
+it('does not scroll the modal into view when the backdrop is pressed', async () => {
+ await render();
+
+ await userEvent.click(page.getByRole('button', {name: 'Open modal'}));
+ await expect.element(page.getByRole('dialog')).toBeInTheDocument();
+
+ let overlay = page.getByTestId('scroll-jump-backdrop').element() as HTMLElement;
+ let modal = page.getByTestId('scroll-jump-modal').element() as HTMLElement;
+
+ overlay.scrollTop = 0;
+ let modalTopBefore = Math.round(modal.getBoundingClientRect().top);
+ expect(overlay.scrollTop).toBe(0);
+ expect(modalTopBefore).toBeGreaterThan(0);
+
+ // Do not release so we can observe the state
+ await commands.mouseDownOnElement(page.getByTestId('scroll-jump-backdrop').selector, 5);
+
+ // Wait a couple frames for FocusScope's requestAnimationFrame focus restore to run.
+ await new Promise(resolve =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve(null)))
+ );
+
+ // the modal stays at its offset
+ expect(overlay.scrollTop).toBe(0);
+ expect(Math.round(modal.getBoundingClientRect().top)).toBe(modalTopBefore);
+
+ await commands.mouseUp();
+});
diff --git a/packages/react-aria-components/test/Tree.browser.test.tsx b/packages/react-aria-components/test/Tree.browser.test.tsx
new file mode 100644
index 00000000000..5e5c2c49404
--- /dev/null
+++ b/packages/react-aria-components/test/Tree.browser.test.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+
+// Regression test for https://github.com/adobe/react-spectrum/issues/10093
+
+import {createRoot} from 'react-dom/client';
+import {enableShadowDOM} from 'react-stately/private/flags/flags';
+import {expect, it} from 'vitest';
+import {ListLayout} from 'react-stately/useVirtualizerState';
+import React from 'react';
+import {Tree, TreeItem, TreeItemContent} from '../src/Tree';
+import {Virtualizer} from '../src/Virtualizer';
+
+// Mirror what the reproduction does — must be set before mounting.
+enableShadowDOM();
+
+const ROW_HEIGHT = 30;
+const CONTAINER_HEIGHT = 300;
+const items = Array.from({length: 50}, (_, i) => ({id: `item-${i}`, name: `Item ${i}`}));
+
+function VirtualizedTree() {
+ return (
+
+
+ {(item: any) => (
+
+ {item.name}
+
+ )}
+
+
+ );
+}
+
+it('virtualizer inside shadow DOM updates visible items on scroll', async () => {
+ let host = document.createElement('div');
+ document.body.appendChild(host);
+ let shadowRoot = host.attachShadow({mode: 'open'});
+ let mountPoint = document.createElement('div');
+ shadowRoot.appendChild(mountPoint);
+
+ let root = createRoot(mountPoint);
+ root.render();
+ // Wait for initial render, ResizeObserver measurement, and ScrollView's size update.
+ await new Promise(resolve => setTimeout(() => resolve(), 200));
+
+ // The scrollport is the treegrid element (Tree's outer div with overflow: auto).
+ // The [role="presentation"] div is the inner content container, not the scrollport.
+ let scrollport = shadowRoot.querySelector('[role="treegrid"]');
+ expect(scrollport).not.toBeNull();
+ expect(scrollport!.scrollHeight).toBeGreaterThan(CONTAINER_HEIGHT);
+
+ let rows = shadowRoot.querySelectorAll('[role="row"]');
+ expect(rows.length).toBeGreaterThan(0);
+ // Only a subset of items should be visible (not all 50) due to virtualization.
+ expect(rows.length).toBeLessThan(items.length);
+ expect(Array.from(rows).some(r => r.textContent?.includes('Item 0'))).toBe(true);
+
+ // Scroll past 20 items (20 × 30px) so Item 0 is outside any extra items the layout may buffer.
+ scrollport!.scrollTop = ROW_HEIGHT * 20;
+ await new Promise(resolve => setTimeout(() => resolve(), 200));
+
+ let updatedRows = shadowRoot.querySelectorAll('[role="row"]');
+ expect(Array.from(updatedRows).some(r => r.textContent?.includes('Item 0'))).toBe(false);
+ expect(Array.from(updatedRows).some(r => r.textContent?.includes('Item 20'))).toBe(true);
+
+ root.unmount();
+ document.body.removeChild(host);
+});
diff --git a/packages/react-aria/exports/private/utils/domHelpers.ts b/packages/react-aria/exports/private/utils/domHelpers.ts
index bc415a70891..90f31ad7626 100644
--- a/packages/react-aria/exports/private/utils/domHelpers.ts
+++ b/packages/react-aria/exports/private/utils/domHelpers.ts
@@ -1 +1,6 @@
-export {getOwnerDocument, getOwnerWindow, isShadowRoot} from '../../../src/utils/domHelpers';
+export {
+ addEvent,
+ getOwnerDocument,
+ getOwnerWindow,
+ isShadowRoot
+} from '../../../src/utils/domHelpers';
diff --git a/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts
index 8e24abab17f..0af757fbf2b 100644
--- a/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts
+++ b/packages/react-aria/exports/private/utils/shadowdom/DOMFunctions.ts
@@ -1,5 +1,6 @@
export {
getEventTarget,
+ getPropagationTargets,
nodeContains,
isFocusWithin,
getActiveElement
diff --git a/packages/react-aria/src/focus/FocusScope.tsx b/packages/react-aria/src/focus/FocusScope.tsx
index 6f423f12b7f..ad4f0a427a3 100644
--- a/packages/react-aria/src/focus/FocusScope.tsx
+++ b/packages/react-aria/src/focus/FocusScope.tsx
@@ -410,7 +410,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo
// If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
// restore focus to the previously focused node or the first tabbable element in the active scope.
if (focusedNode.current) {
- focusedNode.current.focus();
+ focusElement(focusedNode.current);
} else if (activeScope && activeScope.current) {
focusFirstInScope(activeScope.current);
}
@@ -444,7 +444,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo
let target = getEventTarget(e) as FocusableElement;
if (target && target.isConnected) {
focusedNode.current = target;
- focusedNode.current?.focus();
+ focusElement(focusedNode.current);
} else if (activeScope.current) {
focusFirstInScope(activeScope.current);
}
diff --git a/packages/react-aria/src/form/useFormValidation.ts b/packages/react-aria/src/form/useFormValidation.ts
index d738487cdc7..5a26df343a4 100644
--- a/packages/react-aria/src/form/useFormValidation.ts
+++ b/packages/react-aria/src/form/useFormValidation.ts
@@ -117,8 +117,13 @@ export function useFormValidation(
};
}
+ // 'change' and 'reset' do not compose across shadow DOM boundaries, but these listeners are
+ // intentionally scoped to this specific input/form element (not a global target), so shadow
+ // root propagation does not apply here.
input.addEventListener('invalid', onInvalid);
+ // oxlint-disable-next-line rsp-rules/no-non-composing-event-listener
input.addEventListener('change', onChange);
+ // oxlint-disable-next-line rsp-rules/no-non-composing-event-listener
form?.addEventListener('reset', onReset);
return () => {
input!.removeEventListener('invalid', onInvalid);
diff --git a/packages/react-aria/src/overlays/useCloseOnScroll.ts b/packages/react-aria/src/overlays/useCloseOnScroll.ts
index 0d7e7698876..5c50db3b1c4 100644
--- a/packages/react-aria/src/overlays/useCloseOnScroll.ts
+++ b/packages/react-aria/src/overlays/useCloseOnScroll.ts
@@ -10,7 +10,8 @@
* governing permissions and limitations under the License.
*/
-import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions';
+import {addEvent} from '../utils/domHelpers';
+import {getEventTarget, getPropagationTargets, nodeContains} from '../utils/shadowdom/DOMFunctions';
import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
@@ -60,9 +61,6 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void {
}
};
- window.addEventListener('scroll', onScroll, true);
- return () => {
- window.removeEventListener('scroll', onScroll, true);
- };
+ return addEvent(getPropagationTargets(triggerRef.current), 'scroll', onScroll, true);
}, [isOpen, onClose, triggerRef]);
}
diff --git a/packages/react-aria/src/utils/domHelpers.ts b/packages/react-aria/src/utils/domHelpers.ts
index c2a9367a489..957e43fe983 100644
--- a/packages/react-aria/src/utils/domHelpers.ts
+++ b/packages/react-aria/src/utils/domHelpers.ts
@@ -10,6 +10,8 @@
* governing permissions and limitations under the License.
*/
+import type {EventMapType} from '@react-types/shared';
+
export const getOwnerDocument = (target?: EventTarget | null): Document => {
if (isWindow(target)) return target.document;
@@ -63,3 +65,29 @@ export function isShadowRoot(value: unknown): value is ShadowRoot {
// 11 = DOCUMENT_FRAGMENT_NODE
return isNode(value) && value.nodeType === 11 && 'host' in value;
}
+
+/**
+ * Attaches an event listener on target(s) and returns a cleanup function.
+ */
+export function addEvent>>(
+ target: T | EventTarget[] | null,
+ event: Extract | (string & {}),
+ listener?: (this: T, ev: EventMapType>[K]) => any,
+ options?: boolean | AddEventListenerOptions
+): () => void {
+ if (listener == null || target == null) {
+ return () => {};
+ }
+
+ let eventTargets = Array.isArray(target) ? target : [target];
+
+ for (let eventTarget of eventTargets) {
+ eventTarget.addEventListener(event, listener as EventListener, options);
+ }
+
+ return () => {
+ for (let eventTarget of eventTargets) {
+ eventTarget.removeEventListener(event, listener as EventListener, options);
+ }
+ };
+}
diff --git a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts
index 2a7b2598199..5190bfd103c 100644
--- a/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts
+++ b/packages/react-aria/src/utils/shadowdom/DOMFunctions.ts
@@ -83,6 +83,50 @@ export function getEventTarget(event: T): Even
return event.target as EventTargetType;
}
+/**
+ * Returns the set of event targets a listener must be attached to in order to
+ * globally observe an event.
+ *
+ * @param from - The target element to start from.
+ * @param to - The element to stop at when bubbling. @default getOwnerWindow(from)
+ * `to` is generally going to be either `document` or `window`, but
+ * it can be any intermediate node.
+ * @returns [global, ...shadowRoots]
+ */
+export function getPropagationTargets(
+ from: Element | null | undefined,
+ to?: Document | Window | Element | null
+): EventTarget[] {
+ // If `to` is coming from a ref, its type technically allows `null`.
+ // In practice, this function will generally be called from within a useEffect.
+ // If the ref has not resolved by that point, then a coding error has been made.
+ // Better to return an empty array than `[window]`, which may appear to work
+ // in the light DOM, but fail in the shadow DOM.
+ if (to === null) {
+ return [];
+ }
+ to = to ?? getOwnerWindow(from);
+ let targets: EventTarget[] = [to];
+ if (!shadowDOM() || !from || from === to) {
+ return targets;
+ }
+
+ // The root `to` itself lives in. The event already reaches `to` once
+ // it is inside this root, so we must NOT collect this root or anything above
+ // it — only the shadow roots strictly between `refNode` and `to`.
+ // `window` has no getRootNode; its boundary is the document, which the walk
+ // reaches naturally (the document is not a ShadowRoot, so the loop exits).
+ let toRoot = 'getRootNode' in to ? to.getRootNode() : null;
+ let current: Node | null = from.getRootNode() ?? null;
+ while (isShadowRoot(current) && current !== toRoot) {
+ // order shouldn't matter
+ targets.push(current);
+ current = current.host.getRootNode();
+ }
+
+ return targets;
+}
+
/**
* ShadowDOM safe fast version of node.contains(document.activeElement).
*
diff --git a/packages/react-aria/src/utils/useFormReset.ts b/packages/react-aria/src/utils/useFormReset.ts
index 0bd315a554a..fd63e7e042e 100644
--- a/packages/react-aria/src/utils/useFormReset.ts
+++ b/packages/react-aria/src/utils/useFormReset.ts
@@ -28,6 +28,10 @@ export function useFormReset(
useEffect(() => {
let form = ref?.current?.form;
+ // 'reset' does not compose across shadow DOM boundaries, but this listener is intentionally
+ // scoped to this specific form element (not a global target), so shadow root propagation does
+ // not apply here.
+ // oxlint-disable-next-line rsp-rules/no-non-composing-event-listener
form?.addEventListener('reset', handleReset);
return () => {
form?.removeEventListener('reset', handleReset);
diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx
index 4f0cf3ffb7d..86b94e4174f 100644
--- a/packages/react-aria/src/virtualizer/ScrollView.tsx
+++ b/packages/react-aria/src/virtualizer/ScrollView.tsx
@@ -10,9 +10,9 @@
* governing permissions and limitations under the License.
*/
-// @ts-ignore
+import {addEvent, getOwnerDocument} from '../utils/domHelpers';
import {flushSync} from 'react-dom';
-import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions';
+import {getEventTarget, getPropagationTargets, nodeContains} from '../utils/shadowdom/DOMFunctions';
import {getScrollLeft} from './utils';
import {Point, Rect, Size} from 'react-stately/useVirtualizerState';
import React, {
@@ -220,9 +220,13 @@ export function useScrollView(
// Attach a document-level capturing scroll listener so we can account for scrollable ancestors.
useEffect(() => {
- document.addEventListener('scroll', onScroll, true);
- return () => document.removeEventListener('scroll', onScroll, true);
- }, [onScroll]);
+ return addEvent(
+ getPropagationTargets(ref.current, getOwnerDocument(ref.current)),
+ 'scroll',
+ onScroll,
+ true
+ );
+ }, [onScroll, ref]);
useEffect(() => {
return () => {
diff --git a/packages/react-aria/test/utils/DOMFunctions.test.js b/packages/react-aria/test/utils/DOMFunctions.test.js
deleted file mode 100644
index b2ae7e6d850..00000000000
--- a/packages/react-aria/test/utils/DOMFunctions.test.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * 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 {createShadowRoot, render} from '@react-spectrum/test-utils-internal';
-import {enableShadowDOM} from 'react-stately/private/flags/flags';
-import {nodeContains} from '../../src/utils/shadowdom/DOMFunctions';
-import React from 'react';
-import ReactDOM from 'react-dom';
-import {screen} from 'shadow-dom-testing-library';
-
-describe('nodeContains with shadow DOM', function () {
- beforeAll(() => {
- enableShadowDOM();
- });
-
- it('can tell if a node is contained even if it is within a shadow DOM', function () {
- const {shadowRoot, shadowHost, cleanup} = createShadowRoot();
- let Wrapper = () =>
- ReactDOM.createPortal(
-
-
-
-
-
,
- shadowRoot
- );
- render();
-
- let button = screen.getByShadowRole('button');
-
- expect(nodeContains(shadowRoot, button)).toBe(true);
- expect(nodeContains(shadowHost, button)).toBe(true);
-
- cleanup();
- });
-
- it('can tell if slotted light DOM content is contained by a node inside the shadow root', function () {
- const {shadowHost, shadowRoot, cleanup} = createShadowRoot();
-
- // A lives inside the shadow root, wrapped in a container element.
- const container = document.createElement('div');
- const slot = document.createElement('slot');
- container.appendChild(slot);
- shadowRoot.appendChild(container);
-
- // A light DOM child of the host gets projected into the slot.
- const button = document.createElement('button');
- shadowHost.appendChild(button);
-
- // Sanity check that the browser assigned the button to the slot.
- expect(button.assignedSlot).toBe(slot);
-
- // The button's DOM parent is the host (light DOM), so reaching `container`
- // (inside the shadow root) is only possible by following assignedSlot:
- // button -> assignedSlot (slot) -> slot.parentNode (container).
- expect(nodeContains(container, button)).toBe(true);
- expect(nodeContains(shadowRoot, button)).toBe(true);
-
- // A sibling outside the slotted subtree should not be considered contained.
- const outside = document.createElement('span');
- document.body.appendChild(outside);
- expect(nodeContains(container, outside)).toBe(false);
- document.body.removeChild(outside);
-
- cleanup();
- });
-});
diff --git a/packages/react-aria/test/utils/DOMFunctions.test.tsx b/packages/react-aria/test/utils/DOMFunctions.test.tsx
new file mode 100644
index 00000000000..af7fc724c95
--- /dev/null
+++ b/packages/react-aria/test/utils/DOMFunctions.test.tsx
@@ -0,0 +1,140 @@
+/*
+ * 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 {createShadowRoot, render} from '@react-spectrum/test-utils-internal';
+import {enableShadowDOM} from 'react-stately/private/flags/flags';
+import {getPropagationTargets, nodeContains} from '../../src/utils/shadowdom/DOMFunctions';
+import React from 'react';
+import {screen} from 'shadow-dom-testing-library';
+
+describe('nodeContains with shadow DOM', function () {
+ beforeAll(() => {
+ enableShadowDOM();
+ });
+
+ it('can tell if a node is contained even if it is within a shadow DOM', function () {
+ const {shadowRoot, shadowHost, cleanup} = createShadowRoot();
+
+ render(
+
+
+
+
+
,
+ {container: shadowRoot as unknown as HTMLElement}
+ );
+
+ let button = screen.getByShadowRole('button');
+
+ expect(nodeContains(shadowRoot, button)).toBe(true);
+ expect(nodeContains(shadowHost, button)).toBe(true);
+
+ cleanup();
+ });
+
+ it('can tell if slotted light DOM content is contained by a node inside the shadow root', function () {
+ const {shadowHost, shadowRoot, cleanup} = createShadowRoot();
+
+ // A lives inside the shadow root, wrapped in a container element.
+ const container = document.createElement('div');
+ const slot = document.createElement('slot');
+ container.appendChild(slot);
+ shadowRoot.appendChild(container);
+
+ // A light DOM child of the host gets projected into the slot.
+ const button = document.createElement('button');
+ shadowHost.appendChild(button);
+
+ // Sanity check that the browser assigned the button to the slot.
+ expect(button.assignedSlot).toBe(slot);
+
+ // The button's DOM parent is the host (light DOM), so reaching `container`
+ // (inside the shadow root) is only possible by following assignedSlot:
+ // button -> assignedSlot (slot) -> slot.parentNode (container).
+ expect(nodeContains(container, button)).toBe(true);
+ expect(nodeContains(shadowRoot, button)).toBe(true);
+
+ // A sibling outside the slotted subtree should not be considered contained.
+ const outside = document.createElement('span');
+ document.body.appendChild(outside);
+ expect(nodeContains(container, outside)).toBe(false);
+ document.body.removeChild(outside);
+
+ cleanup();
+ });
+});
+
+describe('getPropagationTargets with shadow DOM', function () {
+ beforeAll(() => {
+ enableShadowDOM();
+ });
+
+ it('can just get the global window', function () {
+ const {shadowRoot, cleanup} = createShadowRoot();
+ render(
+
+
Shadow root
+
,
+ {container: shadowRoot as unknown as HTMLElement}
+ );
+ expect(getPropagationTargets(null)).toEqual([window]);
+ // @ts-expect-error - can fix this after improved ts pr is merged
+ expect(getPropagationTargets(document)).toEqual([window]);
+ // @ts-expect-error - can fix this after improved ts pr is merged
+ expect(getPropagationTargets(window)).toEqual([window]);
+ cleanup();
+ });
+
+ it('can get the propagation targets from a shadow root', function () {
+ const {shadowRoot, cleanup} = createShadowRoot();
+ render(
+
+
+
,
+ {container: shadowRoot as unknown as HTMLElement}
+ );
+ let target = screen.getByShadowRole('button');
+ expect(getPropagationTargets(target)).toEqual([window, shadowRoot]);
+ expect(getPropagationTargets(target, document)).toEqual([document, shadowRoot]);
+ expect(getPropagationTargets(target, window)).toEqual([window, shadowRoot]);
+ cleanup();
+ });
+
+ it('can get the propagation targets for multiple nested shadow roots', function () {
+ const {shadowRoot, cleanup} = createShadowRoot();
+ const intermediateNode = document.createElement('div');
+ shadowRoot.appendChild(intermediateNode);
+ const {shadowRoot: shadowRoot2, cleanup: cleanup2} = createShadowRoot(intermediateNode);
+
+ render(, {container: shadowRoot2 as unknown as HTMLElement});
+
+ let target = screen.getByShadowRole('button');
+ expect(getPropagationTargets(target)).toEqual([window, shadowRoot2, shadowRoot]);
+ expect(getPropagationTargets(target, document)).toEqual([document, shadowRoot2, shadowRoot]);
+ expect(getPropagationTargets(target, intermediateNode)).toEqual([
+ intermediateNode,
+ shadowRoot2
+ ]);
+ cleanup2();
+ shadowRoot.removeChild(intermediateNode);
+ cleanup();
+ });
+
+ it('does not return propagation targets when given a null destination', function () {
+ const {shadowRoot, cleanup} = createShadowRoot();
+ render(, {container: shadowRoot as unknown as HTMLElement});
+
+ let target = screen.getByShadowRole('button');
+ expect(getPropagationTargets(target, null)).toEqual([]);
+ cleanup();
+ });
+});
diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts
index d250a6b8b1f..1b0e7732da9 100644
--- a/vitest.browser.config.ts
+++ b/vitest.browser.config.ts
@@ -191,6 +191,10 @@ declare module 'vitest/browser' {
) => Promise;
// Commit text that doesn't come from a key press (finalizes an active composition).
commitComposition: (text: string) => Promise;
+ // Placeholder until newer version of library
+ mouseDownOnElement: (selector: string, offsetX?: number, offsetY?: number) => Promise;
+ // Same as above
+ mouseUp: () => Promise;
}
}
@@ -305,6 +309,25 @@ export default defineConfig({
commitComposition: async ({page, context}: any, text) => {
const cdp = await getCDP(page, context);
await cdp.send('Input.insertText', {text});
+ },
+ // Once we upgrade to a newer version, we can use the below and delete mouseDownOnElement
+ // await userEvent.hover(button)
+ // await userEvent.pointer({ keys: '[MouseLeft>]', target: button })
+ // await userEvent.pointer('[/MouseLeft]')
+ mouseDownOnElement: async (
+ {page, iframe}: any,
+ selector: string,
+ offsetX: number = 5,
+ offsetY?: number
+ ) => {
+ const box = await iframe.locator(selector).boundingBox();
+ const x = box.x + offsetX;
+ const y = offsetY == null ? box.y + box.height / 2 : box.y + offsetY;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ },
+ mouseUp: async ({page}: any) => {
+ await page.mouse.up();
}
}
},