Fix more jank
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-09-16 22:04:57 +03:00
parent 3442d9f8de
commit 4f81f2f3ce
7 changed files with 122 additions and 54 deletions

View File

@@ -33,8 +33,17 @@
border-radius: var(--radius-box);
}
/* long boards skip laying out and painting the rows scrolled out of view;
the skipped ones must still be exactly as tall as the rendered ones or the
page jumps while scrolling, and as engines disagree on whether the
intrinsic size includes the padding, the height is pinned with a min-height
instead: an avatar row, or a weapon image row */
content-visibility: auto;
contain-intrinsic-size: auto 32px;
min-block-size: calc(24px + 2 * var(--s-2));
&:has(.weapon) {
min-block-size: calc(32px + 2 * var(--s-2));
}
}
.dividerRow {

View File

@@ -1,7 +1,7 @@
import clsx from "clsx";
import { X } from "lucide-react";
import * as React from "react";
import { createPortal } from "react-dom";
import { createPortal, flushSync } from "react-dom";
import { useNavigate } from "react-router";
import {
SendouButton,
@@ -199,12 +199,24 @@ function TriggeredDialog({
// React wires `onToggle` on a hydrated <dialog> only when it is also a
// popover, so opens are observed natively (also seeding from a dialog
// opened before hydration)
const trackOpenState = (dialog: HTMLDialogElement) => {
// opened before hydration). Lazy content is committed on `beforetoggle`,
// which fires synchronously before the dialog shows, so it is in the
// dialog's first painted frame rather than a frame behind it. Wired once
// on mount: a ref callback would rerun on every render and, on an open
// dialog, take the focus back from whatever inside it the user is typing in.
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleOpened = () => {
setOpen(true);
dialog.focus();
};
const onBeforeToggle = (event: Event) => {
if ((event as ToggleEvent).newState === "open") {
flushSync(() => setOpen(true));
}
};
const onToggle = (event: Event) => {
if ((event as ToggleEvent).newState === "open") {
handleOpened();
@@ -212,10 +224,14 @@ function TriggeredDialog({
setOpen(false);
}
};
dialog.addEventListener("beforetoggle", onBeforeToggle);
dialog.addEventListener("toggle", onToggle);
if (dialog.open) handleOpened();
return () => dialog.removeEventListener("toggle", onToggle);
};
return () => {
dialog.removeEventListener("beforetoggle", onBeforeToggle);
dialog.removeEventListener("toggle", onToggle);
};
}, []);
return (
<>
@@ -230,10 +246,7 @@ function TriggeredDialog({
},
})}
<DialogElement
ref={(dialog) => {
dialogRef.current = dialog;
return dialog ? trackOpenState(dialog) : undefined;
}}
ref={dialogRef}
id={dialogId}
{...dialogElementProps(chrome, dialogId, handleClosed)}
>

View File

@@ -9,7 +9,12 @@ import {
import { Image } from "../Image";
import { useAnchorPositioning } from "./anchor-positioning";
import styles from "./Menu.module.css";
import { focusLeftTo, isOwnToggle, useAnchorSafeId } from "./Popover";
import {
focusLeftTo,
isOwnToggle,
useAnchorSafeId,
useShowPopoverOnOpen,
} from "./Popover";
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
type MenuPlacement = "bottom start" | "bottom end" | "bottom right";
@@ -51,6 +56,11 @@ export function SendouMenu({
if (popoverRef.current?.matches(":popover-open")) setOpen(true);
}, []);
const onBeforeToggle = useShowPopoverOnOpen({
popoverRef,
open,
onOpen: () => setOpen(true),
});
useCloseOnScrollClip(open, popoverRef, () =>
popoverRef.current?.hidePopover(),
);
@@ -68,11 +78,11 @@ export function SendouMenu({
if (!isOwnToggle(event)) return;
const next = event.newState === "open";
if (next === open) return;
setOpen(next);
if (next) {
requestAnimationFrame(() => popoverRef.current?.focus());
popoverRef.current?.focus();
}
if (next !== open) {
setOpen(next);
}
};
@@ -124,6 +134,7 @@ export function SendouMenu({
})}
style={{ positionAnchor: anchorName } as React.CSSProperties}
data-placement={placement}
onBeforeToggle={onBeforeToggle}
onToggle={onToggle}
onKeyDown={onKeyDown}
onBlur={onBlur}

View File

@@ -1,5 +1,7 @@
import clsx from "clsx";
import * as React from "react";
import { flushSync } from "react-dom";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import {
type AnchorPlacement,
useAnchorPositioning,
@@ -23,6 +25,50 @@ export function isOwnToggle(event: React.ToggleEvent<HTMLElement>) {
return event.target === event.currentTarget;
}
/**
* 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
* frame after it. Returns the `beforetoggle` handler for the popover: the
* 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
* that measures it.
*/
export function useShowPopoverOnOpen({
popoverRef,
open,
onOpen,
}: {
popoverRef: React.RefObject<HTMLElement | null>;
open: boolean;
onOpen: () => void;
}) {
const showingRef = React.useRef(false);
const onOpenRef = React.useRef(onOpen);
onOpenRef.current = onOpen;
useIsomorphicLayoutEffect(() => {
const popover = popoverRef.current;
if (!open || !popover || popover.matches(":popover-open")) return;
showingRef.current = true;
popover.showPopover();
showingRef.current = false;
}, [open, popoverRef]);
return (event: React.ToggleEvent<HTMLElement>) => {
if (
!isOwnToggle(event) ||
event.newState !== "open" ||
showingRef.current
) {
return;
}
event.preventDefault();
requestAnimationFrame(() => flushSync(() => onOpenRef.current()));
};
}
/**
* Whether a focusout moved focus to an element outside all of `containers`,
* so an overlay tabbed out of can close. Focus lost to nowhere (a click on a
@@ -95,13 +141,16 @@ export function SendouPopover({
setOpenRef.current(true);
return;
}
if (open) {
popover.showPopover();
} else {
if (!open) {
popover.hidePopover();
}
}, [open]);
const onBeforeToggle = useShowPopoverOnOpen({
popoverRef,
open,
onOpen: () => setOpen(true),
});
useCloseOnScrollClip(open, popoverRef, () => setOpen(false));
useAnchorPositioning({
isOpen: open,
@@ -155,6 +204,7 @@ export function SendouPopover({
role="dialog"
tabIndex={-1}
data-placement={placement}
onBeforeToggle={onBeforeToggle}
onToggle={onToggle}
onBlur={onBlur}
>
@@ -183,7 +233,8 @@ export function SendouAnchoredPopover({
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// 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;

View File

@@ -1,7 +1,6 @@
import clsx from "clsx";
import { ChevronsUpDown, Search, X } from "lucide-react";
import * as React from "react";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouButton } from "~/components/elements/Button";
@@ -9,7 +8,12 @@ import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus";
import { Image } from "../Image";
import { useAnchorPositioning } from "./anchor-positioning";
import { focusLeftTo, isOwnToggle, useAnchorSafeId } from "./Popover";
import {
focusLeftTo,
isOwnToggle,
useAnchorSafeId,
useShowPopoverOnOpen,
} from "./Popover";
import styles from "./Select.module.css";
import { useCloseOnScrollClip } from "./useCloseOnScrollClip";
@@ -168,8 +172,6 @@ export function SendouSelect<T extends object>({
};
const [open, setOpenState] = React.useState(false);
/** Set while the layout effect below shows the popover, so the beforetoggle handler lets that one through. */
const showingRef = React.useRef(false);
const [, rerenderWithRegisteredContent] = React.useReducer(
(count: number) => count + 1,
0,
@@ -194,15 +196,11 @@ export function SendouSelect<T extends object>({
const listboxRef = React.useRef<HTMLDivElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
// shown only once the options and search field are committed, and before
// the positioning effect measures it, so the first paint is the final one
useIsomorphicLayoutEffect(() => {
const popover = popoverRef.current;
if (!open || !popover || popover.matches(":popover-open")) return;
showingRef.current = true;
popover.showPopover();
showingRef.current = false;
}, [open]);
const onPopoverBeforeToggle = useShowPopoverOnOpen({
popoverRef,
open,
onOpen: () => setOpen(true),
});
useCloseOnScrollClip(open, popoverRef, () => setOpen(false));
useAnchorPositioning({
isOpen: open,
@@ -367,24 +365,6 @@ export function SendouSelect<T extends object>({
}
};
/**
* The trigger's popoverTarget opens the popover before the options exist:
* mount them first and let the layout effect show it. That happens in the
* next frame, still before it paints, as showing a popover from inside the
* show operation being cancelled here is an error.
*/
const onPopoverBeforeToggle = (event: React.ToggleEvent<HTMLDivElement>) => {
if (
!isOwnToggle(event) ||
event.newState !== "open" ||
showingRef.current
) {
return;
}
event.preventDefault();
requestAnimationFrame(() => flushSync(() => setOpen(true)));
};
const onPopoverToggle = (event: React.ToggleEvent<HTMLDivElement>) => {
if (!isOwnToggle(event) || event.newState === "open" || !open) return;

View File

@@ -0,0 +1,8 @@
---
type: bug
---
Popover and dialog polish
- Weapon selects, popovers, menus and image export dialogs no longer flash empty for a frame when opened
- Weapon select lists and leaderboards no longer jump while scrolling
- A search field inside a dialog no longer loses focus (and closes its results) while you type

View File

@@ -1,4 +0,0 @@
---
type: bug
---
Fix weapon select popover flashing empty for a frame when opened and the list jumping while scrolling