From a9d000cf45d223e4c33c6e05ceaa13249200a65d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 22 Jul 2026 13:47:42 -0700 Subject: [PATCH 1/4] chore: add SideNav and TokenField illustrations (#10352) --- .../component-illustrations/dark/SideNav.avif | Bin 0 -> 6082 bytes .../component-illustrations/dark/TokenField.avif | Bin 0 -> 5961 bytes .../component-illustrations/light/SideNav.avif | Bin 0 -> 5173 bytes .../light/TokenField.avif | Bin 0 -> 5144 bytes packages/dev/s2-docs/src/ComponentCard.tsx | 9 +++++++++ 5 files changed, 9 insertions(+) create mode 100644 packages/dev/s2-docs/assets/component-illustrations/dark/SideNav.avif create mode 100644 packages/dev/s2-docs/assets/component-illustrations/dark/TokenField.avif create mode 100644 packages/dev/s2-docs/assets/component-illustrations/light/SideNav.avif create mode 100644 packages/dev/s2-docs/assets/component-illustrations/light/TokenField.avif 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 0000000000000000000000000000000000000000..2f749cf8c44620a62c207678c409be71406ffafa GIT binary patch literal 6082 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..63d7b3c52fd2febaf81445295d4c110807e03ef2 GIT binary patch literal 5961 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 LRH2>yF%16$Ka#CY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..730fd19a7eb7c5aad3252fe59d30380b3cf0a972 GIT binary patch literal 5144 zcmYLF2Q-{p7afc?MDG$Uh%yY(qD60`B%%vOkHL(=M6_tpMkgWBd+(x0PxMa2h#-hW z?=9-jd+V+L+_lcxXYX^*x4#Pj0PfnOy--#jaC^W_+*SnKUKjzly6JI(5q4;+TmB~4 z9c-Q5{)GSl3T}h`5C2!mP;fNjp9be9i^82wOsxe1&iz^!r{Z2uyhzW*4i zr#I{)I2`$pjeq88ZyLLQFWwpx6286ofAU88kNgZrxuI^vbU4b!<)%{GxH!8ZZJa%C zw%-8|1l&-hRvyAifxx>r5I_TWN7>y1006H80KhfA0XOD7;kOJ9(M@_}_0m)6wtMM@ zltQ5FUi_03rEK6fC@FU@ceEYCUFv2A?hLnb^OCZ1MmhZRO#gm`^B>a`|82x0BD&ql z0!JbK8M?u^030ArC>|jI7Z`*G47p*L;BI#R{`!rVo6XL*Gq;aJ<%zJhLQ?@igpAxi ze_Th(Aax1?i#gxK@vD%tFK#>gQ*;*1vmP(+81bsO+f5wv0U4lf9uvk?Hu?b zYb18wKT==IGp044n>Ez$l<~eXetsmPm~4hH34P%T@e0SJ(?4e4m-|M!Da5qIy@Kxhtc1%FjjZJznKyyR?f&ZJGqo9kV~Ds{yYX z^~mw(FVu&ZlRZ@d<7j6CP0-z(Vtln!tb(rr;*LgV%PXclz$t3+LYlrttthQJ2iX#a zdS*-XU_`JTF+X(TZy>)QU2bThbD6guI8xudg4nItak)% z;7FD0ktmRH;r_7YUIwu@0Q~g1SDzrGM;bOjC4UBZcy?X=%SLAC_l2O*x`$Mm)^+(< znXrU%Tj6SdURIBUZms#m9noThW8oz!S6FiYkvXecUeO21gRNX_9DBCgQUzO6xm4yg zYAnEcQsASaKU`kBu3@cmrsuft`(Q)!yg)UJ1TocI(KTf>sd)G~Z@asK^n0NaJhbGd zrb8S5$hw#7kDi0T@f>EFyzhf8GOW;ZYPX3*&a~Qj&9($+e|IL0E(@*^?Z{R|DlK`w zER=$j+zl>Dw2FayWjJaa{zA(|?WZ10SNIPehBF9Pv)5jL^=;o6HuBbkC_WKf65*{9C7di!R!vMC{B`1=KGp-4UOHMf(D!>dX(F;kEwq+JyX+;g;5P-0?{nMjv|-F zl%FG3G8^301wj2_ZfLqMwFe)&Nyb0}V&x3c-f03I;}$?nmYB@&H*qFy?nnvyd%cPE z2|sy=Ojlia5v?R(iP$YWgT{wpB%l|M3c);LAZ{##AI79}A~F_ELQOyX*G4X3iz{C& z#WsKuzh766^rhu|zqvNmz6kISeHYQmT z+ZDG32zKoFnf-vag3WzX#?`N85f9aDSmo3#8=q4E-WHzDoamGxAb9U`z9>uE)IW?^ zvLvZ5ah9f@@ZvX}de=l#Y~hC9lH-*+756uKR4H1veTtIt!mtSu{B8Ga4kBxqAN~cF zOr&{7B>J6Y^w|qJ3a80>`9JwYj`V?qBH6IS-Q*-!Dt+PRp~V3#TUeH|GN@qIm$v)i-YOrfFx~kLX9zAXue9d*jf%$b&*j}-IO|Tb)rwf zxYv;iYaJDw*E8}gdef+6(b`MoXAS(>+s_&MCDjVa6qsRJd;>rWERAvfdAPfza??Gr zU2k&Wp@m}3fdev?qf%a$upN-r71yzv!bS{l60flx`3?W5`Qfv%1oIoJb4t29}sQSwFhai6{^h?nbB6A z=n6xm$dWjFdnBIRH>(3lwndWXwIq!!Zx6~famM@}hlM(B+=YdAnGh6(N-c8T@g^f! zAVAxG{0hEiiPi1ry^MWwIM0=HSbq9iN9mn(@H3MH&T-F85G)*Acf}?68s>NC;Fl{N zP$9efCrLN~c1XKe*V%5r#+oP?`VerE)@F$BABFw|o9E|@2SWWwxLZ37N6n5<5Lq60w@Ym%}BA||}oAS&~_Eq7!n#{e$c z<{+G;ofIwno~`SM!mKTSpX{MnV*QN;7+-f`Ub_i0(vHxMg!potRz(@_9HFFloST+( zPcz$Q>_J!ZS$rb8Oa8N{qn;+NE%)++PkAkZ(`}1?FHhoJ?h=KO+QDeyb@9 zA&>myT4r{zUpqED3crMmDus`akX&XelfUxEnND3bFe0}$IVgIMu$3``))ws%shly3 zzUp;4KV9|d%rI{Z`G)>PNJ zqn1pBY2qR*%`py8UjOyo^mRi;EA_UrhKTjH2u8vf zX@c|6)CaUAi0+1%jIkIN`yE8d$VrwxK3iWfUqc*3MU^tP>0PMC%nRp{{8~N+2^z

TzbD3uUjCAWy|35LZE5u*%NX*W9prmtGE{HIhnPUk^)W2J)ifOz2v!Ps|k#%;qSbqY&VD|9RHxYm3g7yoir9o>p51uIBHM1P< zLIZV)PC@uyEiKM_E^B2r+bNRg|%Q~c@tG*8eIH%V(U-~f!7w=Ys#wXPB#$!39 zUdeCGuCHonN=baAdR0&=i;pea7jik|-P~OlmB3M};YyfPQ~l;~s=Jp*bf|py<(xr2o%;&`3{s>Zq~D`IQGppXeW|Y5XW7EI^89u9M@*ztPgOn9 zT!`R+V(WHjO=b!_!3LuHUEdZ-EP61kXJC?FeEO5x@rheYB2jGw3_5-Aq0!90jIFv` zz>p{;on;^|CrWijrVNM2P7Q=VWF6+&{+t4yLOAHI8KI(UloXCFB^7p-#lbHG{?>c(amANeC?7s%@7($DHpGh;b!%duG-3as2|aYbQnMZ(6SKjSXE5uCZ!_nGgjx zBLnFFT+pgP+_X6NoUA={{`g==Th=8XE)WIMWz__Fe4GkU)H!>4h`~?v8m@!Al51%8 z6M9mhV{_#_k@h%y-KTja?eW&L2kg;EY|rksl)Qr|mHu^iAg&QeihH35*)tmqTGJo@ zDXx4POG;b|Ma16~&ifgT`jWj#|7wK4;yr%PW3wKIjycB*s%zy~;O#*7Kkz_Q@ zC6$K5rSA~kLul;7-qc5F_|lCb zrX1(cd#zp0u4-U_4_$u zzG~Jf?Pp@I>V4H{Turt0{7>EzR~m>_ZA+8l2q+{+d1sq@hK9|14f_P;*w_~apqf5T znVO$uxb(|Mb*bF9G&fZeGO7q#AKEKyd7J(Xx0AvG+~C0p;YbynIjz_^TD#bZP3JVy zjsuS@P2$KFW2+1AGgV>QQ|^AMB{a6Zv(6Zvu1aaDTF?d$E?a_x&P{ zrUfjQjeLfY*I?x!?ch7di;Ag|RRS!CZAXlyCOP*f18weeCjBq$74zYwt90E!F4HO?*T(LtE_kbc(%#xW9xYhPZH>m zQOH2ZQ`asg5lRxT22v3`#qAzjzpyR%P1j2oOA&ERKgF^kOVhrz z0(|<(uw0T3b8IT-iu9(1s_V zjf`Dv&CaRW%l#o~td0fEGRvDzqYkULmkO@MFLf)4V@DfzZ&rD-HB1suu?N?m(g%3{k&qch-Kvx%Sq!hbORTtRbEclos>U( z!gxUFowB`KP0d&PN`C~xo!pBm>8D@%{3QdOAu>IE6|Od!aw*bcM%Qyy+fRS$UGveH z_9BoT4!|9p1Ia(^zC#shG7|>nHvXwU80v{0xnDJ!NE;+R+5ES~pB6d%n=`n3tL(zW zcn%W#>8tp?+Mr>znDmlp^Cf@RQ)7?6`*-hS1GHjeNDG>2u&E!ieB*=P5IGtaOs{;zVuo&XMx4#7_hl`y$L2y=Ch-y% zRXp`T-W`x9?cQ;#kb0b5%ZRLW3q~h5lHU(jY%yuB5AROYlLl+=*AgjcAzV=M<(qwF8-XqHA(#| mjwg-)yeIs9LhDODhsPJM_?y1VX(20QX)jZ@-^jG|M*R=q)n29m literal 0 HcmV?d00001 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], From a54c33a8e43adf79f26cfa609e3f0b5532214e13 Mon Sep 17 00:00:00 2001 From: pzaczkiewicz-athenahealth <139897226+pzaczkiewicz-athenahealth@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:02:59 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20shadow=20DOM=20scroll=20events=20?= =?UTF-8?q?=E2=80=94=20addGlobalScrollListener=20utility=20+=20close-on-sc?= =?UTF-8?q?roll=20fix=20(#10188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Virtualizer, useCloseOnScroll are able to work in the shadow DOM (#10093) * reduce scope of testing, add tests for helper, fix storybook behaviours * fix ts * getPropagationTargets `to` can be a non-global node * feat: lint rules for non-composing events on addEvent/addEventListener Add two rsp-rules ESLint rules to prevent shadow DOM regressions with non-composing events (scroll, scrollend, change, submit, reset, select, selectstart, slotchange), which do not cross shadow DOM boundaries: - add-event-non-composing: flags addEvent(target, 'scroll', ...) unless the target is getPropagationTargets(...). - no-non-composing-event-listener: flags x.addEventListener('scroll', ...) for non-composing events, exempting non-DOM-tree receivers (visualViewport, MediaQueryList). Both rules are enabled globally and disabled for non-shipping code (tests, stories, docs, chromatic, dev). Existing legitimate element-scoped listeners in useFormValidation, useFormReset, and Chat carry inline exemptions with explanations. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix imports not ultimately used * rename test --------- Co-authored-by: Rob Snow Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Daniel Lu --- .oxlintrc.json | 8 +- .storybook/custom-addons/shadowDOM/index.js | 14 ++ .storybook/custom-addons/shadowDOM/manager.js | 40 +++++ .storybook/main.mjs | 3 +- .storybook/preview.js | 2 + .../src/menu/useCloseOnScroll.ts | 12 +- packages/@react-spectrum/ai/src/Chat.tsx | 4 + packages/@react-types/shared/src/events.d.ts | 14 ++ packages/dev/eslint-plugin-rsp-rules/index.js | 4 + .../rules/add-event-non-composing.js | 84 +++++++++++ .../rules/no-non-composing-event-listener.js | 90 +++++++++++ .../test/add-event-non-composing.test-lint.js | 43 ++++++ ...-non-composing-event-listener.test-lint.js | 43 ++++++ .../stories/Tree.stories.tsx | 91 +++++++++++- .../test/ComboBox.browser.test.tsx | 122 +++++++++++++++ .../test/Tree.browser.test.tsx | 86 +++++++++++ .../exports/private/utils/domHelpers.ts | 7 +- .../private/utils/shadowdom/DOMFunctions.ts | 1 + .../react-aria/src/form/useFormValidation.ts | 5 + .../src/overlays/useCloseOnScroll.ts | 8 +- packages/react-aria/src/utils/domHelpers.ts | 28 ++++ .../src/utils/shadowdom/DOMFunctions.ts | 44 ++++++ packages/react-aria/src/utils/useFormReset.ts | 4 + .../react-aria/src/virtualizer/ScrollView.tsx | 14 +- .../test/utils/DOMFunctions.test.js | 76 ---------- .../test/utils/DOMFunctions.test.tsx | 140 ++++++++++++++++++ 26 files changed, 891 insertions(+), 96 deletions(-) create mode 100644 .storybook/custom-addons/shadowDOM/index.js create mode 100644 .storybook/custom-addons/shadowDOM/manager.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/rules/add-event-non-composing.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/rules/no-non-composing-event-listener.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/test/add-event-non-composing.test-lint.js create mode 100644 packages/dev/eslint-plugin-rsp-rules/test/no-non-composing-event-listener.test-lint.js create mode 100644 packages/react-aria-components/test/ComboBox.browser.test.tsx create mode 100644 packages/react-aria-components/test/Tree.browser.test.tsx delete mode 100644 packages/react-aria/test/utils/DOMFunctions.test.js create mode 100644 packages/react-aria/test/utils/DOMFunctions.test.tsx 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/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/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/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(); + }); +}); From 5de2d02e46b94c3d01df4440cd336f8480cb469e Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:28:57 -0700 Subject: [PATCH 3/4] docs: fix typo in Card docs (#10354) --- packages/dev/s2-docs/pages/s2/Card.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 - + From 7e58986088e2b968ca315abc60159017ee461f4f Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 22 Jul 2026 17:23:17 -0700 Subject: [PATCH 4/4] fix: restore focus in FocusScope containment without scrolling the dialog into view (#10339) * fix: restore focus without scrolling in FocusScope containment * fix: route containment restore through focusElement for owner-document scroll safety * create a test for the goal, not the implementation --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Rob Snow --- .../test/Modal.browser.test.tsx | 110 ++++++++++++++++++ packages/react-aria/src/focus/FocusScope.tsx | 4 +- vitest.browser.config.ts | 23 ++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 packages/react-aria-components/test/Modal.browser.test.tsx 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/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/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(); } } },