fix(popover): gate visibility until positioned, tighten types

This commit is contained in:
Ilia Mashkov
2026-06-02 16:12:11 +03:00
parent 9e0c8f740b
commit 93c52dd132
+24 -3
View File
@@ -47,7 +47,7 @@ interface Props {
* ARIA role for the content * ARIA role for the content
* @default 'dialog' * @default 'dialog'
*/ */
role?: string; role?: 'dialog' | 'menu' | 'listbox';
/** /**
* Trigger snippet — spread the provided props onto your trigger element * Trigger snippet — spread the provided props onto your trigger element
*/ */
@@ -74,8 +74,20 @@ const contentId = `popover-${uid}`;
let triggerEl: HTMLElement | undefined = $state(); let triggerEl: HTMLElement | undefined = $state();
let contentEl: HTMLElement | undefined = $state(); let contentEl: HTMLElement | undefined = $state();
/**
* Side actually used after flip. Seeded from the `side` prop; the authoritative
* value is written by updatePosition() on every open, so the seed only matters
* for the closed state (hence the intentional state_referenced_locally warning).
*/
let resolvedSide = $state(side); let resolvedSide = $state(side);
/**
* True once updatePosition has applied coordinates for the current open.
* Gates visibility so the content never paints at its pre-positioned (0,0)
* top-layer default before the first measurement.
*/
let positioned = $state(false);
/** /**
* Actual DOM open state, driven by the `toggle` event. Source of truth for * Actual DOM open state, driven by the `toggle` event. Source of truth for
* whether the browser currently shows the popover; `open` is the public binding. * whether the browser currently shows the popover; `open` is the public binding.
@@ -122,14 +134,18 @@ function updatePosition(): void {
resolvedSide = result.side; resolvedSide = result.side;
contentEl.style.left = `${result.x}px`; contentEl.style.left = `${result.x}px`;
contentEl.style.top = `${result.y}px`; contentEl.style.top = `${result.y}px`;
positioned = true;
} }
/** /**
* Mirror the `toggle` event into our state. * Mirror the `toggle` event into our state.
*/ */
function onToggle(event: Event & { newState?: string }): void { function onToggle(event: ToggleEvent): void {
shown = event.newState === 'open'; shown = event.newState === 'open';
open = shown; open = shown;
if (!shown) {
positioned = false;
}
} }
/** /**
@@ -175,6 +191,11 @@ $effect(() => {
{@render trigger(triggerProps)} {@render trigger(triggerProps)}
<!--
inset:auto + margin:0 neutralize the UA popover stylesheet (which sets
inset:0; margin:auto to center it) so the JS-applied left/top win.
visibility is hidden until updatePosition runs (see `positioned`).
-->
<div <div
bind:this={contentEl} bind:this={contentEl}
id={contentId} id={contentId}
@@ -183,7 +204,7 @@ $effect(() => {
data-side={resolvedSide} data-side={resolvedSide}
data-state={shown ? 'open' : 'closed'} data-state={shown ? 'open' : 'closed'}
ontoggle={onToggle} ontoggle={onToggle}
style="position: fixed; inset: auto; margin: 0;" style={`position: fixed; inset: auto; margin: 0;${positioned ? '' : ' visibility: hidden;'}`}
class={cn( class={cn(
'opacity-0 scale-95 transition-discrete transition-all duration-fast', 'opacity-0 scale-95 transition-discrete transition-all duration-fast',
'starting:opacity-0 starting:scale-95', 'starting:opacity-0 starting:scale-95',