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-grid/tree-grid-provider.svelte.ts

703 lines
22 KiB

import { context, type WithRefOpts, type ProviderOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef } from '$adom';
import {
readableActive,
state,
type Active,
type ActiveProps,
type StateProps
} from '$libs/reactive';
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';
const attrs = createAttrs(treeGridMorfo);
// ── Root provider ──────────────────────────────────────────────────────────
interface TreeGridOpts
extends
WithRefOpts,
StateProps<{
expanded: string[];
value: string[];
}>,
ActiveProps<{
selectionMode: TreeGridSelectionMode;
loop: boolean;
typeahead: boolean;
typeaheadTimeout: number;
disabled: boolean;
readonly: boolean;
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
onValueChange: OnChangeFn<string[]> | undefined;
onExpandedChange: OnChangeFn<string[]> | undefined;
}> {}
export class TreeGridProvider {
readonly opts: TreeGridOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<TreeGridProvider>('TreeGrid');
static get(): TreeGridProvider | undefined {
return this.ctx.getOr(undefined) as TreeGridProvider | undefined;
}
static require(): TreeGridProvider {
return this.ctx.get();
}
static create(opts: TreeGridOpts) {
return new TreeGridProvider(opts);
}
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,
ref: opts.ref,
owner: this,
context: TreeGridProvider.ctx
});
$effect(() => {
return () => this.typeahead.destroy();
});
}
readonly resolvedDir: Active<Direction> = readableActive(
() => this.soma.prefs.getDir() ?? 'ltr'
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() =>
this.opts.ariaLabelledby.current
? undefined
: this.opts.ariaLabel.current || this.soma.langs.ts(TREE_GRID_LANGS.LABEL) || undefined
);
readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0);
// ── DOM queries ─────────────────────────────────────────────────────────
/** All visible, enabled rows (not hidden inside a collapsed parent). */
getVisibleRows(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
const all = Array.from(
root.querySelectorAll<HTMLElement>(`[${attrs.row}]:not([data-disabled])`)
).filter((el) => el.closest(`[${attrs.provider}]`) === root);
// Exclude rows inside collapsed `data-expanded="false"` ancestor row-children.
return all.filter((el) => {
let cursor: HTMLElement | null = el.parentElement;
while (cursor && cursor !== root) {
if (cursor.matches(`[${attrs['row-children']}]`)) {
const parentRow = cursor.parentElement?.closest<HTMLElement>(`[${attrs.row}]`);
if (parentRow && parentRow.getAttribute('data-expanded') === 'false') {
return false;
}
}
cursor = cursor.parentElement;
}
return true;
});
}
/** All rows (including those hidden inside collapsed ancestors) for range selection. */
getAllRows(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
return Array.from(root.querySelectorAll<HTMLElement>(`[${attrs.row}]`)).filter(
(el) => el.closest(`[${attrs.provider}]`) === root
);
}
readonly rovingTargetEl = $derived.by(() => {
const rows = this.getVisibleRows();
if (rows.length === 0) return undefined;
const selected = new Set(this.opts.value.current);
return rows.find((el) => selected.has(el.getAttribute('data-value') ?? '')) ?? rows[0];
});
// ── Expand / collapse ──────────────────────────────────────────────────
isExpanded(value: string): boolean {
return this.opts.expanded.current.includes(value);
}
toggleExpand(value: string) {
if (this.opts.disabled.current) return;
const current = this.opts.expanded.current;
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
this.opts.expanded.current = next;
this.opts.onExpandedChange.current?.(next);
}
expand(value: string) {
if (this.opts.disabled.current) return;
if (this.opts.expanded.current.includes(value)) return;
const next = [...this.opts.expanded.current, value];
this.opts.expanded.current = next;
this.opts.onExpandedChange.current?.(next);
}
collapse(value: string) {
if (this.opts.disabled.current) return;
if (!this.opts.expanded.current.includes(value)) return;
const next = this.opts.expanded.current.filter((v) => v !== value);
this.opts.expanded.current = next;
this.opts.onExpandedChange.current?.(next);
}
// ── Selection ───────────────────────────────────────────────────────────
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
}
select(value: string, mode: 'replace' | 'toggle' | 'range') {
if (this.opts.disabled.current || this.opts.readonly.current) return;
const selectionMode = this.opts.selectionMode.current;
if (selectionMode === 'none') return;
const current = this.opts.value.current;
let next: string[];
if (mode === 'range' && selectionMode === 'multiple' && this.anchor) {
next = this.computeRange(this.anchor, value);
} else if (mode === 'toggle' && selectionMode === 'multiple') {
next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
} else {
next = current.length === 1 && current[0] === value ? current : [value];
}
if (next !== current) {
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
}
if (mode !== 'range') this.anchor = value;
}
private computeRange(from: string, to: string): string[] {
// Range uses visible rows so Shift+Click doesn't select hidden descendants.
const all = this.getVisibleRows().map((el) => el.getAttribute('data-value') ?? '');
const i = all.indexOf(from);
const j = all.indexOf(to);
if (i < 0 || j < 0) return [to];
const [lo, hi] = i <= j ? [i, j] : [j, i];
return all.slice(lo, hi + 1);
}
selectAll() {
if (this.opts.selectionMode.current !== 'multiple') return;
const next = this.getVisibleRows().map((el) => el.getAttribute('data-value') ?? '');
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
}
clear() {
if (this.opts.value.current.length === 0) return;
this.opts.value.current = [];
this.opts.onValueChange.current?.([]);
this.anchor = null;
}
// ── Keyboard ────────────────────────────────────────────────────────────
private focusAt(index: number) {
const rows = this.getVisibleRows();
if (rows.length === 0) return;
const clamped = Math.max(0, Math.min(rows.length - 1, index));
rows[clamped]?.focus();
}
/** Find the parent row of the given row element (traverse up past row-children). */
private parentRow(row: HTMLElement): HTMLElement | undefined {
let cursor: HTMLElement | null = row.parentElement;
while (cursor && cursor !== this.opts.ref.current) {
if (cursor.matches(`[${attrs['row-children']}]`)) {
const parent = cursor.parentElement?.closest<HTMLElement>(`[${attrs.row}]`);
if (parent) return parent;
}
cursor = cursor.parentElement;
}
return undefined;
}
handleRowKeydown(e: SomaKeyboardEvent<HTMLElement>) {
if (this.opts.disabled.current) return;
const row = e.currentTarget;
const value = row.getAttribute('data-value') ?? '';
const hasChildren = row.getAttribute('data-has-children') === 'true';
const expandedAttr = row.getAttribute('data-expanded');
const dir = this.resolvedDir.current;
const { nextKey: hNextKey, prevKey: hPrevKey } = getDirectionalKeys(dir, 'horizontal');
const rows = this.getVisibleRows();
if (rows.length === 0) return;
const currentIndex = rows.indexOf(row);
const loop = this.opts.loop.current;
// ArrowDown / ArrowUp
if (e.key === KEYS.ARROW_DOWN) {
e.preventDefault();
const next = loop
? (currentIndex + 1) % rows.length
: Math.min(currentIndex + 1, rows.length - 1);
this.focusAt(next);
return;
}
if (e.key === KEYS.ARROW_UP) {
e.preventDefault();
const prev = loop
? (currentIndex - 1 + rows.length) % rows.length
: Math.max(currentIndex - 1, 0);
this.focusAt(prev);
return;
}
// ArrowRight / ArrowLeft (RTL-aware via h*Key). APG:
// - ArrowRight on collapsed branch → expand
// - ArrowRight on expanded branch → focus first child
// - ArrowRight on leaf → no-op (or move into first cell)
// - ArrowLeft on expanded branch → collapse
// - ArrowLeft on collapsed branch or leaf → focus parent row
if (e.key === hNextKey) {
if (hasChildren && expandedAttr === 'false') {
e.preventDefault();
this.expand(value);
return;
}
if (hasChildren && expandedAttr === 'true') {
e.preventDefault();
// Focus first child visible row (the next in visible-order beneath this one
// whose parent row == this row).
const next = rows[currentIndex + 1];
if (next && this.parentRow(next) === row) next.focus();
return;
}
// Leaf: fall through so the browser can move into any focusable cell.
return;
}
if (e.key === hPrevKey) {
if (hasChildren && expandedAttr === 'true') {
e.preventDefault();
this.collapse(value);
return;
}
// Collapsed branch or leaf → focus parent row.
const parent = this.parentRow(row);
if (parent) {
e.preventDefault();
parent.focus();
}
return;
}
if (e.key === KEYS.HOME) {
e.preventDefault();
if (e.ctrlKey) this.focusAt(0);
else this.focusAt(0);
return;
}
if (e.key === KEYS.END) {
e.preventDefault();
this.focusAt(rows.length - 1);
return;
}
if (e.key === KEYS.PAGE_UP) {
e.preventDefault();
this.focusAt(Math.max(currentIndex - 10, 0));
return;
}
if (e.key === KEYS.PAGE_DOWN) {
e.preventDefault();
this.focusAt(Math.min(currentIndex + 10, rows.length - 1));
return;
}
if (e.key === KEYS.SPACE) {
e.preventDefault();
const mode =
e.shiftKey && this.opts.selectionMode.current === 'multiple'
? 'range'
: e.ctrlKey || e.metaKey
? 'toggle'
: this.opts.selectionMode.current === 'multiple'
? 'toggle'
: 'replace';
this.select(value, mode);
return;
}
if (e.key === KEYS.ENTER && hasChildren) {
e.preventDefault();
this.toggleExpand(value);
return;
}
if (
(e.ctrlKey || e.metaKey) &&
(e.key === 'a' || e.key === 'A') &&
this.opts.selectionMode.current === 'multiple'
) {
e.preventDefault();
this.selectAll();
return;
}
if (e.key === KEYS.ESCAPE) {
if (this.opts.value.current.length > 0) {
e.preventDefault();
this.clear();
}
return;
}
if (
this.opts.typeahead.current &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
e.key.length === 1 &&
/\S/.test(e.key)
) {
this.handleTypeahead(e.key, currentIndex);
}
}
private handleTypeahead(char: string, fromIndex: number) {
const needle = this.typeahead.push(char);
if (needle === null) return;
const rows = this.getVisibleRows();
if (rows.length === 0) return;
const start = needle.length === 1 ? fromIndex + 1 : fromIndex;
for (let i = 0; i < rows.length; i++) {
const idx = (start + i) % rows.length;
const el = rows[idx];
if (!el) continue;
const text = (el.getAttribute('data-text-value') || el.textContent || '')
.trim()
.toLowerCase();
if (text.startsWith(needle)) {
el.focus();
return;
}
}
}
handleRowClick(value: string, e: SomaMouseEvent<HTMLElement>) {
if (this.opts.disabled.current || this.opts.readonly.current) return;
const mode =
e.shiftKey && this.opts.selectionMode.current === 'multiple'
? 'range'
: (e.ctrlKey || e.metaKey) && this.opts.selectionMode.current === 'multiple'
? 'toggle'
: 'replace';
this.select(value, mode);
e.currentTarget.focus();
}
readonly snippetProps = $derived.by(() => ({
expanded: this.opts.expanded.current,
selected: this.opts.value.current,
toggleExpand: (v: string) => this.toggleExpand(v),
selectAll: () => this.selectAll(),
clear: () => this.clear()
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'treegrid' as const,
'aria-label': this.resolvedAriaLabel.current,
'aria-labelledby': this.opts.ariaLabelledby.current,
'aria-multiselectable': this.opts.selectionMode.current === 'multiple' ? true : undefined,
'aria-disabled': this.opts.disabled.current ? true : undefined,
'aria-readonly': this.opts.readonly.current ? true : undefined,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
'data-selection-mode': this.opts.selectionMode.current
} as const)
);
}
// ── Header ─────────────────────────────────────────────────────────────────
interface TreeGridHeaderOpts extends WithRefOpts {}
export class TreeGridHeaderProvider {
readonly opts: TreeGridHeaderOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeGridHeaderOpts) {
return new TreeGridHeaderProvider(opts);
}
private constructor(opts: TreeGridHeaderOpts) {
this.opts = opts;
const provider = TreeGridProvider.require();
this.runtimePart = provider.runtime.part('header', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props, role: 'row' as const } as const)
);
}
// ── ColumnHeader ───────────────────────────────────────────────────────────
interface TreeGridColumnHeaderOpts extends WithRefOpts {}
export class TreeGridColumnHeaderProvider {
readonly opts: TreeGridColumnHeaderOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeGridColumnHeaderOpts) {
return new TreeGridColumnHeaderProvider(opts);
}
private constructor(opts: TreeGridColumnHeaderOpts) {
this.opts = opts;
const provider = TreeGridProvider.require();
this.runtimePart = provider.runtime.part('column-header', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'columnheader' as const
} as const)
);
}
// ── Row ────────────────────────────────────────────────────────────────────
interface TreeGridRowOpts
extends
WithRefOpts,
ActiveProps<{
value: string;
hasChildren: boolean;
disabled: boolean;
textValue: string | undefined;
}> {}
export class TreeGridRowProvider {
readonly opts: TreeGridRowOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<TreeGridRowProvider>('TreeGridRow');
static get(): TreeGridRowProvider | undefined {
return this.ctx.getOr(undefined) as TreeGridRowProvider | undefined;
}
static require(): TreeGridRowProvider {
return this.ctx.get();
}
static create(opts: TreeGridRowOpts) {
return new TreeGridRowProvider(opts);
}
readonly provider: TreeGridProvider;
readonly parentRow: TreeGridRowProvider | undefined;
readonly level: number;
private constructor(opts: TreeGridRowOpts) {
this.opts = opts;
this.provider = TreeGridProvider.require();
this.parentRow = TreeGridRowProvider.get();
this.level = (this.parentRow?.level ?? 0) + 1;
this.runtimePart = this.provider.runtime.part('row', {
id: opts.id,
ref: opts.ref,
owner: this,
context: TreeGridRowProvider.ctx
});
}
readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));
readonly isExpanded = $derived.by(() => this.provider.isExpanded(this.opts.value.current));
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.provider.opts.disabled.current
);
readonly isRovingTarget = $derived.by(
() => this.opts.ref.current === this.provider.rovingTargetEl
);
readonly onclick = (e: MouseEvent) => {
if (this.isDisabled) return;
this.provider.handleRowClick(this.opts.value.current, e as SomaMouseEvent<HTMLElement>);
};
readonly onkeydown = (e: KeyboardEvent) => {
this.provider.handleRowKeydown(e as SomaKeyboardEvent<HTMLElement>);
};
readonly snippetProps = $derived.by(() => ({
expanded: this.isExpanded,
selected: this.isSelected,
disabled: this.isDisabled,
level: this.level,
hasChildren: this.opts.hasChildren.current
}));
readonly props = $derived.by(() => {
const hasChildren = this.opts.hasChildren.current;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'row' as const,
tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1,
'aria-level': this.level,
'aria-expanded': hasChildren ? this.isExpanded : undefined,
'aria-selected':
this.provider.opts.selectionMode.current === 'none' ? undefined : this.isSelected,
'aria-disabled': this.isDisabled ? true : undefined,
'data-value': this.opts.value.current,
'data-text-value': this.opts.textValue.current,
'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const),
'data-expanded': hasChildren ? String(this.isExpanded) : undefined,
'data-has-children': hasChildren ? 'true' : 'false',
'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-level': this.level,
onclick: this.onclick,
onkeydown: this.onkeydown
} as const);
});
}
// ── RowChildren ────────────────────────────────────────────────────────────
interface TreeGridRowChildrenOpts extends WithRefOpts {}
export class TreeGridRowChildrenProvider {
readonly opts: TreeGridRowChildrenOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeGridRowChildrenOpts) {
return new TreeGridRowChildrenProvider(opts);
}
readonly row: TreeGridRowProvider;
private constructor(opts: TreeGridRowChildrenOpts) {
this.opts = opts;
this.row = TreeGridRowProvider.require();
this.runtimePart = this.row.provider.runtime.part('row-children', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isExpanded = $derived.by(() => this.row.isExpanded);
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const
} as const)
);
}
// ── Cell ───────────────────────────────────────────────────────────────────
interface TreeGridCellOpts extends WithRefOpts {}
export class TreeGridCellProvider {
readonly opts: TreeGridCellOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeGridCellOpts) {
return new TreeGridCellProvider(opts);
}
private constructor(opts: TreeGridCellOpts) {
this.opts = opts;
const provider = TreeGridProvider.require();
this.runtimePart = provider.runtime.part('cell', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'gridcell' as const
} as const)
);
}
// ── ExpandTrigger ──────────────────────────────────────────────────────────
interface TreeGridExpandTriggerOpts
extends ProviderOpts, ActiveProps<{ ariaLabel: string | undefined }> {}
export class TreeGridExpandTriggerProvider {
readonly opts: TreeGridExpandTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: TreeGridExpandTriggerOpts) {
return new TreeGridExpandTriggerProvider(opts);
}
readonly provider: TreeGridProvider;
readonly row: TreeGridRowProvider;
private constructor(opts: TreeGridExpandTriggerOpts) {
this.opts = opts;
this.provider = TreeGridProvider.require();
this.row = TreeGridRowProvider.require();
this.runtimePart = this.provider.runtime.part('expand-trigger', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly resolvedAriaLabel = $derived.by(() => {
if (this.opts.ariaLabel.current) return this.opts.ariaLabel.current;
const key = this.row.isExpanded ? TREE_GRID_LANGS.COLLAPSE : TREE_GRID_LANGS.EXPAND;
return this.provider.soma.langs.ts(key);
});
readonly onclick = (e: MouseEvent) => {
e.stopPropagation();
this.provider.toggleExpand(this.row.opts.value.current);
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
tabindex: -1,
'aria-label': this.resolvedAriaLabel,
'aria-hidden': true as const,
'data-state': this.row.isExpanded ? ('open' as const) : ('closed' as const),
onclick: this.onclick
} as const)
);
}

Powered by TurnKey Linux.