import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider'; import { createAttrs, registerContract } 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 { TREE_GRID_LANGS } from './langs'; import type { TreeGridSelectionMode } from './types'; import { treeGridMorfo } from '../../../morfo/components/tree-grid'; const attrs = createAttrs(treeGridMorfo); registerContract(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 | undefined; onExpandedChange: OnChangeFn | undefined; }> {} export class TreeGridProvider extends Provider { static readonly ctx = context('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); } readonly soma = Soma.get(); private typeaheadBuffer = ''; private typeaheadTimer: ReturnType | null = null; private anchor: string | null = null; private constructor(opts: TreeGridOpts) { super(opts, 'TreeGrid', 'provider', attrs.provider, TreeGridProvider.ctx); $effect(() => { return () => { if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer); }; }); } readonly resolvedDir: Active = readableActive( () => this.soma?.presentation.getDir() ?? 'ltr' ); readonly resolvedAriaLabel: Active = 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(`[${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( `[${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(`[${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(`[${attrs.row}]`); if (parent) return parent; } cursor = cursor.parentElement; } return undefined; } handleRowKeydown(e: SomaKeyboardEvent) { 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) { this.typeaheadBuffer += char.toLowerCase(); if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer); this.typeaheadTimer = setTimeout(() => { this.typeaheadBuffer = ''; }, this.opts.typeaheadTimeout.current); const rows = this.getVisibleRows(); if (rows.length === 0) return; const needle = this.typeaheadBuffer; 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) { 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.assertProps({ ...this.baseProps, 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 extends Provider { static create(opts: TreeGridHeaderOpts) { return new TreeGridHeaderProvider(opts); } private constructor(opts: TreeGridHeaderOpts) { super(opts, 'TreeGrid', 'header', attrs.header); TreeGridProvider.require(); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'row' as const } as const) ); } // ── ColumnHeader ─────────────────────────────────────────────────────────── interface TreeGridColumnHeaderOpts extends WithRefOpts {} export class TreeGridColumnHeaderProvider extends Provider { static create(opts: TreeGridColumnHeaderOpts) { return new TreeGridColumnHeaderProvider(opts); } private constructor(opts: TreeGridColumnHeaderOpts) { super(opts, 'TreeGrid', 'column-header', attrs['column-header']); TreeGridProvider.require(); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'columnheader' as const } as const) ); } // ── Row ──────────────────────────────────────────────────────────────────── interface TreeGridRowOpts extends WithRefOpts, ActiveProps<{ value: string; hasChildren: boolean; disabled: boolean; textValue: string | undefined; }> {} export class TreeGridRowProvider extends Provider { static readonly ctx = context('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) { super(opts, 'TreeGrid', 'row', attrs.row, TreeGridRowProvider.ctx); this.provider = TreeGridProvider.require(); this.parentRow = TreeGridRowProvider.get(); this.level = (this.parentRow?.level ?? 0) + 1; } 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 ); }; readonly onkeydown = (e: KeyboardEvent) => { this.provider.handleRowKeydown(e as SomaKeyboardEvent); }; 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.assertProps({ ...this.baseProps, 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 extends Provider { static create(opts: TreeGridRowChildrenOpts) { return new TreeGridRowChildrenProvider(opts); } readonly row: TreeGridRowProvider; private constructor(opts: TreeGridRowChildrenOpts) { super(opts, 'TreeGrid', 'row-children', attrs['row-children']); this.row = TreeGridRowProvider.require(); } readonly isExpanded = $derived.by(() => this.row.isExpanded); readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'group' as const } as const) ); } // ── Cell ─────────────────────────────────────────────────────────────────── interface TreeGridCellOpts extends WithRefOpts {} export class TreeGridCellProvider extends Provider { static create(opts: TreeGridCellOpts) { return new TreeGridCellProvider(opts); } private constructor(opts: TreeGridCellOpts) { super(opts, 'TreeGrid', 'cell', attrs.cell); TreeGridProvider.require(); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'gridcell' as const } as const) ); } // ── ExpandTrigger ────────────────────────────────────────────────────────── interface TreeGridExpandTriggerOpts extends ProviderOpts, ActiveProps<{ ariaLabel: string | undefined }> {} export class TreeGridExpandTriggerProvider extends Provider { static create(opts: TreeGridExpandTriggerOpts) { return new TreeGridExpandTriggerProvider(opts); } readonly provider: TreeGridProvider; readonly row: TreeGridRowProvider; private constructor(opts: TreeGridExpandTriggerOpts) { super(opts, 'TreeGrid', 'expand-trigger', attrs['expand-trigger']); this.provider = TreeGridProvider.require(); this.row = TreeGridRowProvider.require(); } 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.assertProps({ ...this.baseProps, 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) ); }