From 89911c36c2e83dc18da25a680803930a6b7721ca Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 13:41:30 +0200 Subject: [PATCH] Route Soma announce timers through scheduler --- .../announce/announce-provider.svelte.ts | 77 +++++++++++++------ .../soma/components/announce/global.svelte.ts | 63 ++++++++++++--- .../drag-drop/drag-drop-provider.svelte.ts | 2 +- 3 files changed, 107 insertions(+), 35 deletions(-) diff --git a/src/uix/soma/components/announce/announce-provider.svelte.ts b/src/uix/soma/components/announce/announce-provider.svelte.ts index 1f264a00d..8c0e756f9 100644 --- a/src/uix/soma/components/announce/announce-provider.svelte.ts +++ b/src/uix/soma/components/announce/announce-provider.svelte.ts @@ -6,7 +6,7 @@ import type { AnnouncePriority, AnnounceRole } from './types'; import { announceMorfo } from '../../../morfo/components/announce'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; - +import type { TimerHandle } from '$timer'; /** Visually-hidden style kept in-sync with the WAI "sr-only" recipe. */ const VISUALLY_HIDDEN_STYLE = { @@ -58,8 +58,8 @@ export class AnnounceProvider { private politeToggle = false; private assertiveToggle = false; - private politeTimer: ReturnType | null = null; - private assertiveTimer: ReturnType | null = null; + private politeTimer: TimerHandle | null = null; + private assertiveTimer: TimerHandle | null = null; private constructor(opts: AnnounceOpts) { this.opts = opts; @@ -73,10 +73,7 @@ export class AnnounceProvider { }); $effect(() => { - return () => { - if (this.politeTimer) clearTimeout(this.politeTimer); - if (this.assertiveTimer) clearTimeout(this.assertiveTimer); - }; + return () => this.clearTimers(); }); } @@ -96,12 +93,17 @@ export class AnnounceProvider { this.assertiveA = ''; this.assertiveB = message; } - if (this.assertiveTimer) clearTimeout(this.assertiveTimer); - this.assertiveTimer = setTimeout(() => { - this.assertiveA = ''; - this.assertiveB = ''; - this.assertiveTimer = null; - }, clearAfter); + this.clearAssertiveTimer(); + this.assertiveTimer = this.soma.uix.timers.schedule( + this.assertiveTimerKey, + clearAfter, + () => { + this.assertiveA = ''; + this.assertiveB = ''; + this.assertiveTimer = null; + }, + { replace: true, meta: { component: 'announce', action: 'clear-assertive' } } + ); } else { this.politeToggle = !this.politeToggle; if (this.politeToggle) { @@ -111,12 +113,17 @@ export class AnnounceProvider { this.politeA = ''; this.politeB = message; } - if (this.politeTimer) clearTimeout(this.politeTimer); - this.politeTimer = setTimeout(() => { - this.politeA = ''; - this.politeB = ''; - this.politeTimer = null; - }, clearAfter); + this.clearPoliteTimer(); + this.politeTimer = this.soma.uix.timers.schedule( + this.politeTimerKey, + clearAfter, + () => { + this.politeA = ''; + this.politeB = ''; + this.politeTimer = null; + }, + { replace: true, meta: { component: 'announce', action: 'clear-polite' } } + ); } }; @@ -125,12 +132,36 @@ export class AnnounceProvider { this.politeB = ''; this.assertiveA = ''; this.assertiveB = ''; - if (this.politeTimer) clearTimeout(this.politeTimer); - if (this.assertiveTimer) clearTimeout(this.assertiveTimer); - this.politeTimer = null; - this.assertiveTimer = null; + this.clearTimers(); }; + private clearPoliteTimer() { + if (this.politeTimer) { + this.politeTimer.cancel(); + this.politeTimer = null; + } + } + + private clearAssertiveTimer() { + if (this.assertiveTimer) { + this.assertiveTimer.cancel(); + this.assertiveTimer = null; + } + } + + private clearTimers() { + this.clearPoliteTimer(); + this.clearAssertiveTimer(); + } + + private get politeTimerKey() { + return `soma:announce:${this.opts.id.current}:polite`; + } + + private get assertiveTimerKey() { + return `soma:announce:${this.opts.id.current}:assertive`; + } + readonly snippetProps = $derived.by(() => ({ announce: this.announce, clear: this.clear diff --git a/src/uix/soma/components/announce/global.svelte.ts b/src/uix/soma/components/announce/global.svelte.ts index 63a7b949b..4086053d8 100644 --- a/src/uix/soma/components/announce/global.svelte.ts +++ b/src/uix/soma/components/announce/global.svelte.ts @@ -1,4 +1,5 @@ import type { ActiveDom } from '$adom'; +import type { TimerHandle, TimerScheduler } from '$timer'; import type { AnnouncePriority, AnnounceApi } from './types'; /** @@ -32,10 +33,11 @@ type RegionPair = { a: HTMLElement; b: HTMLElement; toggle: boolean; - timer: ReturnType | null; + timer: AnnounceTimer | null; }; type AnnouncerCache = WeakMap>; +type AnnounceTimer = Pick; function ensurePair( dom: ActiveDom, @@ -91,7 +93,35 @@ function ensurePair( return pair; } -function write(dom: ActiveDom, pair: RegionPair, text: string, timeout: number) { +function scheduleClear( + key: string, + timeout: number, + task: () => void, + timers?: TimerScheduler +): AnnounceTimer { + if (timers) { + return timers.schedule(key, timeout, task, { + replace: true, + meta: { component: 'announce', action: 'global-clear' } + }); + } + + const native = setTimeout(task, timeout); + return { + cancel() { + clearTimeout(native); + return true; + } + }; +} + +function write( + dom: ActiveDom, + pair: RegionPair, + text: string, + timeout: number, + timers?: TimerScheduler +) { pair.toggle = !pair.toggle; if (pair.toggle) { dom.writeText(pair.a, text); @@ -100,12 +130,17 @@ function write(dom: ActiveDom, pair: RegionPair, text: string, timeout: number) dom.writeText(pair.a, ''); dom.writeText(pair.b, text); } - if (pair.timer) clearTimeout(pair.timer); - pair.timer = setTimeout(() => { - dom.writeText(pair.a, ''); - dom.writeText(pair.b, ''); - pair.timer = null; - }, timeout); + if (pair.timer) pair.timer.cancel(); + pair.timer = scheduleClear( + `soma:announce:global:${pair.a.id}`, + timeout, + () => { + dom.writeText(pair.a, ''); + dom.writeText(pair.b, ''); + pair.timer = null; + }, + timers + ); } /** @@ -122,13 +157,19 @@ function write(dom: ActiveDom, pair: RegionPair, text: string, timeout: number) * ``` * * @param defaultTimeout Ms before messages are cleared. @default 1000 + * @param timers Optional UIX/App scheduler. When omitted, the global fallback + * uses native timers because this API is intentionally usable outside UIX. */ -export function createAnnouncer(dom: ActiveDom, defaultTimeout = 1000): AnnounceApi { +export function createAnnouncer( + dom: ActiveDom, + defaultTimeout = 1000, + timers?: TimerScheduler +): AnnounceApi { const announce: AnnounceApi['announce'] = (message, priority = 'polite', timeout) => { const baseId = priority === 'assertive' ? ASSERTIVE_ID : POLITE_ID; const pair = ensurePair(dom, priority, baseId); if (!pair) return; - write(dom, pair, message, timeout ?? defaultTimeout); + write(dom, pair, message, timeout ?? defaultTimeout, timers); }; const clear: AnnounceApi['clear'] = () => { @@ -147,7 +188,7 @@ export function createAnnouncer(dom: ActiveDom, defaultTimeout = 1000): Announce for (const pair of cache.values()) { dom.writeText(pair.a, ''); dom.writeText(pair.b, ''); - if (pair.timer) clearTimeout(pair.timer); + if (pair.timer) pair.timer.cancel(); pair.timer = null; } }; diff --git a/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts index 2c0451da7..68a07a9ff 100644 --- a/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts +++ b/src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts @@ -60,7 +60,7 @@ export class DragDropProvider { private constructor(opts: DragDropOpts) { this.opts = opts; - this.globalAnnounce = createAnnouncer(this.soma.dom, 1500); + this.globalAnnounce = createAnnouncer(this.soma.dom, 1500, this.soma.uix.timers); this.runtime = this.soma.runtime(dragDropMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id,