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([]); 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', () => { it('guards hardcoded Soma component data attrs with morfo contracts', () => {
const knownAttrs = collectKnownMorfoDataAttrs(); const knownAttrs = collectKnownMorfoDataAttrs();
const violations = listSourceFiles(join(HERE, 'soma', 'components')).flatMap((file) => { 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 type { OnChangeFn, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard'; import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import { TypeaheadBuffer } from '../../typeahead';
import { FieldProvider } from '../field/field-provider.svelte'; import { FieldProvider } from '../field/field-provider.svelte';
import { GRID_LIST_LANGS } from './langs'; import { GRID_LIST_LANGS } from './langs';
import type { GridListSelectionMode } from './types'; import type { GridListSelectionMode } from './types';
import { gridListMorfo } from '../../../morfo/components/grid-list'; import { gridListMorfo } from '../../../morfo/components/grid-list';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { TimerHandle } from '$timer';
const attrs = createAttrs(gridListMorfo); const attrs = createAttrs(gridListMorfo);
@ -55,14 +55,19 @@ export class GridListProvider {
readonly field = FieldProvider.get(); readonly field = FieldProvider.get();
private typeaheadBuffer = ''; private readonly typeahead: TypeaheadBuffer;
private typeaheadTimer: TimerHandle | null = null;
/** Anchor for Shift+Click range selection. */ /** Anchor for Shift+Click range selection. */
private anchor: string | null = null; private anchor: string | null = null;
private constructor(opts: GridListOpts) { private constructor(opts: GridListOpts) {
this.opts = opts; this.opts = opts;
this.soma = Soma.require(); 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.runtime = this.soma.runtime(gridListMorfo, {});
this.runtimePart = this.runtime.part('provider', { this.runtimePart = this.runtime.part('provider', {
id: opts.id, id: opts.id,
@ -72,7 +77,7 @@ export class GridListProvider {
}); });
$effect(() => { $effect(() => {
return () => this.clearTypeaheadTimer(); return () => this.typeahead.destroy();
}); });
} }
@ -365,21 +370,11 @@ export class GridListProvider {
} }
private handleTypeahead(char: string, fromIndex: number) { private handleTypeahead(char: string, fromIndex: number) {
this.typeaheadBuffer += char.toLowerCase(); const needle = this.typeahead.push(char);
this.clearTypeaheadTimer(); if (needle === null) return;
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 rows = this.getRows(); const rows = this.getRows();
if (rows.length === 0) return; if (rows.length === 0) return;
const needle = this.typeaheadBuffer;
const start = needle.length === 1 ? fromIndex + 1 : fromIndex; const start = needle.length === 1 ? fromIndex + 1 : fromIndex;
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const idx = (start + i) % rows.length; 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>) { handleRowClick(value: string, e: SomaMouseEvent<HTMLElement>) {
if (this.isDisabled || this.isReadonly) return; if (this.isDisabled || this.isReadonly) return;
const mode = const mode =

@ -11,13 +11,13 @@ import type {
} from '../../types'; } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard'; import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import { TypeaheadBuffer } from '../../typeahead';
import { FieldProvider } from '../field/field-provider.svelte'; import { FieldProvider } from '../field/field-provider.svelte';
import { LISTBOX_LANGS } from './langs'; import { LISTBOX_LANGS } from './langs';
import type { ListboxSelectionMode } from './types'; import type { ListboxSelectionMode } from './types';
import { listboxMorfo } from '../../../morfo/components/listbox'; import { listboxMorfo } from '../../../morfo/components/listbox';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { TimerHandle } from '$timer';
const attrs = createAttrs(listboxMorfo); const attrs = createAttrs(listboxMorfo);
@ -64,12 +64,17 @@ export class ListboxProvider {
readonly field = FieldProvider.get(); readonly field = FieldProvider.get();
focused = $state(false); focused = $state(false);
private typeaheadBuffer = ''; private readonly typeahead: TypeaheadBuffer;
private typeaheadTimer: TimerHandle | null = null;
private constructor(opts: ListboxOpts) { private constructor(opts: ListboxOpts) {
this.opts = opts; this.opts = opts;
this.soma = Soma.require(); 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.runtime = this.soma.runtime(listboxMorfo, {});
this.runtimePart = this.runtime.part('provider', { this.runtimePart = this.runtime.part('provider', {
id: opts.id, id: opts.id,
@ -79,7 +84,7 @@ export class ListboxProvider {
}); });
$effect(() => { $effect(() => {
return () => this.clearTypeaheadTimer(); return () => this.typeahead.destroy();
}); });
} }
@ -259,21 +264,11 @@ export class ListboxProvider {
} }
private handleTypeahead(char: string, fromIndex: number) { private handleTypeahead(char: string, fromIndex: number) {
this.typeaheadBuffer += char.toLowerCase(); const needle = this.typeahead.push(char);
this.clearTypeaheadTimer(); if (needle === null) return;
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 items = this.getItems(); const items = this.getItems();
if (items.length === 0) return; if (items.length === 0) return;
const needle = this.typeaheadBuffer;
const start = needle.length === 1 ? fromIndex + 1 : fromIndex; const start = needle.length === 1 ? fromIndex + 1 : fromIndex;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const idx = (start + i) % items.length; 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>) { handleItemClick(value: string, e: SomaMouseEvent<HTMLElement>) {
if (this.isDisabled || this.isReadonly) return; if (this.isDisabled || this.isReadonly) return;
if (this.opts.selectionMode.current === 'multiple') { if (this.opts.selectionMode.current === 'multiple') {

@ -11,12 +11,12 @@ import {
import type { OnChangeFn, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types'; import type { OnChangeFn, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard'; import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import { TypeaheadBuffer } from '../../typeahead';
import { TREE_GRID_LANGS } from './langs'; import { TREE_GRID_LANGS } from './langs';
import type { TreeGridSelectionMode } from './types'; import type { TreeGridSelectionMode } from './types';
import { treeGridMorfo } from '../../../morfo/components/tree-grid'; import { treeGridMorfo } from '../../../morfo/components/tree-grid';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { TimerHandle } from '$timer';
const attrs = createAttrs(treeGridMorfo); const attrs = createAttrs(treeGridMorfo);
@ -59,13 +59,18 @@ export class TreeGridProvider {
return new TreeGridProvider(opts); return new TreeGridProvider(opts);
} }
private typeaheadBuffer = ''; private readonly typeahead: TypeaheadBuffer;
private typeaheadTimer: TimerHandle | null = null;
private anchor: string | null = null; private anchor: string | null = null;
private constructor(opts: TreeGridOpts) { private constructor(opts: TreeGridOpts) {
this.opts = opts; this.opts = opts;
this.soma = Soma.require(); 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.runtime = this.soma.runtime(treeGridMorfo, {});
this.runtimePart = this.runtime.part('provider', { this.runtimePart = this.runtime.part('provider', {
id: opts.id, id: opts.id,
@ -74,7 +79,7 @@ export class TreeGridProvider {
context: TreeGridProvider.ctx context: TreeGridProvider.ctx
}); });
$effect(() => { $effect(() => {
return () => this.clearTypeaheadTimer(); return () => this.typeahead.destroy();
}); });
} }
@ -378,21 +383,11 @@ export class TreeGridProvider {
} }
private handleTypeahead(char: string, fromIndex: number) { private handleTypeahead(char: string, fromIndex: number) {
this.typeaheadBuffer += char.toLowerCase(); const needle = this.typeahead.push(char);
this.clearTypeaheadTimer(); if (needle === null) return;
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 rows = this.getVisibleRows(); const rows = this.getVisibleRows();
if (rows.length === 0) return; if (rows.length === 0) return;
const needle = this.typeaheadBuffer;
const start = needle.length === 1 ? fromIndex + 1 : fromIndex; const start = needle.length === 1 ? fromIndex + 1 : fromIndex;
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const idx = (start + i) % rows.length; 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>) { handleRowClick(value: string, e: SomaMouseEvent<HTMLElement>) {
if (this.opts.disabled.current || this.opts.readonly.current) return; if (this.opts.disabled.current || this.opts.readonly.current) return;
const mode = 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 type { Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard'; import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte'; import { Soma } from '../../core/soma.svelte';
import { Typeahead } from '../../typeahead';
import { TREE_VIEW_LANGS } from './langs'; import { TREE_VIEW_LANGS } from './langs';
import { treeViewMorfo } from '../../../morfo/components/tree-view'; import { treeViewMorfo } from '../../../morfo/components/tree-view';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { TimerHandle } from '$timer';
const attrs = createAttrs(treeViewMorfo); const attrs = createAttrs(treeViewMorfo);
@ -67,13 +67,17 @@ export class TreeViewProvider {
return first?.getAttribute('data-value') ?? null; return first?.getAttribute('data-value') ?? null;
}); });
// Typeahead private readonly typeahead: Typeahead<HTMLElement>;
private typeaheadBuffer = '';
private typeaheadTimer: TimerHandle | null = null;
private constructor(opts: TreeViewOpts) { private constructor(opts: TreeViewOpts) {
this.opts = opts; this.opts = opts;
this.soma = Soma.require(); 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.runtime = this.soma.runtime(treeViewMorfo, {});
this.runtimePart = this.runtime.part('provider', { this.runtimePart = this.runtime.part('provider', {
id: opts.id, id: opts.id,
@ -84,7 +88,7 @@ export class TreeViewProvider {
// Cleanup typeahead timer on unmount // Cleanup typeahead timer on unmount
$effect(() => { $effect(() => {
return () => this.clearTypeaheadTimer(); return () => this.typeahead.destroy();
}); });
} }
@ -165,37 +169,10 @@ export class TreeViewProvider {
handleTypeahead(char: string) { handleTypeahead(char: string) {
if (!this.opts.typeahead.current) return; if (!this.opts.typeahead.current) return;
this.typeaheadBuffer += char.toLowerCase(); const match = this.typeahead.handle(char, this.getVisibleItems());
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);
});
if (match) this.focusItem(match); 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) ──────────────────────────────────────────────── // ── Keyboard (root-level) ────────────────────────────────────────────────
private handleExpandKey(activeEl: HTMLElement, items: HTMLElement[], currentIndex: number) { private handleExpandKey(activeEl: HTMLElement, items: HTMLElement[], currentIndex: number) {

Loading…
Cancel
Save

Powered by TurnKey Linux.