Add scroll lock, replace CSS anchors with floating layer, and various fixes (#3407)

This commit is contained in:
hfcRed
2026-09-22 13:15:01 -04:00
committed by GitHub
parent 7976224d53
commit 2b31ecd0fa
51 changed files with 2363 additions and 1085 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);

View File

@@ -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(
<>
<button
type="button"
onClick={onBehindClick}
style={{ position: "fixed", top: 0, left: 0 }}
>
Behind
</button>
<SendouDialog heading="Hello" isDismissable onClose={onClose}>
Content
</SendouDialog>
</>,
),
);
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(
<SendouDialog heading="Hello" isDismissable onClose={onClose}>
Content
</SendouDialog>,
),
);
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(
<SendouDialog
heading="Hello"
trigger={<button type="button">Open</button>}
showCloseButton
>
Content
</SendouDialog>,
),
);
// 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(
<SendouDialog heading="Hello" onClose={() => {}}>
Content
</SendouDialog>,
),
);
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(
<SendouDialog heading="Hello" onClose={() => {}}>
<div style={{ height: 2000 }} />
</SendouDialog>,
),
);
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(

View File

@@ -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 {

View File

@@ -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<HTMLDialogElement>(null);
const backdropPressHandlers = useBackdropDismiss(isDismissable);
useScrollLockWhileOpen(dialogRef);
return (
<dialog
ref={ref}
ref={(dialog) => {
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}
</dialog>
);
}
// Safari 26 is missing `closedby`, close on backdrop clicks manually
function closeOnBackdropClick(event: React.MouseEvent<HTMLDialogElement>) {
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<HTMLDialogElement>) => {
pressStartedOnBackdropRef.current = isOnBackdrop(event);
},
onClick: (event: React.MouseEvent<HTMLDialogElement>) => {
if (pressStartedOnBackdropRef.current && isOnBackdrop(event)) {
event.currentTarget.close();
}
},
};
}
function isOnBackdrop(event: React.MouseEvent<HTMLDialogElement>) {
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. */

View File

@@ -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;
}

View File

@@ -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<HTMLDivElement>(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({
<span
ref={triggerContainerRef}
className={styles.triggerContainer}
style={{ "--menu-anchor": anchorName } as React.CSSProperties}
onBlur={onBlur}
>
{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}

View File

@@ -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 = (
<SendouPopover trigger={<button type="button">Open</button>}>
Popover content
</SendouPopover>
);
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<HTMLElement>("[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();
});
});

View File

@@ -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;
}
}

View File

@@ -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<HTMLElement>) {
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<HTMLElement>) {
* 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<HTMLDivElement>) => {
@@ -189,11 +187,10 @@ export function SendouPopover({
<span
ref={triggerContainerRef}
className={styles.triggerContainer}
style={{ "--popover-anchor": anchorName } as React.CSSProperties}
onBlur={onBlur}
>
{React.cloneElement(trigger, {
popoverTarget: popoverId,
popoverTarget,
"aria-haspopup": "dialog",
})}
</span>
@@ -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<HTMLElement | null>;
"aria-label"?: string;
}) {
const uid = useAnchorSafeId();
const anchorName = `--popover-anchor-${uid}`;
const popoverRef = React.useRef<HTMLDivElement>(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<HTMLDivElement>) => {
@@ -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}

View File

@@ -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 {

View File

@@ -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<T extends object> {
/**
* 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<T extends object>({
children,
}: SendouSelectProps<T>) {
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<T extends object>({
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<T extends object>({
: undefined
}
data-required={isRequired || undefined}
popoverTarget={popoverId}
style={{ anchorName } as React.CSSProperties}
popoverTarget={popoverTarget}
onKeyDown={onTriggerKeyDown}
>
<span
@@ -575,12 +572,7 @@ export function SendouSelect<T extends object>({
id={popoverId}
popover="auto"
className={clsx(styles.popover, popoverClassName)}
style={
{
positionAnchor: anchorName,
...topLayerStyle,
} as React.CSSProperties
}
style={topLayerStyle}
onBeforeToggle={onPopoverBeforeToggle}
onToggle={onPopoverToggle}
onKeyDown={onPopoverKeyDown}

View File

@@ -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;

View File

@@ -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(
<div style={{ padding: "100px" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>,
);
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(
<div style={{ padding: "100px" }}>
<SendouSelect
label="Season"
items={SEASONS}
placeholder="Pick a season"
>
{({ id, name }: (typeof SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
</div>,
);
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(<SelectNearViewportBottom />);
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(
<div style={{ padding: "100px" }}>
<SendouSelect
label="Season"
items={MANY_SEASONS}
placeholder="Pick a season"
>
{({ id, name }: (typeof MANY_SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
</div>,
);
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(<SelectNearViewportBottom />);
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 (
<div style={{ marginTop: "calc(100vh - 100px)" }}>
<SendouSelect
label="Season"
items={MANY_SEASONS}
placeholder="Pick a season"
search={{ placeholder: "Search seasons..." }}
>
{({ id, name }: (typeof MANY_SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
</div>
);
}

View File

@@ -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<HTMLElement | null>;
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<string, string> = 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<string, string>) {
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`;
}

View File

@@ -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);
});
});

View File

@@ -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<Side, Side> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
const ALIGN_ORIGIN: Record<Align, string> = {
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<Side, number> {
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}`;
}

View File

@@ -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"));
}

View File

@@ -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<HTMLDivElement>(null);
useCloseOnScrollClip(true, ref, close);
return (
<>
<div style={{ height: PAGE_HEIGHT }} />
<div
ref={ref}
style={{ position: "absolute", top, left: 0, width: 100, height }}
/>
</>
);
}
function ScrollingOverlay({
height,
close,
}: {
height: number;
close: () => void;
}) {
const ref = React.useRef<HTMLDivElement>(null);
useCloseOnScrollClip(true, ref, close);
return (
<>
<div style={{ height: PAGE_HEIGHT }} />
<div
ref={ref}
data-testid="scroller"
style={{
position: "absolute",
top: 200,
left: 0,
width: 100,
height,
overflowY: "auto",
}}
>
<div style={{ height: 1000 }} />
</div>
</>
);
}
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(<Overlay top={200} height={100} close={close} />);
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(<Overlay top={0} height={PAGE_HEIGHT * 2} close={close} />);
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(<Overlay top={-50} height={100} close={close} />);
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(<Overlay top={200} height={100} close={close} />);
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(<Overlay top={200} height={100} close={close} />);
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(
<ScrollingOverlay height={100} close={close} />,
);
await settle();
const scroller = document.querySelector<HTMLElement>(
'[data-testid="scroller"]',
);
invariant(scroller);
scroller.scrollTop = 500;
await settle();
// the content growing then clips it, which alone must never close
screen.rerender(<ScrollingOverlay height={PAGE_HEIGHT} close={close} />);
await settle();
expect(close).not.toHaveBeenCalled();
});
});

View File

@@ -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<HTMLElement | null>,
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;
}

View File

@@ -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<HTMLElement>("[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 (
<SendouSelect
label="Season"
items={MANY_SEASONS}
placeholder="Pick a season"
search={{ placeholder: "Search seasons..." }}
>
{({ id, name }: (typeof MANY_SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
);
}
describe("useFloatingLayer", () => {
test("centers the popover under its trigger", async () => {
const screen = await render(
<div style={{ padding: "100px" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>,
);
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(
<div style={{ padding: "100px" }}>
<SendouSelect
label="Season"
items={SEASONS}
placeholder="Pick a season"
>
{({ id, name }: (typeof SEASONS)[number]) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
</div>,
);
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(
<div style={{ marginTop: "calc(100vh - 100px)" }}>
<ManySeasonsSelect />
</div>,
);
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(
<div style={{ padding: "100px" }}>
<ManySeasonsSelect />
</div>,
);
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(
<div style={{ marginTop: "calc(100vh - 100px)" }}>
<ManySeasonsSelect />
</div>,
);
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(
<div style={{ padding: "100px", height: "300vh" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>,
);
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(
<div style={{ padding: "100px" }}>
<ManySeasonsSelect />
</div>,
);
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(
<div style={{ marginTop: "calc(100vh - 100px)", height: "300vh" }}>
<ManySeasonsSelect />
</div>,
);
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(
<div style={{ padding: "100px" }}>
<ManySeasonsSelect />
</div>,
);
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(
<div style={{ marginTop: innerHeight - 200, height: "300vh" }}>
<ManySeasonsSelect />
</div>,
);
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(
<div style={{ height: "300vh" }}>
<header style={{ position: "sticky", top: 0, padding: 20 }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</header>
</div>,
);
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(
<div style={{ padding: "100px", height: "300vh" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>,
);
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(
<div data-testid="scroller" style={{ height: 200, overflow: "auto" }}>
<div style={{ padding: "20px 0" }}>
<SendouPopover trigger={<button type="button">Filters</button>}>
Filter by season
</SendouPopover>
</div>
<div style={{ height: 1000 }} />
</div>,
);
await screen.getByRole("button", { name: "Filters" }).click();
const popover = screen.getByRole("dialog").element();
const scroller = document.querySelector<HTMLElement>(
'[data-testid="scroller"]',
);
invariant(scroller);
await waitForFocus(popover);
scroller.scrollTop = 300;
await vi.waitFor(() => {
expect(getComputedStyle(popover).visibility).toBe("hidden");
});
});
});

View File

@@ -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<HTMLElement | null>;
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`;
}

View File

@@ -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<HTMLDivElement>(null);
useScrollIntoView(true, () => ref.current);
return (
<>
<div style={{ height: PAGE_HEIGHT }} />
<div
ref={ref}
data-testid="anchor"
style={{
position: "absolute",
top,
left: 0,
width: 100,
height: ANCHOR_HEIGHT,
}}
/>
</>
);
}
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(<Anchored top={top} />);
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(<Anchored top={100} />);
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(<Anchored top={top} />);
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(<Anchored top={top} />);
const release = lockScroll();
try {
openKeyboard();
await settle();
expect(window.scrollY).toBe(0);
} finally {
release();
}
});
});

View File

@@ -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 });
}
}

View File

@@ -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: <GlobalSearch /> },
]);
await render(<RouterProvider router={router} />);
await expect.element(page.getByRole("dialog")).toBeVisible();
window.history.back();
await expect.element(page.getByRole("dialog")).not.toBeInTheDocument();
});
});

View File

@@ -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;

View File

@@ -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 (
<div onClickCapture={handleClickCapture}>
<div className={styles.content} onClickCapture={handleClickCapture}>
<WeaponDestinationMenu
selectedWeapon={selectedWeapon}
onBack={handleBackToWeaponSearch}
@@ -322,7 +322,7 @@ function GlobalSearchContent({
}
return (
<div onClickCapture={handleClickCapture}>
<div className={styles.content} onClickCapture={handleClickCapture}>
<div className={styles.inputContainer}>
<p className={styles.inputPrefix}>
{`${SEARCH_TYPE_TO_PREFIX[searchType]}.`}

View File

@@ -1,5 +1,5 @@
.listBox {
max-height: 325px;
min-height: 0;
overflow-y: auto;
padding: var(--s-2);
outline: none;

View File

@@ -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 <RouterProvider router={router} />;
}
function openPopovers() {
return [...document.querySelectorAll<HTMLElement>("[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(<TopNavMenus />));
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(<TopNavMenus />));
await userEvent.hover(screen.getByRole("button", { name: "Tools" }));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(openPopovers()).toHaveLength(0);
});
});

View File

@@ -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)));
}
}

View File

@@ -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<string | null>(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 (
<nav className={styles.container}>
{NAV_CATEGORIES.map((category) => (
<CategoryMenu key={category.name} category={category} />
<CategoryMenu
key={category.name}
category={category}
openState={openStateOf(category.name)}
/>
))}
{process.env.NODE_ENV === "development" ? <DevMenu /> : null}
{process.env.NODE_ENV === "development" ? (
<DevMenu openState={openStateOf("dev")} />
) : null}
</nav>
);
}
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() {
<button
type="button"
className={styles.menuButton}
onPointerEnter={() => setIsPreviewSuppressed(false)}
onPointerEnter={(event) => {
setIsPreviewSuppressed(false);
takeOverOnHover(openState, event);
}}
>
Dev
<ChevronDown className={styles.menuButtonChevron} />
@@ -106,8 +138,8 @@ function DevMenu() {
}
popoverClassName={styles.menuPopover}
placement="bottom start"
isOpen={isOpen}
onOpenChange={setIsOpen}
isOpen={openState.isOpen}
onOpenChange={openState.onOpenChange}
eager
>
<div className={styles.menuContent}>
@@ -140,7 +172,7 @@ function DevMenu() {
to={item.url}
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
openState.onOpenChange(false);
setIsPreviewSuppressed(true);
}}
>
@@ -155,7 +187,7 @@ function DevMenu() {
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
{!openState.isOpen && !isPreviewSuppressed ? (
<div className={styles.preview}>
{DEV_IMPERSONATE_ITEMS.map((item) => (
<Form
@@ -198,11 +230,12 @@ function DevMenu() {
function CategoryMenu({
category,
openState,
}: {
category: (typeof NAV_CATEGORIES)[number];
openState: MenuOpenState;
}) {
const { t } = useTranslation(["common", "front"]);
const [isOpen, setIsOpen] = useState(false);
const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false);
const user = useUser();
const isStaff = user?.roles.includes("STAFF") ?? false;
@@ -221,7 +254,10 @@ function CategoryMenu({
<button
type="button"
className={styles.menuButton}
onPointerEnter={() => setIsPreviewSuppressed(false)}
onPointerEnter={(event) => {
setIsPreviewSuppressed(false);
takeOverOnHover(openState, event);
}}
>
{t(`front:nav.${category.name}`)}
<ChevronDown className={styles.menuButtonChevron} />
@@ -229,8 +265,8 @@ function CategoryMenu({
}
popoverClassName={styles.menuPopover}
placement="bottom start"
isOpen={isOpen}
onOpenChange={setIsOpen}
isOpen={openState.isOpen}
onOpenChange={openState.onOpenChange}
eager
>
<div className={styles.menuContent}>
@@ -241,7 +277,7 @@ function CategoryMenu({
prefetch="intent"
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
openState.onOpenChange(false);
setIsPreviewSuppressed(true);
}}
>
@@ -256,7 +292,7 @@ function CategoryMenu({
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
{!openState.isOpen && !isPreviewSuppressed ? (
<div className={styles.preview}>
{visibleItems.map((item) => (
<Link

View File

@@ -25,8 +25,9 @@ import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigati
import { useHydrated } from "~/hooks/useHydrated";
import { MOBILE_LAYOUT_QUERY, useLayoutSize } from "~/hooks/useLayoutSize";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { useScrollLock } from "~/hooks/useScrollLock";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import { useVisualViewportHeight } from "~/hooks/useVisualViewportHeight";
import { useVisualViewport } from "~/hooks/useVisualViewport";
import { useSearchParam } from "~/modules/search-params/hooks";
import type { RootLoaderData } from "~/root";
import { generateIdenticon } from "~/utils/identicon";
@@ -261,9 +262,10 @@ export function Layout({
const sideNavRef = React.useRef<HTMLElement>(null);
const [sideNavDrawerOpen, setSideNavDrawerOpen] = React.useState(false);
useClosePopoversOnNavigation(sideNavRef);
useScrollLock(sideNavDrawerOpen);
useVisualViewport();
const [chatSidebarModalOpen, setChatSidebarModalOpen] =
useTabletModal(isTabletLayout);
useVisualViewportHeight();
const chatSidebarOpen = chatContext?.chatOpen ?? false;
const setChatSidebarOpen = chatContext?.setChatOpen ?? (() => {});

View File

@@ -12,8 +12,9 @@
}
.popover {
border: none;
padding: 0;
background: none;
max-height: unset;
}
.dialog {
@@ -26,11 +27,9 @@
flex-direction: column;
gap: var(--s-5);
width: 18rem;
max-width: calc(100vw - var(--s-4));
max-width: 100%;
padding: 0 var(--s-4) var(--s-4);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-box);
overflow: hidden;
}

View File

@@ -336,7 +336,8 @@
display: grid;
place-items: center;
border-radius: var(--radius-box);
padding: var(--s-2);
height: var(--field-size);
aspect-ratio: 1 / 1;
flex-shrink: 0;
& svg {

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import { lockScroll } from "~/modules/scroll-lock/scroll-lock";
export function useScrollLock(locked: boolean) {
React.useEffect(() => {
if (!locked) return;
return lockScroll();
}, [locked]);
}
// Same as `useScrollLock` but specifically for native elements like dialor or popover
export function useScrollLockWhileOpen(
ref: React.RefObject<HTMLElement | null>,
) {
React.useEffect(() => {
const element = ref.current;
if (!element) return;
let release: (() => void) | undefined;
const syncLock = (isOpen: boolean) => {
if (isOpen) {
release ??= lockScroll();
} else {
release?.();
release = undefined;
}
};
const onToggle = (event: Event) => {
syncLock((event as ToggleEvent).newState === "open");
};
element.addEventListener("toggle", onToggle);
syncLock(isElementOpen(element));
return () => {
element.removeEventListener("toggle", onToggle);
syncLock(false);
};
}, [ref]);
}
function isElementOpen(element: HTMLElement) {
return element instanceof HTMLDialogElement
? element.open
: element.matches(":popover-open");
}

View File

@@ -0,0 +1,30 @@
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
const HEIGHT_PROPERTY = "--visual-viewport-height";
const OFFSET_TOP_PROPERTY = "--visual-viewport-offset-top";
// Syncs the ACTUAL visual viewport to two CSS variables so elements can respect the space taken by the mobile keyboard
export function useVisualViewport() {
useIsomorphicLayoutEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;
const update = () => {
const style = document.documentElement.style;
style.setProperty(HEIGHT_PROPERTY, `${viewport.height}px`);
style.setProperty(OFFSET_TOP_PROPERTY, `${viewport.offsetTop}px`);
};
update();
viewport.addEventListener("resize", update);
viewport.addEventListener("scroll", update);
return () => {
viewport.removeEventListener("resize", update);
viewport.removeEventListener("scroll", update);
document.documentElement.style.removeProperty(HEIGHT_PROPERTY);
document.documentElement.style.removeProperty(OFFSET_TOP_PROPERTY);
};
}, []);
}

View File

@@ -1,29 +0,0 @@
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
const CSS_VARIABLE = "--visual-viewport-height";
/** Syncs `--visual-viewport-height` on the root; CSS can't read the visual viewport, which elements above the mobile keyboard need. */
export function useVisualViewportHeight() {
useIsomorphicLayoutEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;
const update = () => {
document.documentElement.style.setProperty(
CSS_VARIABLE,
`${viewport.height}px`,
);
};
update();
viewport.addEventListener("resize", update);
viewport.addEventListener("scroll", update);
return () => {
viewport.removeEventListener("resize", update);
viewport.removeEventListener("scroll", update);
document.documentElement.style.removeProperty(CSS_VARIABLE);
};
}, []);
}

View File

@@ -0,0 +1,104 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { isScrollLocked, lockScroll } from "./scroll-lock";
let cleanupFns: Array<() => void> = [];
afterEach(async () => {
for (const cleanup of cleanupFns) {
cleanup();
}
cleanupFns = [];
await vi.waitFor(() => expect(isScrollLocked()).toBe(false));
});
/** The release lands a moment after the last lock goes, this outwaits it. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
/** Content spanning the page, which must not move when the scrollbar goes. */
function makePageScroll() {
const content = document.createElement("div");
content.style.height = "300vh";
content.style.width = "100%";
document.body.appendChild(content);
cleanupFns.push(() => content.remove());
return content;
}
describe("lockScroll", () => {
test("hides the scrollbar without moving the content", async () => {
const content = makePageScroll();
const root = document.documentElement;
const scrollbarWidth = window.innerWidth - root.clientWidth;
const widthBefore = content.getBoundingClientRect().width;
const release = lockScroll();
cleanupFns.push(release);
expect(document.body.style.overflow).toBe("hidden");
expect(content.getBoundingClientRect().width).toBe(widthBefore);
if (scrollbarWidth > 0) {
expect(Number.parseFloat(document.body.style.paddingRight)).toBe(
scrollbarWidth,
);
expect(root.style.getPropertyValue("--scrollbar-width")).toBe(
`${scrollbarWidth}px`,
);
}
release();
await vi.waitFor(() => expect(document.body.style.overflow).toBe(""));
expect(document.body.style.paddingRight).toBe("");
expect(root.style.getPropertyValue("--scrollbar-width")).toBe("");
expect(content.getBoundingClientRect().width).toBe(widthBefore);
});
test("keeps the page locked until every lock is released", async () => {
const releaseFirst = lockScroll();
const releaseSecond = lockScroll();
cleanupFns.push(releaseFirst, releaseSecond);
releaseFirst();
await settle();
expect(document.body.style.overflow).toBe("hidden");
expect(isScrollLocked()).toBe(true);
releaseSecond();
await vi.waitFor(() => expect(document.body.style.overflow).toBe(""));
expect(isScrollLocked()).toBe(false);
});
test("lets a lock taken right after the last release keep the styles in place", async () => {
const releaseFirst = lockScroll();
releaseFirst();
const releaseSecond = lockScroll();
cleanupFns.push(releaseSecond);
await settle();
expect(document.body.style.overflow).toBe("hidden");
expect(isScrollLocked()).toBe(true);
});
test("puts back the inline styles it replaced", async () => {
document.body.style.overflow = "scroll";
cleanupFns.push(() => document.body.style.removeProperty("overflow"));
const release = lockScroll();
expect(document.body.style.overflow).toBe("hidden");
release();
await vi.waitFor(() => expect(document.body.style.overflow).toBe("scroll"));
});
test("releasing the same lock twice does not release another", async () => {
const releaseFirst = lockScroll();
const releaseSecond = lockScroll();
cleanupFns.push(releaseSecond);
releaseFirst();
releaseFirst();
await settle();
expect(document.body.style.overflow).toBe("hidden");
});
});

View File

@@ -0,0 +1,67 @@
const RELEASE_DELAY_MS = 24;
const SCROLLBAR_WIDTH_PROPERTY = "--scrollbar-width";
let lockCount = 0;
let restoreStyles: (() => void) | null = null;
let releaseTimeoutId: number | null = null;
export function lockScroll() {
cancelScheduledRelease();
lockCount++;
restoreStyles ??= applyLockStyles();
let released = false;
return () => {
if (released) return;
released = true;
lockCount--;
if (lockCount === 0) {
scheduleRelease();
}
};
}
export function isScrollLocked() {
return restoreStyles !== null;
}
function applyLockStyles() {
const root = document.documentElement;
const body = document.body;
const previous = {
overflow: body.style.overflow,
paddingRight: body.style.paddingRight,
scrollbarWidth: root.style.getPropertyValue(SCROLLBAR_WIDTH_PROPERTY),
};
const scrollbarWidth = window.innerWidth - root.clientWidth;
body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
// Replace scrollbar with padding instead of using scrollbar-gutter stable because of some Chromium quirk
const bodyPadding = Number.parseFloat(getComputedStyle(body).paddingRight);
body.style.paddingRight = `${bodyPadding + scrollbarWidth}px`;
root.style.setProperty(SCROLLBAR_WIDTH_PROPERTY, `${scrollbarWidth}px`);
}
return () => {
body.style.overflow = previous.overflow;
body.style.paddingRight = previous.paddingRight;
root.style.setProperty(SCROLLBAR_WIDTH_PROPERTY, previous.scrollbarWidth);
};
}
function scheduleRelease() {
cancelScheduledRelease();
releaseTimeoutId = window.setTimeout(() => {
releaseTimeoutId = null;
if (lockCount > 0) return;
restoreStyles?.();
restoreStyles = null;
}, RELEASE_DELAY_MS);
}
function cancelScheduledRelease() {
if (releaseTimeoutId === null) return;
window.clearTimeout(releaseTimeoutId);
releaseTimeoutId = null;
}

View File

@@ -2,7 +2,6 @@ import clsx from "clsx";
import generalI18next from "i18next";
import NProgress from "nprogress";
import * as React from "react";
import { useEffect } from "react";
import { ErrorBoundary as ClientErrorBoundary } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import type { LoaderFunctionArgs, MetaFunction } from "react-router";
@@ -434,44 +433,6 @@ export default function App() {
const rootData = useLoaderData<RootLoaderData>();
const [openModals, setOpenModals] = React.useState(0);
// Move overflow:hidden from html to body to allow position: sticky and position: fixed
// elements to work properly when a React Aria Component disabled scrolling
useEffect(() => {
const htmlStyle = document.documentElement.style;
const bodyStyle = document.body.style;
const observer = new MutationObserver(() => {
observer.disconnect();
if (htmlStyle.overflow === "hidden") {
htmlStyle.overflow = "";
htmlStyle.scrollbarGutter = "";
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
htmlStyle.overflow = "initial";
bodyStyle.overflow = "hidden";
bodyStyle.paddingRight = `${scrollbarWidth}px`;
} else if (bodyStyle.overflow === "hidden") {
bodyStyle.overflow = "";
bodyStyle.paddingRight = "";
}
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style"],
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style"],
});
return () => observer.disconnect();
}, []);
return (
<OpenModalsContext value={{ count: openModals, setCount: setOpenModals }}>
<ThemeProvider

View File

@@ -252,8 +252,12 @@ html,
var(--layout-main-padding) +
env(safe-area-inset-top)
);
/* anchored popovers close once scrolling pushes them under the sticky header */
--popover-boundary-top: var(--layout-nav-height);
--popover-boundary-bottom: 0px;
--modal-margin-block: 5rem;
--floating-gap: var(--s-2);
--floating-viewport-padding: 12px;
/*
Boxes contain other elements (cards, containers, modals). Fields are interactive elements
@@ -281,13 +285,31 @@ html,
--label-margin: var(--s-1);
}
/* registered so scripts read it as a resolved length even when it is a calc() */
/* registered so scripts read them as resolved lengths even when they are a calc() */
@property --popover-boundary-top {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
@property --popover-boundary-bottom {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
@property --floating-gap {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
@property --floating-viewport-padding {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
@media screen and (display-mode: standalone) {
html {
--popover-boundary-top: calc(
@@ -296,3 +318,16 @@ html,
);
}
}
@media (width < 600px) {
html {
--modal-margin-block: calc(
var(--s-4) +
max(env(safe-area-inset-top, 0px), env(safe-area-inset-bottom, 0px))
);
--popover-boundary-bottom: calc(
var(--layout-nav-height) +
env(safe-area-inset-bottom, 0px)
);
}
}

View File

@@ -0,0 +1,48 @@
const KEYBOARD_MIN_HEIGHT = 150;
export interface VisibleViewportRect {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
}
export function visibleViewportRect(): VisibleViewportRect {
const root = document.documentElement;
const layoutWidth = root.clientWidth;
const layoutHeight = root.clientHeight;
const viewport = window.visualViewport;
if (!viewport) {
return {
top: 0,
left: 0,
right: layoutWidth,
bottom: layoutHeight,
width: layoutWidth,
height: layoutHeight,
};
}
const top = viewport.offsetTop;
const left = viewport.offsetLeft;
const right = Math.min(layoutWidth, left + viewport.width);
const bottom = Math.min(layoutHeight, top + viewport.height);
return {
top,
left,
right,
bottom,
width: right - left,
height: bottom - top,
};
}
export function keyboardIsOpen() {
const viewport = window.visualViewport;
if (!viewport) return false;
return window.innerHeight - viewport.height > KEYBOARD_MIN_HEIGHT;
}

View File

@@ -0,0 +1,4 @@
---
type: bug
---
Selects, menus and popovers stay open and follow their trigger while you scroll, hiding while the trigger is out of sight, are placed the same way in every browser, and keep clear of the mobile keyboard and the mobile navigation bar instead of clipping under them

View File

@@ -0,0 +1,4 @@
---
type: bug
---
Dialogs and the search modal stay above the mobile keyboard instead of clipping under it. Dialogs can now use the whole screen height

View File

@@ -0,0 +1,4 @@
---
type: bug
---
The page behind a dialog, the mobile menu panels and the mobile sidebar no longer scrolls, with no layout shift

View File

@@ -0,0 +1,4 @@
---
type: bug
---
Going back in the browser history can now close the search modal

View File

@@ -43,7 +43,7 @@ Configuration for these lives in `app/config.ts` (client, `VITE_*` variables, im
sendou.ink/
├── app/
│ ├── components/ -- React components used by many features
│ │ └── elements/ -- Wrappers providing styling etc. around React Aria Components
│ │ └── elements/ -- The design system's own components (buttons, inputs, popovers, selects, dialogs...)
│ ├── db/ -- Database seeds, types & connection
│ ├── features/ -- See "feature folders" below
│ ├── form/ -- SendouForm & shared form field builders (see `forms.md`)

67
docs/dev/overlays.md Normal file
View File

@@ -0,0 +1,67 @@
# Overlays
Popovers, menus, selects, and dialogs are native top layer elements, meaing the browser handles show, dismiss, focus and stacking. However there are some things CSS can't handle perfectly on it's own, like placing a popover next to its anchor, scaling a popover to the available view height, preventing the page from scrolling, and keeping things in view when the mobile keyboard opens. We have three hooks to solve these issues.
## Floating layer
`useFloatingLayer` places a popover next to its anchor. Used by Popover, Menu and Select, but anything new that is anchored should use it too.
```tsx
useFloatingLayer({
isOpen,
floatingRef: popoverRef,
getAnchor: () => triggerRef.current,
placement: "bottom start",
});
```
Placements are `"top"`, `"bottom"`, `"right"`, `"bottom start"` and `"bottom end"`. The side is only a preference. When the content does not fit on one side and the opposite side has more room, the popover flips.
However the hook only handles positioning. For sizing the popover you can use the CSS variables that the hook sets:
| Variable | Usage |
| --- | --- |
| `--floating-available-width` / `--floating-available-height` | Can be used for `max-width` / `max-height` on the anchor |
| `--floating-anchor-width` / `--floating-anchor-height` | Can be used to match the popover size to the anchor |
| `--floating-transform-origin` | Can be used for `transform-origin` for transform animations |
The hook also sets `data-side` and `data-align` for styling by placement.
```css
.popover {
position: absolute;
margin: 0;
width: var(--floating-anchor-width);
max-height: var(--floating-available-height, none);
}
```
The settings for the hook are global CSS properties in `vars.css`:
| Variable | Meaning |
| --- | --- |
| `--floating-gap` | The gap between the popover and its anchor |
| `--floating-viewport-padding` | The room between the edge of the popover and edge of the viewport |
| `--popover-boundary-top` | The top nav, the popovers stay below it |
| `--popover-boundary-bottom` | The mobile nav, the popover stays above it |
### Behaviour
- **Available room is recalculatedd on every scroll and resize.**
- **The visible viewport is the boundary**, not the entire viewport, so it respects the mobile keyboard as well as the top nav and mobile nav.
- **Positioning modes.** `position: absolute` in most cases, `position: fixed` if the anchor is inside or is itself a fixed or sticky element.
- **Hidden when detached.** The popover is hidden when the anchor leaves the viewport, this includes being covered by the top nav and mobile nav.
- **Nothing opens before hydration.** A trigger gets its `popovertarget` only once hydrated so the browser can't open the popover with no logic to place it. A click does nothing until the floating layer is loaded.
- **The far edge stutters while scrolling.** When scrolling the popover resizing can sometimes look a bit laggy/stuttery. This is acceptable, no possible workaround (all UI libs have this).
The calcuations live in `floating-layer.ts` as pure functions.
## Scroll lock
`lockScroll()` keeps the page from scrolling until the release is called. Locks can be nested. `useScrollLock(locked)` wraps it for state-driven logic. `useScrollLockWhileOpen(ref)` lets you follows a native dialogs or popovers open state through its toggle events (this works before hydration). Dialogs, the mobile nav panels and the mobile side nav lock.
The lock goes on `body` and not `html`. Because we use `body { overflow-x: hidden }` in the global styles, a hidden `html` would stop the bodys overflow from propagating which turns the body into a scroll container, which breaks the sticky header (and other sticky elements). The width that the scrollbar took is added as padding to the body, because of some quirk with Chromiums `scrollbar-gutter`. A fixed element like the toast or the mobile nav bar should use `--scrollbar-width` as right side padding.
## Scroll into view
`useScrollIntoView(isOpen, getAnchor)` brings a popovers anchor back into the viewport when a mobile keyboard opens over it. It will not scroll a scroll locked page! Select and Popover use it.

View File

@@ -13,18 +13,20 @@ import { SideNav } from "./pages/layout/side-nav";
import { TopNavMenus } from "./pages/layout/top-nav-menus";
test.describe("Navigation", () => {
navigationTests();
topNavMenuTests();
mobileNavigationTests();
});
// the navigation shell is native popovers and links, so it works before
// hydration too, or with scripts turned off altogether
// the mobile nav is native popovers and links, so it works before hydration
// too, or with scripts turned off altogether; the top nav menus open only once
// hydrated (see docs/dev/overlays.md)
test.describe("Navigation without JavaScript", () => {
test.use({ javaScriptEnabled: false });
navigationTests();
mobileNavigationTests();
});
function navigationTests() {
function topNavMenuTests() {
test("desktop navigation", async ({ page }) => {
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
@@ -47,6 +49,21 @@ function navigationTests() {
await expect(page).toHaveURL(/\/builds/);
});
test("tablet navigation", async ({ page }) => {
await page.setViewportSize(TABLET_VIEWPORT);
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const topNav = new TopNavMenus(page);
await topNav.open("Play");
await expect(topNav.link("SendouQ")).toBeVisible();
await topNav.close();
await expect(new MobileNav(page).tab("menu")).not.toBeVisible();
});
}
function mobileNavigationTests() {
test("mobile navigation", async ({ page }) => {
await page.setViewportSize(MOBILE_VIEWPORT);
await impersonate(page, NZAP_TEST_ID);
@@ -96,17 +113,4 @@ function navigationTests() {
await mobileNav.locators.youPanelTeamLink.click();
await expect(page).toHaveURL(teamPage(team.customUrl));
});
test("tablet navigation", async ({ page }) => {
await page.setViewportSize(TABLET_VIEWPORT);
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: "/" });
const topNav = new TopNavMenus(page);
await topNav.open("Play");
await expect(topNav.link("SendouQ")).toBeVisible();
await topNav.close();
await expect(new MobileNav(page).tab("menu")).not.toBeVisible();
});
}