diff --git a/src/uix/soma/components/combobox/combobox-provider.svelte.ts b/src/uix/soma/components/combobox/combobox-provider.svelte.ts index 1c178393a..69d56e815 100644 --- a/src/uix/soma/components/combobox/combobox-provider.svelte.ts +++ b/src/uix/soma/components/combobox/combobox-provider.svelte.ts @@ -412,6 +412,7 @@ export class ComboboxContentProvider { // Dismissal — isValidEvent returns false for clicks on the input element this.dismissal = Dismissal.use({ dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, ref: readableActive(() => opts.ref.current), enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { diff --git a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts index b736873ec..97d4e3d40 100644 --- a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts +++ b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts @@ -276,6 +276,7 @@ export class ContextMenuContentProvider { this.dismissal = Dismissal.use({ ref: readableActive(() => opts.ref.current), dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); @@ -793,6 +794,7 @@ export class ContextMenuSubProvider { this.safePolygon = new SafePolygon({ dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, enabled: () => opts.open.current, triggerNode: () => this.subTriggerRef.current, contentNode: () => this.contentRef.current, diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.ts index a884f026e..ce1639914 100644 --- a/src/uix/soma/components/dialog/dialog-provider.svelte.ts +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.ts @@ -340,6 +340,7 @@ export class DialogContentProvider { this.dismissal = Dismissal.use({ ref: readableActive(() => opts.ref.current), dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); diff --git a/src/uix/soma/components/drawer/drawer-provider.svelte.ts b/src/uix/soma/components/drawer/drawer-provider.svelte.ts index 596b5fd66..561cc3f48 100644 --- a/src/uix/soma/components/drawer/drawer-provider.svelte.ts +++ b/src/uix/soma/components/drawer/drawer-provider.svelte.ts @@ -463,6 +463,7 @@ export class DrawerContentProvider { // Escape is the only dismissal mechanism from the Dismissal layer. this.dismissal = Dismissal.use({ dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, ref: readableActive(() => opts.ref.current), enabled: readableActive( () => this.provider.opts.open.current && this.provider.opts.dismissible.current && isModal() diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts index 192f7be93..081951ec1 100644 --- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts +++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts @@ -297,6 +297,7 @@ export class MenuContentProvider { this.dismissal = Dismissal.use({ ref: readableActive(() => opts.ref.current), dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); @@ -866,6 +867,7 @@ export class MenuSubProvider { // SafePolygon handles the pointer gap between SubTrigger and SubContent this.safePolygon = new SafePolygon({ dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, enabled: () => opts.open.current, triggerNode: () => this.subTriggerRef.current, contentNode: () => this.contentRef.current, diff --git a/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts b/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts index afa2937a1..9a889451b 100644 --- a/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts +++ b/src/uix/soma/components/link-preview/link-preview-provider.svelte.ts @@ -87,6 +87,7 @@ export class LinkPreviewProvider { // between the trigger anchor and the content. new SafePolygon({ dom: this.soma.dom, + timers: this.soma.uix?.timers, enabled: () => opts.open.current, triggerNode: () => this.triggerRef.current, contentNode: () => this.contentRef.current, @@ -300,6 +301,7 @@ export class LinkPreviewContentProvider { this.dismissal = Dismissal.use({ dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, ref: readableActive(() => opts.ref.current), enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { diff --git a/src/uix/soma/components/popover/popover-provider.svelte.ts b/src/uix/soma/components/popover/popover-provider.svelte.ts index 09d214830..e7413d19f 100644 --- a/src/uix/soma/components/popover/popover-provider.svelte.ts +++ b/src/uix/soma/components/popover/popover-provider.svelte.ts @@ -456,6 +456,7 @@ export class PopoverContentProvider { this.dismissal = Dismissal.use({ ref: readableActive(() => opts.ref.current), dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index 07ae9fbea..705066382 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -472,6 +472,7 @@ export class SelectContentProvider { // Dismissal — close on outside click, but exclude the trigger element this.dismissal = Dismissal.use({ dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, ref: readableActive(() => opts.ref.current), enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { diff --git a/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts b/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts index a256d4fa3..e94c43f34 100644 --- a/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts +++ b/src/uix/soma/components/tooltip/tooltip-provider.svelte.ts @@ -162,6 +162,7 @@ export class TooltipProvider { // the gap between trigger and content (hoverable tooltips) new SafePolygon({ dom: this.soma.dom, + timers: this.soma.uix?.timers, enabled: () => opts.open.current && !this.hoverableDisabled, triggerNode: () => this.triggerRef.current, contentNode: () => this.contentRef.current, @@ -424,6 +425,7 @@ export class TooltipContentProvider { // Dismissal (escape only, no interact-outside for tooltip) this.dismissal = Dismissal.use({ dom: this.provider.soma.dom, + timers: this.provider.soma.uix?.timers, ref: readableActive(() => opts.ref.current), enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { diff --git a/src/uix/soma/layers/dismissal.svelte.ts b/src/uix/soma/layers/dismissal.svelte.ts index 2af51befd..6d93d23c3 100644 --- a/src/uix/soma/layers/dismissal.svelte.ts +++ b/src/uix/soma/layers/dismissal.svelte.ts @@ -32,6 +32,7 @@ import { watch } from 'runed'; import { readableActive, type Active } from '$libs/reactive'; import { isElement, isHTMLElement, contains, type ActiveDom } from '$adom'; import { KEYS } from '$soma/keyboard'; +import type { TimerHandle, TimerScheduler } from '$timer'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -44,6 +45,7 @@ export type DismissalBehavior = export interface DismissalOpts { ref: Active; dom: ActiveDom; + timers?: TimerScheduler; enabled: Active; onEscapeKeydown: Active<(e: KeyboardEvent) => void>; escapeKeydownBehavior: Active; @@ -63,6 +65,8 @@ const registry = new Map< } >(); +let dismissalId = 0; + // ── Dismissal ──────────────────────────────────────────────────────────────── export class Dismissal { @@ -71,6 +75,7 @@ export class Dismissal { } readonly opts: DismissalOpts; + readonly timerKey = `soma:dismissal:${++dismissalId}:register`; private interceptedPointerdown = false; private isResponsibleLayer = false; private isFocusInsideDOMTree = false; @@ -85,8 +90,9 @@ export class Dismissal { if (!enabled || !ref) return; // Small delay to avoid catching the same event that opened the layer - const win = opts.dom.getWindow(ref); - const timer = win.setTimeout(() => { + let nativeTimer: number | null = null; + let schedulerTimer: TimerHandle | null = null; + const register = () => { if (!opts.ref.current) return; registry.set(this, { escapeBehavior: opts.escapeKeydownBehavior, @@ -94,10 +100,21 @@ export class Dismissal { }); unsubEvents(); unsubEvents = this.addEventListeners(); - }, 1); + }; + + if (opts.timers) { + schedulerTimer = opts.timers.schedule(this.timerKey, 1, register, { + replace: true, + meta: { component: 'dismissal', action: 'register' } + }); + } else { + const win = opts.dom.getWindow(ref); + nativeTimer = win.setTimeout(register, 1); + } return () => { - win.clearTimeout(timer); + schedulerTimer?.cancel(); + if (nativeTimer !== null) opts.dom.getWindow(ref).clearTimeout(nativeTimer); unsubEvents(); this.resetState(); registry.delete(this); diff --git a/src/uix/soma/layers/floating/safe-polygon.ts b/src/uix/soma/layers/floating/safe-polygon.ts index c299a5ef6..60948f1e9 100644 --- a/src/uix/soma/layers/floating/safe-polygon.ts +++ b/src/uix/soma/layers/floating/safe-polygon.ts @@ -1,6 +1,7 @@ import { watch } from 'runed'; import type { Side } from '@floating-ui/dom'; import { isElement, type ActiveDom } from '$adom'; +import type { TimerHandle, TimerScheduler } from '$timer'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -49,6 +50,7 @@ function getSide(triggerRect: DOMRect, contentRect: DOMRect): Side { export type SafePolygonOptions = { dom: ActiveDom; + timers?: TimerScheduler; enabled: () => boolean; triggerNode: () => HTMLElement | null; contentNode: () => HTMLElement | null; @@ -62,12 +64,15 @@ export type SafePolygonOptions = { setIsPointerInTransit?: (value: boolean) => void; }; +let safePolygonId = 0; + // ─── SafePolygon ───────────────────────────────────────────────────────────── export class SafePolygon { readonly opts: SafePolygonOptions; readonly buffer: number; readonly transitIntentTimeout: number | null; + readonly timerKey = `soma:safe-polygon:${++safePolygonId}:transit-intent`; exitPoint: Point | null = null; exitTarget: 'trigger' | 'content' | null = null; @@ -75,6 +80,7 @@ export class SafePolygon { trackedTriggerNode: HTMLElement | null = null; leaveFallbackRafId: number | null = null; transitIntentTimeoutId: number | null = null; + transitIntentTimer: TimerHandle | null = null; cancelLeaveFallback() { if (this.leaveFallbackRafId !== null) { @@ -94,6 +100,10 @@ export class SafePolygon { } cancelTransitIntentTimeout() { + if (this.transitIntentTimer !== null) { + this.transitIntentTimer.cancel(); + this.transitIntentTimer = null; + } if (this.transitIntentTimeoutId !== null) { this.opts.dom.getWindow(this.trackedTriggerNode).clearTimeout(this.transitIntentTimeoutId); this.transitIntentTimeoutId = null; @@ -103,13 +113,30 @@ export class SafePolygon { scheduleTransitIntentTimeout() { if (this.transitIntentTimeout === null) return; this.cancelTransitIntentTimeout(); + const leave = () => { + this.transitIntentTimer = null; + this.transitIntentTimeoutId = null; + if (!this.exitPoint || !this.exitTarget) return; + this.clearTracking(); + this.opts.onPointerExit(); + }; + if (this.opts.timers) { + this.transitIntentTimer = this.opts.timers.schedule( + this.timerKey, + this.transitIntentTimeout, + leave, + { + replace: true, + meta: { component: 'safe-polygon', action: 'transit-intent' } + } + ); + return; + } this.transitIntentTimeoutId = this.opts.dom .getWindow(this.trackedTriggerNode) .setTimeout(() => { this.transitIntentTimeoutId = null; - if (!this.exitPoint || !this.exitTarget) return; - this.clearTracking(); - this.opts.onPointerExit(); + leave(); }, this.transitIntentTimeout); }