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 ( + + + + + + Modal in a scrollable overlay + {Array.from({length: 10}, (_, i) => ( +
+ {i + 1} +
+ ))} +
+
+
+
+ ); +} + +// 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(); } } },