From 8f4be6d42536047eaa7cad406809c2346fe30d27 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 13:33:12 +0200 Subject: [PATCH] Route Soma menu typeahead timers --- .../context-menu-provider.svelte.ts | 67 ++++++++++++++----- .../dropdown-menu-provider.svelte.ts | 60 ++++++++++++----- .../select/select-provider.svelte.ts | 35 +++++++--- 3 files changed, 121 insertions(+), 41 deletions(-) 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 bdf0e3112..806716c55 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 @@ -32,24 +32,35 @@ import { SafePolygon } from '../../layers/floating/safe-polygon'; import { contextMenuMorfo } from '../../../morfo/components/context-menu'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import type { ActiveTimers, TimerHandle } from '$timer'; const attrs = createAttrs(contextMenuMorfo); // ── Typeahead helper ───────────────────────────────────────────────────────── class Typeahead { + constructor( + private readonly timers: ActiveTimers, + private readonly key: () => string + ) {} + private search = ''; - private timer: ReturnType | null = null; + private timer: TimerHandle | null = null; handle(key: string, items: HTMLElement[]): HTMLElement | null { if (key.length !== 1) return null; this.search += key.toLowerCase(); - if (this.timer) clearTimeout(this.timer); - this.timer = setTimeout(() => { - this.search = ''; - this.timer = null; - }, 500); + this.cancelTimer(); + this.timer = this.timers.schedule( + this.key(), + 500, + () => { + this.search = ''; + this.timer = null; + }, + { replace: true, meta: { component: 'context-menu', action: 'typeahead-reset' } } + ); const match = items.find((el) => { const text = el.getAttribute('data-text-value') || el.textContent || ''; @@ -61,8 +72,12 @@ class Typeahead { reset() { this.search = ''; + this.cancelTimer(); + } + + private cancelTimer() { if (this.timer) { - clearTimeout(this.timer); + this.timer.cancel(); this.timer = null; } } @@ -102,7 +117,7 @@ export class ContextMenuProvider { readonly floatingProvider: FloatingProvider; readonly contentPresence: Presence; - readonly typeahead = new Typeahead(); + readonly typeahead: Typeahead; contentId = state(''); triggerId = state(''); @@ -120,6 +135,10 @@ export class ContextMenuProvider { private constructor(opts: ContextMenuOpts) { this.opts = opts; this.soma = Soma.require(); + this.typeahead = new Typeahead( + this.soma.uix.timers, + () => `soma:context-menu:${this.opts.id.current}:typeahead` + ); this.runtime = this.soma.runtime(contextMenuMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, @@ -788,7 +807,7 @@ export class ContextMenuSubProvider { readonly contentPresence: Presence; readonly floatingProvider: FloatingProvider; readonly safePolygon: SafePolygon; - readonly typeahead = new Typeahead(); + readonly typeahead: Typeahead; contentId = state(''); subTriggerId = state(''); @@ -798,6 +817,11 @@ export class ContextMenuSubProvider { private constructor(opts: ContextMenuSubOpts) { this.opts = opts; this.provider = ContextMenuProvider.require(); + this.typeahead = new Typeahead( + this.provider.soma.uix.timers, + () => + `soma:context-menu:${this.provider.opts.id.current}:${this.subTriggerId.current || this.contentId.current || 'sub'}:typeahead` + ); this.floatingProvider = FloatingProvider.create(); this.contentPresence = new Presence({ @@ -845,7 +869,7 @@ export class ContextMenuSubTriggerProvider { } readonly sub: ContextMenuSubProvider; - private openTimeout: ReturnType | null = null; + private openTimer: TimerHandle | null = null; private constructor(opts: ContextMenuSubTriggerOpts) { // A30: register subTriggerRef via `onRefChange` (attachRef fires at @@ -873,19 +897,24 @@ export class ContextMenuSubTriggerProvider { } private clearOpenTimeout() { - if (this.openTimeout) { - clearTimeout(this.openTimeout); - this.openTimeout = null; + if (this.openTimer) { + this.openTimer.cancel(); + this.openTimer = null; } } readonly onpointerenter = () => { if (this.opts.disabled.current) return; this.clearOpenTimeout(); - this.openTimeout = setTimeout(() => { - this.openTimeout = null; - this.sub.handleOpen(); - }, 100); + this.openTimer = this.sub.provider.soma.uix.timers.schedule( + this.openTimerKey, + 100, + () => { + this.openTimer = null; + this.sub.handleOpen(); + }, + { replace: true, meta: { component: 'context-menu', action: 'submenu-open' } } + ); }; readonly onpointerleave = () => { @@ -911,6 +940,10 @@ export class ContextMenuSubTriggerProvider { } }; + private get openTimerKey() { + return `soma:context-menu:${this.opts.id.current}:submenu-open`; + } + readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, 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 2e6ac5b8a..faeb2e9fd 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 @@ -37,24 +37,35 @@ import { SafePolygon } from '../../layers/floating/safe-polygon'; import { dropdownMenuMorfo } from '../../../morfo/components/dropdown-menu'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import type { ActiveTimers, TimerHandle } from '$timer'; const attrs = createAttrs(dropdownMenuMorfo); // ── Typeahead helper ───────────────────────────────────────────────────────── class Typeahead { + constructor( + private readonly timers: ActiveTimers, + private readonly key: () => string + ) {} + private search = ''; - private timer: ReturnType | null = null; + private timer: TimerHandle | null = null; handle(key: string, items: HTMLElement[]): HTMLElement | null { if (key.length !== 1) return null; this.search += key.toLowerCase(); - if (this.timer) clearTimeout(this.timer); - this.timer = setTimeout(() => { - this.search = ''; - this.timer = null; - }, 500); + this.cancelTimer(); + this.timer = this.timers.schedule( + this.key(), + 500, + () => { + this.search = ''; + this.timer = null; + }, + { replace: true, meta: { component: 'dropdown-menu', action: 'typeahead-reset' } } + ); const match = items.find((el) => { const text = el.getAttribute('data-text-value') || el.textContent || ''; @@ -66,8 +77,12 @@ class Typeahead { reset() { this.search = ''; + this.cancelTimer(); + } + + private cancelTimer() { if (this.timer) { - clearTimeout(this.timer); + this.timer.cancel(); this.timer = null; } } @@ -109,7 +124,7 @@ export class MenuProvider { readonly floatingProvider: FloatingProvider; readonly contentPresence: Presence; - readonly typeahead = new Typeahead(); + readonly typeahead: Typeahead; contentId = state(''); triggerId = state(''); @@ -118,6 +133,10 @@ export class MenuProvider { private constructor(opts: MenuOpts) { this.opts = opts; this.soma = Soma.require(); + this.typeahead = new Typeahead( + this.soma.uix.timers, + () => `soma:dropdown-menu:${this.opts.id.current}:typeahead` + ); this.runtime = this.soma.runtime(dropdownMenuMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, @@ -925,7 +944,7 @@ export class MenuSubTriggerProvider { } readonly sub: MenuSubProvider; - private openTimeout: ReturnType | null = null; + private openTimer: TimerHandle | null = null; private constructor(opts: MenuSubTriggerOpts) { // A30: register subTriggerRef via `onRefChange` (attachRef fires at @@ -954,9 +973,9 @@ export class MenuSubTriggerProvider { } private clearOpenTimeout() { - if (this.openTimeout) { - clearTimeout(this.openTimeout); - this.openTimeout = null; + if (this.openTimer) { + this.openTimer.cancel(); + this.openTimer = null; } } @@ -964,10 +983,15 @@ export class MenuSubTriggerProvider { if (this.opts.disabled.current) return; this.clearOpenTimeout(); // SafePolygon handles the close coordination — just open on enter - this.openTimeout = setTimeout(() => { - this.openTimeout = null; - this.sub.handleOpen(); - }, 100); + this.openTimer = this.sub.provider.soma.uix.timers.schedule( + this.openTimerKey, + 100, + () => { + this.openTimer = null; + this.sub.handleOpen(); + }, + { replace: true, meta: { component: 'dropdown-menu', action: 'submenu-open' } } + ); }; readonly onpointerleave = () => { @@ -1003,6 +1027,10 @@ export class MenuSubTriggerProvider { } }; + private get openTimerKey() { + return `soma:dropdown-menu:${this.opts.id.current}:submenu-open`; + } + readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index d13d60ff0..9a57d76c1 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -34,6 +34,7 @@ import { import { selectMorfo } from '../../../morfo/components/select'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import type { ActiveTimers, TimerHandle } from '$timer'; const attrs = createAttrs(selectMorfo); @@ -43,17 +44,27 @@ export type ScrollAlignment = 'nearest' | 'center' | 'start' | 'end'; // ── Typeahead ──────────────────────────────────────────────────────────────── class Typeahead { + constructor( + private readonly timers: ActiveTimers, + private readonly key: () => string + ) {} + private search = ''; - private timer: ReturnType | null = null; + private timer: TimerHandle | null = null; handle(key: string, items: HTMLElement[]): HTMLElement | null { if (key.length !== 1) return null; this.search += key.toLowerCase(); - if (this.timer) clearTimeout(this.timer); - this.timer = setTimeout(() => { - this.search = ''; - this.timer = null; - }, 500); + this.cancelTimer(); + this.timer = this.timers.schedule( + this.key(), + 500, + () => { + this.search = ''; + this.timer = null; + }, + { replace: true, meta: { component: 'select', action: 'typeahead-reset' } } + ); return ( items.find((el) => { const text = el.getAttribute('data-label') || el.textContent || ''; @@ -64,8 +75,12 @@ class Typeahead { reset() { this.search = ''; + this.cancelTimer(); + } + + private cancelTimer() { if (this.timer) { - clearTimeout(this.timer); + this.timer.cancel(); this.timer = null; } } @@ -112,7 +127,7 @@ export class SelectProvider { readonly floatingProvider: FloatingProvider; readonly contentPresence: Presence; - readonly typeahead = new Typeahead(); + readonly typeahead: Typeahead; contentId = state(''); triggerId = state(''); @@ -138,6 +153,10 @@ export class SelectProvider { private constructor(opts: SelectOpts) { this.opts = opts; this.soma = Soma.require(); + this.typeahead = new Typeahead( + this.soma.uix.timers, + () => `soma:select:${this.opts.id.current}:typeahead` + ); this.runtime = this.soma.runtime(selectMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id,