Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions packages/@react-aria/focus/src/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ function isTabbableRadio(element: HTMLInputElement): boolean {

function useFocusContainment(scopeRef: RefObject<Element[] | null>, contain?: boolean) {
let focusedNode = useRef<FocusableElement>(undefined);
let lastPointerDownTarget = useRef<Element | null>(null);

let raf = useRef<ReturnType<typeof requestAnimationFrame>>(undefined);
useLayoutEffect(() => {
Expand Down Expand Up @@ -395,6 +396,10 @@ function useFocusContainment(scopeRef: RefObject<Element[] | null>, 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) {
Expand All @@ -407,27 +412,57 @@ function useFocusContainment(scopeRef: RefObject<Element[] | null>, 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));
Expand Down
31 changes: 31 additions & 0 deletions packages/@react-spectrum/s2/stories/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down Expand Up @@ -109,3 +111,32 @@ function PendingButtonExample(props) {
onPress={handlePress} />
);
}

export function App() {
return (
<div className="App">
<h1>Hello, My React Aria Problem</h1>
<h2>
Click the input at the top of the scrollable content, then try clicking
the button at the bottom.
</h2>

<FocusScope restoreFocus autoFocus contain>
<div className="ScollContainer">
<input className="Input" placeholder="INPUT" />

<span className="StickyElement">Scroll to input ...</span>
<span className="Spacer" />

<button
className="Button"
onClick={() => {
console.log('WIN!');
}}>
BUTTON
</button>
</div>
</FocusScope>
</div>
);
}
64 changes: 64 additions & 0 deletions packages/@react-spectrum/s2/stories/buttonstyles.css
Original file line number Diff line number Diff line change
@@ -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;
}