diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index ecde2ec2385..811d4c36581 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -11,7 +11,6 @@ */ import {AriaModalOverlayProps, useModalOverlay} from 'react-aria/useModalOverlay'; - import { ClassNameOrFunction, ContextValue, @@ -25,6 +24,7 @@ import { import {DismissButton, Overlay} from 'react-aria/Overlay'; import {DOMAttributes, forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {getActiveElement} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {isScrollable} from 'react-aria/private/utils/isScrollable'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; @@ -34,11 +34,22 @@ import { useOverlayTriggerState } from 'react-stately/useOverlayTriggerState'; import {OverlayTriggerStateContext} from './Dialog'; -import React, {createContext, ForwardedRef, forwardRef, useContext, useMemo, useRef} from 'react'; +import React, { + createContext, + ForwardedRef, + forwardRef, + useContext, + useMemo, + useRef, + useState +} from 'react'; import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation'; import {useIsSSR} from 'react-aria/SSRProvider'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useViewportSize} from 'react-aria/private/utils/useViewportSize'; +import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; +import {willOpenKeyboard} from 'react-aria/private/utils/keyboard'; export interface ModalOverlayProps extends @@ -300,11 +311,12 @@ interface ModalContentProps function ModalContent(props: ModalContentProps) { let {modalProps, modalRef, isExiting, isDismissable} = useContext(InternalModalContext)!; + let [isOpen, setOpen] = useState(false); let state = useContext(OverlayTriggerStateContext)!; let mergedRefs = useMemo(() => mergeRefs(props.modalRef, modalRef), [props.modalRef, modalRef]); let ref = useObjectRef(mergedRefs); - let entering = useEnterAnimation(ref); + let entering = useEnterAnimation(ref, isOpen); let renderProps = useRenderProps({ ...props, defaultClassName: 'react-aria-Modal', @@ -315,15 +327,40 @@ function ModalContent(props: ModalContentProps) { } }); + // Hide the modal initially, since an auto-focused input may cause a viewport resize in the next frame. + // If so, delay the reveal by another frame to avoid layout shift when the viewport settles. + useLayoutEffect(() => { + let frame: number, frame2: number; + + frame = requestAnimationFrame(() => { + let activeElement = getActiveElement(); + if (activeElement && willOpenKeyboard(activeElement)) { + frame2 = requestAnimationFrame(() => setOpen(true)); + } else { + setOpen(true); + } + }); + + return () => { + cancelAnimationFrame(frame); + cancelAnimationFrame(frame2); + }; + }, []); + + let {visuallyHiddenProps} = useVisuallyHidden(); + let contentStyle = isOpen ? {display: 'contents'} : visuallyHiddenProps.style; + return ( - - {isDismissable && } - {renderProps.children} + + + {isDismissable && } + {renderProps.children} + ); } diff --git a/packages/react-aria/src/overlays/usePreventScroll.ts b/packages/react-aria/src/overlays/usePreventScroll.ts index 6ed499570d6..69cc0c8ae52 100644 --- a/packages/react-aria/src/overlays/usePreventScroll.ts +++ b/packages/react-aria/src/overlays/usePreventScroll.ts @@ -11,11 +11,10 @@ */ import {chain} from '../utils/chain'; - import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {getNonce} from '../utils/getNonce'; import {getScrollParent} from '../utils/getScrollParent'; -import {isIOS} from '../utils/platform'; +import {isIOS, isWebKit} from '../utils/platform'; import {isScrollable} from '../utils/isScrollable'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {willOpenKeyboard} from '../utils/keyboard'; @@ -46,7 +45,7 @@ export function usePreventScroll(options: PreventScrollOptions = {}): void { preventScrollCount++; if (preventScrollCount === 1) { - if (isIOS()) { + if (isIOS() && isWebKit()) { restore = preventScrollMobileSafari(); } else { restore = preventScrollStandard(); @@ -197,18 +196,22 @@ function preventScrollMobileSafari() { // Override programmatic focus to scroll into view without scrolling the whole page. let focus = HTMLElement.prototype.focus; - HTMLElement.prototype.focus = function (opts) { - // Track whether the keyboard was already visible before. - let activeElement = getActiveElement(); - let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); - - // Focus the element without scrolling the page. - focus.call(this, {...opts, preventScroll: true}); - - if (!opts || !opts.preventScroll) { - scrollIntoViewWhenReady(this, wasKeyboardVisible); + Reflect.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: function (opts?: FocusOptions) { + // Track whether the keyboard was already visible before. + let activeElement = getActiveElement(); + let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); + + // Focus the element without scrolling the page. + focus.call(this, {...opts, preventScroll: true}); + + if (!opts || !opts.preventScroll) { + scrollIntoViewWhenReady(this, wasKeyboardVisible); + } } - }; + }); let removeEvents = chain( addEvent(document, 'touchstart', onTouchStart, {passive: false, capture: true}), @@ -220,7 +223,11 @@ function preventScrollMobileSafari() { restoreOverflow(); removeEvents(); style.remove(); - HTMLElement.prototype.focus = focus; + Reflect.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: focus + }); }; }