Share Soma menu typeahead helper

active-uix
dev 5 months ago
parent 077953976c
commit 5e975b6825

@ -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({

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

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

@ -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';

@ -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<Record<string, unknown>>;
}
export type TypeaheadText<TItem> = (item: TItem) => string;
export interface TypeaheadSearchOptions<TItem> extends TypeaheadOptions {
readonly getText: TypeaheadText<TItem>;
}
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<TItem = HTMLElement> extends TypeaheadBuffer {
constructor(private readonly searchOptions: TypeaheadSearchOptions<TItem>) {
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<TItem>(
items: readonly TItem[],
query: string,
getText: TypeaheadText<TItem>
): TItem | null {
return (
items.find((item) => {
const text = getText(item);
return text.toLowerCase().startsWith(query);
}) ?? null
);
}
Loading…
Cancel
Save

Powered by TurnKey Linux.