diff --git a/app/components/WeaponSelect.module.css b/app/components/WeaponSelect.module.css index f76f663ff..ee7e34c6d 100644 --- a/app/components/WeaponSelect.module.css +++ b/app/components/WeaponSelect.module.css @@ -1,3 +1,7 @@ +.option { + --option-content-height: 24px; +} + .item { display: flex; gap: var(--s-2); diff --git a/app/components/WeaponSelect.tsx b/app/components/WeaponSelect.tsx index 8a74db0ee..baeb5e7d4 100644 --- a/app/components/WeaponSelect.tsx +++ b/app/components/WeaponSelect.tsx @@ -141,6 +141,7 @@ export function WeaponSelect< key={weapon.anyWeaponId} id={weapon.anyWeaponId} textValue={weaponName} + className={styles.option} isDisabled={ includeSubSpecial ? false diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 52db6931c..8933b9775 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -120,9 +120,13 @@ cursor: pointer; outline: none; /* long lists (every weapon) skip laying out and painting the options - scrolled out of view, keeping them in the accessibility tree */ + scrolled out of view, keeping them in the accessibility tree; the skipped + ones must still be exactly as tall as the rendered ones or the list jumps + while scrolling, and as engines disagree on whether the intrinsic size + includes the padding, the height is pinned with a min-height instead */ content-visibility: auto; - contain-intrinsic-block-size: auto 33px; + --option-content-height: calc(var(--font-sm) * var(--line-height)); + min-block-size: calc(var(--option-content-height) + 2 * var(--s-1-5)); &[data-disabled] { color: var(--color-text-high); diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index ef429dad3..895813085 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -1,9 +1,11 @@ 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"; +import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus"; import { Image } from "../Image"; import { useAnchorPositioning } from "./anchor-positioning"; @@ -166,6 +168,8 @@ export function SendouSelect({ }; 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, @@ -190,6 +194,15 @@ export function SendouSelect({ const listboxRef = React.useRef(null); const searchInputRef = React.useRef(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]); useCloseOnScrollClip(open, popoverRef, () => setOpen(false)); useAnchorPositioning({ isOpen: open, @@ -198,6 +211,16 @@ export function SendouSelect({ matchAnchorWidth: true, constrainHeight: true, }); + // after positioning, so the selection scrolls into the space the list ends up with + useIsomorphicLayoutEffect(() => { + if (!open) return; + if (search) { + searchInputRef.current?.focus(); + } else { + listboxRef.current?.focus(); + } + scrollIntoView(currentKey); + }, [open]); const commitSelection = (key: SelectKey | null) => { if (!isControlled) { @@ -209,11 +232,14 @@ export function SendouSelect({ }; function setOpen(next: boolean) { - if (next) { - popoverRef.current?.showPopover(); - } else { + if (!next) { popoverRef.current?.hidePopover(); + return; } + if (open) return; + setOpenState(true); + onOpenChange?.(true); + focusStore.set(currentKey); } const commitSelectionRef = React.useRef(commitSelection); @@ -260,7 +286,9 @@ export function SendouSelect({ const scrollIntoView = (key: SelectKey | null) => { if (key === null) return; - itemsMapRef.current.get(key)?.element.scrollIntoView({ block: "nearest" }); + document + .getElementById(registry.optionIdFor(key)) + ?.scrollIntoView({ block: "nearest" }); }; const moveFocus = (move: FocusMove) => { @@ -339,31 +367,32 @@ export function SendouSelect({ } }; - const onPopoverToggle = (event: React.ToggleEvent) => { - if (!isOwnToggle(event)) return; - - const next = event.newState === "open"; - if (next === open) return; - setOpenState(next); - onOpenChange?.(next); - - if (next) { - focusStore.set(currentKey); - // the toggle event's render mounts the options synchronously, so they - // are registered by the time this runs - requestAnimationFrame(() => { - if (search) { - searchInputRef.current?.focus(); - } else { - listboxRef.current?.focus(); - } - scrollIntoView(currentKey); - }); - } else { - setSearchValue(""); - focusStore.set(null); - typeaheadRef.current = { query: "", at: 0 }; + /** + * 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) => { + if ( + !isOwnToggle(event) || + event.newState !== "open" || + showingRef.current + ) { + return; } + event.preventDefault(); + requestAnimationFrame(() => flushSync(() => setOpen(true))); + }; + + const onPopoverToggle = (event: React.ToggleEvent) => { + if (!isOwnToggle(event) || event.newState === "open" || !open) return; + + setOpenState(false); + onOpenChange?.(false); + setSearchValue(""); + focusStore.set(null); + typeaheadRef.current = { query: "", at: 0 }; }; const normalizedSearchValue = normalizeForSearch(searchValue); @@ -565,6 +594,7 @@ export function SendouSelect({ popover="auto" className={clsx(styles.popover, popoverClassName)} style={{ positionAnchor: anchorName } as React.CSSProperties} + onBeforeToggle={onPopoverBeforeToggle} onToggle={onPopoverToggle} onKeyDown={onPopoverKeyDown} tabIndex={-1} diff --git a/changelog/2026-09-16-weapon-select-open-flash.md b/changelog/2026-09-16-weapon-select-open-flash.md new file mode 100644 index 000000000..713a7d2c5 --- /dev/null +++ b/changelog/2026-09-16-weapon-select-open-flash.md @@ -0,0 +1,4 @@ +--- +type: bug +--- +Fix weapon select popover flashing empty for a frame when opened and the list jumping while scrolling