You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/tree-view/tree-view-provider.svelte.ts

591 lines
19 KiB

import { context, type WithRefOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef } from '$adom';
import { state, type Active, type ActiveProps, type StateProps } from '$libs/reactive';
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';
const attrs = createAttrs(treeViewMorfo);
export type SelectionMode = 'single' | 'multiple';
// ── Root ─────────────────────────────────────────────────────────────────────
interface TreeViewOpts
extends
WithRefOpts,
StateProps<{
expandedValue: string[];
selectedValue: string[];
}>,
ActiveProps<{
selectionMode: SelectionMode;
dir: Direction;
expandOnClick: boolean;
typeahead: boolean;
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
}> {}
export class TreeViewProvider {
readonly opts: TreeViewOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<TreeViewProvider>('TreeView');
static get(): TreeViewProvider | undefined {
return this.ctx.getOr(undefined) as TreeViewProvider | undefined;
}
static require(): TreeViewProvider {
return this.ctx.get();
}
static create(opts: TreeViewOpts) {
return new TreeViewProvider(opts);
}
// Focus tracking
focusedValue = $state<string | null>(null);
/**
* Value of the item that holds the roving `tabindex=0`.
* Falls back to the first visible non-disabled item when nothing is focused
* yet — otherwise the tree would be unreachable from the keyboard.
*/
readonly tabStopValue = $derived.by(() => {
if (this.focusedValue !== null) return this.focusedValue;
// Track expanded state so the fallback re-evaluates when the tree
// structure changes.
void this.opts.expandedValue.current;
const first = this.getVisibleItems()[0];
return first?.getAttribute('data-value') ?? 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,
ref: opts.ref,
owner: this,
context: TreeViewProvider.ctx
});
// Cleanup typeahead timer on unmount
$effect(() => {
return () => this.typeahead.destroy();
});
}
// ── Expand ───────────────────────────────────────────────────────────────
isExpanded(value: string): boolean {
return this.opts.expandedValue.current.includes(value);
}
toggleExpand(value: string) {
const current = this.opts.expandedValue.current;
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
this.opts.expandedValue.current = next;
}
expand(value: string) {
if (this.isExpanded(value)) return;
this.toggleExpand(value);
}
collapse(value: string) {
if (!this.isExpanded(value)) return;
this.toggleExpand(value);
}
/** Expand all siblings of a given item (WAI-ARIA * key). */
expandSiblings(itemEl: HTMLElement) {
const parent = itemEl.closest('[role="group"], [role="tree"]');
if (!parent) return;
const siblings = parent.querySelectorAll<HTMLElement>(`:scope > [${attrs.branch}]`);
const values: string[] = [];
siblings.forEach((el) => {
const val = el.getAttribute('data-value');
if (val) values.push(val);
});
const current = this.opts.expandedValue.current;
const next = [...new Set([...current, ...values])];
this.opts.expandedValue.current = next;
}
// ── Select ───────────────────────────────────────────────────────────────
isSelected(value: string): boolean {
return this.opts.selectedValue.current.includes(value);
}
select(value: string) {
let next: string[];
if (this.opts.selectionMode.current === 'single') {
next = this.isSelected(value) ? [] : [value];
} else {
next = this.isSelected(value)
? this.opts.selectedValue.current.filter((v) => v !== value)
: [...this.opts.selectedValue.current, value];
}
this.opts.selectedValue.current = next;
}
// ── Focus & navigation ───────────────────────────────────────────────────
/** Get all visible treeitem elements in DOM order, scoped to this root. */
getVisibleItems(): HTMLElement[] {
const root = this.opts.ref?.current;
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>(`[role="treeitem"]:not([aria-disabled="true"])`)
).filter((el) => el.closest(`[${attrs.provider}]`) === root);
}
focusItem(el: HTMLElement) {
el.focus();
const value = el.getAttribute('data-value');
if (value) this.focusedValue = value;
}
// ── Typeahead ────────────────────────────────────────────────────────────
handleTypeahead(char: string) {
if (!this.opts.typeahead.current) return;
const match = this.typeahead.handle(char, this.getVisibleItems());
if (match) this.focusItem(match);
}
// ── Keyboard (root-level) ────────────────────────────────────────────────
private handleExpandKey(activeEl: HTMLElement, items: HTMLElement[], currentIndex: number) {
const value = activeEl.getAttribute('data-value');
const isBranch = activeEl.closest(`[${attrs.branch}]`);
if (value && isBranch) {
if (!this.isExpanded(value)) {
this.expand(value);
} else {
// Focus first child
const nextIdx = currentIndex + 1;
if (nextIdx < items.length) this.focusItem(items[nextIdx]);
}
}
}
private handleCollapseKey(activeEl: HTMLElement) {
const value = activeEl.getAttribute('data-value');
if (value && this.isExpanded(value)) {
this.collapse(value);
} else {
// Focus parent branch
const parentBranch = activeEl.parentElement?.closest(`[${attrs.branch}]`);
if (parentBranch) {
const parentControl = parentBranch.querySelector<HTMLElement>(
`[${attrs['branch-control']}]`
);
if (parentControl) this.focusItem(parentControl);
}
}
}
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
const items = this.getVisibleItems();
if (items.length === 0) return;
const activeEl = document.activeElement as HTMLElement;
const currentIndex = items.indexOf(activeEl);
const dir = this.opts.dir.current;
const { nextKey: expandKey, prevKey: collapseKey } = getDirectionalKeys(dir, 'horizontal');
switch (e.key) {
case KEYS.ARROW_DOWN: {
e.preventDefault();
const next = currentIndex < items.length - 1 ? currentIndex + 1 : currentIndex;
this.focusItem(items[next]);
break;
}
case KEYS.ARROW_UP: {
e.preventDefault();
const prev = currentIndex > 0 ? currentIndex - 1 : 0;
this.focusItem(items[prev]);
break;
}
case expandKey: {
e.preventDefault();
this.handleExpandKey(activeEl, items, currentIndex);
break;
}
case collapseKey: {
e.preventDefault();
this.handleCollapseKey(activeEl);
break;
}
case KEYS.HOME: {
e.preventDefault();
this.focusItem(items[0]);
break;
}
case KEYS.END: {
e.preventDefault();
this.focusItem(items[items.length - 1]);
break;
}
case KEYS.ENTER:
case KEYS.SPACE: {
e.preventDefault();
const value = activeEl.getAttribute('data-value');
if (value) this.select(value);
break;
}
default: {
// * key expands all siblings
if (e.key === '*') {
e.preventDefault();
this.expandSiblings(activeEl);
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Typeahead
this.handleTypeahead(e.key);
}
}
}
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
dir: this.opts.dir.current,
role: 'tree' as const,
'aria-multiselectable': this.opts.selectionMode.current === 'multiple' || undefined,
// Consumer override > labelledby reference > default translation.
'aria-label': this.opts.ariaLabelledby.current
? undefined
: (this.opts.ariaLabel.current ?? this.soma.langs.ts(TREE_VIEW_LANGS.LABEL)),
'aria-labelledby': this.opts.ariaLabelledby.current,
onkeydown: this.onkeydown
} as const)
);
}
// ── Branch ───────────────────────────────────────────────────────────────────
interface TreeViewBranchOpts
extends
WithRefOpts,
ActiveProps<{
value: string;
disabled: boolean;
depth: number;
}> {}
export class TreeViewBranchProvider {
readonly opts: TreeViewBranchOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<TreeViewBranchProvider>('TreeViewBranch');
static get(): TreeViewBranchProvider | undefined {
return this.ctx.getOr(undefined) as TreeViewBranchProvider | undefined;
}
static require(): TreeViewBranchProvider {
return this.ctx.get();
}
static create(opts: TreeViewBranchOpts) {
return new TreeViewBranchProvider(opts);
}
readonly provider: TreeViewProvider;
private constructor(opts: TreeViewBranchOpts) {
this.opts = opts;
this.provider = TreeViewProvider.require();
this.runtimePart = this.provider.runtime.part('branch', {
id: opts.id,
ref: opts.ref,
owner: this,
context: TreeViewBranchProvider.ctx
});
}
readonly isExpanded = $derived.by(() => this.provider.isExpanded(this.opts.value.current));
readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'treeitem' as const,
'aria-expanded': this.isExpanded,
'aria-selected': this.isSelected,
'aria-level': this.opts.depth.current + 1,
'aria-disabled': this.isDisabled || undefined,
'data-value': this.opts.value.current,
'data-state': this.isExpanded ? 'open' : 'closed',
'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-depth': this.opts.depth.current,
style: {
'--soma-tree-depth': this.opts.depth.current
}
} as const)
);
}
// ── BranchControl ────────────────────────────────────────────────────────────
interface TreeViewBranchControlOpts extends WithRefOpts {}
export class TreeViewBranchControlProvider {
readonly opts: TreeViewBranchControlOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeViewBranchControlOpts) {
return new TreeViewBranchControlProvider(opts);
}
readonly branch: TreeViewBranchProvider;
readonly provider: TreeViewProvider;
private constructor(opts: TreeViewBranchControlOpts) {
this.opts = opts;
this.branch = TreeViewBranchProvider.require();
this.provider = this.branch.provider;
this.runtimePart = this.provider.runtime.part('branch-control', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.branch.isDisabled) return;
if (this.provider.opts.expandOnClick.current) {
this.provider.toggleExpand(this.branch.opts.value.current);
}
this.provider.select(this.branch.opts.value.current);
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
tabindex: this.provider.tabStopValue === this.branch.opts.value.current ? 0 : -1,
'data-value': this.branch.opts.value.current,
'data-state': this.branch.isExpanded ? 'open' : 'closed',
'data-selected': boolToEmptyStrOrUndef(this.branch.isSelected),
'data-disabled': boolToEmptyStrOrUndef(this.branch.isDisabled),
'data-depth': this.branch.opts.depth.current,
onclick: this.onclick
} as const)
);
}
// ── BranchContent ────────────────────────────────────────────────────────────
interface TreeViewBranchContentOpts extends WithRefOpts {}
export class TreeViewBranchContentProvider {
readonly opts: TreeViewBranchContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeViewBranchContentOpts) {
return new TreeViewBranchContentProvider(opts);
}
readonly branch: TreeViewBranchProvider;
private constructor(opts: TreeViewBranchContentOpts) {
this.opts = opts;
this.branch = TreeViewBranchProvider.require();
this.runtimePart = this.branch.provider.runtime.part('branch-content', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
/** Proxy to `branch.isExpanded` so the wrapper doesn't need `ctx.get()`. */
readonly isExpanded = $derived.by(() => this.branch.isExpanded);
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const,
'data-state': this.branch.isExpanded ? 'open' : 'closed',
'data-depth': this.branch.opts.depth.current
} as const)
);
}
// ── BranchIndicator ──────────────────────────────────────────────────────────
interface TreeViewBranchIndicatorOpts extends WithRefOpts {}
export class TreeViewBranchIndicatorProvider {
readonly opts: TreeViewBranchIndicatorOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeViewBranchIndicatorOpts) {
return new TreeViewBranchIndicatorProvider(opts);
}
readonly branch: TreeViewBranchProvider;
private constructor(opts: TreeViewBranchIndicatorOpts) {
this.opts = opts;
this.branch = TreeViewBranchProvider.require();
this.runtimePart = this.branch.provider.runtime.part('branch-indicator', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly onclick = (e: MouseEvent) => {
e.stopPropagation();
if (this.branch.isDisabled) return;
this.branch.provider.toggleExpand(this.branch.opts.value.current);
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'aria-hidden': true as const,
'data-state': this.branch.isExpanded ? 'open' : 'closed',
onclick: this.onclick
} as const)
);
}
// ── BranchIndentGuide ────────────────────────────────────────────────────────
interface TreeViewBranchIndentGuideOpts extends WithRefOpts {}
export class TreeViewBranchIndentGuideProvider {
readonly opts: TreeViewBranchIndentGuideOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeViewBranchIndentGuideOpts) {
return new TreeViewBranchIndentGuideProvider(opts);
}
readonly branch: TreeViewBranchProvider;
private constructor(opts: TreeViewBranchIndentGuideOpts) {
this.opts = opts;
this.branch = TreeViewBranchProvider.require();
this.runtimePart = this.branch.provider.runtime.part('branch-indent-guide', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'aria-hidden': true as const,
'data-depth': this.branch.opts.depth.current
} as const)
);
}
// ── Item (leaf) ──────────────────────────────────────────────────────────────
interface TreeViewItemOpts
extends
WithRefOpts,
ActiveProps<{
value: string;
disabled: boolean;
depth: number;
}> {}
export class TreeViewItemProvider {
readonly opts: TreeViewItemOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeViewItemOpts) {
return new TreeViewItemProvider(opts);
}
readonly provider: TreeViewProvider;
private constructor(opts: TreeViewItemOpts) {
this.opts = opts;
this.provider = TreeViewProvider.require();
this.runtimePart = this.provider.runtime.part('item', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.isDisabled) return;
this.provider.select(this.opts.value.current);
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'treeitem' as const,
'aria-selected': this.isSelected,
'aria-level': this.opts.depth.current + 1,
'aria-disabled': this.isDisabled || undefined,
tabindex: this.provider.tabStopValue === this.opts.value.current ? 0 : -1,
'data-value': this.opts.value.current,
'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-depth': this.opts.depth.current,
style: {
'--soma-tree-depth': this.opts.depth.current
},
onclick: this.onclick
} as const)
);
}
// ── Label ────────────────────────────────────────────────────────────────────
interface TreeViewLabelOpts extends WithRefOpts {}
export class TreeViewLabelProvider {
readonly opts: TreeViewLabelOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeViewLabelOpts) {
return new TreeViewLabelProvider(opts);
}
private constructor(opts: TreeViewLabelOpts) {
this.opts = opts;
const provider = TreeViewProvider.require();
this.runtimePart = provider.runtime.part('label', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}

Powered by TurnKey Linux.