From 5e975b68255bd3c46baec9e28d5b4027af8e9d2c Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 14:13:47 +0200 Subject: [PATCH] Share Soma menu typeahead helper --- .../context-menu-provider.svelte.ts | 76 ++++------------ .../dropdown-menu-provider.svelte.ts | 64 ++------------ .../select/select-provider.svelte.ts | 61 ++----------- src/uix/soma/index.ts | 10 +++ src/uix/soma/typeahead.ts | 88 +++++++++++++++++++ 5 files changed, 128 insertions(+), 171 deletions(-) create mode 100644 src/uix/soma/typeahead.ts 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 806716c55..cc408d3df 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 @@ -12,6 +12,7 @@ import type { } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; +import { Typeahead } from '../../typeahead'; import { Presence } from '../../layers/presence.svelte'; import { FocusScope } from '../../layers/focus-scope.svelte'; @@ -32,61 +33,10 @@ 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'; +import type { TimerHandle } from '$timer'; const attrs = createAttrs(contextMenuMorfo); -// ── Typeahead helper ───────────────────────────────────────────────────────── - -class Typeahead { - constructor( - private readonly timers: ActiveTimers, - private readonly key: () => string - ) {} - - private search = ''; - private timer: TimerHandle | null = null; - - handle(key: string, items: HTMLElement[]): HTMLElement | null { - if (key.length !== 1) return null; - - this.search += key.toLowerCase(); - 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 || ''; - return text.toLowerCase().startsWith(this.search); - }); - - return match ?? null; - } - - reset() { - this.search = ''; - this.cancelTimer(); - } - - private cancelTimer() { - if (this.timer) { - this.timer.cancel(); - this.timer = null; - } - } - - destroy() { - this.reset(); - } -} - // ── Root ───────────────────────────────────────────────────────────────────── interface ContextMenuOpts @@ -135,10 +85,12 @@ 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.typeahead = new Typeahead({ + timers: this.soma.uix.timers, + key: () => `soma:context-menu:${this.opts.id.current}:typeahead`, + meta: { component: 'context-menu', action: 'typeahead-reset' }, + getText: (el) => el.getAttribute('data-text-value') || el.textContent || '' + }); this.runtime = this.soma.runtime(contextMenuMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, @@ -817,11 +769,13 @@ 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.typeahead = new Typeahead({ + timers: this.provider.soma.uix.timers, + key: () => + `soma:context-menu:${this.provider.opts.id.current}:${this.subTriggerId.current || this.contentId.current || 'sub'}:typeahead`, + meta: { component: 'context-menu', action: 'typeahead-reset' }, + getText: (el) => el.getAttribute('data-text-value') || el.textContent || '' + }); this.floatingProvider = FloatingProvider.create(); this.contentPresence = new Presence({ 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 faeb2e9fd..7059963b5 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 @@ -18,6 +18,7 @@ import type { } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; +import { Typeahead } from '../../typeahead'; import { Presence } from '../../layers/presence.svelte'; import { FocusScope } from '../../layers/focus-scope.svelte'; @@ -37,61 +38,10 @@ 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'; +import type { TimerHandle } from '$timer'; const attrs = createAttrs(dropdownMenuMorfo); -// ── Typeahead helper ───────────────────────────────────────────────────────── - -class Typeahead { - constructor( - private readonly timers: ActiveTimers, - private readonly key: () => string - ) {} - - private search = ''; - private timer: TimerHandle | null = null; - - handle(key: string, items: HTMLElement[]): HTMLElement | null { - if (key.length !== 1) return null; - - this.search += key.toLowerCase(); - 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 || ''; - return text.toLowerCase().startsWith(this.search); - }); - - return match ?? null; - } - - reset() { - this.search = ''; - this.cancelTimer(); - } - - private cancelTimer() { - if (this.timer) { - this.timer.cancel(); - this.timer = null; - } - } - - destroy() { - this.reset(); - } -} - // ── Root ───────────────────────────────────────────────────────────────────── interface MenuOpts @@ -133,10 +83,12 @@ 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.typeahead = new Typeahead({ + timers: this.soma.uix.timers, + key: () => `soma:dropdown-menu:${this.opts.id.current}:typeahead`, + meta: { component: 'dropdown-menu', action: 'typeahead-reset' }, + getText: (el) => el.getAttribute('data-text-value') || el.textContent || '' + }); this.runtime = this.soma.runtime(dropdownMenuMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, diff --git a/src/uix/soma/components/select/select-provider.svelte.ts b/src/uix/soma/components/select/select-provider.svelte.ts index 9a57d76c1..b37e82c2e 100644 --- a/src/uix/soma/components/select/select-provider.svelte.ts +++ b/src/uix/soma/components/select/select-provider.svelte.ts @@ -18,6 +18,7 @@ import type { } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; +import { Typeahead } from '../../typeahead'; import { Presence } from '../../layers/presence.svelte'; import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte'; @@ -34,62 +35,12 @@ import { import { selectMorfo } from '../../../morfo/components/select'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; -import type { ActiveTimers, TimerHandle } from '$timer'; const attrs = createAttrs(selectMorfo); export type SelectType = 'single' | 'multiple'; export type ScrollAlignment = 'nearest' | 'center' | 'start' | 'end'; -// ── Typeahead ──────────────────────────────────────────────────────────────── - -class Typeahead { - constructor( - private readonly timers: ActiveTimers, - private readonly key: () => string - ) {} - - private search = ''; - private timer: TimerHandle | null = null; - - handle(key: string, items: HTMLElement[]): HTMLElement | null { - if (key.length !== 1) return null; - this.search += key.toLowerCase(); - 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 || ''; - return text.toLowerCase().startsWith(this.search); - }) ?? null - ); - } - - reset() { - this.search = ''; - this.cancelTimer(); - } - - private cancelTimer() { - if (this.timer) { - this.timer.cancel(); - this.timer = null; - } - } - - destroy() { - this.reset(); - } -} - // ── Root ───────────────────────────────────────────────────────────────────── interface SelectOpts @@ -153,10 +104,12 @@ 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.typeahead = new Typeahead({ + timers: this.soma.uix.timers, + key: () => `soma:select:${this.opts.id.current}:typeahead`, + meta: { component: 'select', action: 'typeahead-reset' }, + getText: (el) => el.getAttribute('data-label') || el.textContent || '' + }); this.runtime = this.soma.runtime(selectMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, diff --git a/src/uix/soma/index.ts b/src/uix/soma/index.ts index 45ae6e0bb..ae860b220 100644 --- a/src/uix/soma/index.ts +++ b/src/uix/soma/index.ts @@ -16,6 +16,16 @@ export { mergeProps, composeHandlers } from './props'; // ── Keyboard ───────────────────────────────────────────────────────────────── export { KEYS, FIRST_KEYS, LAST_KEYS, SELECTION_KEYS, getDirectionalKeys } from './keyboard'; +// ── Typeahead ──────────────────────────────────────────────────────────────── +export { + Typeahead, + TypeaheadBuffer, + matchTypeahead, + type TypeaheadOptions, + type TypeaheadSearchOptions, + type TypeaheadText +} from './typeahead'; + // ── Events ─────────────────────────────────────────────────────────────────── export { addEventListener, type EventCallback } from './events'; diff --git a/src/uix/soma/typeahead.ts b/src/uix/soma/typeahead.ts new file mode 100644 index 000000000..fcf51fab8 --- /dev/null +++ b/src/uix/soma/typeahead.ts @@ -0,0 +1,88 @@ +import type { TimerHandle, TimerScheduler } from '$timer'; + +export interface TypeaheadOptions { + readonly timers: TimerScheduler; + readonly key: () => string; + readonly timeout?: () => number; + readonly meta?: Readonly>; +} + +export type TypeaheadText = (item: TItem) => string; + +export interface TypeaheadSearchOptions extends TypeaheadOptions { + readonly getText: TypeaheadText; +} + +export class TypeaheadBuffer { + private search = ''; + private timer: TimerHandle | null = null; + + constructor(private readonly options: TypeaheadOptions) {} + + get value(): string { + return this.search; + } + + push(key: string): string | null { + if (key.length !== 1) return null; + + this.search += key.toLowerCase(); + this.scheduleReset(); + + return this.search; + } + + reset(): void { + this.search = ''; + this.cancelTimer(); + } + + destroy(): void { + this.reset(); + } + + private scheduleReset(): void { + this.cancelTimer(); + this.timer = this.options.timers.schedule( + this.options.key(), + this.options.timeout?.() ?? 500, + () => { + this.search = ''; + this.timer = null; + }, + { replace: true, meta: this.options.meta } + ); + } + + private cancelTimer(): void { + if (this.timer) { + this.timer.cancel(); + this.timer = null; + } + } +} + +export class Typeahead extends TypeaheadBuffer { + constructor(private readonly searchOptions: TypeaheadSearchOptions) { + super(searchOptions); + } + + handle(key: string, items: readonly TItem[]): TItem | null { + const query = this.push(key); + if (query === null) return null; + return matchTypeahead(items, query, this.searchOptions.getText); + } +} + +export function matchTypeahead( + items: readonly TItem[], + query: string, + getText: TypeaheadText +): TItem | null { + return ( + items.find((item) => { + const text = getText(item); + return text.toLowerCase().startsWith(query); + }) ?? null + ); +}