diff --git a/AGENTS.md b/AGENTS.md index 71ba99c07..90232fa2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command - typical way to structure pure logic is into Modules divided by logical domains which are imported with the "* as Module" import and then used like so "Module.foo()". These functions always need JSDoc. - non-exported functions typically do not need JSDoc or at least it can be kept short -- more topic docs live in `docs/dev/` — notably [architecture.md](./docs/dev/architecture.md) (feature folder layout) and [permissions.md](./docs/dev/permissions.md) (authorization: global roles via `requireRole()`/`useHasRole()`, per-object `permissions` computed in repositories) +- more topic docs live in `docs/dev/` — notably [architecture.md](./docs/dev/architecture.md) (feature folder layout), [permissions.md](./docs/dev/permissions.md) (authorization: global roles via `requireRole()`/`useHasRole()`, per-object `permissions` computed in repositories) and [overlays.md](./docs/dev/overlays.md) (popovers, menus, selects and dialogs: the floating layer, scroll lock and mobile keyboard handling) ## Commands diff --git a/app/browser-test-setup.ts b/app/browser-test-setup.ts index ad31b411c..02e59e1f6 100644 --- a/app/browser-test-setup.ts +++ b/app/browser-test-setup.ts @@ -10,6 +10,8 @@ import "~/styles/utils.css"; import "~/styles/flags.css"; document.documentElement.classList.add("dark"); +document.documentElement.style.setProperty("--popover-boundary-top", "0px"); +document.documentElement.style.setProperty("--popover-boundary-bottom", "0px"); i18next.use(initReactI18next).init({ ...config, diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index a0a530c9b..3f480267f 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -3,7 +3,7 @@ position: fixed; /* captured on its own so animating content slides under it instead of over it */ view-transition-name: layout-mobile-nav; - inset: auto 0 0 0; + inset: auto var(--scrollbar-width, 0px) 0 0; width: auto; height: auto; margin: 0; @@ -102,7 +102,7 @@ .panel { position: fixed; - inset: auto 0 var(--mobile-nav-height) 0; + inset: auto var(--scrollbar-width, 0px) var(--mobile-nav-height) 0; margin: 0; padding: 0; border: none; @@ -176,7 +176,7 @@ .menuOverlay { position: fixed; - inset: 0 0 var(--mobile-nav-height) 0; + inset: 0 var(--scrollbar-width, 0px) var(--mobile-nav-height) 0; margin: 0; padding: 0; border: none; diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx index 9df7d6f0d..96b4b678a 100644 --- a/app/components/MobileNav.tsx +++ b/app/components/MobileNav.tsx @@ -22,6 +22,7 @@ import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants"; import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigation"; +import { useScrollLock } from "~/hooks/useScrollLock"; import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests"; import type { RootLoaderData } from "~/root"; import { @@ -79,6 +80,7 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) { PANEL_TYPES.map((panel) => [panel, panelDomId(uid, panel)]), ) as PanelIds; + useScrollLock(activePanel !== null); useClosePopoversOnNavigation(rootRef); const chatContextRef = React.useRef(chatContext); diff --git a/app/components/elements/Dialog.browser.test.tsx b/app/components/elements/Dialog.browser.test.tsx index 1d073b907..41d96a3c0 100644 --- a/app/components/elements/Dialog.browser.test.tsx +++ b/app/components/elements/Dialog.browser.test.tsx @@ -3,7 +3,7 @@ import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { createMemoryRouter, RouterProvider } from "react-router"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { page } from "vitest/browser"; +import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; import { SendouDialog } from "./Dialog"; @@ -29,13 +29,77 @@ function openDialog() { return dialog; } -function clickDialogAt(dialog: HTMLDialogElement, x: number, y: number) { +/** A press on the dialog element, the way one on its backdrop arrives; it starts where it ends unless `pressedAt` says otherwise. */ +function clickDialogAt( + dialog: HTMLDialogElement, + x: number, + y: number, + { pressedAt = { x, y } }: { pressedAt?: { x: number; y: number } } = {}, +) { + dialog.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + clientX: pressedAt.x, + clientY: pressedAt.y, + }), + ); dialog.dispatchEvent( new MouseEvent("click", { bubbles: true, clientX: x, clientY: y }), ); } describe("SendouDialog", () => { + test("keeps a backdrop click from reaching the page underneath", async () => { + const onClose = vi.fn(); + const onBehindClick = vi.fn(); + await render( + withRouter( + <> + + + Content + + , + ), + ); + await expect.element(page.getByText("Content")).toBeVisible(); + + // forced past the actionability check, as the backdrop covers the button; + // the press then lands on the backdrop at the button's spot + await userEvent.click(page.getByText("Behind"), { force: true }); + + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + expect(onBehindClick).not.toHaveBeenCalled(); + }); + + test("keeps a dismissable dialog open when a press starts inside its box and ends outside", async () => { + const onClose = vi.fn(); + await render( + withRouter( + + Content + , + ), + ); + await expect.element(page.getByText("Content")).toBeVisible(); + + const dialog = openDialog(); + const rect = dialog.getBoundingClientRect(); + clickDialogAt(dialog, rect.right + 5, rect.bottom + 5, { + pressedAt: { x: rect.left + 1, y: rect.top + 1 }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onClose).not.toHaveBeenCalled(); + expect(dialog.open).toBe(true); + }); + test("closes a dismissable dialog on a backdrop click", async () => { const onClose = vi.fn(); await render( @@ -120,6 +184,83 @@ describe("SendouDialog", () => { expect(openDialog().open).toBe(true); }); + test("locks page scrolling while open without moving the page content", async () => { + const content = document.createElement("div"); + content.style.height = "300vh"; + content.style.width = "100%"; + document.body.appendChild(content); + cleanupFns.push(() => content.remove()); + + const screen = await render( + withRouter( + Open} + showCloseButton + > + Content + , + ), + ); + // after the render, whose container shares the body's flex row with the probe + const widthBefore = content.getBoundingClientRect().width; + + await screen.getByRole("button", { name: "Open" }).click(); + await expect.element(screen.getByText("Content")).toBeVisible(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + expect(content.getBoundingClientRect().width).toBe(widthBefore); + + await screen.getByRole("button", { name: "Close" }).click(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("")); + expect(document.body.style.paddingRight).toBe(""); + expect(content.getBoundingClientRect().width).toBe(widthBefore); + }); + + test("releases the scroll lock when an open dialog unmounts", async () => { + const screen = await render( + withRouter( + {}}> + Content + , + ), + ); + await expect.element(screen.getByText("Content")).toBeVisible(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("hidden")); + + await screen.unmount(); + await vi.waitFor(() => expect(document.body.style.overflow).toBe("")); + }); + + test("lets tall content take the visible height less the mobile margins", async () => { + const { innerWidth, innerHeight } = window; + await page.viewport(375, 667); + + try { + await render( + withRouter( + {}}> +
+ , + ), + ); + await expect.element(page.getByRole("dialog")).toBeVisible(); + + const dialog = openDialog(); + // the open animation scales the box, and the rect includes transforms + await Promise.all( + dialog.getAnimations().map((animation) => animation.finished), + ); + const rect = dialog.getBoundingClientRect(); + const maxHeight = Number.parseFloat(getComputedStyle(dialog).maxHeight); + + expect(maxHeight).toBeGreaterThan(window.innerHeight * 0.9); + expect(rect.height).toBeCloseTo(maxHeight, 0); + expect(rect.top).toBeCloseTo((window.innerHeight - rect.height) / 2, 0); + } finally { + await page.viewport(innerWidth, innerHeight); + } + }); + test("focuses the dialog itself instead of the close button on open", async () => { await render( withRouter( diff --git a/app/components/elements/Dialog.module.css b/app/components/elements/Dialog.module.css index 4f7c35854..274cceeae 100644 --- a/app/components/elements/Dialog.module.css +++ b/app/components/elements/Dialog.module.css @@ -1,7 +1,19 @@ .modal { + --dialog-padding-block: var(--s-6); + width: calc(100% - 2rem); max-width: 28rem; - max-height: min(80dvh, calc(var(--visual-viewport-height, 100dvh) - 10rem)); + inset-block-start: var(--visual-viewport-offset-top, 0px); + inset-block-end: calc( + 100% - + var(--visual-viewport-offset-top, 0px) - + var(--visual-viewport-height, 100%) + ); + max-height: calc( + var(--visual-viewport-height, 100dvh) - + 2 * + var(--modal-margin-block) + ); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; @@ -9,7 +21,7 @@ border: 1px solid var(--color-border); background-color: var(--color-bg); color: var(--color-text); - padding: var(--s-6); + padding: var(--dialog-padding-block) var(--s-6); text-align: left; vertical-align: middle; box-shadow: @@ -18,6 +30,10 @@ margin: auto; outline: none; animation: zoom-in-95 300ms ease-out; + + @media (width < 600px) { + --dialog-padding-block: var(--s-4); + } } .blurredBackdrop::backdrop { @@ -40,6 +56,7 @@ min-width: 100%; height: 100dvh; max-height: 100dvh; + inset-block: 0; border-radius: 0; margin: 0; padding-block-start: calc(env(safe-area-inset-top) + var(--s-6)); @@ -67,7 +84,7 @@ } .noHeading { - margin-block-start: -14px; + margin-block-start: calc(10px - var(--dialog-padding-block)); } .heading { diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx index eaf7c6357..814af1e08 100644 --- a/app/components/elements/Dialog.tsx +++ b/app/components/elements/Dialog.tsx @@ -8,6 +8,7 @@ import { type SendouButtonProps, } from "~/components/elements/Button"; import { useHydrated } from "~/hooks/useHydrated"; +import { useScrollLockWhileOpen } from "~/hooks/useScrollLock"; import { useReportModalOpen, useTopLayerViewTransitionStyle, @@ -73,10 +74,20 @@ function DialogElement({ ref, }: DialogElementProps) { const topLayerStyle = useTopLayerViewTransitionStyle(); + const dialogRef = React.useRef(null); + const backdropPressHandlers = useBackdropDismiss(isDismissable); + useScrollLockWhileOpen(dialogRef); return ( { + dialogRef.current = dialog; + if (typeof ref === "function") { + ref(dialog); + } else if (ref) { + ref.current = dialog; + } + }} id={id} style={topLayerStyle} className={clsx(className, { @@ -85,27 +96,44 @@ function DialogElement({ aria-label={ariaLabel} aria-labelledby={ariaLabelledby} tabIndex={-1} - closedby={isDismissable ? "any" : "closerequest"} + closedby="closerequest" onClose={onClose} - onClick={isDismissable ? closeOnBackdropClick : undefined} + {...backdropPressHandlers} > {children} ); } -// Safari 26 is missing `closedby`, close on backdrop clicks manually -function closeOnBackdropClick(event: React.MouseEvent) { - if (event.target !== event.currentTarget) return; +// Native `closedby` closes on pointer up so the click can land on stuff like buttons underneath the backdrop +// We just roll our own click handler here because that can't "leak" through +function useBackdropDismiss(enabled: boolean | undefined) { + const pressStartedOnBackdropRef = React.useRef(false); + + if (!enabled) return {}; + + return { + onPointerDown: (event: React.PointerEvent) => { + pressStartedOnBackdropRef.current = isOnBackdrop(event); + }, + onClick: (event: React.MouseEvent) => { + if (pressStartedOnBackdropRef.current && isOnBackdrop(event)) { + event.currentTarget.close(); + } + }, + }; +} + +function isOnBackdrop(event: React.MouseEvent) { + if (event.target !== event.currentTarget) return false; const rect = event.currentTarget.getBoundingClientRect(); - const outside = + + return ( event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || - event.clientY > rect.bottom; - if (outside) { - event.currentTarget.close(); - } + event.clientY > rect.bottom + ); } /** Invoker commands open and close the dialog natively; this guards the JS fallback for browsers without them. */ diff --git a/app/components/elements/Menu.module.css b/app/components/elements/Menu.module.css index 196354e1e..d383e1897 100644 --- a/app/components/elements/Menu.module.css +++ b/app/components/elements/Menu.module.css @@ -1,38 +1,24 @@ .triggerContainer { display: contents; - - > * { - anchor-name: var(--menu-anchor); - } } .popover { - position: fixed; - position-area: block-end span-inline-end; - margin: var(--s-2) 0; + position: absolute; + margin: 0; outline: none; border-radius: var(--radius-box); background-color: var(--color-bg-high); border: var(--border-style); width: max-content; - max-width: calc(100vw - var(--s-4)); + max-width: var(--floating-available-width, 100vw); font-size: var(--font-sm); font-weight: var(--weight-semi); padding: var(--s-2); color: var(--color-text); - - &[data-placement="bottom end"], - &[data-placement="bottom right"] { - position-area: block-end span-inline-start; - } -} - -.opensLeft { - position-area: block-end span-inline-start; } .scrolling { - max-height: 300px !important; + max-height: min(300px, var(--floating-available-height, 100vh)); overflow-y: auto; } diff --git a/app/components/elements/Menu.tsx b/app/components/elements/Menu.tsx index 7c3d536e8..15b09dc66 100644 --- a/app/components/elements/Menu.tsx +++ b/app/components/elements/Menu.tsx @@ -8,15 +8,14 @@ import { } 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"; import { focusLeftTo, isOwnToggle, - useAnchorSafeId, + usePopoverTargetOnceHydrated, useShowPopoverOnOpen, } from "./Popover"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { useFloatingLayer } from "./useFloatingLayer"; type MenuPlacement = "bottom start" | "bottom end" | "bottom right"; @@ -27,7 +26,7 @@ interface SendouMenuProps { children: React.ReactNode; popoverClassName?: string; placement?: MenuPlacement; - /** Render the items while closed too, so the menu works before hydration (and without JavaScript). */ + /** Render the items while closed too, so they are in the server markup and ready the moment the menu opens. */ eager?: boolean; } @@ -44,10 +43,9 @@ export function SendouMenu({ popoverClassName, eager, }: SendouMenuProps) { - const uid = useAnchorSafeId(); + const popoverId = `${React.useId()}-menu`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const topLayerStyle = useTopLayerViewTransitionStyle(); - const popoverId = `${uid}-menu`; - const anchorName = `--menu-anchor-${uid}`; const [open, setOpen] = React.useState(false); const popoverRef = React.useRef(null); @@ -63,12 +61,10 @@ export function SendouMenu({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => - popoverRef.current?.hidePopover(), - ); - useAnchorPositioning({ + + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null, placement: opensLeft || (placement && placement !== "bottom start") @@ -115,11 +111,10 @@ export function SendouMenu({ {React.cloneElement(trigger, { - popoverTarget: popoverId, + popoverTarget, "aria-expanded": open, "aria-haspopup": "menu", })} @@ -132,15 +127,8 @@ export function SendouMenu({ tabIndex={-1} className={clsx(styles.popover, "scrollbar", popoverClassName, { [styles.scrolling]: scrolling, - [styles.opensLeft]: opensLeft, })} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } - data-placement={placement} + style={topLayerStyle} onBeforeToggle={onBeforeToggle} onToggle={onToggle} onKeyDown={onKeyDown} diff --git a/app/components/elements/Popover.browser.test.tsx b/app/components/elements/Popover.browser.test.tsx index ae1325827..fd1419838 100644 --- a/app/components/elements/Popover.browser.test.tsx +++ b/app/components/elements/Popover.browser.test.tsx @@ -1,6 +1,6 @@ import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { page, userEvent } from "vitest/browser"; import { render } from "vitest-browser-react"; import { SendouPopover } from "./Popover"; @@ -93,4 +93,32 @@ describe("SendouPopover", () => { await expect.element(page.getByText("Popover content")).toBeVisible(); expect(popover.matches(":popover-open")).toBe(true); }); + + test("does not open before hydration, when nothing could place it", async () => { + const app = ( + Open}> + Popover content + + ); + + const container = document.createElement("div"); + container.innerHTML = renderToString(app); + document.body.appendChild(container); + cleanupFns.push(() => container.remove()); + + const trigger = container.querySelector("button"); + const popover = container.querySelector("[popover]"); + if (!trigger || !popover) throw new Error("no popover rendered"); + trigger.click(); + expect(popover.matches(":popover-open")).toBe(false); + + const root = hydrateRoot(container, app); + cleanupFns.push(() => root.unmount()); + + await vi.waitFor(() => + expect(trigger.getAttribute("popovertarget")).toBe(popover.id), + ); + trigger.click(); + await expect.element(page.getByText("Popover content")).toBeVisible(); + }); }); diff --git a/app/components/elements/Popover.module.css b/app/components/elements/Popover.module.css index 392ff7b5a..ea11b9f81 100644 --- a/app/components/elements/Popover.module.css +++ b/app/components/elements/Popover.module.css @@ -1,17 +1,12 @@ .triggerContainer { display: contents; - - > * { - anchor-name: var(--popover-anchor); - } } .content { - position: fixed; - position-area: block-end; - justify-self: anchor-center; - margin: var(--s-2) 0; - max-width: min(20rem, calc(100vw - var(--s-4))); + position: absolute; + margin: 0; + max-width: min(20rem, var(--floating-available-width, 100vw)); + max-height: var(--floating-available-height, none); overflow: auto; padding: var(--s-2); border: var(--border-style); @@ -22,24 +17,4 @@ background-color: var(--color-bg); color: var(--color-text); outline: none; - - &[data-placement="top"] { - position-area: block-start; - } - - &[data-placement="bottom start"] { - position-area: block-end span-inline-end; - justify-self: unset; - } - - &[data-placement="bottom end"] { - position-area: block-end span-inline-start; - justify-self: unset; - } - - &[data-placement="right"] { - position-area: inline-end; - justify-self: unset; - align-self: anchor-center; - } } diff --git a/app/components/elements/Popover.tsx b/app/components/elements/Popover.tsx index cf7b6a2fa..626c53bcf 100644 --- a/app/components/elements/Popover.tsx +++ b/app/components/elements/Popover.tsx @@ -1,21 +1,14 @@ import clsx from "clsx"; import * as React from "react"; import { flushSync } from "react-dom"; +import { useHydrated } from "~/hooks/useHydrated"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { useTopLayerViewTransitionStyle } from "~/utils/view-transition"; -import { - type AnchorPlacement, - useAnchorPositioning, -} from "./anchor-positioning"; import styles from "./Popover.module.css"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { type FloatingPlacement, useFloatingLayer } from "./useFloatingLayer"; +import { useScrollIntoView } from "./useScrollIntoView"; -export type PopoverPlacement = AnchorPlacement; - -/** `useId` values hold characters CSS idents can't (e.g. `:`), strip them for anchor names. */ -export function useAnchorSafeId() { - return React.useId().replace(/[^a-zA-Z0-9-]/g, ""); -} +export type PopoverPlacement = FloatingPlacement; /** * `toggle` does not bubble natively but React propagates it anyway, so an @@ -26,6 +19,10 @@ export function isOwnToggle(event: React.ToggleEvent) { return event.target === event.currentTarget; } +export function usePopoverTargetOnceHydrated(popoverId: string) { + return useHydrated() ? popoverId : undefined; +} + /** * Shows a popover once React has committed `open`, so content mounted only * while open is in the popover's first painted frame instead of appearing a @@ -33,7 +30,7 @@ export function isOwnToggle(event: React.ToggleEvent) { * browser's own open (the trigger's `popoverTarget`) is cancelled there and * redone through `onOpen` in the next frame, still before it paints, as a * popover cannot be shown from inside the show operation being cancelled. - * Call it before `useAnchorPositioning` so the popover is showing by the time + * Call it before `useFloatingLayer` so the popover is showing by the time * that measures it. */ export function useShowPopoverOnOpen({ @@ -86,10 +83,10 @@ export function focusLeftTo( /** * Popover opened by `trigger` (a SendouButton); controlled or uncontrolled. Renders through the - * native popover API with CSS anchor positioning. + * native popover API, placed next to the trigger by `useFloatingLayer`. * - * With `eager` the content is rendered while closed too, so the popover opens with its content - * before hydration (and without JavaScript altogether). + * With `eager` the content is rendered while closed too, so it is in the server markup and there + * is nothing left to mount when the popover opens. */ export function SendouPopover({ children, @@ -108,9 +105,8 @@ export function SendouPopover({ isOpen?: boolean; eager?: boolean; }) { - const uid = useAnchorSafeId(); - const popoverId = `${uid}-popover`; - const anchorName = `--popover-anchor-${uid}`; + const popoverId = `${React.useId()}-popover`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const [isControlled] = React.useState(isOpen !== undefined); const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); @@ -153,13 +149,15 @@ export function SendouPopover({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); - useAnchorPositioning({ + useScrollIntoView( + open, + () => triggerContainerRef.current?.firstElementChild ?? null, + ); + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerContainerRef.current?.firstElementChild ?? null, placement, - constrainHeight: true, }); const onToggle = (event: React.ToggleEvent) => { @@ -189,11 +187,10 @@ export function SendouPopover({ {React.cloneElement(trigger, { - popoverTarget: popoverId, + popoverTarget, "aria-haspopup": "dialog", })} @@ -202,15 +199,9 @@ export function SendouPopover({ id={popoverId} popover="auto" className={clsx(styles.content, popoverClassName)} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } + style={topLayerStyle} role="dialog" tabIndex={-1} - data-placement={placement} onBeforeToggle={onBeforeToggle} onToggle={onToggle} onBlur={onBlur} @@ -235,38 +226,28 @@ export function SendouAnchoredPopover({ triggerRef: React.RefObject; "aria-label"?: string; }) { - const uid = useAnchorSafeId(); - 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(() => { - const trigger = triggerRef.current; const popover = popoverRef.current; if (!popover) return; if (isOpen) { - trigger?.style.setProperty("anchor-name", anchorName); if (!popover.matches(":popover-open")) { popover.showPopover(); } } else if (popover.matches(":popover-open")) { popover.hidePopover(); } + }, [isOpen]); - return () => { - trigger?.style.removeProperty("anchor-name"); - }; - }, [isOpen, triggerRef, anchorName]); - - useCloseOnScrollClip(isOpen, popoverRef, () => onOpenChange(false)); - useAnchorPositioning({ + useScrollIntoView(isOpen, () => triggerRef.current); + useFloatingLayer({ isOpen, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerRef.current, - constrainHeight: true, }); const onToggle = (event: React.ToggleEvent) => { @@ -286,9 +267,7 @@ export function SendouAnchoredPopover({ ref={popoverRef} popover="auto" className={styles.content} - style={ - { positionAnchor: anchorName, ...topLayerStyle } as React.CSSProperties - } + style={topLayerStyle} role="dialog" tabIndex={-1} aria-label={ariaLabel} diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 8933b9775..bce5375a5 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -72,17 +72,11 @@ } .popover { - position: fixed; - position-area: block-end; - width: anchor-size(width); - min-width: anchor-size(width); - /* fills the space the position-area leaves (its containing block), less the - gap to the anchor (the popover's own margin) and the padding to the - viewport edge; which side that space is on gets pinned when the popover - opens, which also sets the cap in pixels as WebKit does not resolve this - percentage */ - max-height: calc(100% - var(--s-2) - 12px); - margin: var(--s-2) 0; + position: absolute; + margin: 0; + width: var(--floating-anchor-width); + min-width: var(--floating-anchor-width); + max-height: var(--floating-available-height, none); padding: var(--s-1); border: var(--border-style); border-radius: var(--radius-box); @@ -96,11 +90,6 @@ &:popover-open { display: flex; } - - /* opened upwards the sticky header is the ceiling, not the viewport top */ - &[data-side="above"] { - max-height: calc(100% - var(--s-2) - 12px - var(--popover-boundary-top)); - } } .listBox { diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index 6c79ca718..cfab43b5b 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -8,15 +8,15 @@ 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 { focusLeftTo, isOwnToggle, - useAnchorSafeId, + usePopoverTargetOnceHydrated, useShowPopoverOnOpen, } from "./Popover"; import styles from "./Select.module.css"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; +import { useFloatingLayer } from "./useFloatingLayer"; +import { useScrollIntoView } from "./useScrollIntoView"; export type SelectKey = string | number; @@ -96,7 +96,7 @@ export interface SendouSelectProps { /** * A customizable select component with optional search functionality, - * rendered through the native popover API with CSS anchor positioning. + * rendered through the native popover API and placed by `useFloatingLayer`. * * Options mount only while the popover is open (plus the selected one, so the * trigger can show it); the trigger's content is read straight from the @@ -144,11 +144,11 @@ export function SendouSelect({ children, }: SendouSelectProps) { const { t } = useTranslation(["common"]); - const uid = useAnchorSafeId(); + const uid = React.useId(); const topLayerStyle = useTopLayerViewTransitionStyle(); const popoverId = `${uid}-select-popover`; + const popoverTarget = usePopoverTargetOnceHydrated(popoverId); const listboxId = `${uid}-select-listbox`; - const anchorName = `--select-anchor-${uid}`; const labelId = label ? `${uid}-select-label` : undefined; const valueId = `${uid}-select-value`; const triggerId = `${uid}-select-trigger`; @@ -203,13 +203,11 @@ export function SendouSelect({ open, onOpen: () => setOpen(true), }); - useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); - useAnchorPositioning({ + useScrollIntoView(open, () => triggerElementRef.current); + useFloatingLayer({ isOpen: open, - popoverRef, + floatingRef: popoverRef, getAnchor: () => triggerElementRef.current, - matchAnchorWidth: true, - constrainHeight: true, }); // after positioning, so the selection scrolls into the space the list ends up with useIsomorphicLayoutEffect(() => { @@ -539,8 +537,7 @@ export function SendouSelect({ : undefined } data-required={isRequired || undefined} - popoverTarget={popoverId} - style={{ anchorName } as React.CSSProperties} + popoverTarget={popoverTarget} onKeyDown={onTriggerKeyDown} > ({ id={popoverId} popover="auto" className={clsx(styles.popover, popoverClassName)} - style={ - { - positionAnchor: anchorName, - ...topLayerStyle, - } as React.CSSProperties - } + style={topLayerStyle} onBeforeToggle={onPopoverBeforeToggle} onToggle={onPopoverToggle} onKeyDown={onPopoverKeyDown} diff --git a/app/components/elements/Toast.module.css b/app/components/elements/Toast.module.css index ad1da1bf2..3776b1be5 100644 --- a/app/components/elements/Toast.module.css +++ b/app/components/elements/Toast.module.css @@ -4,7 +4,7 @@ position: fixed; inset: unset; top: calc(var(--layout-nav-height) + var(--s-2)); - right: 10px; + right: calc(10px + var(--scrollbar-width, 0px)); margin: 0; padding: 0; border: none; diff --git a/app/components/elements/anchor-positioning.browser.test.tsx b/app/components/elements/anchor-positioning.browser.test.tsx deleted file mode 100644 index 5689f6f61..000000000 --- a/app/components/elements/anchor-positioning.browser.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; -import { render } from "vitest-browser-react"; -import { SendouPopover } from "./Popover"; -import { SendouSelect, SendouSelectItem } from "./Select"; - -const SEASONS = [{ id: 1, name: "Season 1" }]; -const MANY_SEASONS = Array.from({ length: 40 }, (_, index) => ({ - id: index + 1, - name: `Season ${index + 1}`, -})); - -let disablingStyle: HTMLStyleElement | null = null; - -afterEach(() => { - disablingStyle?.remove(); - disablingStyle = null; - vi.restoreAllMocks(); -}); - -/** The test browser has anchor positioning, so the fallback has to be forced on. */ -function disableAnchorPositioning() { - vi.spyOn(CSS, "supports").mockReturnValue(false); - - disablingStyle = document.createElement("style"); - disablingStyle.textContent = `[popover] { - position-area: none !important; - justify-self: normal !important; - align-self: normal !important; - }`; - document.head.append(disablingStyle); -} - -function rectOf(element: Element) { - return element.getBoundingClientRect(); -} - -describe("useAnchorPositioning", () => { - test("centers the popover under its trigger", async () => { - disableAnchorPositioning(); - const screen = await render( -
- Filters}> - Filter by season - -
, - ); - - const trigger = screen.getByRole("button", { name: "Filters" }); - await trigger.click(); - - const triggerRect = rectOf(trigger.element()); - const popoverRect = rectOf(screen.getByRole("dialog").element()); - - expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); - expect( - Math.abs( - popoverRect.left + - popoverRect.width / 2 - - (triggerRect.left + triggerRect.width / 2), - ), - ).toBeLessThan(2); - }); - - test("gives the select popover the width of its trigger", async () => { - disableAnchorPositioning(); - const screen = await render( -
- - {({ id, name }: (typeof SEASONS)[number]) => ( - - {name} - - )} - -
, - ); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1" })) - .toBeVisible(); - - const triggerRect = rectOf(trigger.element()); - const popover = document.querySelector("[popover]"); - const popoverRect = rectOf(popover as Element); - - expect(popoverRect.width).toBeCloseTo(triggerRect.width, 0); - expect(popoverRect.left).toBeCloseTo(triggerRect.left, 0); - expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); - }); - - test("opens a select upwards when its options do not fit below the trigger", async () => { - const screen = await render(); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - - const triggerRect = rectOf(trigger.element()); - const popoverRect = rectOf(document.querySelector("[popover]") as Element); - - expect(popoverRect.bottom).toBeLessThanOrEqual(triggerRect.top); - expect(popoverRect.top).toBeGreaterThanOrEqual(0); - }); - - test("caps a long select to the space below its trigger", async () => { - const screen = await render( -
- - {({ id, name }: (typeof MANY_SEASONS)[number]) => ( - - {name} - - )} - -
, - ); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - - const popover = document.querySelector("[popover]") as HTMLElement; - const listbox = screen.getByRole("listbox").element(); - const spaceBelow = - window.innerHeight - - rectOf(trigger.element()).bottom - - Number.parseFloat(getComputedStyle(popover).marginTop) - - 12; - - // an explicit cap, as WebKit does not resolve the percentage one in the CSS - expect(Number.parseFloat(popover.style.maxHeight)).toBeCloseTo( - spaceBelow, - 0, - ); - expect(rectOf(popover).bottom).toBeLessThanOrEqual(window.innerHeight); - expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight); - }); - - test("keeps the select where it opened when searching shrinks the list", async () => { - const screen = await render(); - - const trigger = screen.getByRole("button", { name: /Pick a season/ }); - await trigger.click(); - await expect - .element(screen.getByRole("option", { name: "Season 1", exact: true })) - .toBeVisible(); - const popover = document.querySelector("[popover]") as Element; - const bottomOnOpen = rectOf(popover).bottom; - - await screen.getByRole("combobox").fill("Season 40"); - await expect - .element(screen.getByRole("option", { name: "Season 40" })) - .toBeVisible(); - - expect(rectOf(popover).bottom).toBeCloseTo(bottomOnOpen, 0); - }); -}); - -function SelectNearViewportBottom() { - return ( -
- - {({ id, name }: (typeof MANY_SEASONS)[number]) => ( - - {name} - - )} - -
- ); -} diff --git a/app/components/elements/anchor-positioning.ts b/app/components/elements/anchor-positioning.ts deleted file mode 100644 index 36a584042..000000000 --- a/app/components/elements/anchor-positioning.ts +++ /dev/null @@ -1,334 +0,0 @@ -import * as React from "react"; -import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; - -const VIEWPORT_PADDING = 12; -const POSITION_PROPERTIES = [ - "top", - "right", - "bottom", - "left", - "max-height", - "width", -]; - -/** Mirrors the `position-area` values the popovers declare in their CSS. */ -export type AnchorPlacement = - | "top" - | "bottom" - | "right" - | "bottom start" - | "bottom end"; - -/** The `position-area` of the side each placement asks for, and of the opposite one. */ -const POSITION_AREAS: Record< - AnchorPlacement, - { preferred: string; flipped: string } -> = { - top: { preferred: "block-start", flipped: "block-end" }, - bottom: { preferred: "block-end", flipped: "block-start" }, - "bottom start": { - preferred: "block-end span-inline-end", - flipped: "block-start span-inline-end", - }, - "bottom end": { - preferred: "block-end span-inline-start", - flipped: "block-start span-inline-start", - }, - right: { preferred: "inline-end", flipped: "inline-start" }, -}; - -/** - * Opens a popover on the side of its anchor that fits its content, keeping it - * there for as long as it stays open. - * - * Where CSS anchor positioning is supported it only pins the `position-area` - * and, with `constrainHeight`, caps the height in pixels (WebKit resolves a - * percentage `max-height` of an anchor-positioned box against nothing, so a - * long list would run past the viewport with nothing to scroll). The CSS - * handles the rest. `position-try-fallbacks` is deliberately not used: - * it flips only when a side overflows, so a popover capped to the space it has - * never flips, and on iOS 26 a popover carrying it locks up the page for good - * when it leaves the top layer during a navigation. Presumably an iOS 26 WebKit - * bug, so a pure CSS solution is worth retrying once it is fixed upstream, but - * verify it in the iOS simulator before deploying. Browsers without anchor - * positioning (Chrome < 125, Safari < 26, Firefox < 147), where the popover - * would land in the top left corner of the viewport, get positioned here in full. - */ -export function useAnchorPositioning({ - isOpen, - popoverRef, - getAnchor, - placement = "bottom", - matchAnchorWidth = false, - constrainHeight = false, -}: { - isOpen: boolean; - popoverRef: React.RefObject; - getAnchor: () => Element | null; - placement?: AnchorPlacement; - /** Take the anchor's width, like the CSS `width: anchor-size(width)` does. */ - matchAnchorWidth?: boolean; - /** Cap the height to the space on the chosen side. Only for popovers that scroll their content. */ - constrainHeight?: boolean; -}) { - const getAnchorRef = React.useRef(getAnchor); - getAnchorRef.current = getAnchor; - - useIsomorphicLayoutEffect(() => { - const popover = popoverRef.current; - if (!isOpen || !popover) return; - - const anchorPositioned = CSS.supports("anchor-name: --a"); - - /** Picked on the first measurement, so growing or shrinking content cannot move the popover. */ - let fitsPreferred: boolean | null = null; - - const position = () => { - const anchor = getAnchorRef.current(); - // a popover shown after this effect (a controlled one) measures as hidden - if (!anchor || !popover.matches(":popover-open")) return; - - fitsPreferred ??= preferredSideFits(popover, anchor, placement); - const below = placement === "top" ? !fitsPreferred : fitsPreferred; - if (placement !== "right") { - popover.dataset.side = below ? "below" : "above"; - } - - if (anchorPositioned) { - const area = POSITION_AREAS[placement]; - popover.style.setProperty( - "position-area", - fitsPreferred ? area.preferred : area.flipped, - ); - if (constrainHeight) { - popover.style.setProperty( - "max-height", - px(availableHeight(popover, anchor, placement, below)), - ); - } - return; - } - - applyStyles( - popover, - positionStyles(popover, anchor, { - below, - placement, - matchAnchorWidth, - constrainHeight, - }), - ); - }; - position(); - - popover.addEventListener("toggle", position); - let contentObserver: ResizeObserver | undefined; - if (!anchorPositioned) { - // the popover is fixed, so it has to follow an anchor moved by scrolling - window.addEventListener("scroll", position, { - capture: true, - passive: true, - }); - window.addEventListener("resize", position); - contentObserver = new ResizeObserver(position); - contentObserver.observe(popover); - } - - return () => { - popover.removeEventListener("toggle", position); - window.removeEventListener("scroll", position, { capture: true }); - window.removeEventListener("resize", position); - contentObserver?.disconnect(); - delete popover.dataset.side; - popover.style.removeProperty("position-area"); - applyStyles(popover, {}); - }; - }, [isOpen, popoverRef, placement, matchAnchorWidth, constrainHeight]); -} - -/** Whether to keep to the side the placement asks for; the roomier one is taken when the content does not fit there. */ -function preferredSideFits( - popover: HTMLElement, - anchor: Element, - placement: AnchorPlacement, -) { - const anchorRect = anchor.getBoundingClientRect(); - const computed = getComputedStyle(popover); - - if (placement === "right") { - const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING; - const spaceInlineEnd = - window.innerWidth - anchorRect.right - VIEWPORT_PADDING; - const [preferred, other] = - computed.direction === "rtl" - ? [spaceInlineStart, spaceInlineEnd] - : [spaceInlineEnd, spaceInlineStart]; - const width = popover.getBoundingClientRect().width; - return width <= preferred || preferred >= other; - } - - const { above, below } = spaceAroundAnchor(anchorRect, computed); - const [preferred, other] = - placement === "top" ? [above, below] : [below, above]; - return naturalHeight(popover) <= preferred || preferred >= other; -} - -/** The height the popover may take on the side it opened to. */ -function availableHeight( - popover: HTMLElement, - anchor: Element, - placement: AnchorPlacement, - below: boolean, -) { - if (placement === "right") { - return Math.max(0, window.innerHeight - 2 * VIEWPORT_PADDING); - } - const space = spaceAroundAnchor( - anchor.getBoundingClientRect(), - getComputedStyle(popover), - ); - return Math.max(0, below ? space.below : space.above); -} - -/** - * Height each side of the anchor has for the popover, its margin and the - * viewport padding taken out. Above the anchor the sticky header - * (`--popover-boundary-top`) is the ceiling, not the top of the viewport. - */ -function spaceAroundAnchor(anchorRect: DOMRect, computed: CSSStyleDeclaration) { - return { - above: - anchorRect.top - - (Number.parseFloat(computed.getPropertyValue("--popover-boundary-top")) || - 0) - - VIEWPORT_PADDING - - Number.parseFloat(computed.marginBottom), - below: - window.innerHeight - - anchorRect.bottom - - VIEWPORT_PADDING - - Number.parseFloat(computed.marginTop), - }; -} - -/** The height the content wants, which the `max-height` capping it to one side's space hides. */ -function naturalHeight(popover: HTMLElement) { - const capped = popover.style.maxHeight; - popover.style.setProperty("max-height", "none"); - const height = popover.getBoundingClientRect().height; - if (capped) { - popover.style.setProperty("max-height", capped); - } else { - popover.style.removeProperty("max-height"); - } - return height; -} - -function positionStyles( - popover: HTMLElement, - anchor: Element, - { - below, - placement, - matchAnchorWidth, - constrainHeight, - }: { - below: boolean; - placement: AnchorPlacement; - matchAnchorWidth: boolean; - constrainHeight: boolean; - }, -) { - const anchorRect = anchor.getBoundingClientRect(); - const popoverRect = popover.getBoundingClientRect(); - const computed = getComputedStyle(popover); - const isRtl = computed.direction === "rtl"; - // the margins of the popover offset it from the inset it is given, which is - // the gap to the anchor in the block axis and drift to undo everywhere else - const marginTop = Number.parseFloat(computed.marginTop); - const marginLeft = Number.parseFloat(computed.marginLeft); - - const width = matchAnchorWidth ? anchorRect.width : popoverRect.width; - - const styles: Record = matchAnchorWidth - ? { width: px(anchorRect.width) } - : {}; - - if (placement === "right") { - // inline-end of the anchor, flipping over it like `flip-inline` does - const height = Math.max(popoverRect.height, popover.scrollHeight); - const spaceInlineStart = anchorRect.left - VIEWPORT_PADDING; - const spaceInlineEnd = - window.innerWidth - anchorRect.right - VIEWPORT_PADDING; - const [preferred, other] = isRtl - ? [spaceInlineStart, spaceInlineEnd] - : [spaceInlineEnd, spaceInlineStart]; - const towardsInlineEnd = width <= preferred || preferred >= other; - - return { - ...styles, - top: px(anchorRect.top + anchorRect.height / 2 - height / 2 - marginTop), - bottom: "auto", - ...horizontalPlacement( - towardsInlineEnd !== isRtl ? anchorRect.right : anchorRect.left - width, - width, - marginLeft, - ), - }; - } - - const alignedToAnchorLeft = - placement === "bottom start" - ? !isRtl - : placement === "bottom end" - ? isRtl - : null; - const left = - alignedToAnchorLeft === null - ? anchorRect.left + anchorRect.width / 2 - width / 2 - : alignedToAnchorLeft - ? anchorRect.left - : anchorRect.right - width; - - return { - ...styles, - ...(below - ? { top: px(anchorRect.bottom), bottom: "auto" } - : { top: "auto", bottom: px(window.innerHeight - anchorRect.top) }), - ...(constrainHeight - ? { "max-height": px(availableHeight(popover, anchor, placement, below)) } - : {}), - ...horizontalPlacement(left, width, marginLeft), - }; -} - -function horizontalPlacement(left: number, width: number, marginLeft: number) { - const rightmost = Math.max( - VIEWPORT_PADDING, - window.innerWidth - VIEWPORT_PADDING - width, - ); - - return { - left: px( - Math.min(Math.max(left, VIEWPORT_PADDING), rightmost) - marginLeft, - ), - right: "auto", - }; -} - -/** Writes the positioning properties, clearing the ones the placement leaves out. */ -function applyStyles(popover: HTMLElement, styles: Record) { - for (const property of POSITION_PROPERTIES) { - const value = styles[property]; - - if (value === undefined) { - popover.style.removeProperty(property); - } else if (popover.style.getPropertyValue(property) !== value) { - popover.style.setProperty(property, value); - } - } -} - -function px(value: number) { - return `${Math.round(value)}px`; -} diff --git a/app/components/elements/floating-layer.test.ts b/app/components/elements/floating-layer.test.ts new file mode 100644 index 000000000..54ad17225 --- /dev/null +++ b/app/components/elements/floating-layer.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "vitest"; +import * as FloatingLayer from "./floating-layer"; + +const BOUNDS: FloatingLayer.Bounds = { + top: 0, + right: 1000, + bottom: 800, + left: 0, +}; +const VIEWPORT = { width: 1000, height: 800 }; +const GAP = 8; +const PADDING = 12; + +function anchorAt(top: number, left = 100): FloatingLayer.Rect { + return { top, left, width: 200, height: 40 }; +} + +describe("FloatingLayer.resolve", () => { + test.each([ + { + why: "stays below when the content fits there", + anchorTop: 100, + height: 300, + side: "bottom", + availableHeight: 640, + }, + { + why: "flips above when the content does not fit below but has more room there", + anchorTop: 700, + height: 300, + side: "top", + availableHeight: 680, + }, + { + why: "takes the roomier side when the content fits on neither", + anchorTop: 700, + height: 900, + side: "top", + availableHeight: 680, + }, + { + why: "keeps the asked side when it is the roomier one though the content fits on neither", + anchorTop: 100, + height: 900, + side: "bottom", + availableHeight: 640, + }, + ])("$why", ({ anchorTop, height, side, availableHeight }) => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(anchorTop), + floating: { width: 200, height }, + bounds: BOUNDS, + placement: "bottom", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe(side); + expect(resolution.availableHeight).toBe(availableHeight); + expect(resolution.availableWidth).toBe(976); + }); + + test("keeps a sticky header at the top of the bounds out of the room above", () => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(300), + floating: { width: 200, height: 250 }, + bounds: { ...BOUNDS, top: 55 }, + placement: "top", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe("bottom"); + expect(resolution.availableHeight).toBe(440); + }); + + test("puts a beside placement on the other side when the content has more room there", () => { + const resolution = FloatingLayer.resolve({ + anchor: { top: 100, left: 800, width: 100, height: 40 }, + floating: { width: 200, height: 40 }, + bounds: BOUNDS, + placement: "right", + gap: GAP, + padding: PADDING, + rtl: false, + }); + + expect(resolution.side).toBe("left"); + expect(resolution.availableWidth).toBe(780); + expect(resolution.availableHeight).toBe(776); + }); + + test.each([ + { placement: "bottom", rtl: false, align: "center", origin: "50% 0%" }, + { placement: "bottom start", rtl: false, align: "start", origin: "0% 0%" }, + { placement: "bottom end", rtl: false, align: "end", origin: "100% 0%" }, + { placement: "bottom end", rtl: true, align: "end", origin: "0% 0%" }, + { placement: "top", rtl: false, align: "center", origin: "50% 100%" }, + { placement: "right", rtl: false, align: "center", origin: "0% 50%" }, + ] as const)( + "$placement (rtl: $rtl) aligns $align with its origin at $origin", + ({ placement, rtl, align, origin }) => { + const resolution = FloatingLayer.resolve({ + anchor: anchorAt(100, 400), + floating: { width: 100, height: 50 }, + bounds: BOUNDS, + placement, + gap: GAP, + padding: PADDING, + rtl, + }); + + expect(resolution.align).toBe(align); + expect(resolution.transformOrigin).toBe(origin); + }, + ); +}); + +describe("FloatingLayer.spaceAcross", () => { + test.each([ + { placement: "bottom", space: 976 }, + { placement: "top", space: 976 }, + { placement: "right", space: 776 }, + ] as const)("$placement has $space across", ({ placement, space }) => { + expect(FloatingLayer.spaceAcross(BOUNDS, placement, PADDING)).toBe(space); + }); +}); + +describe("FloatingLayer.isVerticalPlacement", () => { + test.each([ + { placement: "bottom start", vertical: true }, + { placement: "top", vertical: true }, + { placement: "right", vertical: false }, + ] as const)("$placement -> $vertical", ({ placement, vertical }) => { + expect(FloatingLayer.isVerticalPlacement(placement)).toBe(vertical); + }); +}); + +describe("FloatingLayer.insets", () => { + const box = { width: 100, height: 50 }; + + test.each([ + { + why: "centers under the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 150 }, + }, + { + why: "hangs from the edge facing the anchor above it", + anchor: anchorAt(100), + floating: box, + side: "top", + align: "center", + rtl: false, + expected: { top: null, right: null, bottom: 708, left: 150 }, + }, + { + why: "lines up with the start edge of the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "start", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 100 }, + }, + { + why: "lines up with the end edge of the anchor", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "end", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 200 }, + }, + { + why: "reads start as the right edge in rtl", + anchor: anchorAt(100), + floating: box, + side: "bottom", + align: "start", + rtl: true, + expected: { top: 148, right: null, bottom: null, left: 200 }, + }, + { + why: "shifts back inside the bounds on the right", + anchor: anchorAt(100, 900), + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 888 }, + }, + { + why: "shifts back inside the bounds on the left", + anchor: { top: 100, left: 0, width: 50, height: 40 }, + floating: box, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 12 }, + }, + { + why: "sits at the padding when wider than the bounds", + anchor: anchorAt(100), + floating: { width: 1200, height: 50 }, + side: "bottom", + align: "center", + rtl: false, + expected: { top: 148, right: null, bottom: null, left: 12 }, + }, + { + why: "sits beside the anchor", + anchor: { top: 300, left: 100, width: 100, height: 40 }, + floating: box, + side: "right", + align: "center", + rtl: false, + expected: { top: 295, right: null, bottom: null, left: 208 }, + }, + { + why: "hangs from the edge facing the anchor on its left", + anchor: { top: 300, left: 100, width: 100, height: 40 }, + floating: box, + side: "left", + align: "center", + rtl: false, + expected: { top: 295, right: 908, bottom: null, left: null }, + }, + { + why: "shifts down inside the bounds beside a high anchor", + anchor: { top: 10, left: 100, width: 100, height: 40 }, + floating: box, + side: "right", + align: "center", + rtl: false, + expected: { top: 12, right: null, bottom: null, left: 208 }, + }, + ] as const)("$why", ({ anchor, floating, side, align, rtl, expected }) => { + expect( + FloatingLayer.insets({ + anchor, + floating, + bounds: BOUNDS, + side, + align, + gap: GAP, + padding: PADDING, + rtl, + viewport: VIEWPORT, + }), + ).toEqual(expected); + }); +}); + +describe("FloatingLayer.documentInsets", () => { + test.each([ + { + why: "moves the near edges down the document by the scroll offset", + insets: { top: 148, right: null, bottom: null, left: 150 }, + scroll: { x: 0, y: 300 }, + expected: { top: 448, right: null, bottom: null, left: 150 }, + }, + { + why: "moves the far edges the other way", + insets: { top: null, right: 908, bottom: 708, left: null }, + scroll: { x: 40, y: 300 }, + expected: { top: null, right: 868, bottom: 408, left: null }, + }, + { + why: "leaves an unscrolled page as it is", + insets: { top: 148, right: null, bottom: null, left: 150 }, + scroll: { x: 0, y: 0 }, + expected: { top: 148, right: null, bottom: null, left: 150 }, + }, + ])("$why", ({ insets, scroll, expected }) => { + expect(FloatingLayer.documentInsets(insets, scroll)).toEqual(expected); + }); +}); diff --git a/app/components/elements/floating-layer.ts b/app/components/elements/floating-layer.ts new file mode 100644 index 000000000..161832533 --- /dev/null +++ b/app/components/elements/floating-layer.ts @@ -0,0 +1,241 @@ +export type Side = "top" | "bottom" | "left" | "right"; +export type Align = "start" | "center" | "end"; + +export type Placement = + | "top" + | "bottom" + | "right" + | "bottom start" + | "bottom end"; + +export interface Size { + width: number; + height: number; +} + +export interface Rect extends Size { + top: number; + left: number; +} + +export interface Bounds { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface Resolution { + side: Side; + align: Align; + availableWidth: number; + availableHeight: number; + transformOrigin: string; +} + +export interface Insets { + top: number | null; + right: number | null; + bottom: number | null; + left: number | null; +} + +const OPPOSITE_SIDE: Record = { + top: "bottom", + bottom: "top", + left: "right", + right: "left", +}; + +const ALIGN_ORIGIN: Record = { + start: "0%", + center: "50%", + end: "100%", +}; + +export function resolve({ + anchor, + floating, + bounds, + placement, + gap, + padding, + rtl, +}: { + anchor: Rect; + floating: Size; + bounds: Bounds; + placement: Placement; + gap: number; + padding: number; + rtl: boolean; +}): Resolution { + const { side: preferred, align } = parsePlacement(placement); + + const space = spaceAround(anchor, bounds, gap, padding); + const opposite = OPPOSITE_SIDE[preferred]; + const needed = isVertical(preferred) ? floating.height : floating.width; + const across = spaceAcross(bounds, placement, padding); + + const side = + needed <= space[preferred] || space[preferred] >= space[opposite] + ? preferred + : opposite; + + return { + side, + align, + availableWidth: Math.max(0, isVertical(side) ? across : space[side]), + availableHeight: Math.max(0, isVertical(side) ? space[side] : across), + transformOrigin: transformOrigin(side, align, rtl), + }; +} + +export function spaceAcross( + bounds: Bounds, + placement: Placement, + padding: number, +) { + const size = isVerticalPlacement(placement) + ? bounds.right - bounds.left + : bounds.bottom - bounds.top; + + return Math.max(0, size - 2 * padding); +} + +export function isVerticalPlacement(placement: Placement) { + return isVertical(parsePlacement(placement).side); +} + +export function insets({ + anchor, + floating, + bounds, + side, + align, + gap, + padding, + rtl, + viewport, +}: { + anchor: Rect; + floating: Size; + bounds: Bounds; + side: Side; + align: Align; + gap: number; + padding: number; + rtl: boolean; + viewport: Size; +}): Insets { + if (isVertical(side)) { + const left = clamp( + alignedStart( + anchor.left, + anchor.width, + floating.width, + physicalAlign(align, rtl), + ), + bounds.left + padding, + bounds.right - padding - floating.width, + ); + + return side === "bottom" + ? { + top: anchor.top + anchor.height + gap, + right: null, + bottom: null, + left, + } + : { + top: null, + right: null, + bottom: viewport.height - (anchor.top - gap), + left, + }; + } + + const top = clamp( + alignedStart(anchor.top, anchor.height, floating.height, align), + bounds.top + padding, + bounds.bottom - padding - floating.height, + ); + + return side === "right" + ? { top, right: null, bottom: null, left: anchor.left + anchor.width + gap } + : { + top, + right: viewport.width - (anchor.left - gap), + bottom: null, + left: null, + }; +} + +export function documentInsets( + viewportInsets: Insets, + scroll: { x: number; y: number }, +): Insets { + const { top, right, bottom, left } = viewportInsets; + + return { + top: top === null ? null : top + scroll.y, + right: right === null ? null : right - scroll.x, + bottom: bottom === null ? null : bottom - scroll.y, + left: left === null ? null : left + scroll.x, + }; +} + +function parsePlacement(placement: Placement): { side: Side; align: Align } { + const [side, align = "center"] = placement.split(" ") as [Side, Align?]; + return { side, align }; +} + +function isVertical(side: Side) { + return side === "top" || side === "bottom"; +} + +function spaceAround( + anchor: Rect, + bounds: Bounds, + gap: number, + padding: number, +): Record { + const taken = gap + padding; + + return { + top: anchor.top - bounds.top - taken, + bottom: bounds.bottom - (anchor.top + anchor.height) - taken, + left: anchor.left - bounds.left - taken, + right: bounds.right - (anchor.left + anchor.width) - taken, + }; +} + +function alignedStart( + anchorStart: number, + anchorSize: number, + floatingSize: number, + align: Align, +) { + if (align === "start") return anchorStart; + if (align === "end") return anchorStart + anchorSize - floatingSize; + return anchorStart + anchorSize / 2 - floatingSize / 2; +} + +function physicalAlign(align: Align, rtl: boolean): Align { + if (!rtl || align === "center") return align; + return align === "start" ? "end" : "start"; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(value, max)); +} + +function transformOrigin(side: Side, align: Align, rtl: boolean) { + if (isVertical(side)) { + const x = ALIGN_ORIGIN[physicalAlign(align, rtl)]; + return side === "bottom" ? `${x} 0%` : `${x} 100%`; + } + + const y = ALIGN_ORIGIN[align]; + return side === "right" ? `0% ${y}` : `100% ${y}`; +} diff --git a/app/components/elements/tests/keyboard.ts b/app/components/elements/tests/keyboard.ts new file mode 100644 index 000000000..980c12e65 --- /dev/null +++ b/app/components/elements/tests/keyboard.ts @@ -0,0 +1,23 @@ +import { invariant } from "~/utils/invariant"; + +export const KEYBOARD_HEIGHT = 300; + +export function openKeyboard() { + const viewport = window.visualViewport; + invariant(viewport); + + const shrunk = viewport.height - KEYBOARD_HEIGHT; + Object.defineProperty(viewport, "height", { + configurable: true, + get: () => shrunk, + }); + viewport.dispatchEvent(new Event("resize")); +} + +export function closeKeyboard() { + const viewport = window.visualViewport; + invariant(viewport); + + Reflect.deleteProperty(viewport, "height"); + viewport.dispatchEvent(new Event("resize")); +} diff --git a/app/components/elements/useCloseOnScrollClip.browser.test.tsx b/app/components/elements/useCloseOnScrollClip.browser.test.tsx deleted file mode 100644 index ba617fa22..000000000 --- a/app/components/elements/useCloseOnScrollClip.browser.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import * as React from "react"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { render } from "vitest-browser-react"; -import { invariant } from "~/utils/invariant"; -import { useCloseOnScrollClip } from "./useCloseOnScrollClip"; - -const PAGE_HEIGHT = 5000; - -afterEach(() => { - window.scrollTo(0, 0); - closeKeyboard(); -}); - -function Overlay({ - top, - height, - close, -}: { - top: number; - height: number; - close: () => void; -}) { - const ref = React.useRef(null); - useCloseOnScrollClip(true, ref, close); - - return ( - <> -
-
- - ); -} - -function ScrollingOverlay({ - height, - close, -}: { - height: number; - close: () => void; -}) { - const ref = React.useRef(null); - useCloseOnScrollClip(true, ref, close); - - return ( - <> -
-
-
-
- - ); -} - -const settle = () => new Promise((resolve) => setTimeout(resolve, 150)); - -const KEYBOARD_HEIGHT = 300; - -/** Shrinks the visual viewport the way the virtual keyboard opening does. */ -function openKeyboard() { - const viewport = window.visualViewport; - invariant(viewport); - - const shrunk = viewport.height - KEYBOARD_HEIGHT; - Object.defineProperty(viewport, "height", { - configurable: true, - get: () => shrunk, - }); - viewport.dispatchEvent(new Event("resize")); -} - -function closeKeyboard() { - const viewport = window.visualViewport; - invariant(viewport); - - Reflect.deleteProperty(viewport, "height"); - viewport.dispatchEvent(new Event("resize")); -} - -describe("useCloseOnScrollClip", () => { - test("closes once scrolling clips a popover that was fully visible", async () => { - const close = vi.fn(); - await render(); - await settle(); - expect(close).not.toHaveBeenCalled(); - - window.scrollTo(0, 250); - - await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); - }); - - test("never closes a popover too tall to have been fully visible", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("does not close a popover that was already clipped when it opened", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("never closes over the scroll the virtual keyboard opening causes", async () => { - const close = vi.fn(); - await render(); - await settle(); - - openKeyboard(); - window.scrollTo(0, 250); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("forgets a scroll the keyboard only lands after", async () => { - const close = vi.fn(); - await render(); - await settle(); - - window.scrollTo(0, 250); - // the scroll can reach the page before the keyboard has shrunk the viewport - window.dispatchEvent(new Event("scroll")); - openKeyboard(); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); - - test("never closes over a scroll of the popover's own content", async () => { - const close = vi.fn(); - const screen = await render( - , - ); - await settle(); - - const scroller = document.querySelector( - '[data-testid="scroller"]', - ); - invariant(scroller); - scroller.scrollTop = 500; - await settle(); - - // the content growing then clips it, which alone must never close - screen.rerender(); - await settle(); - - expect(close).not.toHaveBeenCalled(); - }); -}); diff --git a/app/components/elements/useCloseOnScrollClip.ts b/app/components/elements/useCloseOnScrollClip.ts deleted file mode 100644 index 5b130cc5b..000000000 --- a/app/components/elements/useCloseOnScrollClip.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as React from "react"; - -const VISIBLE_RATIO_THRESHOLD = 0.98; -/** A visual viewport shorter than the window by more than this is the virtual keyboard, not collapsing browser chrome. */ -const KEYBOARD_MIN_HEIGHT = 150; - -/** - * Closes an open popover once scrolling clips it against the sticky header - * (`--popover-boundary-top`) or the bottom of the viewport. - * - * Only scrolling may close: a popover clipped by its own content growing (the - * moment before anchor positioning flips it into view), one too tall to ever - * fit fully, or one measured before it is shown must not close itself. The - * virtual keyboard opening is not scrolling either, even though the browser - * scrolls the page to keep the focused field in view as it does: a popover - * left under the keyboard beats one that closes as its own search input is - * focused. - */ -export function useCloseOnScrollClip( - isOpen: boolean, - elementRef: React.RefObject, - close: () => void, -) { - const closeRef = React.useRef(close); - closeRef.current = close; - - React.useEffect(() => { - if (!isOpen) return; - const element = elementRef.current; - if (!element) return; - - const marginTop = - Number.parseFloat( - getComputedStyle(element).getPropertyValue("--popover-boundary-top"), - ) || 0; - - let wasFullyVisible = false; - let scrolledSinceFullyVisible = false; - - const onScroll = (event: Event) => { - // the popover scrolling its own content (e.g. a select revealing the - // selected option) is not the page moving out from under it - if (event.target instanceof Node && element.contains(event.target)) { - return; - } - if (keyboardIsOpen()) return; - scrolledSinceFullyVisible = true; - }; - window.addEventListener("scroll", onScroll, { - capture: true, - passive: true, - }); - - // the keyboard can land after the scroll it causes, which then has to be forgotten - const onViewportResize = () => { - if (keyboardIsOpen()) { - scrolledSinceFullyVisible = false; - } - }; - window.visualViewport?.addEventListener("resize", onViewportResize); - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries.at(-1); - if (!entry) return; - if (entry.intersectionRatio >= VISIBLE_RATIO_THRESHOLD) { - wasFullyVisible = true; - scrolledSinceFullyVisible = false; - } else if (wasFullyVisible && scrolledSinceFullyVisible) { - closeRef.current(); - } - }, - { - threshold: [0, VISIBLE_RATIO_THRESHOLD], - rootMargin: `${-marginTop}px 0px 0px 0px`, - }, - ); - observer.observe(element); - - return () => { - window.removeEventListener("scroll", onScroll, { capture: true }); - window.visualViewport?.removeEventListener("resize", onViewportResize); - observer.disconnect(); - }; - }, [isOpen, elementRef]); -} - -function keyboardIsOpen() { - const viewport = window.visualViewport; - if (!viewport) return false; - - return window.innerHeight - viewport.height > KEYBOARD_MIN_HEIGHT; -} diff --git a/app/components/elements/useFloatingLayer.browser.test.tsx b/app/components/elements/useFloatingLayer.browser.test.tsx new file mode 100644 index 000000000..e4ddb722e --- /dev/null +++ b/app/components/elements/useFloatingLayer.browser.test.tsx @@ -0,0 +1,426 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { page } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { invariant } from "~/utils/invariant"; +import { SendouPopover } from "./Popover"; +import { SendouSelect, SendouSelectItem } from "./Select"; +import { closeKeyboard, KEYBOARD_HEIGHT, openKeyboard } from "./tests/keyboard"; + +const SEASONS = [{ id: 1, name: "Season 1" }]; +const MANY_SEASONS = Array.from({ length: 40 }, (_, index) => ({ + id: index + 1, + name: `Season ${index + 1}`, +})); + +afterEach(() => { + window.scrollTo(0, 0); + closeKeyboard(); +}); + +function rectOf(element: Element) { + return element.getBoundingClientRect(); +} + +function openPopover() { + const popover = document.querySelector("[popover]"); + invariant(popover); + return popover; +} + +function lengthOf(element: Element, property: string) { + return Number.parseFloat( + getComputedStyle(element).getPropertyValue(property), + ); +} + +/** A popover takes focus a frame after opening, scrolling itself into view, which would undo a scroll made before then. */ +async function waitForFocus(popover: Element) { + await vi.waitFor(() => expect(document.activeElement).toBe(popover)); +} + +function ManySeasonsSelect() { + return ( + + {({ id, name }: (typeof MANY_SEASONS)[number]) => ( + + {name} + + )} + + ); +} + +describe("useFloatingLayer", () => { + test("centers the popover under its trigger", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + + const popover = screen.getByRole("dialog").element(); + const triggerRect = rectOf(trigger.element()); + const popoverRect = rectOf(popover); + + expect(popoverRect.top).toBeCloseTo( + triggerRect.bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + expect( + Math.abs( + popoverRect.left + + popoverRect.width / 2 - + (triggerRect.left + triggerRect.width / 2), + ), + ).toBeLessThan(2); + expect(popover.getAttribute("data-side")).toBe("bottom"); + expect(popover.getAttribute("data-align")).toBe("center"); + }); + + test("gives the select popover the width of its trigger", async () => { + const screen = await render( +
+ + {({ id, name }: (typeof SEASONS)[number]) => ( + + {name} + + )} + +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1" })) + .toBeVisible(); + + const triggerRect = rectOf(trigger.element()); + const popoverRect = rectOf(openPopover()); + + expect(popoverRect.width).toBeCloseTo(triggerRect.width, 0); + expect(popoverRect.left).toBeCloseTo(triggerRect.left, 0); + expect(popoverRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); + }); + + test("opens a select upwards when its options do not fit below the trigger", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const triggerRect = rectOf(trigger.element()); + const popover = openPopover(); + const popoverRect = rectOf(popover); + + expect(popoverRect.bottom).toBeLessThanOrEqual(triggerRect.top); + expect(popoverRect.top).toBeGreaterThanOrEqual(0); + expect(popover.getAttribute("data-side")).toBe("top"); + }); + + test("caps a long select to the space below its trigger", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const popover = openPopover(); + const listbox = screen.getByRole("listbox").element(); + const spaceBelow = Math.floor( + window.innerHeight - + lengthOf(popover, "--popover-boundary-bottom") - + rectOf(trigger.element()).bottom - + lengthOf(popover, "--floating-gap") - + lengthOf(popover, "--floating-viewport-padding"), + ); + + expect(lengthOf(popover, "--floating-available-height")).toBe(spaceBelow); + expect(Number.parseFloat(getComputedStyle(popover).maxHeight)).toBe( + spaceBelow, + ); + expect(rectOf(popover).bottom).toBeLessThanOrEqual(window.innerHeight); + expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight); + }); + + test("keeps the select where it opened when searching shrinks the list", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + const bottomOnOpen = rectOf(popover).bottom; + + await screen.getByRole("combobox").fill("Season 40"); + await expect + .element(screen.getByRole("option", { name: "Season 40" })) + .toBeVisible(); + + expect(rectOf(popover).bottom).toBeCloseTo(bottomOnOpen, 0); + }); + + test("follows its trigger when the page scrolls", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + const popover = screen.getByRole("dialog").element(); + const gap = lengthOf(popover, "--floating-gap"); + await waitForFocus(popover); + + window.scrollTo(0, 40); + + await vi.waitFor(() => { + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + gap, + 0, + ); + }); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("keeps a select out from under the keyboard taking the space below it", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + const viewport = window.visualViewport; + invariant(viewport); + expect(rectOf(popover).bottom).toBeGreaterThan( + viewport.height - KEYBOARD_HEIGHT, + ); + + openKeyboard(); + + await vi.waitFor(() => { + expect(rectOf(popover).bottom).toBeLessThanOrEqual(viewport.height); + }); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("flips over when scrolling leaves it more room on the other side", async () => { + const screen = await render( +
+ +
, + ); + + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + expect(popover.getAttribute("data-side")).toBe("top"); + + window.scrollTo(0, 400); + + await vi.waitFor(() => { + expect(popover.getAttribute("data-side")).toBe("bottom"); + }); + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + expect(popover.matches(":popover-open")).toBe(true); + }); + + test("keeps above the mobile nav's floor", async () => { + const { innerWidth, innerHeight } = window; + const root = document.documentElement; + const floorBefore = root.style.getPropertyValue( + "--popover-boundary-bottom", + ); + await page.viewport(375, 667); + // what the layout sets at the mobile breakpoint, with no layout rendered here + root.style.setProperty("--popover-boundary-bottom", "55px"); + + try { + const screen = await render( +
+ +
, + ); + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + + const popover = openPopover(); + const floor = lengthOf(popover, "--popover-boundary-bottom"); + expect(floor).toBeGreaterThan(0); + expect(rectOf(popover).bottom).toBeLessThanOrEqual( + window.innerHeight - floor, + ); + } finally { + root.style.setProperty("--popover-boundary-bottom", floorBefore); + await page.viewport(innerWidth, innerHeight); + } + }); + + test("caps the content again after the viewport shrinks with the popover above its trigger", async () => { + const { innerWidth, innerHeight } = window; + + try { + // pixels rather than viewport units, so the trigger stays put when the + // viewport shrinks, and high enough up to stay in view once it has + const screen = await render( +
+ +
, + ); + const trigger = screen.getByRole("button", { name: /Pick a season/ }); + await trigger.click(); + await expect + .element(screen.getByRole("option", { name: "Season 1", exact: true })) + .toBeVisible(); + const popover = openPopover(); + expect(popover.getAttribute("data-side")).toBe("top"); + + await page.viewport(innerWidth, innerHeight - 40); + + await vi.waitFor(() => { + expect( + Number.parseFloat(getComputedStyle(popover).maxHeight), + ).toBeLessThanOrEqual(window.innerHeight); + }); + expect(getComputedStyle(popover).visibility).toBe("visible"); + expect(rectOf(popover).top).toBeGreaterThanOrEqual(0); + } finally { + await page.viewport(innerWidth, innerHeight); + } + }); + + test("stays put under an anchor in a sticky header while the page scrolls", async () => { + const screen = await render( +
+
+ Filters}> + Filter by season + +
+
, + ); + + const trigger = screen.getByRole("button", { name: "Filters" }); + await trigger.click(); + const popover = screen.getByRole("dialog").element(); + const topBefore = rectOf(popover).top; + expect(getComputedStyle(popover).position).toBe("fixed"); + await waitForFocus(popover); + + window.scrollTo(0, 200); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + + expect(rectOf(popover).top).toBeCloseTo(topBefore, 0); + expect(rectOf(popover).top).toBeCloseTo( + rectOf(trigger.element()).bottom + lengthOf(popover, "--floating-gap"), + 0, + ); + }); + + test("hides while its trigger is scrolled out of sight and shows again once it is back", async () => { + const screen = await render( +
+ Filters}> + Filter by season + +
, + ); + + await screen.getByRole("button", { name: "Filters" }).click(); + const popover = screen.getByRole("dialog").element(); + expect(getComputedStyle(popover).visibility).toBe("visible"); + await waitForFocus(popover); + + window.scrollTo(0, 600); + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("hidden"); + }); + expect(popover.matches(":popover-open")).toBe(true); + + window.scrollTo(0, 0); + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("visible"); + }); + }); + + test("hides once a scrolling container clips its trigger", async () => { + const screen = await render( +
+
+ Filters}> + Filter by season + +
+
+
, + ); + + await screen.getByRole("button", { name: "Filters" }).click(); + const popover = screen.getByRole("dialog").element(); + const scroller = document.querySelector( + '[data-testid="scroller"]', + ); + invariant(scroller); + await waitForFocus(popover); + + scroller.scrollTop = 300; + + await vi.waitFor(() => { + expect(getComputedStyle(popover).visibility).toBe("hidden"); + }); + }); +}); diff --git a/app/components/elements/useFloatingLayer.ts b/app/components/elements/useFloatingLayer.ts new file mode 100644 index 000000000..1cb1e9ac7 --- /dev/null +++ b/app/components/elements/useFloatingLayer.ts @@ -0,0 +1,317 @@ +import * as React from "react"; +import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; +import { visibleViewportRect } from "~/utils/visual-viewport"; +import * as FloatingLayer from "./floating-layer"; + +const GAP_PROPERTY = "--floating-gap"; +const PADDING_PROPERTY = "--floating-viewport-padding"; +const CEILING_PROPERTY = "--popover-boundary-top"; +const FLOOR_PROPERTY = "--popover-boundary-bottom"; + +const AVAILABLE_WIDTH_PROPERTY = "--floating-available-width"; +const AVAILABLE_HEIGHT_PROPERTY = "--floating-available-height"; +const ANCHOR_WIDTH_PROPERTY = "--floating-anchor-width"; +const ANCHOR_HEIGHT_PROPERTY = "--floating-anchor-height"; +const TRANSFORM_ORIGIN_PROPERTY = "--floating-transform-origin"; + +const OUTPUT_PROPERTIES = [ + AVAILABLE_WIDTH_PROPERTY, + AVAILABLE_HEIGHT_PROPERTY, + ANCHOR_WIDTH_PROPERTY, + ANCHOR_HEIGHT_PROPERTY, + TRANSFORM_ORIGIN_PROPERTY, +]; + +const INSET_PROPERTIES = ["top", "right", "bottom", "left"] as const; +const UNCAPPED = "10000000px"; + +export type FloatingPlacement = FloatingLayer.Placement; + +export function useFloatingLayer({ + isOpen, + floatingRef, + getAnchor, + placement = "bottom", +}: { + isOpen: boolean; + floatingRef: React.RefObject; + getAnchor: () => Element | null; + placement?: FloatingPlacement; +}) { + const getAnchorRef = React.useRef(getAnchor); + getAnchorRef.current = getAnchor; + + useIsomorphicLayoutEffect(() => { + const floating = floatingRef.current; + if (!isOpen || !floating) return; + + let natural: FloatingLayer.Size | null = null; + let lastBounds: FloatingLayer.Bounds | null = null; + let lastResolution: FloatingLayer.Resolution | null = null; + let viewportAnchored: boolean | null = null; + let frameId: number | null = null; + + const update = () => { + const anchor = getAnchorRef.current(); + if (!anchor || !floating.matches(":popover-open")) return; + + if (viewportAnchored === null) { + viewportAnchored = isViewportAnchored(anchor); + floating.style.position = viewportAnchored ? "fixed" : "absolute"; + } + + const computed = getComputedStyle(floating); + const gap = lengthOf(computed, GAP_PROPERTY); + const padding = lengthOf(computed, PADDING_PROPERTY); + const rtl = computed.direction === "rtl"; + const bounds = floatingBounds(floating); + const anchorRect = anchor.getBoundingClientRect(); + const detached = isAnchorDetached( + anchor, + anchorRect, + computed, + viewportAnchored, + ); + floating.style.visibility = detached ? "hidden" : ""; + if (detached) return; + + floating.style.setProperty(ANCHOR_WIDTH_PROPERTY, px(anchorRect.width)); + floating.style.setProperty(ANCHOR_HEIGHT_PROPERTY, px(anchorRect.height)); + + if (natural === null || !sameBounds(bounds, lastBounds)) { + lastBounds = bounds; + natural = naturalSize(floating, bounds, placement, padding); + lastResolution = null; + } + + const resolution = FloatingLayer.resolve({ + anchor: anchorRect, + floating: natural, + bounds, + placement, + gap, + padding, + rtl, + }); + if (!sameResolution(resolution, lastResolution)) { + lastResolution = resolution; + floating.style.setProperty( + AVAILABLE_WIDTH_PROPERTY, + px(Math.floor(resolution.availableWidth)), + ); + floating.style.setProperty( + AVAILABLE_HEIGHT_PROPERTY, + px(Math.floor(resolution.availableHeight)), + ); + floating.style.setProperty( + TRANSFORM_ORIGIN_PROPERTY, + resolution.transformOrigin, + ); + floating.dataset.side = resolution.side; + floating.dataset.align = resolution.align; + } + + const root = document.documentElement; + const viewportInsets = FloatingLayer.insets({ + anchor: anchorRect, + floating: floating.getBoundingClientRect(), + bounds, + side: resolution.side, + align: resolution.align, + gap, + padding, + rtl, + viewport: { width: root.clientWidth, height: root.clientHeight }, + }); + const insets = viewportAnchored + ? viewportInsets + : FloatingLayer.documentInsets(viewportInsets, { + x: window.scrollX, + y: window.scrollY, + }); + for (const property of INSET_PROPERTIES) { + const value = insets[property]; + floating.style.setProperty( + property, + value === null ? "auto" : px(roundToDevicePixels(value)), + ); + } + }; + update(); + + const scheduleUpdate = () => { + if (frameId !== null) return; + frameId = requestAnimationFrame(() => { + frameId = null; + update(); + }); + }; + const onScroll = (event: Event) => { + if (event.target instanceof Node && floating.contains(event.target)) { + return; + } + update(); + }; + + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(floating); + const anchor = getAnchorRef.current(); + if (anchor) { + resizeObserver.observe(anchor); + } + + floating.addEventListener("toggle", update); + window.addEventListener("scroll", onScroll, { + capture: true, + passive: true, + }); + window.addEventListener("resize", scheduleUpdate); + window.visualViewport?.addEventListener("resize", scheduleUpdate); + window.visualViewport?.addEventListener("scroll", scheduleUpdate); + + return () => { + resizeObserver.disconnect(); + floating.removeEventListener("toggle", update); + window.removeEventListener("scroll", onScroll, { capture: true }); + window.removeEventListener("resize", scheduleUpdate); + window.visualViewport?.removeEventListener("resize", scheduleUpdate); + window.visualViewport?.removeEventListener("scroll", scheduleUpdate); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + for (const property of [ + "position", + "visibility", + ...INSET_PROPERTIES, + ...OUTPUT_PROPERTIES, + ]) { + floating.style.removeProperty(property); + } + delete floating.dataset.side; + delete floating.dataset.align; + }; + }, [isOpen, floatingRef, placement]); +} + +function naturalSize( + floating: HTMLElement, + bounds: FloatingLayer.Bounds, + placement: FloatingPlacement, + padding: number, +): FloatingLayer.Size { + const vertical = FloatingLayer.isVerticalPlacement(placement); + + floating.style.setProperty( + vertical ? AVAILABLE_WIDTH_PROPERTY : AVAILABLE_HEIGHT_PROPERTY, + px(FloatingLayer.spaceAcross(bounds, placement, padding)), + ); + floating.style.setProperty( + vertical ? AVAILABLE_HEIGHT_PROPERTY : AVAILABLE_WIDTH_PROPERTY, + UNCAPPED, + ); + + const { width, height } = floating.getBoundingClientRect(); + + return { width, height }; +} + +export function floatingBounds(element: Element): FloatingLayer.Bounds { + const computed = getComputedStyle(element); + const visible = visibleViewportRect(); + const layoutHeight = document.documentElement.clientHeight; + + return { + top: Math.max(visible.top, lengthOf(computed, CEILING_PROPERTY)), + right: visible.right, + bottom: Math.min( + visible.bottom, + layoutHeight - lengthOf(computed, FLOOR_PROPERTY), + ), + left: visible.left, + }; +} + +function isAnchorDetached( + anchor: Element, + anchorRect: DOMRect, + computed: CSSStyleDeclaration, + viewportAnchored: boolean, +) { + const root = document.documentElement; + + let top = viewportAnchored ? 0 : lengthOf(computed, CEILING_PROPERTY); + let right = root.clientWidth; + let bottom = + root.clientHeight - + (viewportAnchored ? 0 : lengthOf(computed, FLOOR_PROPERTY)); + let left = 0; + + let element = anchor.parentElement; + while (element !== null && element !== document.body) { + if (getComputedStyle(element).overflow !== "visible") { + const rect = element.getBoundingClientRect(); + + top = Math.max(top, rect.top); + right = Math.min(right, rect.right); + bottom = Math.min(bottom, rect.bottom); + left = Math.max(left, rect.left); + } + element = element.parentElement; + } + + return ( + anchorRect.bottom <= top || + anchorRect.top >= bottom || + anchorRect.right <= left || + anchorRect.left >= right + ); +} + +function isViewportAnchored(anchor: Element) { + let element: Element | null = anchor; + while (element !== null && element !== document.body) { + const position = getComputedStyle(element).position; + if (position === "fixed" || position === "sticky") return true; + + element = element.parentElement; + } + + return false; +} + +function sameBounds(a: FloatingLayer.Bounds, b: FloatingLayer.Bounds | null) { + return ( + b !== null && + a.top === b.top && + a.right === b.right && + a.bottom === b.bottom && + a.left === b.left + ); +} + +function sameResolution( + a: FloatingLayer.Resolution, + b: FloatingLayer.Resolution | null, +) { + return ( + b !== null && + a.side === b.side && + a.align === b.align && + Math.floor(a.availableWidth) === Math.floor(b.availableWidth) && + Math.floor(a.availableHeight) === Math.floor(b.availableHeight) && + a.transformOrigin === b.transformOrigin + ); +} + +function lengthOf(computed: CSSStyleDeclaration, property: string) { + return Number.parseFloat(computed.getPropertyValue(property)) || 0; +} + +function roundToDevicePixels(value: number) { + const ratio = window.devicePixelRatio || 1; + return Math.round(value * ratio) / ratio; +} + +function px(value: number) { + return `${value}px`; +} diff --git a/app/components/elements/useScrollIntoView.browser.test.tsx b/app/components/elements/useScrollIntoView.browser.test.tsx new file mode 100644 index 000000000..efbc8fa27 --- /dev/null +++ b/app/components/elements/useScrollIntoView.browser.test.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import { afterEach, describe, expect, test } from "vitest"; +import { render } from "vitest-browser-react"; +import { lockScroll } from "~/modules/scroll-lock/scroll-lock"; +import { invariant } from "~/utils/invariant"; +import { closeKeyboard, KEYBOARD_HEIGHT, openKeyboard } from "./tests/keyboard"; +import { useScrollIntoView } from "./useScrollIntoView"; + +const PAGE_HEIGHT = 5000; +const ANCHOR_HEIGHT = 40; + +afterEach(() => { + closeKeyboard(); + window.scrollTo(0, 0); +}); + +const settle = () => new Promise((resolve) => setTimeout(resolve, 100)); + +function Anchored({ top }: { top: number }) { + const ref = React.useRef(null); + useScrollIntoView(true, () => ref.current); + + return ( + <> +
+
+ + ); +} + +function anchorRect() { + const anchor = document.querySelector('[data-testid="anchor"]'); + invariant(anchor); + return anchor.getBoundingClientRect(); +} + +function visualViewportHeight() { + const viewport = window.visualViewport; + invariant(viewport); + return viewport.height; +} + +describe("useScrollIntoView", () => { + test("scrolls the page so the anchor sits above the keyboard opening over it", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + + openKeyboard(); + + await expect + .poll(() => anchorRect().bottom) + .toBeLessThanOrEqual(visualViewportHeight()); + expect(window.scrollY).toBeGreaterThan(0); + }); + + test("leaves the page alone when the anchor is above the keyboard already", async () => { + await render(); + + openKeyboard(); + await settle(); + + expect(window.scrollY).toBe(0); + }); + + test("leaves the page alone when the viewport did not shrink to a keyboard", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + + window.visualViewport?.dispatchEvent(new Event("resize")); + await settle(); + + expect(window.scrollY).toBe(0); + }); + + test("does not scroll a scroll locked page", async () => { + const top = visualViewportHeight() - KEYBOARD_HEIGHT + 20; + await render(); + const release = lockScroll(); + + try { + openKeyboard(); + await settle(); + + expect(window.scrollY).toBe(0); + } finally { + release(); + } + }); +}); diff --git a/app/components/elements/useScrollIntoView.ts b/app/components/elements/useScrollIntoView.ts new file mode 100644 index 000000000..571c51691 --- /dev/null +++ b/app/components/elements/useScrollIntoView.ts @@ -0,0 +1,51 @@ +import * as React from "react"; +import { isScrollLocked } from "~/modules/scroll-lock/scroll-lock"; +import { keyboardIsOpen } from "~/utils/visual-viewport"; +import { floatingBounds } from "./useFloatingLayer"; + +const PADDING_PROPERTY = "--floating-viewport-padding"; + +// Brings the anchor of an open popover back on screen if the mobile keyboard opens over it +export function useScrollIntoView( + isOpen: boolean, + getAnchor: () => Element | null, +) { + const getAnchorRef = React.useRef(getAnchor); + getAnchorRef.current = getAnchor; + + React.useEffect(() => { + const viewport = window.visualViewport; + if (!isOpen || !viewport) return; + + const onResize = () => { + if (!keyboardIsOpen()) return; + const anchor = getAnchorRef.current(); + if (anchor) { + revealAboveKeyboard(anchor); + } + }; + + viewport.addEventListener("resize", onResize); + return () => viewport.removeEventListener("resize", onResize); + }, [isOpen]); +} + +function revealAboveKeyboard(anchor: Element) { + anchor.scrollIntoView({ block: "nearest", inline: "nearest" }); + if (isScrollLocked()) return; + + const padding = + Number.parseFloat( + getComputedStyle(anchor).getPropertyValue(PADDING_PROPERTY), + ) || 0; + const bounds = floatingBounds(anchor); + const rect = anchor.getBoundingClientRect(); + const below = rect.bottom - (bounds.bottom - padding); + const above = bounds.top + padding - rect.top; + + if (below > 0) { + window.scrollBy({ top: below }); + } else if (above > 0) { + window.scrollBy({ top: -above }); + } +} diff --git a/app/components/layout/GlobalSearch.browser.test.tsx b/app/components/layout/GlobalSearch.browser.test.tsx new file mode 100644 index 000000000..0b9fcc945 --- /dev/null +++ b/app/components/layout/GlobalSearch.browser.test.tsx @@ -0,0 +1,37 @@ +import { createMemoryRouter, RouterProvider } from "react-router"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { page } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { GlobalSearch } from "./GlobalSearch"; + +let hrefBefore = window.location.href; + +beforeEach(() => { + hrefBefore = window.location.href; +}); + +afterEach(() => { + window.history.replaceState(null, "", hrefBefore); +}); + +function pushSearchParamOpen() { + const url = new URL(window.location.href); + url.searchParams.set("search", "open"); + window.history.pushState(null, "", url); +} + +describe("GlobalSearch", () => { + test("opens from the search param and closes when navigating back pops it", async () => { + pushSearchParamOpen(); + + const router = createMemoryRouter([ + { path: "*", element: }, + ]); + await render(); + await expect.element(page.getByRole("dialog")).toBeVisible(); + + window.history.back(); + + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css index 09ef28db2..484379722 100644 --- a/app/components/layout/GlobalSearch.module.css +++ b/app/components/layout/GlobalSearch.module.css @@ -44,7 +44,8 @@ } @container (width < 620px) { - .searchLabel { + .searchLabel, + .searchPlaceholder { display: none; } @@ -77,7 +78,20 @@ .modal { width: calc(100% - 2 * var(--layout-main-padding)); max-width: 36rem; - margin: 15vh auto auto; + inset-block-start: var(--visual-viewport-offset-top, 0px); + inset-block-end: calc( + 100% - + var(--visual-viewport-offset-top, 0px) - + var(--visual-viewport-height, 100%) + ); + max-height: calc( + var(--visual-viewport-height, 100dvh) - + 2 * + var(--modal-margin-block) + ); + margin: var(--modal-margin-block) auto auto; + display: flex; + flex-direction: column; padding: 0; border: 1px solid var(--color-border); background-color: var(--color-bg); @@ -97,6 +111,12 @@ } } +.content { + display: flex; + flex-direction: column; + min-height: 0; +} + .inputContainer { display: flex; align-items: center; diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx index b59f33c90..8fe861640 100644 --- a/app/components/layout/GlobalSearch.tsx +++ b/app/components/layout/GlobalSearch.tsx @@ -83,8 +83,8 @@ export function GlobalSearch() { const [isOpen, setIsOpen] = React.useState(searchParamOpen); const prevSearchParamOpen = React.useRef(searchParamOpen); - if (searchParamOpen && !prevSearchParamOpen.current) { - setIsOpen(true); + if (searchParamOpen !== prevSearchParamOpen.current) { + setIsOpen(searchParamOpen); } prevSearchParamOpen.current = searchParamOpen; @@ -310,7 +310,7 @@ function GlobalSearchContent({ if (searchType === "weapons" && selectedWeapon) { return ( -
+
+

{`${SEARCH_TYPE_TO_PREFIX[searchType]}.`} diff --git a/app/components/layout/SearchResults.module.css b/app/components/layout/SearchResults.module.css index 5428cefca..04e57d572 100644 --- a/app/components/layout/SearchResults.module.css +++ b/app/components/layout/SearchResults.module.css @@ -1,5 +1,5 @@ .listBox { - max-height: 325px; + min-height: 0; overflow-y: auto; padding: var(--s-2); outline: none; diff --git a/app/components/layout/TopNavMenus.browser.test.tsx b/app/components/layout/TopNavMenus.browser.test.tsx new file mode 100644 index 000000000..8a8f38a52 --- /dev/null +++ b/app/components/layout/TopNavMenus.browser.test.tsx @@ -0,0 +1,57 @@ +import type * as React from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { TopNavMenus } from "./TopNavMenus"; + +const viewportBefore = { width: window.innerWidth, height: window.innerHeight }; + +// the top nav is the desktop navigation, shown from the tablet breakpoint up +beforeEach(async () => { + await page.viewport(1280, 720); +}); + +afterEach(async () => { + await page.viewport(viewportBefore.width, viewportBefore.height); +}); + +function withRouter(element: React.ReactElement) { + const router = createMemoryRouter([{ path: "*", element }], { + initialEntries: ["/"], + }); + return ; +} + +function openPopovers() { + return [...document.querySelectorAll("[popover]:popover-open")]; +} + +describe("TopNavMenus", () => { + test("hovering another item while a menu is open moves the open menu there", async () => { + const screen = await render(withRouter()); + + await screen.getByRole("button", { name: "Play" }).click(); + await vi.waitFor(() => { + expect(openPopovers()).toHaveLength(1); + expect(openPopovers()[0].querySelector('a[href="/q"]')).not.toBeNull(); + }); + + await userEvent.hover(screen.getByRole("button", { name: "Tools" })); + + await vi.waitFor(() => { + const open = openPopovers(); + expect(open).toHaveLength(1); + expect(open[0].querySelector('a[href="/analyzer"]')).not.toBeNull(); + }); + }); + + test("hovering an item with no menu open leaves every menu closed", async () => { + const screen = await render(withRouter()); + + await userEvent.hover(screen.getByRole("button", { name: "Tools" })); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(openPopovers()).toHaveLength(0); + }); +}); diff --git a/app/components/layout/TopNavMenus.module.css b/app/components/layout/TopNavMenus.module.css index e98c5da14..2cefb1e12 100644 --- a/app/components/layout/TopNavMenus.module.css +++ b/app/components/layout/TopNavMenus.module.css @@ -88,7 +88,7 @@ .preview { position: absolute; - top: calc(100% + var(--s-1)); + top: calc(100% + var(--floating-gap)); left: 0; z-index: 1; display: grid; @@ -121,7 +121,7 @@ content: ""; position: absolute; z-index: -1; - inset: calc(-1 * (var(--s-1) + var(--border-width))); + inset: calc(-1 * (var(--floating-gap) + var(--border-width))); } } diff --git a/app/components/layout/TopNavMenus.tsx b/app/components/layout/TopNavMenus.tsx index 953ac938f..37a7bb1a9 100644 --- a/app/components/layout/TopNavMenus.tsx +++ b/app/components/layout/TopNavMenus.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { ChevronDown } from "lucide-react"; -import { useState } from "react"; +import { type PointerEvent, useState } from "react"; import { useTranslation } from "react-i18next"; import { Form, Link, useLocation } from "react-router"; import { Config } from "~/config"; @@ -74,19 +74,48 @@ const NAV_CATEGORIES = [ }, ] as const; +interface MenuOpenState { + isOpen: boolean; + anotherIsOpen: boolean; + onOpenChange: (open: boolean) => void; +} + export function TopNavMenus() { + const [openMenu, setOpenMenu] = useState(null); + + const openStateOf = (name: string): MenuOpenState => ({ + isOpen: openMenu === name, + anotherIsOpen: openMenu !== null && openMenu !== name, + onOpenChange: (open) => + setOpenMenu((current) => { + if (open) return name; + return current === name ? null : current; + }), + }); + return (

); } -function DevMenu() { - const [isOpen, setIsOpen] = useState(false); +function takeOverOnHover(openState: MenuOpenState, event: PointerEvent) { + if (openState.anotherIsOpen && event.pointerType !== "touch") { + openState.onOpenChange(true); + } +} + +function DevMenu({ openState }: { openState: MenuOpenState }) { const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false); const location = useLocation(); const returnTo = `${location.pathname}${location.search}`; @@ -98,7 +127,10 @@ function DevMenu() {