mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 13:47:56 -05:00
Add scroll lock, replace CSS anchors with floating layer, and various fixes (#3407)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
284
app/components/elements/floating-layer.test.ts
Normal file
284
app/components/elements/floating-layer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
241
app/components/elements/floating-layer.ts
Normal file
241
app/components/elements/floating-layer.ts
Normal 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}`;
|
||||
}
|
||||
23
app/components/elements/tests/keyboard.ts
Normal file
23
app/components/elements/tests/keyboard.ts
Normal 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"));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
426
app/components/elements/useFloatingLayer.browser.test.tsx
Normal file
426
app/components/elements/useFloatingLayer.browser.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
317
app/components/elements/useFloatingLayer.ts
Normal file
317
app/components/elements/useFloatingLayer.ts
Normal 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`;
|
||||
}
|
||||
99
app/components/elements/useScrollIntoView.browser.test.tsx
Normal file
99
app/components/elements/useScrollIntoView.browser.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
51
app/components/elements/useScrollIntoView.ts
Normal file
51
app/components/elements/useScrollIntoView.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
37
app/components/layout/GlobalSearch.browser.test.tsx
Normal file
37
app/components/layout/GlobalSearch.browser.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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]}.`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.listBox {
|
||||
max-height: 325px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--s-2);
|
||||
outline: none;
|
||||
|
||||
57
app/components/layout/TopNavMenus.browser.test.tsx
Normal file
57
app/components/layout/TopNavMenus.browser.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? (() => {});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
45
app/hooks/useScrollLock.ts
Normal file
45
app/hooks/useScrollLock.ts
Normal 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");
|
||||
}
|
||||
30
app/hooks/useVisualViewport.ts
Normal file
30
app/hooks/useVisualViewport.ts
Normal 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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
104
app/modules/scroll-lock/scroll-lock.browser.test.ts
Normal file
104
app/modules/scroll-lock/scroll-lock.browser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
67
app/modules/scroll-lock/scroll-lock.ts
Normal file
67
app/modules/scroll-lock/scroll-lock.ts
Normal 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;
|
||||
}
|
||||
39
app/root.tsx
39
app/root.tsx
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
48
app/utils/visual-viewport.ts
Normal file
48
app/utils/visual-viewport.ts
Normal 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;
|
||||
}
|
||||
4
changelog/2026-09-20-floating-layer.md
Normal file
4
changelog/2026-09-20-floating-layer.md
Normal 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
|
||||
4
changelog/2026-09-20-modals-keyboard.md
Normal file
4
changelog/2026-09-20-modals-keyboard.md
Normal 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
|
||||
4
changelog/2026-09-20-scroll-lock.md
Normal file
4
changelog/2026-09-20-scroll-lock.md
Normal 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
|
||||
4
changelog/2026-09-20-search-modal-back.md
Normal file
4
changelog/2026-09-20-search-modal-back.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: bug
|
||||
---
|
||||
Going back in the browser history can now close the search modal
|
||||
@@ -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
67
docs/dev/overlays.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user