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.
591 lines
19 KiB
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)
|
|
);
|
|
}
|