Share Soma list typeahead buffers

active-uix
dev 5 months ago
parent 5e975b6825
commit 487ec9b5c9

@ -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) => {

@ -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<HTMLElement>) {
if (this.isDisabled || this.isReadonly) return;
const mode =

@ -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<HTMLElement>) {
if (this.isDisabled || this.isReadonly) return;
if (this.opts.selectionMode.current === 'multiple') {

@ -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<HTMLElement>) {
if (this.opts.disabled.current || this.opts.readonly.current) return;
const mode =

@ -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<HTMLElement>;
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) {

Loading…
Cancel
Save

Powered by TurnKey Linux.