Skip to content

Commit f1a021e

Browse files
authored
fix: Improve behavior of modals on iOS 26 (#8888)
* Improve usePreventScroll focus behavior on iOS * Make visual viewport size update before keyboard animation * Update modals and trays to extend underlay behind browser UI * Fix S2 docs header appearing above modals * fix removing event * Clamp scroll position to prevent over-scrolling * Prevent scrolling the whole page when programmatically focusing * Avoid setting size on blur too early * Example app fix * Ensure temporary input has attributes that might affect keyboard size * typo * Fix scroll into view calculation when the address bar is collapsed * Try removing the hidden input entirely
1 parent 60e8535 commit f1a021e

File tree

22 files changed

+458
-348
lines changed

22 files changed

+458
-348
lines changed

packages/@adobe/spectrum-css-temp/components/tray/index.css

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,13 @@
2020

2121
.spectrum-Tray-wrapper {
2222
inset-inline-start: 0;
23-
/* Positioned at the top of the window */
24-
position: fixed;
23+
position: absolute;
2524
top: 0;
2625

2726
display: flex;
2827
justify-content: center;
2928
width: 100%;
30-
height: 100vh;
29+
height: 100dvh;
3130

3231
/* Don't catch clicks */
3332
pointer-events: none;
@@ -55,10 +54,12 @@
5554
max-height: calc(var(--spectrum-visual-viewport-height) - var(--spectrum-tray-margin-top));
5655
/* Add padding at the bottom to account for the rest of the viewport height behind the keyboard.
5756
* This is necessary so that there isn't a visible gap that appears while the keyboard is animating
58-
* in and out. Fall back to the safe area inset to account for things like iOS home indicator. */
59-
padding-bottom: max(calc(100vh - var(--spectrum-visual-viewport-height)), env(safe-area-inset-bottom));
57+
* in and out. Fall back to the safe area inset to account for things like iOS home indicator.
58+
We also add an additional 100vh of padding (offset by the bottom position below) so the tray
59+
extends behind Safari's address bar and keyboard in iOS 26. */
60+
padding-bottom: calc(max(calc(100dvh - var(--spectrum-visual-viewport-height)), env(safe-area-inset-bottom)) + 100vh);
6061
position: absolute;
61-
bottom: 0;
62+
bottom: -100vh;
6263
outline: none;
6364
display: flex;
6465
flex-direction: column;

packages/@adobe/spectrum-css-temp/components/underlay/index.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ governing permissions and limitations under the License.
2323
.spectrum-Underlay {
2424
composes: spectrum-overlay;
2525

26-
position: fixed;
26+
/* Use position: absolute instead of fixed to avoid being clipped to the "inner" viewport in iOS 26 */
27+
position: absolute;
2728
top: 0;
2829
right: 0;
2930
bottom: 0;

packages/@react-aria/dialog/docs/useDialog.mdx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ The `Modal` and `ModalTrigger` components render the dialog within a typical mod
131131
```tsx example export=true render=false
132132
import {useOverlayTriggerState} from '@react-stately/overlays';
133133
import {Overlay, useModalOverlay, useOverlayTrigger} from '@react-aria/overlays';
134+
import {useViewportSize} from '@react-aria/utils';
134135

135136
function Modal({state, children, ...props}) {
136137
let ref = React.useRef(null);
@@ -140,18 +141,27 @@ function Modal({state, children, ...props}) {
140141
<Overlay>
141142
<div
142143
style={{
143-
position: 'fixed',
144+
position: 'absolute',
144145
zIndex: 100,
145146
top: 0,
146147
left: 0,
147-
bottom: 0,
148-
right: 0,
149-
background: 'rgba(0, 0, 0, 0.5)',
148+
width: '100%',
149+
height: document.body.clientHeight,
150+
background: 'rgba(0, 0, 0, 0.5)'
151+
}}
152+
{...underlayProps} />
153+
<div
154+
style={{
155+
position: 'fixed',
156+
top: 0,
157+
left: 0,
158+
width: '100%',
159+
height: useViewportSize().height + 'px',
160+
zIndex: 101,
150161
display: 'flex',
151162
alignItems: 'center',
152163
justifyContent: 'center'
153-
}}
154-
{...underlayProps}>
164+
}}>
155165
<div
156166
{...modalProps}
157167
ref={ref}

packages/@react-aria/overlays/docs/useModalOverlay.mdx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ The `Modal` component uses an &lt;<TypeLink links={docs.links} type={docs.export
7575

7676
```tsx example export=true render=false
7777
import {Overlay, useModalOverlay} from '@react-aria/overlays';
78+
import {useViewportSize} from '@react-aria/utils';
7879

7980
function Modal({state, children, ...props}) {
8081
let ref = React.useRef(null);
@@ -84,18 +85,27 @@ function Modal({state, children, ...props}) {
8485
<Overlay>
8586
<div
8687
style={{
87-
position: 'fixed',
88+
position: 'absolute',
8889
zIndex: 100,
8990
top: 0,
9091
left: 0,
91-
bottom: 0,
92-
right: 0,
93-
background: 'rgba(0, 0, 0, 0.5)',
92+
width: '100%',
93+
height: document.body.clientHeight,
94+
background: 'rgba(0, 0, 0, 0.5)'
95+
}}
96+
{...underlayProps} />
97+
<div
98+
style={{
99+
position: 'fixed',
100+
top: 0,
101+
left: 0,
102+
width: '100%',
103+
height: useViewportSize().height + 'px',
104+
zIndex: 101,
94105
display: 'flex',
95106
alignItems: 'center',
96107
justifyContent: 'center'
97-
}}
98-
{...underlayProps}>
108+
}}>
99109
<div
100110
{...modalProps}
101111
ref={ref}

packages/@react-aria/overlays/src/usePreventScroll.ts

Lines changed: 70 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {chain, getScrollParent, isIOS, useLayoutEffect} from '@react-aria/utils';
13+
import {chain, getScrollParent, isIOS, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils';
1414

1515
interface PreventScrollOptions {
1616
/** Whether the scroll lock is disabled. */
@@ -19,19 +19,6 @@ interface PreventScrollOptions {
1919

2020
const visualViewport = typeof document !== 'undefined' && window.visualViewport;
2121

22-
// HTML input types that do not cause the software keyboard to appear.
23-
const nonTextInputTypes = new Set([
24-
'checkbox',
25-
'radio',
26-
'range',
27-
'color',
28-
'file',
29-
'image',
30-
'button',
31-
'submit',
32-
'reset'
33-
]);
34-
3522
// The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position
3623
let preventScrollCount = 0;
3724
let restore;
@@ -100,32 +87,32 @@ function preventScrollStandard() {
10087
// the top or bottom. Work around a bug where this does not work when the element does not actually overflow
10188
// by preventing default in a `touchmove` event.
10289
// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.
103-
// 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top
104-
// of the page, which prevents it from scrolling the page. After the input is focused, scroll the element
105-
// into view ourselves, without scrolling the whole page.
106-
// 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the
107-
// same visually, but makes the actual scroll position always zero. This is required to make all of the
108-
// above work or Safari will still try to scroll the page when focusing an input.
109-
// 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting
110-
// to navigate to an input with the next/previous buttons that's outside a modal.
90+
// 4. When focus moves to an input, create an off screen input and focus that temporarily. This prevents
91+
// Safari from scrolling the page. After a small delay, focus the real input and scroll it into view
92+
// ourselves, without scrolling the whole page.
11193
function preventScrollMobileSafari() {
11294
let scrollable: Element;
113-
let restoreScrollableStyles;
11495
let onTouchStart = (e: TouchEvent) => {
11596
// Store the nearest scrollable parent element from the element that the user touched.
11697
scrollable = getScrollParent(e.target as Element, true);
11798
if (scrollable === document.documentElement && scrollable === document.body) {
11899
return;
119100
}
120-
121-
// Prevent scrolling up when at the top and scrolling down when at the bottom
122-
// of a nested scrollable area, otherwise mobile Safari will start scrolling
123-
// the window instead.
124-
if (scrollable instanceof HTMLElement && window.getComputedStyle(scrollable).overscrollBehavior === 'auto') {
125-
restoreScrollableStyles = setStyle(scrollable, 'overscrollBehavior', 'contain');
126-
}
127101
};
128102

103+
// Prevent scrolling up when at the top and scrolling down when at the bottom
104+
// of a nested scrollable area, otherwise mobile Safari will start scrolling
105+
// the window instead.
106+
// This must be applied before the touchstart event as of iOS 26, so inject it as a <style> element.
107+
let style = document.createElement('style');
108+
style.textContent = `
109+
@layer {
110+
* {
111+
overscroll-behavior: contain;
112+
}
113+
}`.trim();
114+
document.head.prepend(style);
115+
129116
let onTouchMove = (e: TouchEvent) => {
130117
// Prevent scrolling the window.
131118
if (!scrollable || scrollable === document.documentElement || scrollable === document.body) {
@@ -144,86 +131,48 @@ function preventScrollMobileSafari() {
144131
}
145132
};
146133

147-
let onTouchEnd = () => {
148-
if (restoreScrollableStyles) {
149-
restoreScrollableStyles();
150-
}
151-
};
152-
153-
let onFocus = (e: FocusEvent) => {
134+
let onBlur = (e: FocusEvent) => {
154135
let target = e.target as HTMLElement;
155-
if (willOpenKeyboard(target)) {
156-
setupStyles();
157-
158-
// Apply a transform to trick Safari into thinking the input is at the top of the page
159-
// so it doesn't try to scroll it into view.
160-
target.style.transform = 'translateY(-2000px)';
161-
requestAnimationFrame(() => {
162-
target.style.transform = '';
163-
164-
// This will have prevented the browser from scrolling the focused element into view,
165-
// so we need to do this ourselves in a way that doesn't cause the whole page to scroll.
166-
if (visualViewport) {
167-
if (visualViewport.height < window.innerHeight) {
168-
// If the keyboard is already visible, do this after one additional frame
169-
// to wait for the transform to be removed.
170-
requestAnimationFrame(() => {
171-
scrollIntoView(target);
172-
});
173-
} else {
174-
// Otherwise, wait for the visual viewport to resize before scrolling so we can
175-
// measure the correct position to scroll to.
176-
visualViewport.addEventListener('resize', () => scrollIntoView(target), {once: true});
177-
}
178-
}
179-
});
136+
let relatedTarget = e.relatedTarget as HTMLElement | null;
137+
if (relatedTarget && willOpenKeyboard(relatedTarget)) {
138+
// Focus without scrolling the whole page, and then scroll into view manually.
139+
relatedTarget.focus({preventScroll: true});
140+
scrollIntoViewWhenReady(relatedTarget, willOpenKeyboard(target));
141+
} else if (!relatedTarget) {
142+
// When tapping the Done button on the keyboard, focus moves to the body.
143+
// FocusScope will then restore focus back to the input. Later when tapping
144+
// the same input again, it is already focused, so no blur event will fire,
145+
// resulting in the flow above never running and Safari's native scrolling occurring.
146+
// Instead, move focus to the parent focusable element (e.g. the dialog).
147+
let focusable = target.parentElement?.closest('[tabindex]') as HTMLElement | null;
148+
focusable?.focus({preventScroll: true});
180149
}
181150
};
182151

183-
let restoreStyles: null | (() => void) = null;
184-
let setupStyles = () => {
185-
if (restoreStyles) {
186-
return;
187-
}
188-
189-
let onWindowScroll = () => {
190-
// Last resort. If the window scrolled, scroll it back to the top.
191-
// It should always be at the top because the body will have a negative margin (see below).
192-
window.scrollTo(0, 0);
193-
};
194-
195-
// Record the original scroll position so we can restore it.
196-
// Then apply a negative margin to the body to offset it by the scroll position. This will
197-
// enable us to scroll the window to the top, which is required for the rest of this to work.
198-
let scrollX = window.pageXOffset;
199-
let scrollY = window.pageYOffset;
152+
// Override programmatic focus to scroll into view without scrolling the whole page.
153+
let focus = HTMLElement.prototype.focus;
154+
HTMLElement.prototype.focus = function (opts) {
155+
// Track whether the keyboard was already visible before.
156+
let wasKeyboardVisible = document.activeElement != null && willOpenKeyboard(document.activeElement);
200157

201-
restoreStyles = chain(
202-
addEvent(window, 'scroll', onWindowScroll),
203-
setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`),
204-
setStyle(document.documentElement, 'overflow', 'hidden'),
205-
setStyle(document.body, 'marginTop', `-${scrollY}px`),
206-
() => {
207-
window.scrollTo(scrollX, scrollY);
208-
}
209-
);
158+
// Focus the element without scrolling the page.
159+
focus.call(this, {...opts, preventScroll: true});
210160

211-
// Scroll to the top. The negative margin on the body will make this appear the same.
212-
window.scrollTo(0, 0);
161+
if (!opts || !opts.preventScroll) {
162+
scrollIntoViewWhenReady(this, wasKeyboardVisible);
163+
}
213164
};
214165

215166
let removeEvents = chain(
216167
addEvent(document, 'touchstart', onTouchStart, {passive: false, capture: true}),
217168
addEvent(document, 'touchmove', onTouchMove, {passive: false, capture: true}),
218-
addEvent(document, 'touchend', onTouchEnd, {passive: false, capture: true}),
219-
addEvent(document, 'focus', onFocus, true)
169+
addEvent(document, 'blur', onBlur, true)
220170
);
221171

222172
return () => {
223-
// Restore styles and scroll the page back to where it was.
224-
restoreScrollableStyles?.();
225-
restoreStyles?.();
226173
removeEvents();
174+
style.remove();
175+
HTMLElement.prototype.focus = focus;
227176
};
228177
}
229178

@@ -253,28 +202,42 @@ function addEvent<K extends keyof GlobalEventHandlersEventMap>(
253202
};
254203
}
255204

205+
function scrollIntoViewWhenReady(target: Element, wasKeyboardVisible: boolean) {
206+
if (wasKeyboardVisible || !visualViewport) {
207+
// If the keyboard was already visible, scroll the target into view immediately.
208+
scrollIntoView(target);
209+
} else {
210+
// Otherwise, wait for the visual viewport to resize before scrolling so we can
211+
// measure the correct position to scroll to.
212+
visualViewport.addEventListener('resize', () => scrollIntoView(target), {once: true});
213+
}
214+
}
215+
256216
function scrollIntoView(target: Element) {
257217
let root = document.scrollingElement || document.documentElement;
258218
let nextTarget: Element | null = target;
259219
while (nextTarget && nextTarget !== root) {
260220
// Find the parent scrollable element and adjust the scroll position if the target is not already in view.
261221
let scrollable = getScrollParent(nextTarget);
262222
if (scrollable !== document.documentElement && scrollable !== document.body && scrollable !== nextTarget) {
263-
let scrollableTop = scrollable.getBoundingClientRect().top;
264-
let targetTop = nextTarget.getBoundingClientRect().top;
265-
if (targetTop > scrollableTop + nextTarget.clientHeight) {
266-
scrollable.scrollTop += targetTop - scrollableTop;
223+
let scrollableRect = scrollable.getBoundingClientRect();
224+
let targetRect = nextTarget.getBoundingClientRect();
225+
if (targetRect.top < scrollableRect.top || targetRect.bottom > scrollableRect.top + nextTarget.clientHeight) {
226+
let bottom = scrollableRect.bottom;
227+
if (visualViewport) {
228+
bottom = Math.min(bottom, visualViewport.offsetTop + visualViewport.height);
229+
}
230+
231+
// Center within the viewport.
232+
let adjustment = (targetRect.top - scrollableRect.top) - ((bottom - scrollableRect.top) / 2 - targetRect.height / 2);
233+
scrollable.scrollTo({
234+
// Clamp to the valid range to prevent over-scrolling.
235+
top: Math.max(0, Math.min(scrollable.scrollHeight - scrollable.clientHeight, scrollable.scrollTop + adjustment)),
236+
behavior: 'smooth'
237+
});
267238
}
268239
}
269240

270241
nextTarget = scrollable.parentElement;
271242
}
272243
}
273-
274-
function willOpenKeyboard(target: Element) {
275-
return (
276-
(target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type)) ||
277-
target instanceof HTMLTextAreaElement ||
278-
(target instanceof HTMLElement && target.isContentEditable)
279-
);
280-
}

packages/@react-aria/utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export {useLoadMore} from './useLoadMore';
4848
export {useLoadMoreSentinel, useLoadMoreSentinel as UNSTABLE_useLoadMoreSentinel} from './useLoadMoreSentinel';
4949
export {inertValue} from './inertValue';
5050
export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants';
51-
export {isCtrlKeyPressed} from './keyboard';
51+
export {isCtrlKeyPressed, willOpenKeyboard} from './keyboard';
5252
export {useEnterAnimation, useExitAnimation} from './animation';
5353
export {isFocusable, isTabbable} from './isFocusable';
5454

0 commit comments

Comments
 (0)