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..89cfb3c9fe1 100644 --- a/packages/@react-spectrum/s2/stories/Button.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Button.stories.tsx @@ -13,10 +13,12 @@ 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 './buttonstyles.css'; const events = ['onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp']; @@ -109,3 +111,32 @@ 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; +}