Route Soma layer timers through UIX scheduler

active-uix
dev 5 months ago
parent 3050d273dd
commit eaeebd6991

@ -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) => {

@ -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,

@ -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);

@ -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()

@ -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,

@ -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) => {

@ -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);

@ -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) => {

@ -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) => {

@ -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<HTMLElement | null>;
dom: ActiveDom;
timers?: TimerScheduler;
enabled: Active<boolean>;
onEscapeKeydown: Active<(e: KeyboardEvent) => void>;
escapeKeydownBehavior: Active<DismissalBehavior>;
@ -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);

@ -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);
}

Loading…
Cancel
Save

Powered by TurnKey Linux.