mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 21:58:08 -05:00
Fix more jank
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
8
changelog/2026-09-16-popover-open-flash.md
Normal file
8
changelog/2026-09-16-popover-open-flash.md
Normal 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
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
type: bug
|
||||
---
|
||||
Fix weapon select popover flashing empty for a frame when opened and the list jumping while scrolling
|
||||
Reference in New Issue
Block a user