Route Soma menu typeahead timers

active-uix
dev 5 months ago
parent 66a943093b
commit 8f4be6d425

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

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.