Source: jquery.fancytree.ariagrid.js

/*!
 * 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.focus();
				setActiveDescendant(this, $input);
			} else {
				$td.attr("tabindex", "-1").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" ).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

Fork me on GitHub