mar10/fancytree

View on GitHub
src/jquery.fancytree.ariagrid.js

Summary

Maintainability
F
5 days
Test Coverage
/*!
 * jquery.fancytree.ariagrid.js
 *
 * Support ARIA compliant markup and keyboard navigation for tree grids with
 * embedded input controls.
 * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/)
 *
 * @requires ext-table
 *
 * Copyright (c) 2008-2023, Martin Wendt (https://wwWendt.de)
 *
 * Released under the MIT license
 * https://github.com/mar10/fancytree/wiki/LicenseInfo
 *
 * @version @VERSION
 * @date @DATE
 */

(function (factory) {
    if (typeof define === "function" && define.amd) {
        // AMD. Register as an anonymous module.
        define([
            "jquery",
            "./jquery.fancytree",
            "./jquery.fancytree.table",
        ], factory);
    } else if (typeof module === "object" && module.exports) {
        // Node/CommonJS
        require("./jquery.fancytree.table"); // core + table
        module.exports = factory(require("jquery"));
    } else {
        // Browser globals
        factory(jQuery);
    }
})(function ($) {
    "use strict";

    /*******************************************************************************
     * Private functions and variables
     */

    // Allow these navigation keys even when input controls are focused

    var FT = $.ui.fancytree,
        clsFancytreeActiveCell = "fancytree-active-cell",
        clsFancytreeCellMode = "fancytree-cell-mode",
        clsFancytreeCellNavMode = "fancytree-cell-nav-mode",
        VALID_MODES = ["allow", "force", "start", "off"],
        // Define which keys are handled by embedded <input> control, and should
        // *not* be passed to tree navigation handler in cell-edit mode:
        INPUT_KEYS = {
            text: ["left", "right", "home", "end", "backspace"],
            number: ["up", "down", "left", "right", "home", "end", "backspace"],
            checkbox: [],
            link: [],
            radiobutton: ["up", "down"],
            "select-one": ["up", "down"],
            "select-multiple": ["up", "down"],
        },
        NAV_KEYS = ["up", "down", "left", "right", "home", "end"];

    /* Set aria-activedescendant on container to active cell's ID (generate one if required).*/
    function setActiveDescendant(tree, $target) {
        var id = $target ? $target.uniqueId().attr("id") : "";

        tree.$container.attr("aria-activedescendant", id);
    }

    /* Calculate TD column index (considering colspans).*/
    function getColIdx($tr, $td) {
        var colspan,
            td = $td.get(0),
            idx = 0;

        $tr.children().each(function () {
            if (this === td) {
                return false;
            }
            colspan = $(this).prop("colspan");
            idx += colspan ? colspan : 1;
        });
        return idx;
    }

    /* Find TD at given column index (considering colspans).*/
    function findTdAtColIdx($tr, colIdx) {
        var colspan,
            res = null,
            idx = 0;

        $tr.children().each(function () {
            if (idx >= colIdx) {
                res = $(this);
                return false;
            }
            colspan = $(this).prop("colspan");
            idx += colspan ? colspan : 1;
        });
        return res;
    }

    /* Find adjacent cell for a given direction. Skip empty cells and consider merged cells */
    function findNeighbourTd(tree, $target, keyCode) {
        var nextNode,
            node,
            navMap = { "ctrl+home": "first", "ctrl+end": "last" },
            $td = $target.closest("td"),
            $tr = $td.parent(),
            treeOpts = tree.options,
            colIdx = getColIdx($tr, $td),
            $tdNext = null;

        keyCode = navMap[keyCode] || keyCode;

        switch (keyCode) {
            case "left":
                $tdNext = treeOpts.rtl ? $td.next() : $td.prev();
                break;
            case "right":
                $tdNext = treeOpts.rtl ? $td.prev() : $td.next();
                break;
            case "up":
            case "down":
            case "ctrl+home":
            case "ctrl+end":
                node = $tr[0].ftnode;
                nextNode = tree.findRelatedNode(node, keyCode);
                if (nextNode) {
                    nextNode.makeVisible();
                    nextNode.setActive();
                    $tdNext = findTdAtColIdx($(nextNode.tr), colIdx);
                }
                break;
            case "home":
                $tdNext = treeOpts.rtl
                    ? $tr.children("td").last()
                    : $tr.children("td").first();
                break;
            case "end":
                $tdNext = treeOpts.rtl
                    ? $tr.children("td").first()
                    : $tr.children("td").last();
                break;
        }
        return $tdNext && $tdNext.length ? $tdNext : null;
    }

    /* Return a descriptive string of the current mode. */
    function getGridNavMode(tree) {
        if (tree.$activeTd) {
            return tree.forceNavMode ? "cell-nav" : "cell-edit";
        }
        return "row";
    }

    /* .*/
    function activateEmbeddedLink($td) {
        // $td.find( "a" )[ 0 ].trigger("click");  // does not work (always)?
        // $td.find( "a" ).trigger("click");
        var event = document.createEvent("MouseEvent"),
            a = $td.find("a")[0]; // document.getElementById('nameOfID');

        event = new CustomEvent("click");
        a.dispatchEvent(event);
    }

    /**
     * [ext-ariagrid] Set active cell and activate cell-nav or cell-edit mode if needed.
     * Pass $td=null to enter row-mode.
     *
     * See also FancytreeNode#setActive(flag, {cell: idx})
     *
     * @param {jQuery | Element | integer} [$td]
     * @param {Event|null} [orgEvent=null]
     * @alias Fancytree#activateCell
     * @requires jquery.fancytree.ariagrid.js
     * @since 2.23
     */
    $.ui.fancytree._FancytreeClass.prototype.activateCell = function (
        $td,
        orgEvent
    ) {
        var colIdx,
            $input,
            $tr,
            res,
            tree = this,
            $prevTd = this.$activeTd || null,
            newNode = $td ? FT.getNode($td) : null,
            prevNode = $prevTd ? FT.getNode($prevTd) : null,
            anyNode = newNode || prevNode,
            $prevTr = $prevTd ? $prevTd.closest("tr") : null;

        anyNode.debug(
            "activateCell(" +
                ($prevTd ? $prevTd.text() : "null") +
                ") -> " +
                ($td ? $td.text() : "OFF")
        );

        // Make available as event

        if ($td) {
            FT.assert($td.length, "Invalid active cell");
            colIdx = getColIdx($(newNode.tr), $td);
            res = this._triggerNodeEvent("activateCell", newNode, orgEvent, {
                activeTd: tree.$activeTd,
                colIdx: colIdx,
                mode: null, // editMode ? "cell-edit" : "cell-nav"
            });
            if (res === false) {
                return false;
            }
            this.$container.addClass(clsFancytreeCellMode);
            this.$container.toggleClass(
                clsFancytreeCellNavMode,
                !!this.forceNavMode
            );
            $tr = $td.closest("tr");
            if ($prevTd) {
                // cell-mode => cell-mode
                if ($prevTd.is($td)) {
                    return;
                }
                $prevTd
                    .removeAttr("tabindex")
                    .removeClass(clsFancytreeActiveCell);

                if (!$prevTr.is($tr)) {
                    // We are moving to a different row: only the inputs in the
                    // active row should be tabbable
                    $prevTr.find(">td :input,a").attr("tabindex", "-1");
                }
            }
            $tr.find(">td :input:enabled,a").attr("tabindex", "0");
            newNode.setActive();
            $td.addClass(clsFancytreeActiveCell);
            this.$activeTd = $td;

            $input = $td.find(":input:enabled,a");
            this.debug("Focus input", $input);
            if ($input.length) {
                $input.trigger("focus");
                setActiveDescendant(this, $input);
            } else {
                $td.attr("tabindex", "-1").trigger("focus");
                setActiveDescendant(this, $td);
            }
        } else {
            res = this._triggerNodeEvent("activateCell", prevNode, orgEvent, {
                activeTd: null,
                colIdx: null,
                mode: "row",
            });
            if (res === false) {
                return false;
            }
            // $td == null: switch back to row-mode
            this.$container.removeClass(
                clsFancytreeCellMode + " " + clsFancytreeCellNavMode
            );
            // console.log("activateCell: set row-mode for " + this.activeNode, $prevTd);
            if ($prevTd) {
                // cell-mode => row-mode
                $prevTd
                    .removeAttr("tabindex")
                    .removeClass(clsFancytreeActiveCell);
                // In row-mode, only embedded inputs of the active row are tabbable
                $prevTr
                    .find("td")
                    .blur() // we need to blur first, because otherwise the focus frame is not reliably removed(?)
                    .removeAttr("tabindex");
                $prevTr.find(">td :input,a").attr("tabindex", "-1");
                this.$activeTd = null;
                // The cell lost focus, but the tree still needs to capture keys:
                this.activeNode.setFocus();
                setActiveDescendant(this, $tr);
            } else {
                // row-mode => row-mode (nothing to do)
            }
        }
    };

    /*******************************************************************************
     * Extension code
     */
    $.ui.fancytree.registerExtension({
        name: "ariagrid",
        version: "@VERSION",
        // Default options for this extension.
        options: {
            // Internal behavior flags
            activateCellOnDoubelclick: true,
            cellFocus: "allow",
            // TODO: use a global tree option `name` or `title` instead?:
            label: "Tree Grid", // Added as `aria-label` attribute
        },

        treeInit: function (ctx) {
            var tree = ctx.tree,
                treeOpts = ctx.options,
                opts = treeOpts.ariagrid;

            // ariagrid requires the table extension to be loaded before itself
            if (tree.ext.grid) {
                this._requireExtension("grid", true, true);
            } else {
                this._requireExtension("table", true, true);
            }
            if (!treeOpts.aria) {
                $.error("ext-ariagrid requires `aria: true`");
            }
            if ($.inArray(opts.cellFocus, VALID_MODES) < 0) {
                $.error("Invalid `cellFocus` option");
            }
            this._superApply(arguments);

            // The combination of $activeTd and forceNavMode determines the current
            // navigation mode:
            this.$activeTd = null; // active cell (null in row-mode)
            this.forceNavMode = true;

            this.$container
                .addClass("fancytree-ext-ariagrid")
                .toggleClass(clsFancytreeCellNavMode, !!this.forceNavMode)
                .attr("aria-label", "" + opts.label);
            this.$container
                .find("thead > tr > th")
                .attr("role", "columnheader");

            // Store table options for easier evaluation of default actions
            // depending of active cell column
            this.nodeColumnIdx = treeOpts.table.nodeColumnIdx;
            this.checkboxColumnIdx = treeOpts.table.checkboxColumnIdx;
            if (this.checkboxColumnIdx == null) {
                this.checkboxColumnIdx = this.nodeColumnIdx;
            }

            this.$container
                .on("focusin", function (event) {
                    // Activate node if embedded input gets focus (due to a click)
                    var node = FT.getNode(event.target),
                        $td = $(event.target).closest("td");

                    // tree.debug( "focusin: " + ( node ? node.title : "null" ) +
                    //     ", target: " + ( $td ? $td.text() : null ) +
                    //     ", node was active: " + ( node && node.isActive() ) +
                    //     ", last cell: " + ( tree.$activeTd ? tree.$activeTd.text() : null ) );
                    // tree.debug( "focusin: target", event.target );

                    // TODO: add ":input" as delegate filter instead of testing here
                    if (
                        node &&
                        !$td.is(tree.$activeTd) &&
                        $(event.target).is(":input")
                    ) {
                        node.debug("Activate cell on INPUT focus event");
                        tree.activateCell($td);
                    }
                })
                .on("fancytreeinit", function (event, data) {
                    if (
                        opts.cellFocus === "start" ||
                        opts.cellFocus === "force"
                    ) {
                        tree.debug("Enforce cell-mode on init");
                        tree.debug(
                            "init",
                            tree.getActiveNode() || tree.getFirstChild()
                        );
                        (
                            tree.getActiveNode() || tree.getFirstChild()
                        ).setActive(true, { cell: tree.nodeColumnIdx });
                        tree.debug(
                            "init2",
                            tree.getActiveNode() || tree.getFirstChild()
                        );
                    }
                })
                .on("fancytreefocustree", function (event, data) {
                    // Enforce cell-mode when container gets focus
                    if (opts.cellFocus === "force" && !tree.$activeTd) {
                        var node = tree.getActiveNode() || tree.getFirstChild();
                        tree.debug("Enforce cell-mode on focusTree event");
                        node.setActive(true, { cell: 0 });
                    }
                })
                // .on("fancytreeupdateviewport", function(event, data) {
                //     tree.debug(event.type, data);
                // })
                .on("fancytreebeforeupdateviewport", function (event, data) {
                    // When scrolling, the TR may be re-used by another node, so the
                    // active cell marker an
                    // tree.debug(event.type, data);
                    if (tree.viewport && tree.$activeTd) {
                        tree.info("Cancel cell-mode due to scroll event.");
                        tree.activateCell(null);
                    }
                });
        },
        nodeClick: function (ctx) {
            var targetType = ctx.targetType,
                tree = ctx.tree,
                node = ctx.node,
                event = ctx.originalEvent,
                $target = $(event.target),
                $td = $target.closest("td");

            tree.debug(
                "nodeClick: node: " +
                    (node ? node.title : "null") +
                    ", targetType: " +
                    targetType +
                    ", target: " +
                    ($td.length ? $td.text() : null) +
                    ", node was active: " +
                    (node && node.isActive()) +
                    ", last cell: " +
                    (tree.$activeTd ? tree.$activeTd.text() : null)
            );

            if (tree.$activeTd) {
                // If already in cell-mode, activate new cell
                tree.activateCell($td);
                if ($target.is(":input")) {
                    return;
                } else if (
                    $target.is(".fancytree-checkbox") ||
                    $target.is(".fancytree-expander")
                ) {
                    return this._superApply(arguments);
                }
                return false;
            }
            return this._superApply(arguments);
        },
        nodeDblclick: function (ctx) {
            var tree = ctx.tree,
                treeOpts = ctx.options,
                opts = treeOpts.ariagrid,
                event = ctx.originalEvent,
                $td = $(event.target).closest("td");

            // console.log("nodeDblclick", tree.$activeTd, ctx.options.ariagrid.cellFocus)
            if (
                opts.activateCellOnDoubelclick &&
                !tree.$activeTd &&
                opts.cellFocus === "allow"
            ) {
                // If in row-mode, activate new cell
                tree.activateCell($td);
                return false;
            }
            return this._superApply(arguments);
        },
        nodeRenderStatus: function (ctx) {
            // Set classes for current status
            var res,
                node = ctx.node,
                $tr = $(node.tr);

            res = this._super(ctx);

            if (node.parent) {
                $tr.attr("aria-level", node.getLevel())
                    .attr("aria-setsize", node.parent.children.length)
                    .attr("aria-posinset", node.getIndex() + 1);

                // 2018-06-24: not required according to
                // https://github.com/w3c/aria-practices/issues/132#issuecomment-397698250
                // if ( $tr.is( ":hidden" ) ) {
                //     $tr.attr( "aria-hidden", true );
                // } else {
                //     $tr.removeAttr( "aria-hidden" );
                // }

                // this.debug("nodeRenderStatus: " + this.$activeTd + ", " + $tr.attr("aria-expanded"));
                // In cell-mode, move aria-expanded attribute from TR to first child TD
                if (this.$activeTd && $tr.attr("aria-expanded") != null) {
                    $tr.remove("aria-expanded");
                    $tr.find("td")
                        .eq(this.nodeColumnIdx)
                        .attr("aria-expanded", node.isExpanded());
                } else {
                    $tr.find("td")
                        .eq(this.nodeColumnIdx)
                        .removeAttr("aria-expanded");
                }
            }
            return res;
        },
        nodeSetActive: function (ctx, flag, callOpts) {
            var $td,
                node = ctx.node,
                tree = ctx.tree,
                $tr = $(node.tr);

            flag = flag !== false;
            node.debug("nodeSetActive(" + flag + ")", callOpts);
            // Support custom `cell` option
            if (flag && callOpts && callOpts.cell != null) {
                // `cell` may be a col-index, <td>, or `$(td)`
                if (typeof callOpts.cell === "number") {
                    $td = findTdAtColIdx($tr, callOpts.cell);
                } else {
                    $td = $(callOpts.cell);
                }
                tree.activateCell($td);
                return;
            }
            // tree.debug( "nodeSetActive: activeNode " + this.activeNode );
            return this._superApply(arguments);
        },
        nodeKeydown: function (ctx) {
            var handleKeys,
                inputType,
                res,
                $td,
                $embeddedCheckbox = null,
                tree = ctx.tree,
                node = ctx.node,
                treeOpts = ctx.options,
                opts = treeOpts.ariagrid,
                event = ctx.originalEvent,
                eventString = FT.eventToString(event),
                $target = $(event.target),
                $activeTd = this.$activeTd,
                $activeTr = $activeTd ? $activeTd.closest("tr") : null,
                colIdx = $activeTd ? getColIdx($activeTr, $activeTd) : -1,
                forceNav =
                    $activeTd &&
                    tree.forceNavMode &&
                    $.inArray(eventString, NAV_KEYS) >= 0;

            if (opts.cellFocus === "off") {
                return this._superApply(arguments);
            }

            if ($target.is(":input:enabled")) {
                inputType = $target.prop("type");
            } else if ($target.is("a")) {
                inputType = "link";
            }
            if ($activeTd && $activeTd.find(":checkbox:enabled").length === 1) {
                $embeddedCheckbox = $activeTd.find(":checkbox:enabled");
                inputType = "checkbox";
            }
            tree.debug(
                "nodeKeydown(" +
                    eventString +
                    "), activeTd: '" +
                    ($activeTd && $activeTd.text()) +
                    "', inputType: " +
                    inputType
            );

            if (inputType && eventString !== "esc" && !forceNav) {
                handleKeys = INPUT_KEYS[inputType];
                if (handleKeys && $.inArray(eventString, handleKeys) >= 0) {
                    return; // Let input control handle the key
                }
            }

            switch (eventString) {
                case "right":
                    if ($activeTd) {
                        // Cell mode: move to neighbour (stop on right border)
                        $td = findNeighbourTd(tree, $activeTd, eventString);
                        if ($td) {
                            tree.activateCell($td);
                        }
                    } else if (
                        node &&
                        !node.isExpanded() &&
                        node.hasChildren() !== false
                    ) {
                        // Row mode and current node can be expanded:
                        // default handling will expand.
                        break;
                    } else {
                        // Row mode: switch to cell-mode
                        $td = $(node.tr).find(">td").first();
                        tree.activateCell($td);
                    }
                    return false; // no default handling

                case "left":
                case "home":
                case "end":
                case "ctrl+home":
                case "ctrl+end":
                case "up":
                case "down":
                    if ($activeTd) {
                        // Cell mode: move to neighbour
                        $td = findNeighbourTd(tree, $activeTd, eventString);
                        // Note: $td may be null if we move outside bounds. In this case
                        // we switch back to row-mode (i.e. call activateCell(null) ).
                        if (!$td && "left right".indexOf(eventString) < 0) {
                            // Only switch to row-mode if left/right hits the bounds
                            return false;
                        }
                        if ($td || opts.cellFocus !== "force") {
                            tree.activateCell($td);
                        }
                        return false;
                    }
                    break;

                case "esc":
                    if ($activeTd && !tree.forceNavMode) {
                        // Switch from cell-edit-mode to cell-nav-mode
                        // $target.closest( "td" ).trigger("focus");
                        tree.forceNavMode = true;
                        tree.debug("Enter cell-nav-mode");
                        tree.$container.toggleClass(
                            clsFancytreeCellNavMode,
                            !!tree.forceNavMode
                        );
                        return false;
                    } else if ($activeTd && opts.cellFocus !== "force") {
                        // Switch back from cell-mode to row-mode
                        tree.activateCell(null);
                        return false;
                    }
                    // tree.$container.toggleClass( clsFancytreeCellNavMode, !!tree.forceNavMode );
                    break;

                case "return":
                    // Let user override the default action.
                    // This event is triggered in row-mode and cell-mode
                    res = tree._triggerNodeEvent(
                        "defaultGridAction",
                        node,
                        event,
                        {
                            activeTd: tree.$activeTd ? tree.$activeTd[0] : null,
                            colIdx: colIdx,
                            mode: getGridNavMode(tree),
                        }
                    );
                    if (res === false) {
                        return false;
                    }
                    // Implement default actions (for cell-mode only).
                    if ($activeTd) {
                        // Apply 'default action' for embedded cell control
                        if (colIdx === this.nodeColumnIdx) {
                            node.toggleExpanded();
                        } else if (colIdx === this.checkboxColumnIdx) {
                            // TODO: only in checkbox mode!
                            node.toggleSelected();
                        } else if ($embeddedCheckbox) {
                            // Embedded checkboxes are always toggled (ignoring `autoFocusInput`)
                            $embeddedCheckbox.prop(
                                "checked",
                                !$embeddedCheckbox.prop("checked")
                            );
                        } else if (tree.forceNavMode && $target.is(":input")) {
                            tree.forceNavMode = false;
                            tree.$container.removeClass(
                                clsFancytreeCellNavMode
                            );
                            tree.debug("enable cell-edit-mode");
                        } else if ($activeTd.find("a").length === 1) {
                            activateEmbeddedLink($activeTd);
                        }
                    } else {
                        // ENTER in row-mode: Switch from row-mode to cell-mode
                        // TODO: it was also suggested to expand/collapse instead
                        //    https://github.com/w3c/aria-practices/issues/132#issuecomment-407634891
                        $td = $(node.tr).find(">td").nth(this.nodeColumnIdx);
                        tree.activateCell($td);
                    }
                    return false; // no default handling

                case "space":
                    if ($activeTd) {
                        if (colIdx === this.checkboxColumnIdx) {
                            node.toggleSelected();
                        } else if ($embeddedCheckbox) {
                            $embeddedCheckbox.prop(
                                "checked",
                                !$embeddedCheckbox.prop("checked")
                            );
                        }
                        return false; // no default handling
                    }
                    break;

                default:
                // Allow to focus input by typing alphanum keys
            }
            return this._superApply(arguments);
        },
        treeSetOption: function (ctx, key, value) {
            var tree = ctx.tree,
                opts = tree.options.ariagrid;

            if (key === "ariagrid") {
                // User called `$().fancytree("option", "ariagrid.SUBKEY", VALUE)`
                if (value.cellFocus !== opts.cellFocus) {
                    if ($.inArray(value.cellFocus, VALID_MODES) < 0) {
                        $.error("Invalid `cellFocus` option");
                    }
                    // TODO: fix current focus and mode
                }
            }
            return this._superApply(arguments);
        },
    });
    // Value returned by `require('jquery.fancytree..')`
    return $.ui.fancytree;
}); // End of closure