From 353ace832f9da04b76cfd8c710bc2d54057c1853 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 2 Mar 2026 09:24:32 +1100 Subject: [PATCH 1/2] fix: Safari FocusScope contain bug with native button --- packages/@react-aria/focus/src/FocusScope.tsx | 53 ++++++++++++--- .../s2/stories/Button.stories.tsx | 32 ++++++++++ .../s2/stories/buttonstyles.css | 64 +++++++++++++++++++ 3 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 packages/@react-spectrum/s2/stories/buttonstyles.css diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 0a0dc1310f4..d995abc7cc1 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -331,6 +331,7 @@ function isTabbableRadio(element: HTMLInputElement): boolean { function useFocusContainment(scopeRef: RefObject, contain?: boolean) { let focusedNode = useRef(undefined); + let lastPointerDownTarget = useRef(null); let raf = useRef>(undefined); useLayoutEffect(() => { @@ -395,6 +396,10 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo } }; + let onPointerDown: EventListener = (e) => { + lastPointerDownTarget.current = getEventTarget(e) as Element; + }; + let onBlur: EventListener = (e) => { // Firefox doesn't shift focus back to the Dialog properly without this if (raf.current) { @@ -407,27 +412,57 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo let modality = getInteractionModality(); let shouldSkipFocusRestore = (modality === 'virtual' || modality === null) && isAndroid() && isChrome(); - // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe let activeElement = getActiveElement(ownerDocument); - if (!shouldSkipFocusRestore && activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(activeElement, scopeRef)) { - activeScope = scopeRef; - let target = getEventTarget(e) as FocusableElement; - if (target && target.isConnected) { - focusedNode.current = target; - focusedNode.current?.focus(); - } else if (activeScope.current) { - focusFirstInScope(activeScope.current); + let shouldContain = !shouldSkipFocusRestore && shouldContainFocus(scopeRef) && (!activeElement || !isElementInChildScope(activeElement, scopeRef)); + if (!shouldContain) { + lastPointerDownTarget.current = null; + return; + } + + activeScope = scopeRef; + // Safari moves focus to body when clicking a focusable element (e.g. button); relatedTarget is null. + // Use the pointerdown target so we focus what the user actually clicked instead of the element that lost focus. + let pointerTarget = lastPointerDownTarget.current; + let safariFocusTarget: FocusableElement | null = null; + if (activeElement === ownerDocument.body && pointerTarget?.isConnected && isElementInChildScope(pointerTarget, scopeRef)) { + safariFocusTarget = isFocusable(pointerTarget as FocusableElement) ? (pointerTarget as FocusableElement) : null; + if (!safariFocusTarget && pointerTarget instanceof Element) { + let el: Element | null = pointerTarget; + while (el && isElementInChildScope(el, scopeRef)) { + if (isFocusable(el as FocusableElement)) { + safariFocusTarget = el as FocusableElement; + break; + } + el = el.parentElement; + } } } + lastPointerDownTarget.current = null; + + if (safariFocusTarget?.isConnected && isElementInChildScope(safariFocusTarget, scopeRef)) { + focusedNode.current = safariFocusTarget; + focusElement(safariFocusTarget); + return; + } + + let target = getEventTarget(e) as FocusableElement; + if (target && target.isConnected) { + focusedNode.current = target; + focusedNode.current?.focus(); + } else if (activeScope.current) { + focusFirstInScope(activeScope.current); + } }); }; ownerDocument.addEventListener('keydown', onKeyDown, false); + ownerDocument.addEventListener('pointerdown', onPointerDown, true); ownerDocument.addEventListener('focusin', onFocus, false); scope?.forEach(element => element.addEventListener('focusin', onFocus, false)); scope?.forEach(element => element.addEventListener('focusout', onBlur, false)); return () => { ownerDocument.removeEventListener('keydown', onKeyDown, false); + ownerDocument.removeEventListener('pointerdown', onPointerDown, true); ownerDocument.removeEventListener('focusin', onFocus, false); scope?.forEach(element => element.removeEventListener('focusin', onFocus, false)); scope?.forEach(element => element.removeEventListener('focusout', onBlur, false)); diff --git a/packages/@react-spectrum/s2/stories/Button.stories.tsx b/packages/@react-spectrum/s2/stories/Button.stories.tsx index a4108053a4f..a057bc9ad95 100644 --- a/packages/@react-spectrum/s2/stories/Button.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Button.stories.tsx @@ -17,6 +17,8 @@ import type {Meta, StoryObj} from '@storybook/react'; import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg'; import {style} from '../style/spectrum-theme' with { type: 'macro' }; import {useEffect, useRef, useState} from 'react'; +import { FocusScope } from '@react-aria/focus'; +import './buttonstyles.css'; const events = ['onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp']; @@ -109,3 +111,33 @@ function PendingButtonExample(props) { onPress={handlePress} /> ); } + +export function App() { + return ( +
+

Hello, My React Aria Problem

+

+ Click the input at the top of the scrollable content, then try clicking + the button at the bottom. +

+ + +
+ + + Scroll to input ... + + + +
+
+
+ ); +} diff --git a/packages/@react-spectrum/s2/stories/buttonstyles.css b/packages/@react-spectrum/s2/stories/buttonstyles.css new file mode 100644 index 00000000000..0c86f65d2b8 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/buttonstyles.css @@ -0,0 +1,64 @@ +.App { + font-family: sans-serif; + text-align: center; +} + +* { + box-sizing: border-box; +} + +.ScollContainer { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 200px; + overflow: auto; + padding: 24px; + border: 1px solid black; + background-color: #eee; +} + +.Button { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 64px; + flex-shrink: 0; + font-size: 24px; + font-weight: 700; + border: 1px solid royalblue; + background: royalblue; +} + +.StickyElement { + position: sticky; + top: 0; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 64px; + flex-shrink: 0; + font-size: 24px; + font-weight: 700; +} + +.Spacer { + display: flex; + flex-shrink: 0; + width: 100%; + height: 1000px; +} + +.Input { + display: block; + width: 100%; + height: 64px; + padding: 0 24px; + flex-shrink: 0; + font-size: 24px; + font-weight: 700; + border: 1px solid royalblue; +} From ce67f6470e4e43c8c6c270ac4e4bd2e8a4b5da63 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Mon, 2 Mar 2026 09:28:20 +1100 Subject: [PATCH 2/2] fix lint --- packages/@react-spectrum/s2/stories/Button.stories.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/Button.stories.tsx b/packages/@react-spectrum/s2/stories/Button.stories.tsx index a057bc9ad95..89cfb3c9fe1 100644 --- a/packages/@react-spectrum/s2/stories/Button.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Button.stories.tsx @@ -13,11 +13,11 @@ import {action} from 'storybook/actions'; import {Button, Text} from '../src'; import {categorizeArgTypes, getActionArgs, StaticColorDecorator} from './utils'; +import {FocusScope} from '@react-aria/focus'; import type {Meta, StoryObj} from '@storybook/react'; import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg'; import {style} from '../style/spectrum-theme' with { type: 'macro' }; import {useEffect, useRef, useState} from 'react'; -import { FocusScope } from '@react-aria/focus'; import './buttonstyles.css'; const events = ['onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp']; @@ -131,9 +131,8 @@ export function App() {