diff --git a/.changeset/popover-enter-exit-animations.md b/.changeset/popover-enter-exit-animations.md new file mode 100644 index 00000000000..063d77d079f --- /dev/null +++ b/.changeset/popover-enter-exit-animations.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Popover-based surfaces now open and close with an origin-aware scale-and-fade that grows from the edge nearest the trigger, replacing the previous entrance-only slide animation. This covers the `` / `` menus, the overflow (⋯) menus, and selects — menus keep a quick-in / soft-out feel while selects use a quicker exit. Motion is disabled when `appearance.animations` is `false` or the user prefers reduced motion. diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts index 5f7304f615a..46c970eb59a 100644 --- a/integration/tests/custom-pages.test.ts +++ b/integration/tests/custom-pages.test.ts @@ -405,14 +405,19 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })( const notificationsButton = buttons[2]; const languageButton = buttons[3]; + // Clicking an action closes the popover, which now plays an exit animation and stays + // mounted until it finishes — wait for it to fully close before re-opening, otherwise + // the re-open races the still-visible exiting popover. // Test chat toggle await chatButton.click(); + await u.po.userButton.waitForPopoverClosed(); await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); await expect(chatButton).toHaveText('🌐Chat is ON'); await expect(languageButton).toHaveText('🌍Language: EN'); await notificationsButton.click(); + await u.po.userButton.waitForPopoverClosed(); await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); await expect(notificationsButton).toHaveText('🌐Notifications 🔔 ON'); @@ -460,14 +465,19 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })( const languageButton = buttons[3]; const manageAccountButton = buttons[4]; + // Clicking an action closes the popover, which now plays an exit animation and stays + // mounted until it finishes — wait for it to fully close before re-opening, otherwise + // the re-open races the still-visible exiting popover. // Test chat toggle await chatButton.click(); + await u.po.userButton.waitForPopoverClosed(); await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); await expect(chatButton).toHaveText('🌐Chat is ON'); await expect(languageButton).toHaveText('🌍Language: EN'); await notificationsButton.click(); + await u.po.userButton.waitForPopoverClosed(); await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); await expect(notificationsButton).toHaveText('🌐Notifications 🔔 ON'); @@ -508,6 +518,10 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })( await expect(toggleButton.locator('span')).toHaveText('🔔'); await toggleButton.click(); + // The action closes the popover with an exit animation that keeps it mounted until it + // finishes; wait for the full close so the re-open doesn't race the exiting popover. + await u.po.userButton.waitForPopoverClosed(); + // Re-open menu to see updated items await u.po.userButton.toggleTrigger(); await u.po.userButton.waitForPopover(); diff --git a/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts index 589c2a2f80f..dc3aff30aec 100644 --- a/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts +++ b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts @@ -61,7 +61,10 @@ export const createAPIKeysComponentPageObject = (testArgs: { page: EnhancedPage return page.getByLabel(/Type "Revoke" to confirm/i).fill(value); }, clickConfirmRevokeButton: () => { - return page.getByText(/Revoke key/i).click(); + // Target the modal's submit button by its descriptor class rather than by text: the menu + // action that opens this modal is also labelled "Revoke key", and it now lingers through an + // exit animation, so a text locator transiently matches both (strict-mode violation). + return page.locator('.cl-apiKeysRevokeModal .cl-apiKeysRevokeModalSubmitButton').click(); }, }; return self; diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 08f942932d0..c3e10aadcff 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/ui.shared.browser.js", "maxSize": "40KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "130KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "132KB" }, { "path": "./dist/signin*.js", "maxSize": "17KB" }, { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, diff --git a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 617ba54c300..113a196958b 100644 --- a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -23,7 +23,6 @@ export const OrganizationSwitcherPopover = React.forwardRef unsafeClose?.(false); const card = useCardState(); - const { __experimental_asStandalone } = useOrganizationSwitcherContext(); const { openOrganizationProfile, openCreateOrganization } = useClerk(); const getContainer = usePortalRoot(); const { organization: currentOrg } = useOrganization(); @@ -138,7 +137,6 @@ export const OrganizationSwitcherPopover = React.forwardRef diff --git a/packages/ui/src/components/OrganizationSwitcher/index.tsx b/packages/ui/src/components/OrganizationSwitcher/index.tsx index fc9b00f915d..8939dab433b 100644 --- a/packages/ui/src/components/OrganizationSwitcher/index.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/index.tsx @@ -42,7 +42,7 @@ const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactE diff --git a/packages/ui/src/components/UserButton/UserButtonPopover.tsx b/packages/ui/src/components/UserButton/UserButtonPopover.tsx index 6eab611bf1f..b0d8dad7dff 100644 --- a/packages/ui/src/components/UserButton/UserButtonPopover.tsx +++ b/packages/ui/src/components/UserButton/UserButtonPopover.tsx @@ -19,7 +19,6 @@ export const UserButtonPopover = React.forwardRef unsafeClose?.(false); const { session } = useSession() as { session: SignedInSessionResource }; const userButtonContext = useUserButtonContext(); - const { __experimental_asStandalone } = userButtonContext; const { authConfig } = useEnvironment(); const { user } = useUser(); const { t } = useLocalizations(); @@ -40,7 +39,6 @@ export const UserButtonPopover = React.forwardRef diff --git a/packages/ui/src/components/UserButton/index.tsx b/packages/ui/src/components/UserButton/index.tsx index ecab5631090..ac5fd2a8c50 100644 --- a/packages/ui/src/components/UserButton/index.tsx +++ b/packages/ui/src/components/UserButton/index.tsx @@ -41,7 +41,7 @@ const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>( diff --git a/packages/ui/src/elements/Menu.tsx b/packages/ui/src/elements/Menu.tsx index e9ceed6718c..85ebacae98c 100644 --- a/packages/ui/src/elements/Menu.tsx +++ b/packages/ui/src/elements/Menu.tsx @@ -18,7 +18,6 @@ import { Col, descriptors, SimpleButton } from '../customizables'; import type { UsePopoverReturn } from '../hooks'; import { usePopover } from '../hooks'; import type { PropsOfComponent } from '../styledSystem'; -import { animations } from '../styledSystem'; import { colors } from '../utils/colors'; import { withFloatingTree } from './contexts'; import { Popover } from './Popover'; @@ -124,20 +123,24 @@ type MenuListProps = PropsOfComponent & { export const MenuList = (props: MenuListProps) => { const { sx, asPortal, ...rest } = props; const { popoverCtx, elementId, getFloatingProps, elementsRef } = useMenuState(); - const { floating, styles, isOpen, context, nodeId } = popoverCtx; + const { floating, styles, context, nodeId } = popoverCtx; const containerRef = useRef(null); const mergedRef = useMergeRefs([containerRef, floating]); + // `FloatingList` only provides list-navigation context (no DOM), so it wraps the `Popover` + // rather than nesting inside it — that keeps the `Col` as the `Popover`'s direct child, which is + // the element the `animateExit` transition (origin-aware scale + fade) is applied to. return ( - - + + { padding: t.space.$0x5, overflow: 'hidden', top: `calc(100% + ${t.space.$2})`, - animation: `${animations.dropdownSlideInScaleAndFade} ${t.transitionDuration.$slower} ${t.transitionTiming.$slowBezier}`, - transformOrigin: 'top center', zIndex: t.zIndices.$dropdown, gap: t.space.$0x5, }), @@ -164,8 +165,8 @@ export const MenuList = (props: MenuListProps) => { {...getFloatingProps()} {...rest} /> - - + + ); }; diff --git a/packages/ui/src/elements/PhoneInput/index.tsx b/packages/ui/src/elements/PhoneInput/index.tsx index 77db7f71d79..42abca966f5 100644 --- a/packages/ui/src/elements/PhoneInput/index.tsx +++ b/packages/ui/src/elements/PhoneInput/index.tsx @@ -145,6 +145,7 @@ const PhoneInputBase = forwardRef ({ gap: 0, diff --git a/packages/ui/src/elements/Popover.tsx b/packages/ui/src/elements/Popover.tsx index 9a2ac5a8e0c..13882e9bfa8 100644 --- a/packages/ui/src/elements/Popover.tsx +++ b/packages/ui/src/elements/Popover.tsx @@ -1,14 +1,38 @@ import { usePortalRoot } from '@clerk/shared/react'; import type { FloatingContext, ReferenceType } from '@floating-ui/react'; -import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react'; +import { FloatingFocusManager, FloatingNode, FloatingPortal, useTransitionStyles } from '@floating-ui/react'; import type { PropsWithChildren } from 'react'; import React from 'react'; +import { useAppearance } from '../customizables'; +import { transitionDurationValues, transitionTiming } from '../foundations/transitions'; +import { useDirection, usePrefersReducedMotion } from '../hooks'; + type PopoverProps = PropsWithChildren<{ context: FloatingContext; nodeId?: string; isOpen?: boolean; initialFocus?: number | React.MutableRefObject; + /** + * When `true`, the popover animates in on mount with an origin-aware scale + fade and stays + * mounted through a matching fade-out on close, instead of appearing/unmounting instantly. + * Opt-in so other consumers keep their current instant behavior. + * @default false + */ + animateExit?: boolean; + /** + * When `animateExit` is set, exits quickly (`$faster`, matching the entrance) instead of the + * default soft exit (`$slower`). The entrance is always `$faster`. + * @default false + */ + exitFast?: boolean; + /** + * The scale the popover animates from on enter (and collapses to on exit) when `animateExit` + * is set. Scale is proportional, so compact surfaces travel fewer pixels — lower this + * (e.g. `0.92`) to give small popovers like menus a more pronounced motion. + * @default 0.96 + */ + initialScale?: number; /** * Determines whether outside elements are inert when modal is enabled. This enables pointer modality without a backdrop. * @default false @@ -30,6 +54,27 @@ type PopoverProps = PropsWithChildren<{ root?: HTMLElement | React.MutableRefObject; }>; +/** + * Maps a resolved Floating UI placement to a `transform-origin` so the popover grows from the + * point nearest its trigger. The primary axis anchors to the edge opposite the `side`; when the + * placement is aligned to a corner (`-start` / `-end`) the cross axis hugs that corner, otherwise + * it stays centered. Horizontal alignment is mirrored under RTL to match Floating UI's + * direction-aware resolved position. + */ +const getTransformOrigin = (placement: string, direction: 'ltr' | 'rtl'): string => { + const [side, alignment] = placement.split('-'); + if (side === 'top' || side === 'bottom') { + const y = side === 'top' ? 'bottom' : 'top'; + const start = direction === 'rtl' ? 'right' : 'left'; + const end = direction === 'rtl' ? 'left' : 'right'; + const x = alignment === 'start' ? start : alignment === 'end' ? end : 'center'; + return `${x} ${y}`; + } + const x = side === 'left' ? 'right' : 'left'; + const y = alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center'; + return `${x} ${y}`; +}; + export const Popover = (props: PopoverProps) => { const { context, @@ -39,6 +84,9 @@ export const Popover = (props: PopoverProps) => { modal = true, nodeId, isOpen, + animateExit = false, + exitFast = false, + initialScale = 0.96, portal = true, root, children, @@ -47,11 +95,45 @@ export const Popover = (props: PopoverProps) => { const portalRoot = usePortalRoot(); const effectiveRoot = root ?? portalRoot?.() ?? undefined; + const prefersReducedMotion = usePrefersReducedMotion(); + const direction = useDirection(); + const { animations: layoutAnimations } = useAppearance().parsedOptions; + const isMotionSafe = !prefersReducedMotion && layoutAnimations === true; + const animate = animateExit && isMotionSafe; + const closeDuration = exitFast ? transitionDurationValues.faster : transitionDurationValues.slower; + + // Animate in on mount and out on close with an origin-aware scale + fade. Keeping the element + // mounted through the close transition (via `isMounted`) is what makes the exit animation + // possible. `transformOrigin` is derived from the resolved placement so the popover grows from + // the point nearest its trigger — the aligned corner when present, otherwise the edge center. + const { isMounted, styles: transitionStyles } = useTransitionStyles(context, { + duration: { + open: animate ? transitionDurationValues.faster : 0, + close: animate ? closeDuration : 0, + }, + common: ({ placement }) => ({ + transformOrigin: getTransformOrigin(placement, direction), + transitionTimingFunction: transitionTiming.swiftOut, + }), + initial: { opacity: animate ? 0 : 1, transform: animate ? `scale(${initialScale})` : 'scale(1)' }, + open: { opacity: 1, transform: 'scale(1)' }, + close: { opacity: animate ? 0 : 1, transform: animate ? `scale(${initialScale})` : 'scale(1)' }, + }); + + // Non-animating consumers keep the original synchronous `isOpen` unmount. + const shouldRender = animateExit ? isMounted : isOpen; + const content = + animateExit && React.isValidElement<{ style?: React.CSSProperties }>(children) + ? React.cloneElement(children, { + style: { ...children.props.style, ...transitionStyles }, + }) + : children; + if (portal) { return ( - {isOpen && ( + {shouldRender && ( { order={order} modal={modal} > - <>{children} + <>{content} )} @@ -69,14 +151,15 @@ export const Popover = (props: PopoverProps) => { return ( - {isOpen && ( + {shouldRender && ( - <>{children} + <>{content} )} diff --git a/packages/ui/src/elements/PopoverCard.tsx b/packages/ui/src/elements/PopoverCard.tsx index f46f1431998..880b6f61d07 100644 --- a/packages/ui/src/elements/PopoverCard.tsx +++ b/packages/ui/src/elements/PopoverCard.tsx @@ -3,24 +3,13 @@ import React from 'react'; import { useEnvironment } from '../contexts'; import { Col, descriptors, Flex, Flow, useAppearance } from '../customizables'; import type { ElementDescriptor } from '../customizables/elementDescriptors'; -import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; -import { animations, common } from '../styledSystem'; +import type { PropsOfComponent } from '../styledSystem'; +import { common } from '../styledSystem'; import { colors } from '../utils/colors'; import { Card } from './Card'; -const PopoverCardRoot = React.forwardRef< - HTMLDivElement, - PropsOfComponent & { - shouldEntryAnimate?: boolean; - } ->((props, ref) => { - const { elementDescriptor, shouldEntryAnimate = true, ...rest } = props; - - const withAnimation: ThemableCssProp = t => ({ - animation: shouldEntryAnimate - ? `${animations.dropdownSlideInScaleAndFade} ${t.transitionDuration.$fast}` - : undefined, - }); +const PopoverCardRoot = React.forwardRef>((props, ref) => { + const { elementDescriptor, ...rest } = props; return ( @@ -30,16 +19,13 @@ const PopoverCardRoot = React.forwardRef< ref={ref} // Popover cards always render as raised — flush is scoped to simple card components elevation='raised' - sx={[ - t => ({ - width: t.sizes.$94, - maxWidth: `calc(100vw - ${t.sizes.$8})`, - zIndex: t.zIndices.$modal, - borderRadius: t.radii.$xl, - outline: 'none', - }), - withAnimation, - ]} + sx={t => ({ + width: t.sizes.$94, + maxWidth: `calc(100vw - ${t.sizes.$8})`, + zIndex: t.zIndices.$modal, + borderRadius: t.radii.$xl, + outline: 'none', + })} > {props.children} diff --git a/packages/ui/src/elements/Select.tsx b/packages/ui/src/elements/Select.tsx index ec774a1e5e6..cb8e4dc1526 100644 --- a/packages/ui/src/elements/Select.tsx +++ b/packages/ui/src/elements/Select.tsx @@ -7,7 +7,7 @@ import { Button, descriptors, Flex, Icon, Input, Text } from '../customizables'; import { usePopover, useSearchInput } from '../hooks'; import { ChevronDown } from '../icons'; import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; -import { animations, common } from '../styledSystem'; +import { common } from '../styledSystem'; import { colors } from '../utils/colors'; import { withFloatingTree } from './contexts'; import type { InputWithIcon } from './InputWithIcon'; @@ -257,6 +257,11 @@ type SelectOptionListProps = PropsOfComponent & { containerSx?: ThemableCssProp; footer?: React.ReactNode; onReachEnd?: () => void; + /** + * Scale the dropdown animates from on open. Defaults to the compact select value; raise it + * (e.g. `0.96`) for wider dropdowns like the phone country-code picker. + */ + initialScale?: number; }; export const SelectOptionList = (props: SelectOptionListProps) => { @@ -265,6 +270,7 @@ export const SelectOptionList = (props: SelectOptionListProps) => { sx, footer, onReachEnd, + initialScale = 0.92, id, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, @@ -353,7 +359,9 @@ export const SelectOptionList = (props: SelectOptionListProps) => { @@ -369,8 +377,6 @@ export const SelectOptionList = (props: SelectOptionListProps) => { backgroundColor: colors.makeSolid(theme.colors.$colorBackground), borderRadius: theme.radii.$lg, overflow: 'hidden', - animation: `${animations.dropdownSlideInScaleAndFade} ${theme.transitionDuration.$slower} ${theme.transitionTiming.$slowBezier}`, - transformOrigin: 'top center', boxShadow: theme.shadows.$menuShadow, zIndex: theme.zIndices.$dropdown, }), diff --git a/packages/ui/src/elements/__tests__/Menu.test.tsx b/packages/ui/src/elements/__tests__/Menu.test.tsx index 37afd2934d4..7b2d77a1490 100644 --- a/packages/ui/src/elements/__tests__/Menu.test.tsx +++ b/packages/ui/src/elements/__tests__/Menu.test.tsx @@ -85,7 +85,9 @@ describe('Menu', () => { expect(getByRole('menu')).toBeInTheDocument(); await userEvent.click(getByRole('button', { name: 'Open menu' })); - expect(queryByRole('menu')).not.toBeInTheDocument(); + // The popover stays mounted through its exit transition, so the menu unmounts on + // animation-end rather than synchronously with the close. + await waitFor(() => expect(queryByRole('menu')).not.toBeInTheDocument()); }); it('fires the menu item onClick and closes the menu on selection', async () => { @@ -100,7 +102,7 @@ describe('Menu', () => { await userEvent.click(getByRole('menuitem', { name: 'Remove' })); expect(onClick).toHaveBeenCalledTimes(1); - expect(queryByRole('menu')).not.toBeInTheDocument(); + await waitFor(() => expect(queryByRole('menu')).not.toBeInTheDocument()); }); it('keeps the menu open after selection when closeAfterClick is false', async () => { @@ -277,7 +279,7 @@ describe('Menu', () => { await userEvent.keyboard('{Enter}'); expect(onClick).toHaveBeenCalledTimes(1); - expect(queryByRole('menu')).not.toBeInTheDocument(); + await waitFor(() => expect(queryByRole('menu')).not.toBeInTheDocument()); }); it('does not trap focus — Tab exits the menu', async () => { @@ -305,7 +307,7 @@ describe('Menu', () => { await userEvent.keyboard('{Escape}'); - expect(queryByRole('menu')).not.toBeInTheDocument(); + await waitFor(() => expect(queryByRole('menu')).not.toBeInTheDocument()); expect(trigger).toHaveFocus(); }); diff --git a/packages/ui/src/foundations/transitions.ts b/packages/ui/src/foundations/transitions.ts index 5ababb0ef22..b3eb64edf8d 100644 --- a/packages/ui/src/foundations/transitions.ts +++ b/packages/ui/src/foundations/transitions.ts @@ -3,6 +3,7 @@ const transitionDurationValues = Object.freeze({ slower: 280, slow: 200, fast: 120, + faster: 90, focusRing: 200, controls: 100, textField: 450, @@ -27,6 +28,7 @@ const transitionTiming = Object.freeze({ easeOut: 'ease-out', bezier: 'cubic-bezier(0.32, 0.72, 0, 1)', slowBezier: 'cubic-bezier(0.16, 1, 0.3, 1)', + swiftOut: 'cubic-bezier(0.175, 0.885, 0.32, 1.1)', } as const); export { transitionDuration, transitionTiming, transitionProperty, transitionDurationValues };