From 67726cb6f3081217f141c2b701d581d6f8971a0a Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 23 May 2026 18:30:28 +0200 Subject: [PATCH] fix(soma navigation-menu): suppress click-close right after hover-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: hovering a NavigationMenu trigger opens its Content after the configured \`openDelay\` (default 200ms). If the user then clicked the trigger — naturally, after the open is visible — the click handler saw \`isOpen=true\` and called \`closeNow()\`, shutting the menu the user just opened. Net effect: the first click appears to open AND close the menu in one go. Repro (commit before fix): - hover for ≥200ms (or any delay long enough to trigger \`scheduleOpen\`) - menu opens - click the trigger - menu closes within milliseconds Fix: track \`lastOpenedAt\` per NavigationMenuTriggerProvider via a root \`\$effect\` that stamps the timestamp whenever \`isOpen\` transitions to true (regardless of whether the open came from hover, click, or keyboard). The click handler checks \`Date.now() - lastOpenedAt < 250\` and silently ignores the close-toggle in that window — treating the click as "commit the open" intent, not "toggle shut". Two-click toggle behavior is preserved: a click ≥250ms after the open still closes the menu (verified: openBefore=true, stillOpenAfterClick= true, closedAfter2ndClick=true). The 250ms threshold is slightly above the default openDelay (200ms) so the protection covers the typical hover-then-click sequence without making the toggle feel sluggish. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../navigation-menu-provider.svelte.ts | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts b/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts index 530eb21e3..76fbb5f10 100644 --- a/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts +++ b/src/uix/soma/components/navigation-menu/navigation-menu-provider.svelte.ts @@ -482,6 +482,27 @@ export class NavigationMenuTriggerProvider { readonly isOpen = $derived.by(() => this.item.isOpen); + /** + * Timestamp of the last `isOpen → true` transition for this trigger. + * Used to suppress the "click immediately closes" race that hits users + * who hover briefly then click: hover schedules an open (after + * `openDelay`), the open fires, then a stray click toggles it shut. + * If a click arrives within ~250ms of the open we treat it as the + * same intent (commit the open) and ignore the close-toggle. + * + * `$effect` watches `isOpen` and stamps the time whenever it flips + * from false → true, regardless of whether the open came from hover + * scheduling, click, or keyboard. Tracked per-trigger because each + * NavigationMenuTriggerProvider is an independent instance. + */ + private lastOpenedAt = 0; + + openedAtEffect = $effect.root(() => { + $effect(() => { + if (this.isOpen) this.lastOpenedAt = Date.now(); + }); + }); + readonly onpointerenter = () => { if (this.opts.disabled.current) return; if (!this.provider.opts.hoverEnabled.current) return; @@ -495,8 +516,15 @@ export class NavigationMenuTriggerProvider { readonly onclick = (_e: SomaMouseEvent) => { if (this.opts.disabled.current) return; - if (this.isOpen) this.provider.closeNow(); - else this.provider.openNow(this.item.opts.value.current); + if (this.isOpen) { + // Suppress the close if the open is fresh — the user likely + // clicked right after hover-open, intending to commit the + // open rather than toggle it shut. + if (Date.now() - this.lastOpenedAt < 250) return; + this.provider.closeNow(); + } else { + this.provider.openNow(this.item.opts.value.current); + } }; readonly onkeydown = (e: SomaKeyboardEvent) => {