diff --git a/packages/dev/docs/pages/react-aria/getting-started.mdx b/packages/dev/docs/pages/react-aria/getting-started.mdx
index e7a3eb88af8..f94e5fba948 100644
--- a/packages/dev/docs/pages/react-aria/getting-started.mdx
+++ b/packages/dev/docs/pages/react-aria/getting-started.mdx
@@ -193,6 +193,10 @@ This is a quick way to get started, but you can also create your own custom clas
--origin: translateY(-8px);
}
+ &:not([data-open]) {
+ opacity: 0;
+ }
+
&[data-entering] {
animation: slide 200ms;
}
diff --git a/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx b/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx
index c1fdf6b6a87..c3b9a2248a0 100644
--- a/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx
+++ b/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx
@@ -787,7 +787,7 @@ function PlantModal(props: ModalOverlayProps) {
${isEntering ? 'animate-in fade-in duration-200 ease-out' : ''}
${isExiting ? 'animate-out fade-out duration-200 ease-in' : ''}
`}>
- {({isEntering, isExiting}) => (
+ {({isEntering, isExiting, isOpen}) => (
<>
{/* Inner position: sticky div sized to the visual viewport
height so the modal appears in view.
@@ -799,6 +799,7 @@ function PlantModal(props: ModalOverlayProps) {
diff --git a/packages/react-aria-components/docs/Modal.mdx b/packages/react-aria-components/docs/Modal.mdx
index d17bd684aad..ed45d8282cc 100644
--- a/packages/react-aria-components/docs/Modal.mdx
+++ b/packages/react-aria-components/docs/Modal.mdx
@@ -111,6 +111,10 @@ import {DialogTrigger, Modal, Dialog, Button, Heading, TextField, Label, Input}
width: max-content;
max-width: 300px;
+ &:not([data-open]) {
+ opacity: 0;
+ }
+
&[data-entering] {
animation: modal-zoom 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
diff --git a/packages/react-aria-components/docs/Popover.mdx b/packages/react-aria-components/docs/Popover.mdx
index 6fda2d02268..d648c8bbcca 100644
--- a/packages/react-aria-components/docs/Popover.mdx
+++ b/packages/react-aria-components/docs/Popover.mdx
@@ -100,6 +100,10 @@ import {DialogTrigger, Popover, Dialog, Button, OverlayArrow, Heading, Switch} f
stroke-width: 1px;
}
+ &:not([data-open]) {
+ opacity: 0;
+ }
+
&[data-entering],
&[data-exiting] {
transform: var(--starting-scale) var(--origin);
@@ -447,6 +451,10 @@ Popovers also support entry and exit animations via states exposed as data attri
transition: opacity 300ms, scale 300ms;
transform-origin: var(--trigger-anchor-point);
+ &:not([data-open]) {
+ opacity: 0;
+ }
+
&[data-entering],
&[data-exiting] {
opacity: 0;
diff --git a/packages/react-aria-components/docs/examples/command-palette.mdx b/packages/react-aria-components/docs/examples/command-palette.mdx
index 7f737fda4f4..331ec6a883b 100644
--- a/packages/react-aria-components/docs/examples/command-palette.mdx
+++ b/packages/react-aria-components/docs/examples/command-palette.mdx
@@ -108,7 +108,8 @@ function CommandPaletteExample() {
>
`
+ className={({ isEntering, isExiting, isOpen }) => `
+ ${!isOpen ? 'opacity-0' : ''}
${isEntering ? 'animate-in zoom-in-95 ease-out duration-300' : ''}
${isExiting ? 'animate-out zoom-out-95 ease-in duration-200' : ''}
`}
diff --git a/packages/react-aria-components/docs/examples/destructive-dialog.mdx b/packages/react-aria-components/docs/examples/destructive-dialog.mdx
index af2ca11b8bc..94978b9c46c 100644
--- a/packages/react-aria-components/docs/examples/destructive-dialog.mdx
+++ b/packages/react-aria-components/docs/examples/destructive-dialog.mdx
@@ -50,8 +50,9 @@ function ModalExample() {
${isEntering ? 'animate-in fade-in duration-300 ease-out' : ''}
${isExiting ? 'animate-out fade-out duration-200 ease-in' : ''}
`}>
- `
+ `
sticky top-0 left-0 w-full h-(--visual-viewport-height) flex items-center justify-center p-4 box-border text-center
+ ${!isOpen ? 'opacity-0' : ''}
${isEntering ? 'animate-in zoom-in-95 ease-out duration-300' : ''}
${isExiting ? 'animate-out zoom-out-95 ease-in duration-200' : ''}
`}>
diff --git a/packages/react-aria-components/docs/styling.mdx b/packages/react-aria-components/docs/styling.mdx
index b78753b7043..f83d33e46fd 100644
--- a/packages/react-aria-components/docs/styling.mdx
+++ b/packages/react-aria-components/docs/styling.mdx
@@ -281,6 +281,10 @@ Overlay components such as [Popover](Popover.html) and [Modal](Modal.html) suppo
.react-aria-Popover {
transition: opacity 300ms;
+ &:not([data-open]) {
+ opacity: 0;
+ }
+
&[data-entering],
&[data-exiting] {
opacity: 0;
@@ -295,6 +299,10 @@ Note that the `[data-entering]` state is only applied for one frame when using C
/* entry transition */
transition: transform 300ms, opacity 300ms;
+ &:not([data-open]) {
+ opacity: 0;
+ }
+
/* starting state of the entry transition */
&[data-entering] {
opacity: 0;
@@ -315,6 +323,10 @@ Note that the `[data-entering]` state is only applied for one frame when using C
For more complex animations, you can also apply CSS keyframe animations using the same `[data-entering]` and `[data-exiting]` states.
```css render=false
+.react-aria-Popover:not([data-open]) {
+ opacity: 0;
+}
+
.react-aria-Popover[data-entering] {
animation: slide 300ms;
}
diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx
index ecde2ec2385..10c04671661 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,
@@ -34,9 +33,19 @@ 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 {runAfterKeyboard} from 'react-aria/private/utils/runAfterKeyboard';
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';
@@ -75,6 +84,7 @@ export interface ModalOverlayProps
interface InternalModalContextValue {
modalProps: DOMAttributes;
modalRef: RefObject;
+ isOpen: boolean;
isExiting: boolean;
isDismissable?: boolean;
}
@@ -83,6 +93,12 @@ export const ModalContext = createContext(null);
export interface ModalRenderProps {
+ /**
+ * Whether the modal is ready to be displayed. Use this to avoid layout shift.
+ *
+ * @selector [data-open]
+ */
+ isOpen: boolean;
/**
* Whether the modal is currently entering. Use this to apply animations.
*
@@ -229,11 +245,13 @@ function ModalOverlayInner({UNSTABLE_portalContainer, ...props}: ModalOverlayInn
let {state} = props;
let {modalProps, underlayProps} = useModalOverlay(props, state, modalRef);
- let entering = useEnterAnimation(props.overlayRef) || props.isEntering || false;
+ let [isOpen, setIsOpen] = useState(false);
+ let entering = useEnterAnimation(props.overlayRef, isOpen) || props.isEntering || false;
let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-ModalOverlay',
values: {
+ isOpen,
isEntering: entering,
isExiting: props.isExiting,
state
@@ -262,6 +280,10 @@ function ModalOverlayInner({UNSTABLE_portalContainer, ...props}: ModalOverlayInn
'--page-height': pageHeight !== undefined ? pageHeight + 'px' : undefined
};
+ // Since an auto-focused input may open the OSK, we defer the reveal, as a courtesy, to avoid layout shift.
+ // TODO: This can cause native focus scroll-into-view to abort, so we might want to do that manually?
+ useLayoutEffect(() => runAfterKeyboard(() => setIsOpen(true)), []);
+
return (
@@ -299,16 +328,17 @@ interface ModalContentProps
}
function ModalContent(props: ModalContentProps) {
- let {modalProps, modalRef, isExiting, isDismissable} = useContext(InternalModalContext)!;
+ let {modalProps, modalRef, isExiting, isOpen, isDismissable} = useContext(InternalModalContext)!;
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',
values: {
+ isOpen,
isEntering: entering,
isExiting,
state
@@ -320,6 +350,7 @@ function ModalContent(props: ModalContentProps) {
{...mergeProps(filterDOMProps(props, {global: true}), modalProps)}
{...renderProps}
ref={ref}
+ data-open={isOpen || undefined}
data-entering={entering || undefined}
data-exiting={isExiting || undefined}>
{isDismissable && }
diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx
index 8284bdaa844..5ea2be67636 100644
--- a/packages/react-aria-components/src/Popover.tsx
+++ b/packages/react-aria-components/src/Popover.tsx
@@ -52,6 +52,7 @@ import React, {
useRef,
useState
} from 'react';
+import {runAfterKeyboard} from 'react-aria/private/utils/runAfterKeyboard';
import {useEnterAnimation, useExitAnimation} from 'react-aria/private/utils/animation';
import {useIsHidden} from 'react-aria/private/collections/Hidden';
import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect';
@@ -221,6 +222,9 @@ function PopoverInner({
let groupCtx = useContext(PopoverGroupContext);
let isSubPopover = groupCtx && props.trigger === 'SubmenuTrigger';
+ let [isOpen, setIsOpen] = useState(false);
+ let [isDialog, setIsDialog] = useState(false);
+
let {popoverProps, underlayProps, arrowProps, placement, triggerAnchorPoint} = usePopover(
{
...props,
@@ -234,7 +238,7 @@ function PopoverInner({
);
let ref = props.popoverRef as RefObject;
- let isEntering = useEnterAnimation(ref, !!placement) || props.isEntering || false;
+ let isEntering = useEnterAnimation(ref, !!placement && isOpen) || props.isEntering || false;
let renderProps = useRenderProps({
...props,
defaultClassName: 'react-aria-Popover',
@@ -249,10 +253,9 @@ function PopoverInner({
// Automatically render Popover with role=dialog except when isNonModal is true,
// or a dialog is already nested inside the popover.
let shouldBeDialog = !props.isNonModal || props.trigger === 'SubmenuTrigger';
- let [isDialog, setDialog] = useState(false);
useLayoutEffect(() => {
if (ref.current) {
- setDialog(shouldBeDialog && !ref.current.querySelector('[role=dialog]'));
+ setIsDialog(shouldBeDialog && !ref.current.querySelector('[role=dialog]'));
}
}, [ref, shouldBeDialog]);
@@ -301,6 +304,10 @@ function PopoverInner({
'--trigger-width': renderProps.style?.['--trigger-width'] || triggerWidth
};
+ // Since an auto-focused input may open the OSK, we defer the reveal, as a courtesy, to avoid layout shift.
+ // TODO: This can cause native focus scroll-into-view to abort, so we might want to do that manually?
+ useLayoutEffect(() => runAfterKeyboard(() => setIsOpen(true)), []);
+
let overlay = (
{!props.isNonModal && }
diff --git a/packages/react-aria/exports/private/utils/runAfterKeyboard.ts b/packages/react-aria/exports/private/utils/runAfterKeyboard.ts
new file mode 100644
index 00000000000..f827d09241e
--- /dev/null
+++ b/packages/react-aria/exports/private/utils/runAfterKeyboard.ts
@@ -0,0 +1 @@
+export {runAfterKeyboard, runAfterKeyboardTransition} from '../../../src/utils/runAfterKeyboard';
diff --git a/packages/react-aria/src/overlays/calculatePosition.ts b/packages/react-aria/src/overlays/calculatePosition.ts
index d103dfd48a3..10944298837 100644
--- a/packages/react-aria/src/overlays/calculatePosition.ts
+++ b/packages/react-aria/src/overlays/calculatePosition.ts
@@ -336,8 +336,9 @@ function getMaxHeight(
(position.top != null
? position.top
: containerDimensions[TOTAL_SIZE.height] - (position.bottom ?? 0) - overlayHeight) -
- (containerDimensions.scroll.top ?? 0);
- // calculate the dimentions of the "boundingRect" which is most restrictive top/bottom of the boundaryRect and the visual view port
+ (containerDimensions.scroll.top ?? 0) +
+ (visualViewport?.offsetTop ?? 0);
+ // calculate the dimensions of the "boundingRect" which is most restrictive top/bottom of the boundaryRect and the visual view port
let boundaryToContainerTransformOffset = isContainerDescendentOfBoundary
? containerOffsetWithBoundary.top
: 0;
diff --git a/packages/react-aria/src/overlays/usePreventScroll.ts b/packages/react-aria/src/overlays/usePreventScroll.ts
index 6ed499570d6..a994a75a663 100644
--- a/packages/react-aria/src/overlays/usePreventScroll.ts
+++ b/packages/react-aria/src/overlays/usePreventScroll.ts
@@ -10,13 +10,14 @@
* governing permissions and limitations under the License.
*/
+import {addEvent} from '../utils/useEvent';
import {chain} from '../utils/chain';
-
-import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions';
+import {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 {runAfterKeyboard} from '../utils/runAfterKeyboard';
import {useLayoutEffect} from '../utils/useLayoutEffect';
import {willOpenKeyboard} from '../utils/keyboard';
@@ -46,7 +47,7 @@ export function usePreventScroll(options: PreventScrollOptions = {}): void {
preventScrollCount++;
if (preventScrollCount === 1) {
- if (isIOS()) {
+ if (isIOS() && isWebKit()) {
restore = preventScrollMobileSafari();
} else {
restore = preventScrollStandard();
@@ -183,7 +184,7 @@ function preventScrollMobileSafari() {
if (relatedTarget && willOpenKeyboard(relatedTarget)) {
// Focus without scrolling the whole page, and then scroll into view manually.
relatedTarget.focus({preventScroll: true});
- scrollIntoViewWhenReady(relatedTarget, willOpenKeyboard(target));
+ runAfterKeyboard(() => scrollIntoView(relatedTarget));
} else if (!relatedTarget) {
// When tapping the Done button on the keyboard, focus moves to the body.
// FocusScope will then restore focus back to the input. Later when tapping
@@ -197,18 +198,18 @@ 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) {
+ // Focus the element without scrolling the page.
+ focus.call(this, {...opts, preventScroll: true});
+
+ if (!opts || !opts.preventScroll) {
+ runAfterKeyboard(() => scrollIntoView(this));
+ }
}
- };
+ });
let removeEvents = chain(
addEvent(document, 'touchstart', onTouchStart, {passive: false, capture: true}),
@@ -220,7 +221,11 @@ function preventScrollMobileSafari() {
restoreOverflow();
removeEvents();
style.remove();
- HTMLElement.prototype.focus = focus;
+ Reflect.defineProperty(HTMLElement.prototype, 'focus', {
+ configurable: true,
+ writable: true,
+ value: focus
+ });
};
}
@@ -234,33 +239,6 @@ function setStyle(element: HTMLElement, style: string, value: string) {
};
}
-// Adds an event listener to an element, and returns a function to remove it.
-function addEvent(
- target: Document | Window,
- event: K,
- handler: (this: Document | Window, ev: GlobalEventHandlersEventMap[K]) => any,
- options?: boolean | AddEventListenerOptions
-) {
- // internal function, so it's ok to ignore the difficult to fix type error
- // @ts-ignore
- target.addEventListener(event, handler, options);
- return () => {
- // @ts-ignore
- target.removeEventListener(event, handler, options);
- };
-}
-
-function scrollIntoViewWhenReady(target: Element, wasKeyboardVisible: boolean) {
- if (wasKeyboardVisible || !visualViewport) {
- // If the keyboard was already visible, scroll the target into view immediately.
- scrollIntoView(target);
- } else {
- // Otherwise, wait for the visual viewport to resize before scrolling so we can
- // measure the correct position to scroll to.
- visualViewport.addEventListener('resize', () => scrollIntoView(target), {once: true});
- }
-}
-
function scrollIntoView(target: Element) {
let root = document.scrollingElement || document.documentElement;
let nextTarget: Element | null = target;
diff --git a/packages/react-aria/src/utils/domHelpers.ts b/packages/react-aria/src/utils/domHelpers.ts
index c0fe8198dd8..d041c289372 100644
--- a/packages/react-aria/src/utils/domHelpers.ts
+++ b/packages/react-aria/src/utils/domHelpers.ts
@@ -1,23 +1,47 @@
-export const getOwnerDocument = (el: Element | null | undefined): Document => {
- return el?.ownerDocument ?? document;
+/*
+ * Copyright 2020 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+export const getOwnerDocument = (target?: EventTarget | null): Document => {
+ if (isWindow(target)) return target.document;
+
+ // @ts-expect-error Ensure safe access in SSR environments.
+ return target?.ownerDocument ?? (typeof document !== 'undefined' ? document : undefined);
};
-export const getOwnerWindow = (
- el: (Window & typeof globalThis) | Element | null | undefined
-): Window & typeof globalThis => {
- if (el && 'window' in el && el.window === el) {
- return el;
- }
+export const getOwnerWindow = (el?: EventTarget | null): Window & typeof globalThis => {
+ let ownerDocument = getOwnerDocument(el);
- const doc = getOwnerDocument(el as Element | null | undefined);
- return doc.defaultView || window;
+ // @ts-expect-error Ensure safe access in SSR environments.
+ return ownerDocument?.defaultView ?? (typeof window !== 'undefined' ? window : undefined);
+};
+
+export const getOwnerViewport = (el?: EventTarget | null): VisualViewport | null => {
+ let ownerWindow = getOwnerWindow(el);
+
+ return ownerWindow?.visualViewport ?? null;
+};
+
+export const getOwnerRootElement = (el?: EventTarget | null): HTMLElement => {
+ let ownerDocument = getOwnerDocument(el);
+ let scrollingElement = ownerDocument?.scrollingElement as HTMLElement;
+
+ return scrollingElement ?? ownerDocument?.documentElement;
};
/**
* Type guard that checks if a value is a Node. Verifies the presence and type of the nodeType
* property.
*/
-function isNode(value: unknown): value is Node {
+export function isNode(value?: unknown): value is Node {
return (
value !== null &&
typeof value === 'object' &&
@@ -25,10 +49,43 @@ function isNode(value: unknown): value is Node {
typeof (value as Node).nodeType === 'number'
);
}
+
+/**
+ * Type guard that checks if a value is an Element. Uses window self reference checks to
+ * distinguish Window from other values.
+ */
+export function isWindow(value?: unknown): value is Window & typeof globalThis {
+ return typeof value === 'object' && value != null && 'window' in value && value.window === value;
+}
+
+/**
+ * Type guard that checks if a value is an Element. Uses nodeType and tagName property checks to
+ * distinguish Element from other ElementNodes.
+ */
+export function isElement(value?: unknown): value is Element {
+ return isNode(value) && value.nodeType === Node.ELEMENT_NODE && 'tagName' in value;
+}
+
+/**
+ * Type guard that checks if a value is an HTMLElement. Uses prototype checks to
+ * distinguish Element from other Elements.
+ */
+export function isHTMLElement(value?: unknown): value is HTMLElement {
+ return isElement(value) && value instanceof getOwnerWindow(value).HTMLElement;
+}
+
+/**
+ * Type guard that checks if a value is an SVGElement. Uses prototype checks to
+ * distinguish SVGElement from other Elements.
+ */
+export function isSVGElement(value?: unknown): value is SVGElement {
+ return isElement(value) && value instanceof getOwnerWindow(value).SVGAElement;
+}
+
/**
- * Type guard that checks if a node is a ShadowRoot. Uses nodeType and host property checks to
+ * Type guard that checks if a value is a ShadowRoot. Uses nodeType and host property checks to
* distinguish ShadowRoot from other DocumentFragments.
*/
-export function isShadowRoot(node: Node | null): node is ShadowRoot {
- return isNode(node) && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in node;
+export function isShadowRoot(value?: unknown): value is ShadowRoot {
+ return isNode(value) && value.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in value;
}
diff --git a/packages/react-aria/src/utils/keyboard.tsx b/packages/react-aria/src/utils/keyboard.tsx
index f0f8bd4ea88..8c2200248d1 100644
--- a/packages/react-aria/src/utils/keyboard.tsx
+++ b/packages/react-aria/src/utils/keyboard.tsx
@@ -10,21 +10,13 @@
* governing permissions and limitations under the License.
*/
-import {isMac} from './platform';
+import {addEvent} from './useEvent';
+import {getActiveElement, getEventTarget} from './shadowdom/DOMFunctions';
+import {getOwnerDocument, getOwnerViewport, getOwnerWindow} from './domHelpers';
+import {isFirefox, isIOS, isMac} from './platform';
-interface Event {
- altKey: boolean;
- ctrlKey: boolean;
- metaKey: boolean;
-}
-
-export function isCtrlKeyPressed(e: Event): boolean {
- if (isMac()) {
- return e.metaKey;
- }
-
- return e.ctrlKey;
-}
+// Tracks layout status of the on-screen keyboard.
+const cache = new WeakMap();
// HTML input types that do not cause the software keyboard to appear.
const nonTextInputTypes = new Set([
@@ -39,10 +31,112 @@ const nonTextInputTypes = new Set([
'reset'
]);
-export function willOpenKeyboard(target: Element) {
- return (
- (target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type)) ||
- target instanceof HTMLTextAreaElement ||
- (target instanceof HTMLElement && target.isContentEditable)
- );
+interface KeyboardStatus {
+ isOpen: boolean;
+ innerHeight?: number;
+ resizeTimeStamp?: number;
+ resizeTimeout?: number;
+}
+
+interface KeyPressEvent {
+ altKey: boolean;
+ ctrlKey: boolean;
+ metaKey: boolean;
+}
+
+function onResize(e: Event): void {
+ let target = getEventTarget(e);
+ let ownerWindow = getOwnerWindow(target);
+
+ let status = cache.get(ownerWindow);
+ let timeStamp = Number(status?.resizeTimeStamp ?? 0);
+
+ if (status && timeStamp <= e.timeStamp + 50) {
+ status.resizeTimeStamp = e.timeStamp + 150;
+
+ ownerWindow.clearTimeout(status.resizeTimeout);
+
+ status.resizeTimeout = ownerWindow.setTimeout(() => {
+ status.isOpen = isKeyboardVisible();
+ delete status.resizeTimeout;
+ delete status.resizeTimeStamp;
+ }, 150);
+ }
+}
+
+function onIOSResize(e: Event): void {
+ let target = getEventTarget(e);
+ let ownerWindow = getOwnerWindow(target);
+
+ let status = cache.get(ownerWindow);
+
+ if (status) {
+ status.isOpen = isKeyboardVisible();
+ }
+}
+
+function setupGlobalEvents(): void {
+ let ownerWindow = getOwnerWindow();
+ let ownerViewport = getOwnerViewport();
+
+ let status: KeyboardStatus = {isOpen: false};
+
+ if (ownerWindow == null || ownerViewport == null) return;
+
+ // https://github.com/mozilla-mobile/firefox-ios/issues/33806
+ if (isIOS() && isFirefox()) {
+ status.innerHeight = ownerWindow.innerHeight;
+ }
+
+ addEvent(ownerViewport, 'resize', isIOS() ? onIOSResize : onResize);
+ cache.set(ownerWindow, status);
+}
+
+if (typeof document !== 'undefined') {
+ if (document.readyState !== 'loading') {
+ setupGlobalEvents();
+ } else {
+ addEvent(document, 'DOMContentLoaded', setupGlobalEvents);
+ }
+}
+
+export function willOpenKeyboard(target: EventTarget | null): boolean {
+ let isTextArea = target instanceof HTMLTextAreaElement;
+ let isEditable = target instanceof HTMLElement && target.isContentEditable;
+ let isTextInput = target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type);
+
+ return isTextArea || isEditable || isTextInput;
+}
+
+export function isCtrlKeyPressed(event: KeyPressEvent): boolean {
+ return isMac() ? event.metaKey : event.ctrlKey;
+}
+
+export function isKeyboardOpen(): boolean {
+ let ownerWindow = getOwnerWindow();
+ let ownerViewport = getOwnerViewport();
+
+ if (ownerWindow == null || ownerViewport == null) return false;
+
+ let status = cache.get(ownerWindow);
+
+ return !!status?.isOpen;
+}
+
+export function isKeyboardVisible(): boolean {
+ let ownerWindow = getOwnerWindow();
+ let ownerDocument = getOwnerDocument();
+ let ownerViewport = getOwnerViewport();
+
+ if (ownerWindow == null || ownerViewport == null) return false;
+
+ let status = cache.get(ownerWindow);
+
+ let activeElement = getActiveElement(ownerDocument);
+ let willKeyboardOpen = ownerDocument.hasFocus() && willOpenKeyboard(activeElement);
+
+ let minHeight = Number(ownerViewport?.height) * Number(ownerViewport?.scale);
+ let maxHeight = Number(status?.innerHeight) || Number(ownerWindow.innerHeight);
+
+ return (willKeyboardOpen || !!status?.isOpen) && maxHeight - minHeight > 150;
}
diff --git a/packages/react-aria/src/utils/platform.ts b/packages/react-aria/src/utils/platform.ts
index 673368b737a..55a752ffd2d 100644
--- a/packages/react-aria/src/utils/platform.ts
+++ b/packages/react-aria/src/utils/platform.ts
@@ -67,11 +67,11 @@ export const isAppleDevice: () => boolean = cached(function () {
});
export const isWebKit: () => boolean = cached(function () {
- return testUserAgent(/AppleWebKit/i) && !isChrome();
+ return testUserAgent(/AppleWebKit/i) && !isChrome() && !isFirefox();
});
export const isChrome: () => boolean = cached(function () {
- return testUserAgent(/Chrome/i);
+ return testUserAgent(/Chrome|CriOS|CrMo/i);
});
export const isAndroid: () => boolean = cached(function () {
@@ -79,5 +79,5 @@ export const isAndroid: () => boolean = cached(function () {
});
export const isFirefox: () => boolean = cached(function () {
- return testUserAgent(/Firefox/i);
+ return testUserAgent(/(Firefox|FxiOS)/i);
});
diff --git a/packages/react-aria/src/utils/runAfterKeyboard.ts b/packages/react-aria/src/utils/runAfterKeyboard.ts
new file mode 100644
index 00000000000..268a662697f
--- /dev/null
+++ b/packages/react-aria/src/utils/runAfterKeyboard.ts
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2020 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import {getActiveElement} from './shadowdom/DOMFunctions';
+import {getOwnerDocument, getOwnerViewport, getOwnerWindow} from './domHelpers';
+import {isIOS, isWebKit} from './platform';
+import {isKeyboardOpen, isKeyboardVisible, willOpenKeyboard} from './keyboard';
+
+const intervalByWindow = new WeakMap();
+const timeoutByWindow = new WeakMap();
+const transitionCallbacks = new Set();
+const resizeCallbacks = new Set();
+
+interface QueuedCallback {
+ (isKeyboardOpen: boolean): void;
+}
+
+function onTransitionStart(): void {
+ let ownerWindow = getOwnerWindow();
+
+ let wasOpen = isKeyboardOpen();
+ let wasVisible = isKeyboardVisible();
+
+ let transitionTimeout = timeoutByWindow.get(ownerWindow);
+ let transitionInterval = intervalByWindow.get(ownerWindow);
+
+ let transitionTimer = isIOS() && isWebKit() && wasOpen ? 600 : 300;
+
+ if (transitionInterval != null && transitionTimeout != null) {
+ return;
+ }
+
+ transitionInterval = ownerWindow.setInterval(() => {
+ let isOpen = isKeyboardOpen();
+ let isVisible = isKeyboardVisible();
+
+ if (wasOpen !== isOpen) {
+ for (let callback of resizeCallbacks) {
+ callback(isKeyboardOpen());
+ resizeCallbacks.delete(callback);
+ }
+ }
+
+ if ((!isIOS() && wasVisible !== isVisible) || (wasVisible && !isVisible)) {
+ for (let callback of transitionCallbacks) {
+ callback(isKeyboardVisible());
+ transitionCallbacks.delete(callback);
+ }
+ }
+
+ if (!transitionCallbacks.size && !resizeCallbacks.size) {
+ onTransitionEnd();
+ }
+ }, 50);
+
+ transitionTimeout = ownerWindow.setTimeout(() => {
+ for (let callback of resizeCallbacks) {
+ callback(isKeyboardOpen());
+ resizeCallbacks.delete(callback);
+ }
+
+ for (let callback of transitionCallbacks) {
+ callback(isKeyboardVisible());
+ transitionCallbacks.delete(callback);
+ }
+
+ onTransitionEnd();
+ }, transitionTimer);
+
+ timeoutByWindow.set(ownerWindow, transitionTimeout);
+ intervalByWindow.set(ownerWindow, transitionInterval);
+}
+
+function onTransitionEnd(): void {
+ let ownerWindow = getOwnerWindow();
+
+ let transitionTimeout = timeoutByWindow.get(ownerWindow);
+ let transitionInterval = intervalByWindow.get(ownerWindow);
+
+ ownerWindow.clearTimeout(transitionTimeout);
+ ownerWindow.clearInterval(transitionInterval);
+
+ timeoutByWindow.delete(ownerWindow);
+ intervalByWindow.delete(ownerWindow);
+}
+
+/**
+ * Delays a callback execution until a keyboard transition may no longer impact layout.
+ * Guarantees an invocation if an expected transition did not finish within 300ms.
+ */
+export function runAfterKeyboard(fn: QueuedCallback): () => void {
+ let ownerWindow = getOwnerWindow();
+ let ownerDocument = getOwnerDocument();
+ let ownerViewport = getOwnerViewport();
+
+ // Flush synchronously when the viewport API is unsupported.
+ if (ownerViewport == null) {
+ return fn(false) ?? (() => {});
+ }
+
+ // Assert based on geometry rather than focus to support intermediate states, in which
+ // document.activeElement can't be used to reliably infer the open state of the OSK.
+ let wasKeyboardOpen = isKeyboardOpen();
+
+ // Wait one frame to see if focus lands on an input.
+ let frame = ownerWindow.requestAnimationFrame(() => {
+ let activeElement = getActiveElement(ownerDocument);
+ let willKeyboardOpen = ownerDocument.hasFocus() && willOpenKeyboard(activeElement);
+
+ // If keyboard won't change, call the function immediately.
+ if (wasKeyboardOpen === willKeyboardOpen) {
+ return fn(willKeyboardOpen);
+ }
+
+ // On close, fire immediately since consumers may assert the ICB.
+ if (wasKeyboardOpen && !willKeyboardOpen) {
+ return fn(willKeyboardOpen);
+ }
+
+ resizeCallbacks.add(fn);
+ onTransitionStart();
+ });
+
+ return () => {
+ ownerWindow.cancelAnimationFrame(frame);
+ resizeCallbacks.delete(fn);
+ };
+}
+
+/**
+ * Delays a callback execution until the on-screen keyboard has finished its transition.
+ * Guarantees an invocation if an expected transition did not finish within 600ms.
+ */
+export function runAfterKeyboardTransition(fn: QueuedCallback): () => void {
+ let ownerWindow = getOwnerWindow();
+ let ownerDocument = getOwnerDocument();
+ let ownerViewport = getOwnerViewport();
+
+ // Flush synchronously when the viewport API is unsupported.
+ if (ownerViewport == null) {
+ return fn(false) ?? (() => {});
+ }
+
+ // Assert based on geometry rather than focus to support intermediate states, in which
+ // document.activeElement can't be used to reliably infer the open state of the OSK.
+ let wasKeyboardOpen = isKeyboardOpen();
+
+ // Wait one frame to see if focus lands on an input.
+ let frame = ownerWindow.requestAnimationFrame(() => {
+ let activeElement = getActiveElement(ownerDocument);
+ let willKeyboardOpen = ownerDocument.hasFocus() && willOpenKeyboard(activeElement);
+
+ // If keyboard won't transition, fire immediately.
+ if (wasKeyboardOpen === willKeyboardOpen) {
+ return fn(willKeyboardOpen);
+ }
+
+ transitionCallbacks.add(fn);
+ onTransitionStart();
+ });
+
+ return () => {
+ ownerWindow.cancelAnimationFrame(frame);
+ transitionCallbacks.delete(fn);
+ };
+}
diff --git a/packages/react-aria/src/utils/runAfterTransition.ts b/packages/react-aria/src/utils/runAfterTransition.ts
index 80e31fc112f..bde1286e09e 100644
--- a/packages/react-aria/src/utils/runAfterTransition.ts
+++ b/packages/react-aria/src/utils/runAfterTransition.ts
@@ -10,88 +10,86 @@
* governing permissions and limitations under the License.
*/
+import {addEvent} from './useEvent';
+import {getEventTarget} from './shadowdom/DOMFunctions';
+import {getOwnerDocument, getOwnerWindow} from './domHelpers';
+
// We store a global list of elements that are currently transitioning,
// mapped to a set of CSS properties that are transitioning for that element.
// This is necessary rather than a simple count of transitions because of browser
// bugs, e.g. Chrome sometimes fires both transitionend and transitioncancel rather
// than one or the other. So we need to track what's actually transitioning so that
// we can ignore these duplicate events.
-import {getEventTarget} from './shadowdom/DOMFunctions';
-let transitionsByElement = new Map>();
+const transitionsByElement = new Map>();
+const transitionCallbacks = new Set();
-// A list of callbacks to call once there are no transitioning elements.
-let transitionCallbacks = new Set<() => void>();
+interface QueuedCallback {
+ (isTransition: boolean): void;
+}
-function setupGlobalEvents() {
- if (typeof window === 'undefined') {
+function isTransitionEvent(event: Event): event is TransitionEvent {
+ return 'propertyName' in event;
+}
+
+function onTransitionStart(e: Event) {
+ let eventTarget = getEventTarget(e);
+
+ if (!isTransitionEvent(e) || !eventTarget) {
return;
}
- function isTransitionEvent(event: Event): event is TransitionEvent {
- return 'propertyName' in event;
+ // Add the transitioning property to the list for this element.
+ let transitions = transitionsByElement.get(eventTarget);
+
+ if (!transitions) {
+ transitions = new Set();
+ transitionsByElement.set(eventTarget, transitions);
+
+ // The transitioncancel event must be registered on the element itself, rather than as a global
+ // event. This enables us to handle when the node is deleted from the document while it is transitioning.
+ // In that case, the cancel event would have nowhere to bubble to so we need to handle it directly.
+ eventTarget.addEventListener('transitioncancel', onTransitionEnd, {once: true});
}
- let onTransitionStart = (e: Event) => {
- let eventTarget = getEventTarget(e);
- if (!isTransitionEvent(e) || !eventTarget) {
- return;
- }
- // Add the transitioning property to the list for this element.
- let transitions = transitionsByElement.get(eventTarget);
- if (!transitions) {
- transitions = new Set();
- transitionsByElement.set(eventTarget, transitions);
-
- // The transitioncancel event must be registered on the element itself, rather than as a global
- // event. This enables us to handle when the node is deleted from the document while it is transitioning.
- // In that case, the cancel event would have nowhere to bubble to so we need to handle it directly.
- eventTarget.addEventListener('transitioncancel', onTransitionEnd, {
- once: true
- });
- }
+ transitions.add(e.propertyName);
+}
- transitions.add(e.propertyName);
- };
+function onTransitionEnd(e: Event) {
+ let eventTarget = getEventTarget(e);
- let onTransitionEnd = (e: Event) => {
- let eventTarget = getEventTarget(e);
- if (!isTransitionEvent(e) || !eventTarget) {
- return;
- }
- // Remove property from list of transitioning properties.
- let properties = transitionsByElement.get(eventTarget);
- if (!properties) {
- return;
- }
+ if (!isTransitionEvent(e) || !eventTarget) {
+ return;
+ }
- properties.delete(e.propertyName);
+ // Remove property from list of transitioning properties.
+ let properties = transitionsByElement.get(eventTarget);
- // If empty, remove transitioncancel event, and remove the element from the list of transitioning elements.
- if (properties.size === 0) {
- eventTarget.removeEventListener('transitioncancel', onTransitionEnd);
- transitionsByElement.delete(eventTarget);
- }
+ if (!properties) {
+ return;
+ }
- // If no transitioning elements, call all of the queued callbacks.
- if (transitionsByElement.size === 0) {
- for (let cb of transitionCallbacks) {
- cb();
- }
+ properties.delete(e.propertyName);
- transitionCallbacks.clear();
- }
- };
+ // If empty, remove transitioncancel event, and remove the element from the list of transitioning elements.
+ if (properties.size === 0) {
+ eventTarget.removeEventListener('transitioncancel', onTransitionEnd);
+ transitionsByElement.delete(eventTarget);
+ }
- document.body.addEventListener('transitionrun', onTransitionStart);
- document.body.addEventListener('transitionend', onTransitionEnd);
+ // If no transitioning elements, call all of the queued callbacks.
+ if (transitionsByElement.size === 0) {
+ for (let callback of transitionCallbacks) {
+ callback(true);
+ transitionCallbacks.delete(callback);
+ }
+ }
}
-if (typeof document !== 'undefined') {
- if (document.readyState !== 'loading') {
- setupGlobalEvents();
- } else {
- document.addEventListener('DOMContentLoaded', setupGlobalEvents);
- }
+function setupGlobalEvents() {
+ let ownerDocument = getOwnerDocument();
+
+ addEvent(ownerDocument, 'transitionrun', onTransitionStart);
+ addEvent(ownerDocument, 'transitionend', onTransitionEnd);
}
/**
@@ -109,16 +107,35 @@ function cleanupDetachedElements() {
}
}
-export function runAfterTransition(fn: () => void): void {
+if (typeof document !== 'undefined') {
+ if (document.readyState !== 'loading') {
+ setupGlobalEvents();
+ } else {
+ addEvent(document, 'DOMContentLoaded', setupGlobalEvents);
+ }
+}
+
+/**
+ * Delays a callback execution until all elements finished their transition.
+ */
+export function runAfterTransition(fn: QueuedCallback): () => void {
+ let ownerWindow = getOwnerWindow();
+
// Wait one frame to see if an animation starts, e.g. a transition on mount.
- requestAnimationFrame(() => {
+ let frame = ownerWindow.requestAnimationFrame(() => {
cleanupDetachedElements();
+
// If no transitions are running, call the function immediately.
// Otherwise, add it to a list of callbacks to run at the end of the animation.
if (transitionsByElement.size === 0) {
- fn();
- } else {
- transitionCallbacks.add(fn);
+ return fn(false);
}
+
+ transitionCallbacks.add(fn);
});
+
+ return () => {
+ ownerWindow.cancelAnimationFrame(frame);
+ transitionCallbacks.delete(fn);
+ };
}
diff --git a/packages/react-aria/src/utils/useEvent.ts b/packages/react-aria/src/utils/useEvent.ts
index 1dd35499847..fa32eff78c0 100644
--- a/packages/react-aria/src/utils/useEvent.ts
+++ b/packages/react-aria/src/utils/useEvent.ts
@@ -14,24 +14,44 @@ import {RefObject} from '@react-types/shared';
import {useEffect} from 'react';
import {useEffectEvent} from './useEffectEvent';
-export function useEvent(
- ref: RefObject,
- event: K | (string & {}),
- handler?: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,
+type EventHandlerMap = T extends Window
+ ? WindowEventMap
+ : T extends Document
+ ? DocumentEventMap
+ : T extends Element
+ ? HTMLElementEventMap
+ : T extends VisualViewport
+ ? VisualViewportEventMap
+ : GlobalEventHandlersEventMap;
+
+export function useEvent>(
+ ref: RefObject,
+ event: Extract | (string & {}),
+ listener?: (this: T, ev: EventHandlerMap>[K]) => any,
options?: boolean | AddEventListenerOptions
): void {
- let handleEvent = useEffectEvent(handler);
- let isDisabled = handler == null;
+ let handleEvent = useEffectEvent(listener);
+ let isDisabled = listener == null;
useEffect(() => {
- if (isDisabled || !ref.current) {
+ if (isDisabled || ref.current == null) {
return;
}
- let element = ref.current;
- element.addEventListener(event, handleEvent as EventListener, options);
- return () => {
- element.removeEventListener(event, handleEvent as EventListener, options);
- };
+ return addEvent(ref.current, event, handleEvent, options);
}, [ref, event, options, isDisabled]);
}
+
+export function addEvent>>(
+ target: T | null,
+ event: Extract | (string & {}),
+ listener?: (this: T, ev: EventHandlerMap>[K]) => any,
+ options?: boolean | AddEventListenerOptions
+): () => void {
+ if (listener == null || target == null) {
+ return () => {};
+ }
+
+ target.addEventListener(event, listener as EventListener, options);
+ return () => target.removeEventListener(event, listener as EventListener, options);
+}
diff --git a/packages/react-aria/src/utils/useViewportSize.ts b/packages/react-aria/src/utils/useViewportSize.ts
index 6ff0c4f7d94..66c7027c098 100644
--- a/packages/react-aria/src/utils/useViewportSize.ts
+++ b/packages/react-aria/src/utils/useViewportSize.ts
@@ -10,9 +10,10 @@
* governing permissions and limitations under the License.
*/
-import {getActiveElement, getEventTarget} from './shadowdom/DOMFunctions';
+import {getEventTarget} from './shadowdom/DOMFunctions';
import {isIOS} from './platform';
-import {useEffect, useState} from 'react';
+import {runAfterKeyboard} from './runAfterKeyboard';
+import {useEffect, useRef, useState} from 'react';
import {useIsSSR} from '../ssr/SSRProvider';
import {willOpenKeyboard} from './keyboard';
@@ -25,10 +26,13 @@ let visualViewport = typeof document !== 'undefined' && window.visualViewport;
export function useViewportSize(): ViewportSize {
let isSSR = useIsSSR();
+ let unmountRef = useRef(false);
let [size, setSize] = useState(() => (isSSR ? {width: 0, height: 0} : getViewportSize()));
useEffect(() => {
let updateSize = (newSize: ViewportSize) => {
+ if (unmountRef.current) return;
+
setSize(size => {
if (newSize.width === size.width && newSize.height === size.height) {
return size;
@@ -49,24 +53,23 @@ export function useViewportSize(): ViewportSize {
// When closing the keyboard, iOS does not fire the visual viewport resize event until the animation is complete.
// We can anticipate this and resize early by handling the blur event and using the layout size.
- let frame: number;
let onBlur = (e: FocusEvent) => {
if (visualViewport && visualViewport.scale > 1) {
return;
}
- if (willOpenKeyboard(getEventTarget(e) as Element)) {
- // Wait one frame to see if a new element gets focused.
- frame = requestAnimationFrame(() => {
- let activeElement = getActiveElement();
- if (!activeElement || !willOpenKeyboard(activeElement)) {
- updateSize({
- width: document.documentElement.clientWidth,
- height: document.documentElement.clientHeight
- });
- }
- });
+ if (!willOpenKeyboard(getEventTarget(e))) {
+ return;
}
+
+ runAfterKeyboard(isOpen => {
+ if (isOpen) return;
+
+ updateSize({
+ width: document.documentElement.clientWidth,
+ height: document.documentElement.clientHeight
+ });
+ });
};
updateSize(getViewportSize());
@@ -82,7 +85,7 @@ export function useViewportSize(): ViewportSize {
}
return () => {
- cancelAnimationFrame(frame);
+ unmountRef.current = true;
if (isIOS()) {
window.removeEventListener('blur', onBlur, true);
}
diff --git a/starters/docs/src/Modal.css b/starters/docs/src/Modal.css
index 9241945b5f3..c3472373a24 100644
--- a/starters/docs/src/Modal.css
+++ b/starters/docs/src/Modal.css
@@ -35,6 +35,10 @@
width: max-content;
max-width: min(500px, 90vw);
+ &:not([data-open]) {
+ opacity: 0;
+ }
+
&[data-entering] {
animation: modal-zoom 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
diff --git a/starters/docs/src/Popover.css b/starters/docs/src/Popover.css
index ae55f90464c..0628d9c2d43 100644
--- a/starters/docs/src/Popover.css
+++ b/starters/docs/src/Popover.css
@@ -28,6 +28,10 @@
stroke-width: 2px;
}
+ &:not([data-open]) {
+ opacity: 0;
+ }
+
&[data-entering],
&[data-exiting] {
transform: var(--origin);
diff --git a/starters/tailwind/src/Modal.tsx b/starters/tailwind/src/Modal.tsx
index 13cc96111bc..28a7b49e684 100644
--- a/starters/tailwind/src/Modal.tsx
+++ b/starters/tailwind/src/Modal.tsx
@@ -18,8 +18,8 @@ const overlayStyles = tv({
const modalStyles = tv({
base: 'font-sans w-full max-w-[min(90vw,450px)] max-h-[calc(var(--visual-viewport-height)*.9)] rounded-2xl bg-white dark:bg-neutral-800/70 dark:backdrop-blur-2xl dark:backdrop-saturate-200 forced-colors:bg-[Canvas] text-left align-middle text-neutral-700 dark:text-neutral-300 shadow-2xl bg-clip-padding border border-black/10 dark:border-white/10',
variants: {
- isEntering: {
- true: 'animate-in zoom-in-105 ease-out duration-200'
+ isReady: {
+ false: 'opacity-0'
},
isExiting: {
true: 'animate-out zoom-out-95 ease-in duration-200'