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
* @default 'dialog'
*/
role?: string;
role?: 'dialog' | 'menu' | 'listbox';
/**
* Trigger snippet — spread the provided props onto your trigger element
*/
@@ -74,8 +74,20 @@ const contentId = `popover-${uid}`;
let triggerEl: 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);
/**
* 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
* whether the browser currently shows the popover; `open` is the public binding.
@@ -122,14 +134,18 @@ function updatePosition(): void {
resolvedSide = result.side;
contentEl.style.left = `${result.x}px`;
contentEl.style.top = `${result.y}px`;
positioned = true;
}
/**
* Mirror the `toggle` event into our state.
*/
function onToggle(event: Event & { newState?: string }): void {
function onToggle(event: ToggleEvent): void {
shown = event.newState === 'open';
open = shown;
if (!shown) {
positioned = false;
}
}
/**
@@ -175,6 +191,11 @@ $effect(() => {
{@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
bind:this={contentEl}
id={contentId}
@@ -183,7 +204,7 @@ $effect(() => {
data-side={resolvedSide}
data-state={shown ? 'open' : 'closed'}
ontoggle={onToggle}
style="position: fixed; inset: auto; margin: 0;"
style={`position: fixed; inset: auto; margin: 0;${positioned ? '' : ' visibility: hidden;'}`}
class={cn(
'opacity-0 scale-95 transition-discrete transition-all duration-fast',
'starting:opacity-0 starting:scale-95',