diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index 5f6df54a7..eaf7c6357 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -8,6 +8,10 @@ import { type SendouButtonProps, } from "~/components/elements/Button"; import { useHydrated } from "~/hooks/useHydrated"; +import { + useReportModalOpen, + useTopLayerViewTransitionStyle, +} from "~/utils/view-transition"; import styles from "./Dialog.module.css"; interface DialogElementProps { @@ -35,6 +39,7 @@ interface DialogElementProps { */ export function SendouModal({ ref, ...rest }: DialogElementProps) { const isHydrated = useHydrated(); + useReportModalOpen(isHydrated); if (!isHydrated) return null; return createPortal( @@ -67,10 +72,13 @@ function DialogElement({ children, ref, }: DialogElementProps) { + const topLayerStyle = useTopLayerViewTransitionStyle(); + return ( (null); const [open, setOpen] = React.useState(false); + useReportModalOpen(open); const [contentKey, remountContent] = React.useReducer( (key: number) => key + 1, diff --git a/app/components/elements/Menu.tsx b/app/components/elements/Menu.tsx index 818b78bd1..7c3d536e8 100644 --- a/app/components/elements/Menu.tsx +++ b/app/components/elements/Menu.tsx @@ -6,6 +6,7 @@ import { focusMoveForKey, rovingFocusIndex, } from "~/utils/roving-focus"; +import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; import styles from "./Menu.module.css"; @@ -44,6 +45,7 @@ export function SendouMenu({ eager, }: SendouMenuProps) { const uid = useAnchorSafeId(); + const topLayerStyle = useTopLayerViewTransitionStyle(); const popoverId = `${uid}-menu`; const anchorName = `--menu-anchor-${uid}`; @@ -132,7 +134,12 @@ export function SendouMenu({ [styles.scrolling]: scrolling, [styles.opensLeft]: opensLeft, })} - style={{ positionAnchor: anchorName } as React.CSSProperties} + style={ + { + positionAnchor: anchorName, + ...topLayerStyle, + } as React.CSSProperties + } data-placement={placement} onBeforeToggle={onBeforeToggle} onToggle={onToggle} diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index 08666c353..cf7b6a2fa 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -2,6 +2,7 @@ import clsx from "clsx"; import * as React from "react"; import { flushSync } from "react-dom"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; +import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { type AnchorPlacement, useAnchorPositioning, @@ -117,6 +118,7 @@ export function SendouPopover({ const popoverRef = React.useRef(null); const triggerContainerRef = React.useRef(null); + const topLayerStyle = useTopLayerViewTransitionStyle(); const setOpen = (next: boolean) => { if (!isControlled) { @@ -200,7 +202,12 @@ export function SendouPopover({ id={popoverId} popover="auto" className={clsx(styles.content, popoverClassName)} - style={{ positionAnchor: anchorName } as React.CSSProperties} + style={ + { + positionAnchor: anchorName, + ...topLayerStyle, + } as React.CSSProperties + } role="dialog" tabIndex={-1} data-placement={placement} @@ -232,6 +239,7 @@ export function SendouAnchoredPopover({ const anchorName = `--popover-anchor-${uid}`; const popoverRef = React.useRef(null); + const topLayerStyle = useTopLayerViewTransitionStyle(); // before the positioning effect, so the content is placed by its first paint useIsomorphicLayoutEffect(() => { @@ -278,7 +286,9 @@ export function SendouAnchoredPopover({ ref={popoverRef} popover="auto" className={styles.content} - style={{ positionAnchor: anchorName } as React.CSSProperties} + style={ + { positionAnchor: anchorName, ...topLayerStyle } as React.CSSProperties + } role="dialog" tabIndex={-1} aria-label={ariaLabel} diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index fb7f543cc..6c79ca718 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -6,6 +6,7 @@ import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouButton } from "~/components/elements/Button"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus"; +import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; import { @@ -144,6 +145,7 @@ export function SendouSelect({ }: SendouSelectProps) { const { t } = useTranslation(["common"]); const uid = useAnchorSafeId(); + const topLayerStyle = useTopLayerViewTransitionStyle(); const popoverId = `${uid}-select-popover`; const listboxId = `${uid}-select-listbox`; const anchorName = `--select-anchor-${uid}`; @@ -573,7 +575,12 @@ export function SendouSelect({ id={popoverId} popover="auto" className={clsx(styles.popover, popoverClassName)} - style={{ positionAnchor: anchorName } as React.CSSProperties} + style={ + { + positionAnchor: anchorName, + ...topLayerStyle, + } as React.CSSProperties + } onBeforeToggle={onPopoverBeforeToggle} onToggle={onPopoverToggle} onKeyDown={onPopoverKeyDown} diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index a105dd1e7..8027885d5 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -30,7 +30,10 @@ import { TIERS_PAGE, tierImageUrl, } from "~/utils/urls"; -import { finishUpdateIfUnmoved } from "~/utils/view-transition"; +import { + finishUpdateIfUnmoved, + usePageViewTransitionClass, +} from "~/utils/view-transition"; import type { SQGroup, SQGroupMember, @@ -307,10 +310,12 @@ function GroupCardContainer({ isOwnGroup: boolean; children: React.ReactNode; }) { + const updateClass = usePageViewTransitionClass("card-update"); + if (isOwnGroup) return <>{children}; return ( - + {children} ); diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx index 3b32fc654..fce123d74 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.tsx +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -24,7 +24,10 @@ import type { UnifiedLanguageCode } from "~/modules/i18n/config"; import { languagesUnified } from "~/modules/i18n/config"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { navIconUrl } from "~/utils/urls"; -import { finishUpdateIfUnmoved } from "~/utils/view-transition"; +import { + finishUpdateIfUnmoved, + usePageViewTransitionClass, +} from "~/utils/view-transition"; import { lookingSchema, updateGroupFormSchema, @@ -182,10 +185,12 @@ function LFGGroupCardContainer({ isOwnGroup: boolean; children: React.ReactNode; }) { + const updateClass = usePageViewTransitionClass("card-update"); + if (isOwnGroup) return <>{children}; return ( - + {children} ); diff --git a/app/root.tsx b/app/root.tsx index 7d789a380..fe38e68cf 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -82,6 +82,10 @@ import "~/styles/common.css"; import "~/styles/utils.css"; import "~/styles/flags.css"; import "nprogress/nprogress.css"; +import { + OpenModalsContext, + useHoverCursorForViewTransitions, +} from "~/utils/view-transition"; const PRELOAD_TRANSLATION_TIMEOUT_MS = 3000; @@ -199,6 +203,7 @@ function Document({ usePreloadTranslation(); useLoadingIndicator(); useTriggerToasts(); + useHoverCursorForViewTransitions(); const htmlStyle: Record = { ...Object.fromEntries(customThemeStyle), @@ -427,6 +432,7 @@ function useCustomThemeVars() { export default function App() { const rootData = useLoaderData(); + const [openModals, setOpenModals] = React.useState(0); // Move overflow:hidden from html to body to allow position: sticky and position: fixed // elements to work properly when a React Aria Component disabled scrolling @@ -467,14 +473,16 @@ export default function App() { }, []); return ( - - - - - + + + + + + + ); } diff --git a/app/styles/common.css b/app/styles/common.css index ddc9eeedd..eea711042 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -17,12 +17,31 @@ pointer-events: none; } + /* captured elements are not hit tested while they animate, keep the cursor they had (see useHoverCursorForViewTransitions) */ + html:active-view-transition { + cursor: var(--hover-cursor, auto); + } + /* a card React decided not to animate is left as an old-only snapshot that would still fade for the full duration, holding the next render for a transition of no change */ ::view-transition-old(.card-update):only-child { animation-duration: 0s; } + /* top layer elements (see useTopLayerViewTransitionStyle) show their live state above the rest */ + ::view-transition-group(.top-layer) { + z-index: 2; + animation: none; + } + + ::view-transition-old(.top-layer) { + display: none; + } + + ::view-transition-new(.top-layer) { + animation: none; + } + body { width: 100%; min-height: 100vh; diff --git a/app/utils/view-transition.ts b/app/utils/view-transition.ts index 47b4836ee..b593462a1 100644 --- a/app/utils/view-transition.ts +++ b/app/utils/view-transition.ts @@ -1,4 +1,5 @@ import type { ViewTransitionInstance } from "react"; +import * as React from "react"; interface PseudoElement { getAnimations(): Animation[]; @@ -11,6 +12,16 @@ type PseudoElements = Record< >; const MEASURED_PROPERTIES = ["transform", "width", "height"] as const; +const NUMBER_PATTERN = /-?\d*\.?\d+/g; +const HOVER_CURSOR_PROPERTY = "--hover-cursor"; + +/** How many modal dialogs are open, provided by the root. */ +export const OpenModalsContext = React.createContext<{ + count: number; + setCount: React.Dispatch>; +}>({ count: 0, setCount: () => {} }); +/** Keyframes and computed style serialize lengths at different precisions (209.328px vs 209.312px). */ +const SUBPIXEL_TOLERANCE = 0.5; /** * `onUpdate` handler for a `` that ends its animation right away when the @@ -53,6 +64,71 @@ function hasMoved(groupAnimation: Animation, group: PseudoElement) { groupAnimation.currentTime = startTime; return MEASURED_PROPERTIES.some( - (property, i) => property in from && from[property] !== to[i], + (property, i) => + property in from && !withinTolerance(String(from[property]), to[i]), ); } + +function withinTolerance(a: string, b: string) { + if (a.replace(NUMBER_PATTERN, "") !== b.replace(NUMBER_PATTERN, "")) { + return false; + } + const aNumbers = a.match(NUMBER_PATTERN) ?? []; + const bNumbers = b.match(NUMBER_PATTERN) ?? []; + return aNumbers.every( + (value, i) => + Math.abs(Number(value) - Number(bNumbers[i])) <= SUBPIXEL_TOLERANCE, + ); +} + +/** + * Inline style for an element shown in the top layer (a popover or dialog). Snapshots paint above + * the top layer, so unless the element is captured on its own the page's transitions cover it. + * `common.css` keeps the capture static and on top of the others. + */ +export function useTopLayerViewTransitionStyle(): React.CSSProperties { + const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, ""); + return { + viewTransitionName: `top-layer-${uid}`, + viewTransitionClass: "top-layer", + }; +} + +/** + * Records the cursor of the hovered element on the root. While a transition runs the browser + * skips the captured elements when hit testing, so the cursor would fall back to the page's for + * a few frames; `common.css` shows the recorded one instead. + */ +export function useHoverCursorForViewTransitions() { + React.useEffect(() => { + const onPointerOver = (event: PointerEvent) => { + if (!(event.target instanceof Element)) return; + document.documentElement.style.setProperty( + HOVER_CURSOR_PROPERTY, + getComputedStyle(event.target).cursor, + ); + }; + document.addEventListener("pointerover", onPointerOver, { passive: true }); + return () => document.removeEventListener("pointerover", onPointerOver); + }, []); +} + +/** Counts the calling dialog as an open modal while `isOpen`, see `usePageViewTransitionClass`. */ +export function useReportModalOpen(isOpen: boolean) { + const { setCount } = React.useContext(OpenModalsContext); + + React.useEffect(() => { + if (!isOpen) return; + setCount((count) => count + 1); + return () => setCount((count) => count - 1); + }, [isOpen, setCount]); +} + +/** + * The `` class for an element of the page: `"none"` while a modal dialog is open. + * Snapshots paint above the dialog's backdrop, so a transition under it would show the page + * unblurred for a few frames, and there is nothing worth animating under a modal anyway. + */ +export function usePageViewTransitionClass(className: string) { + return React.useContext(OpenModalsContext).count > 0 ? "none" : className; +}