Source

components/tree-view/AashTreeView.ts

/**
 * Provides a tree view.
 *
 * @module AashTreeView
 */
import { defineComponent, PropType, reactive, computed, nextTick,
    onMounted, ref } from 'vue'
import { provideApi } from "../../AashUtil";

/**
 * A label can either be provided literally or by a function. 
 *
 * @memberof module:AashTreeView
 */
export type LabelSupplier = (string | (() => string));

/**
 * The information about a tree node managed by the view. 
 *
 * @memberof module:AashTreeView
 */
export type TreeNode = {
    /** The name of the branch */
    segment: string,
    /** The label to show in the view */
    label: LabelSupplier,
    /** The node's child nodes */
    children: TreeNode[]
}

/**
 * The interface provided by the component.
 *
 * * `setRoots(roots: TreeNode[]): void`: replaces the root tree nodes
 *
 * @memberof module:AashTreeView
 */
export interface Api {
  setRoots(roots: TreeNode[]): void;
}

interface Presentation {
    children: Map<string,Presentation> | null;
    expanded: boolean;
}

/**
 * A function that is invoked with the desired new state before a
 * tree node is expanded. 
 *
 * @memberof module:AashTreeView
 */
export type ToggleVetoer = (path: string[], expand: boolean, 
                event: Event) => boolean;

class Controller {
    private _roots: TreeNode[];
    private _domRoot: HTMLElement | null = null;
    private _onToggle: ToggleVetoer;
    private _onFocus: (path: string[]) => void;
    private _onSelected: (path: string[], event: Event) => void;
    private _singlePath: boolean;
    private _expanded: Presentation = reactive({ children: null, 
                                                 expanded: false });
    private _focusHolder: string[] = reactive([]);

    constructor(roots: TreeNode[], onToggle: ToggleVetoer, 
        onFocus: (path: string[]) => void, 
        onSelected: (path: string[], event: Event) => void,
        singlePath: boolean) {
        this._roots = reactive(roots);
        if (roots.length > 0) {
            this._focusHolder.push(roots[0].segment);
        }
        this._onToggle = onToggle;
        this._onFocus = onFocus;
        this._onSelected = onSelected;
        this._singlePath = singlePath;
    }

    setDomRoot(root: HTMLElement) {
        this._domRoot = root;
    }

    get roots() {
        return this._roots;
    }

    toNode(path: string[]) {
        let node: TreeNode | null = null;
        let children = this._roots;
        for (let segment of path) {
            node = null;
            for (let child of children) {
                if (child.segment == segment) {
                    node = child;
                    children = node.children;
                }
            }
        }
        return node;
    }

    nextSibling(path: string[]) {
        if (path.length == 0) {
            return false;
        }
        let lastSeg = path.pop()!;
        let nodes = path.length == 0 ? this._roots : this.toNode(path)!.children;
        for (let i = 0; i < nodes.length - 1; i++) {
            if (nodes[i].segment == lastSeg) {
                path.push(nodes[i+1].segment);
                return true;
            }
        }
        path.push(lastSeg);
        return false;
    }

    nextDown(path: string[]) {
        if (this.isExpanded(path)) {
            path.push(this.toNode(path)!.children[0].segment);
            return true;
        }
        if (this.nextSibling(path)) {
            return true;
        }
        while (path.length > 1) {
            path.pop();
            if (this.nextSibling(path)) {
                return true;
            }
        }
        return false;
    }

    prevSibling(path: string[]) {
        if (path.length == 0) {
            return false;
        }
        let lastSeg = path.pop()!;
        let nodes = path.length == 0 ? this._roots : this.toNode(path)!.children;
        for (let i = nodes.length - 1; i > 0; i--) {
            if (nodes[i].segment == lastSeg) {
                path.push(nodes[i-1].segment);
                return true;
            }
        }
        path.push(lastSeg);
        return false;
    }

    prevUp(path: string[]) {
        if (this.prevSibling(path)) {
            while (this.isExpanded(path)) {
                let node = this.toNode(path)!;
                path.push(node.children[
                    node.children.length-1].segment);
            }
        } else {
            if (path.length <= 1) {
                return false;
            }
            path.pop();
        }
        return true;
    }

    collectPath(leaf: HTMLElement) {
        let path: string[] = [];
        while (!leaf.getAttribute("role")
            || leaf.getAttribute("role")?.toLowerCase() != "tree") {
            if (leaf.dataset["segment"]) {
                path.unshift(leaf.dataset["segment"]!);
            }
            leaf = leaf.parentElement!;
        }
        return path;
    }

    isExpandable(path: string[]) {
        let node = this.toNode(path); 
        return node && node["children"] && node.children.length > 0;
    }
    
    isExpanded(path: string[]) {
        let cur = this._expanded;
        for (let i = 0; ; i++) {
            if (i == path.length) {
                return cur.expanded;
            }
            if (!cur.children || !cur.children.has(path[i])) {
                return false;
            }
            cur = cur.children.get(path[i])!;
        }
    }

    toggleExpanded(path: string[], event: Event) {
        let cur = this._expanded;
        for (let i = 0; ; i++) {
            if (i == path.length) {
                if (!cur.expanded && !this.isExpandable(path)) {
                    return;
                }
                let newState = this._onToggle(path, !cur.expanded, event);
                if (newState == cur.expanded) {
                    return;
                }
                cur.expanded = !cur.expanded;
                return;
            }
            if (!cur.children) {
                cur.children = new Map();
            }
            if (!cur.children.has(path[i])) {
                cur.children.set(path[i], { children: null, expanded: false })
            }
            if (this._singlePath) {
                // collapse others on same level
                for (let [key, value] of cur.children) {
                    if (key !== path[i] && value.expanded) {
                        value.expanded = this._onToggle(path.slice(i+1),
                                                        false, event);
                    }
                }
            }
            cur = cur.children.get(path[i])!;
        }
        
    }

    onSelected(path: string[], event: Event) {
        this._onSelected(path, event);
    }

    hasFocus(path: string[]) {
        if (this._focusHolder.length != path.length) {
            return false;
        }
        for (let i = 0; i < this._focusHolder.length; i++) {
            if (this._focusHolder[i] != path[i]) {
                return false;
            }
        }
        return true;
    }
    
    setFocus(path: string[]) {
        if (this._focusHolder.length === path.length) {
            let foundDiff = false;
            for (let seg = 0; seg < path.length; seg++) {
                if (this._focusHolder[seg] !== path[seg]) {
                    foundDiff = true;
                    break;
                }
            }
            if (!foundDiff) {
                return;
            }
        }
        this._focusHolder.length = 0;
        this._focusHolder.push(...path);
        this._onFocus(path);
    }
    
    updateDomFocus() {
        nextTick (() => {
            let cur: Element | null | undefined = this._domRoot;
            for (let i = 0; i < this._focusHolder.length; i++) {
                if (cur == null) {
                    return;
                }
                cur = cur.querySelector(":scope [data-segment='"
                    + this._focusHolder[i] + "']");
            }
            cur = cur?.querySelector(":scope [tabindex]");
            (<HTMLElement>cur)?.focus();
        });
    }
}

/**
 * @classdesc
 * Generates a tree view.
 *
 * @class AashTreeViewComponent
 * @param {Object} props the properties
 * @param {string} props.id the id for the enclosing `div` (optional)
 * @param {TreeNode[]} props.roots the top level tree nodes
 * @param {ToggleVetoer} props.onToggle an optional vetoer which is
 *      invoked before a node is opened or closed
 * @param {Function} props.onFocus a function ((path: string[]) => void)
 *      that is invoked when a node receives the focus (by clicking or
 *      by using the navigation keys)
 * @param {Function} props.onSelected a function 
 *      ((path: string[], event: Event) => void)
 *      that is invoked when a leaf is selected (by clicking on it or
 *      pressing space or enter while it has focus)
 * @param {Boolean} props.singlePath if true, closes other paths if a
 *      new path is opened (closing may be vetoed, however)
 */
export default defineComponent({
    props: {
        id: { type: String, required: false, default: null },
        roots: { type: Array as PropType<TreeNode[]>, required: false, default: [] },
        onToggle: {
            type: Function as PropType<ToggleVetoer>,
            default: (_path: string[], newStateOpen: boolean, _event: Event) => {
                return newStateOpen;
            }
        },
        onFocus: {
            type: Function as PropType<(path: string[]) => void>,
            default: (_path: string[]) => {}
        },
        onSelected: {
            type: Function as PropType<(path: string[], event: Event) => void>,
            default: (_path: string[], _event: Event) => {}
        },
        singlePath: { type: Boolean, required: false, default: false },
        _controller: { type: Controller, required: false },
        _path: { type: Array as PropType<string[]>, required: false, default: [] },
        _nodes: { type: Array as PropType<TreeNode[]>, required: false },
    },
    
    setup(props) {
        let ctrl = props._controller 
            || new Controller(props.roots, props.onToggle!, props.onFocus!,
                props.onSelected!, props.singlePath);

        const nodes = computed(() => {
            return (props._path.length == 0 ? ctrl.roots : props._nodes)!
                .map((node) => { 
                    return {...node, path: props._path.concat(node.segment)} });
        });
        
        const isExpandable = (path: string[]) => {
            return ctrl.isExpandable(path);
        };

        const isExpanded = (path: string[]) => {
            return ctrl.isExpanded(path);
        };

        const ariaExpanded = (path: string[]) => {
            if (!isExpandable(path)) {
                return null;
            }
            return new Boolean(isExpanded(path)).toString();
        };

        const hasFocus = (path: string[]) => {
            return ctrl.hasFocus(path);
        };

        const label = (node: TreeNode) => {
            if(typeof node.label === 'function') {
                return node.label();
            }
            return node.label;
        }
        
        const toggleExpanded = (event: Event) => {
            event.preventDefault();
            let path = ctrl.collectPath(<HTMLElement>event.target);
            ctrl.toggleExpanded(path, event);
            ctrl.setFocus(path);
            ctrl.updateDomFocus();
        }

        const onClick = (event: Event) => {
            let path = ctrl.collectPath(<HTMLElement>event.target);
            ctrl.setFocus(path);
            if (ctrl.isExpandable(path)) {
                ctrl.toggleExpanded(path, event);
            } else {
                ctrl.onSelected(path, event);
            }
        }

        const onKey = (event: KeyboardEvent) => {
            if (event.key == "Enter" || event.key === " ") {
                onClick(event);
                return;
            }
            let path = ctrl.collectPath(<HTMLElement>event.target);
            if (event.key === "ArrowRight") {
                if (ctrl.isExpandable(path)) {
                    if (!isExpanded(path)) {
                        ctrl.toggleExpanded(path, event);
                    } else {
                        path.push(ctrl.toNode(path)!.children[0].segment);
                        ctrl.setFocus(path);
                        event.preventDefault();
                        ctrl.updateDomFocus();
                   }
                }
                return;
            }
            if (event.key === "ArrowLeft") {
                if (isExpanded(path)) {
                    ctrl.toggleExpanded(path, event);
                    return;
                }
                if (path.length > 1) {
                    path.pop();
                    ctrl.setFocus(path);
                    event.preventDefault();
                    ctrl.updateDomFocus();
                }
                return;
            }
            if (event.key === "ArrowDown") {
                if (!ctrl.nextDown(path)) {
                    return;
                }
                ctrl.setFocus(path);
                event.preventDefault();
                ctrl.updateDomFocus();
                return;
            }
            if (event.key === "ArrowUp") {
                if (!ctrl.prevUp(path)) {
                    return;
                }
                ctrl.setFocus(path);
                event.preventDefault();
                ctrl.updateDomFocus();
                return;
            }
            if (event.key === "Home") {
                path = [props.roots[0].segment];
                ctrl.setFocus(path);
                event.preventDefault();
                ctrl.updateDomFocus();
                return;
            }
            if (event.key === "End") {
                path = [props.roots[props.roots.length - 1].segment];
                while (ctrl.nextDown(path)) {
                    // Everything happens when evaluating the condition.
                }
                ctrl.setFocus(path);
                event.preventDefault();
                ctrl.updateDomFocus();
                return;
            }
        }

        const domRoot = ref(null);

        onMounted(() => {
            ctrl.setDomRoot(domRoot.value!);
        });

        provideApi(domRoot, { 
            setRoots: (roots: TreeNode[]) => {
                ctrl.roots.length = 0;
                ctrl.roots.push(...roots);
            }
        });
        
        return { domRoot, ctrl, nodes, isExpandable, isExpanded, ariaExpanded,
            hasFocus, label, toggleExpanded, onClick, onKey };
    }
});