From 487ec9b5c95ff4d47a4cecd4316e8869ec4ea1ee Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 15 May 2026 14:18:00 +0200 Subject: [PATCH] Share Soma list typeahead buffers --- src/uix/contracts.test.ts | 9 ++++ .../grid-list/grid-list-provider.svelte.ts | 38 +++++----------- .../listbox/listbox-provider.svelte.ts | 38 +++++----------- .../tree-grid/tree-grid-provider.svelte.ts | 38 +++++----------- .../tree-view/tree-view-provider.svelte.ts | 43 +++++-------------- 5 files changed, 52 insertions(+), 114 deletions(-) diff --git a/src/uix/contracts.test.ts b/src/uix/contracts.test.ts index def703ad5..73f1476a0 100644 --- a/src/uix/contracts.test.ts +++ b/src/uix/contracts.test.ts @@ -351,6 +351,15 @@ describe('UIX layer contracts', () => { expect(violations).toEqual([]); }); + it('guards Soma components through the shared typeahead helper', () => { + const violations = grepSources( + join(HERE, 'soma', 'components'), + /class\s+Typeahead|typeaheadBuffer|typeaheadTimerKey|clearTypeaheadTimer/ + ); + + expect(violations).toEqual([]); + }); + it('guards hardcoded Soma component data attrs with morfo contracts', () => { const knownAttrs = collectKnownMorfoDataAttrs(); const violations = listSourceFiles(join(HERE, 'soma', 'components')).flatMap((file) => { diff --git a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts index 109cf4d29..d35730264 100644 --- a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts +++ b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts @@ -5,13 +5,13 @@ import { readableActive, type Active, type ActiveProps, type StateProps } from ' import type { OnChangeFn, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; +import { TypeaheadBuffer } from '../../typeahead'; import { FieldProvider } from '../field/field-provider.svelte'; import { GRID_LIST_LANGS } from './langs'; import type { GridListSelectionMode } from './types'; import { gridListMorfo } from '../../../morfo/components/grid-list'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; -import type { TimerHandle } from '$timer'; const attrs = createAttrs(gridListMorfo); @@ -55,14 +55,19 @@ export class GridListProvider { readonly field = FieldProvider.get(); - private typeaheadBuffer = ''; - private typeaheadTimer: TimerHandle | null = null; + private readonly typeahead: TypeaheadBuffer; /** Anchor for Shift+Click range selection. */ private anchor: string | null = null; private constructor(opts: GridListOpts) { this.opts = opts; this.soma = Soma.require(); + this.typeahead = new TypeaheadBuffer({ + timers: this.soma.uix.timers, + key: () => `soma:grid-list:${this.opts.id.current}:typeahead`, + timeout: () => this.opts.typeaheadTimeout.current, + meta: { component: 'grid-list', action: 'typeahead-reset' } + }); this.runtime = this.soma.runtime(gridListMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, @@ -72,7 +77,7 @@ export class GridListProvider { }); $effect(() => { - return () => this.clearTypeaheadTimer(); + return () => this.typeahead.destroy(); }); } @@ -365,21 +370,11 @@ export class GridListProvider { } private handleTypeahead(char: string, fromIndex: number) { - this.typeaheadBuffer += char.toLowerCase(); - this.clearTypeaheadTimer(); - this.typeaheadTimer = this.soma.uix.timers.schedule( - this.typeaheadTimerKey, - this.opts.typeaheadTimeout.current, - () => { - this.typeaheadTimer = null; - this.typeaheadBuffer = ''; - }, - { replace: true, meta: { component: 'grid-list', action: 'typeahead-reset' } } - ); + const needle = this.typeahead.push(char); + if (needle === null) return; const rows = this.getRows(); if (rows.length === 0) return; - const needle = this.typeaheadBuffer; const start = needle.length === 1 ? fromIndex + 1 : fromIndex; for (let i = 0; i < rows.length; i++) { const idx = (start + i) % rows.length; @@ -395,17 +390,6 @@ export class GridListProvider { } } - private clearTypeaheadTimer() { - if (this.typeaheadTimer) { - this.typeaheadTimer.cancel(); - this.typeaheadTimer = null; - } - } - - private get typeaheadTimerKey() { - return `soma:grid-list:${this.opts.id.current}:typeahead`; - } - handleRowClick(value: string, e: SomaMouseEvent) { if (this.isDisabled || this.isReadonly) return; const mode = diff --git a/src/uix/soma/components/listbox/listbox-provider.svelte.ts b/src/uix/soma/components/listbox/listbox-provider.svelte.ts index 670be2add..dbe77f507 100644 --- a/src/uix/soma/components/listbox/listbox-provider.svelte.ts +++ b/src/uix/soma/components/listbox/listbox-provider.svelte.ts @@ -11,13 +11,13 @@ import type { } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; +import { TypeaheadBuffer } from '../../typeahead'; import { FieldProvider } from '../field/field-provider.svelte'; import { LISTBOX_LANGS } from './langs'; import type { ListboxSelectionMode } from './types'; import { listboxMorfo } from '../../../morfo/components/listbox'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; -import type { TimerHandle } from '$timer'; const attrs = createAttrs(listboxMorfo); @@ -64,12 +64,17 @@ export class ListboxProvider { readonly field = FieldProvider.get(); focused = $state(false); - private typeaheadBuffer = ''; - private typeaheadTimer: TimerHandle | null = null; + private readonly typeahead: TypeaheadBuffer; private constructor(opts: ListboxOpts) { this.opts = opts; this.soma = Soma.require(); + this.typeahead = new TypeaheadBuffer({ + timers: this.soma.uix.timers, + key: () => `soma:listbox:${this.opts.id.current}:typeahead`, + timeout: () => this.opts.typeaheadTimeout.current, + meta: { component: 'listbox', action: 'typeahead-reset' } + }); this.runtime = this.soma.runtime(listboxMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, @@ -79,7 +84,7 @@ export class ListboxProvider { }); $effect(() => { - return () => this.clearTypeaheadTimer(); + return () => this.typeahead.destroy(); }); } @@ -259,21 +264,11 @@ export class ListboxProvider { } private handleTypeahead(char: string, fromIndex: number) { - this.typeaheadBuffer += char.toLowerCase(); - this.clearTypeaheadTimer(); - this.typeaheadTimer = this.soma.uix.timers.schedule( - this.typeaheadTimerKey, - this.opts.typeaheadTimeout.current, - () => { - this.typeaheadTimer = null; - this.typeaheadBuffer = ''; - }, - { replace: true, meta: { component: 'listbox', action: 'typeahead-reset' } } - ); + const needle = this.typeahead.push(char); + if (needle === null) return; const items = this.getItems(); if (items.length === 0) return; - const needle = this.typeaheadBuffer; const start = needle.length === 1 ? fromIndex + 1 : fromIndex; for (let i = 0; i < items.length; i++) { const idx = (start + i) % items.length; @@ -289,17 +284,6 @@ export class ListboxProvider { } } - private clearTypeaheadTimer() { - if (this.typeaheadTimer) { - this.typeaheadTimer.cancel(); - this.typeaheadTimer = null; - } - } - - private get typeaheadTimerKey() { - return `soma:listbox:${this.opts.id.current}:typeahead`; - } - handleItemClick(value: string, e: SomaMouseEvent) { if (this.isDisabled || this.isReadonly) return; if (this.opts.selectionMode.current === 'multiple') { diff --git a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts index c3214fdf3..fc7616b2c 100644 --- a/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts +++ b/src/uix/soma/components/tree-grid/tree-grid-provider.svelte.ts @@ -11,12 +11,12 @@ import { import type { OnChangeFn, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; +import { TypeaheadBuffer } from '../../typeahead'; import { TREE_GRID_LANGS } from './langs'; import type { TreeGridSelectionMode } from './types'; import { treeGridMorfo } from '../../../morfo/components/tree-grid'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; -import type { TimerHandle } from '$timer'; const attrs = createAttrs(treeGridMorfo); @@ -59,13 +59,18 @@ export class TreeGridProvider { return new TreeGridProvider(opts); } - private typeaheadBuffer = ''; - private typeaheadTimer: TimerHandle | null = null; + private readonly typeahead: TypeaheadBuffer; private anchor: string | null = null; private constructor(opts: TreeGridOpts) { this.opts = opts; this.soma = Soma.require(); + this.typeahead = new TypeaheadBuffer({ + timers: this.soma.uix.timers, + key: () => `soma:tree-grid:${this.opts.id.current}:typeahead`, + timeout: () => this.opts.typeaheadTimeout.current, + meta: { component: 'tree-grid', action: 'typeahead-reset' } + }); this.runtime = this.soma.runtime(treeGridMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, @@ -74,7 +79,7 @@ export class TreeGridProvider { context: TreeGridProvider.ctx }); $effect(() => { - return () => this.clearTypeaheadTimer(); + return () => this.typeahead.destroy(); }); } @@ -378,21 +383,11 @@ export class TreeGridProvider { } private handleTypeahead(char: string, fromIndex: number) { - this.typeaheadBuffer += char.toLowerCase(); - this.clearTypeaheadTimer(); - this.typeaheadTimer = this.soma.uix.timers.schedule( - this.typeaheadTimerKey, - this.opts.typeaheadTimeout.current, - () => { - this.typeaheadTimer = null; - this.typeaheadBuffer = ''; - }, - { replace: true, meta: { component: 'tree-grid', action: 'typeahead-reset' } } - ); + const needle = this.typeahead.push(char); + if (needle === null) return; const rows = this.getVisibleRows(); if (rows.length === 0) return; - const needle = this.typeaheadBuffer; const start = needle.length === 1 ? fromIndex + 1 : fromIndex; for (let i = 0; i < rows.length; i++) { const idx = (start + i) % rows.length; @@ -408,17 +403,6 @@ export class TreeGridProvider { } } - private clearTypeaheadTimer() { - if (this.typeaheadTimer) { - this.typeaheadTimer.cancel(); - this.typeaheadTimer = null; - } - } - - private get typeaheadTimerKey() { - return `soma:tree-grid:${this.opts.id.current}:typeahead`; - } - handleRowClick(value: string, e: SomaMouseEvent) { if (this.opts.disabled.current || this.opts.readonly.current) return; const mode = diff --git a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts index a6b6a43d3..b3074dca8 100644 --- a/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts +++ b/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts @@ -5,11 +5,11 @@ import { state, type Active, type ActiveProps, type StateProps } from '$libs/rea import type { Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; +import { Typeahead } from '../../typeahead'; import { TREE_VIEW_LANGS } from './langs'; import { treeViewMorfo } from '../../../morfo/components/tree-view'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; -import type { TimerHandle } from '$timer'; const attrs = createAttrs(treeViewMorfo); @@ -67,13 +67,17 @@ export class TreeViewProvider { return first?.getAttribute('data-value') ?? null; }); - // Typeahead - private typeaheadBuffer = ''; - private typeaheadTimer: TimerHandle | null = null; + private readonly typeahead: Typeahead; private constructor(opts: TreeViewOpts) { this.opts = opts; this.soma = Soma.require(); + this.typeahead = new Typeahead({ + timers: this.soma.uix.timers, + key: () => `soma:tree-view:${this.opts.id.current}:typeahead`, + meta: { component: 'tree-view', action: 'typeahead-reset' }, + getText: (el) => el.textContent?.trim() ?? '' + }); this.runtime = this.soma.runtime(treeViewMorfo, {}); this.runtimePart = this.runtime.part('provider', { id: opts.id, @@ -84,7 +88,7 @@ export class TreeViewProvider { // Cleanup typeahead timer on unmount $effect(() => { - return () => this.clearTypeaheadTimer(); + return () => this.typeahead.destroy(); }); } @@ -165,37 +169,10 @@ export class TreeViewProvider { handleTypeahead(char: string) { if (!this.opts.typeahead.current) return; - this.typeaheadBuffer += char.toLowerCase(); - this.clearTypeaheadTimer(); - this.typeaheadTimer = this.soma.uix.timers.schedule( - this.typeaheadTimerKey, - 500, - () => { - this.typeaheadTimer = null; - this.typeaheadBuffer = ''; - }, - { replace: true, meta: { component: 'tree-view', action: 'typeahead-reset' } } - ); - - const items = this.getVisibleItems(); - const match = items.find((el) => { - const text = el.textContent?.trim().toLowerCase() ?? ''; - return text.startsWith(this.typeaheadBuffer); - }); + const match = this.typeahead.handle(char, this.getVisibleItems()); if (match) this.focusItem(match); } - private clearTypeaheadTimer() { - if (this.typeaheadTimer) { - this.typeaheadTimer.cancel(); - this.typeaheadTimer = null; - } - } - - private get typeaheadTimerKey() { - return `soma:tree-view:${this.opts.id.current}:typeahead`; - } - // ── Keyboard (root-level) ──────────────────────────────────────────────── private handleExpandKey(activeEl: HTMLElement, items: HTMLElement[], currentIndex: number) {