1/*!
2 * jquery.fancytree.ariagrid.js
3 *
4 * Support ARIA compliant markup and keyboard navigation for tree grids with
5 * embedded input controls.
6 * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/)
7 *
8 * @requires ext-table
9 *
10 * Copyright (c) 2008-2023, Martin Wendt (https://wwWendt.de)
11 *
12 * Released under the MIT license
13 * https://github.com/mar10/fancytree/wiki/LicenseInfo
14 *
15 * @version 2.38.3
16 * @date 2023-02-01T20:52:50Z
17 */
18
19(function (factory) {
20	if (typeof define === "function" && define.amd) {
21		// AMD. Register as an anonymous module.
22		define([
23			"jquery",
24			"./jquery.fancytree",
25			"./jquery.fancytree.table",
26		], factory);
27	} else if (typeof module === "object" && module.exports) {
28		// Node/CommonJS
29		require("./jquery.fancytree.table"); // core + table
30		module.exports = factory(require("jquery"));
31	} else {
32		// Browser globals
33		factory(jQuery);
34	}
35})(function ($) {
36	"use strict";
37
38	/*******************************************************************************
39	 * Private functions and variables
40	 */
41
42	// Allow these navigation keys even when input controls are focused
43
44	var FT = $.ui.fancytree,
45		clsFancytreeActiveCell = "fancytree-active-cell",
46		clsFancytreeCellMode = "fancytree-cell-mode",
47		clsFancytreeCellNavMode = "fancytree-cell-nav-mode",
48		VALID_MODES = ["allow", "force", "start", "off"],
49		// Define which keys are handled by embedded <input> control, and should
50		// *not* be passed to tree navigation handler in cell-edit mode:
51		INPUT_KEYS = {
52			text: ["left", "right", "home", "end", "backspace"],
53			number: ["up", "down", "left", "right", "home", "end", "backspace"],
54			checkbox: [],
55			link: [],
56			radiobutton: ["up", "down"],
57			"select-one": ["up", "down"],
58			"select-multiple": ["up", "down"],
59		},
60		NAV_KEYS = ["up", "down", "left", "right", "home", "end"];
61
62	/* Set aria-activedescendant on container to active cell's ID (generate one if required).*/
63	function setActiveDescendant(tree, $target) {
64		var id = $target ? $target.uniqueId().attr("id") : "";
65
66		tree.$container.attr("aria-activedescendant", id);
67	}
68
69	/* Calculate TD column index (considering colspans).*/
70	function getColIdx($tr, $td) {
71		var colspan,
72			td = $td.get(0),
73			idx = 0;
74
75		$tr.children().each(function () {
76			if (this === td) {
77				return false;
78			}
79			colspan = $(this).prop("colspan");
80			idx += colspan ? colspan : 1;
81		});
82		return idx;
83	}
84
85	/* Find TD at given column index (considering colspans).*/
86	function findTdAtColIdx($tr, colIdx) {
87		var colspan,
88			res = null,
89			idx = 0;
90
91		$tr.children().each(function () {
92			if (idx >= colIdx) {
93				res = $(this);
94				return false;
95			}
96			colspan = $(this).prop("colspan");
97			idx += colspan ? colspan : 1;
98		});
99		return res;
100	}
101
102	/* Find adjacent cell for a given direction. Skip empty cells and consider merged cells */
103	function findNeighbourTd(tree, $target, keyCode) {
104		var nextNode,
105			node,
106			navMap = { "ctrl+home": "first", "ctrl+end": "last" },
107			$td = $target.closest("td"),
108			$tr = $td.parent(),
109			treeOpts = tree.options,
110			colIdx = getColIdx($tr, $td),
111			$tdNext = null;
112
113		keyCode = navMap[keyCode] || keyCode;
114
115		switch (keyCode) {
116			case "left":
117				$tdNext = treeOpts.rtl ? $td.next() : $td.prev();
118				break;
119			case "right":
120				$tdNext = treeOpts.rtl ? $td.prev() : $td.next();
121				break;
122			case "up":
123			case "down":
124			case "ctrl+home":
125			case "ctrl+end":
126				node = $tr[0].ftnode;
127				nextNode = tree.findRelatedNode(node, keyCode);
128				if (nextNode) {
129					nextNode.makeVisible();
130					nextNode.setActive();
131					$tdNext = findTdAtColIdx($(nextNode.tr), colIdx);
132				}
133				break;
134			case "home":
135				$tdNext = treeOpts.rtl
136					? $tr.children("td").last()
137					: $tr.children("td").first();
138				break;
139			case "end":
140				$tdNext = treeOpts.rtl
141					? $tr.children("td").first()
142					: $tr.children("td").last();
143				break;
144		}
145		return $tdNext && $tdNext.length ? $tdNext : null;
146	}
147
148	/* Return a descriptive string of the current mode. */
149	function getGridNavMode(tree) {
150		if (tree.$activeTd) {
151			return tree.forceNavMode ? "cell-nav" : "cell-edit";
152		}
153		return "row";
154	}
155
156	/* .*/
157	function activateEmbeddedLink($td) {
158		// $td.find( "a" )[ 0 ].trigger("click");  // does not work (always)?
159		// $td.find( "a" ).trigger("click");
160		var event = document.createEvent("MouseEvent"),
161			a = $td.find("a")[0]; // document.getElementById('nameOfID');
162
163		event = new CustomEvent("click");
164		a.dispatchEvent(event);
165	}
166
167	/**
168	 * [ext-ariagrid] Set active cell and activate cell-nav or cell-edit mode if needed.
169	 * Pass $td=null to enter row-mode.
170	 *
171	 * See also FancytreeNode#setActive(flag, {cell: idx})
172	 *
173	 * @param {jQuery | Element | integer} [$td]
174	 * @param {Event|null} [orgEvent=null]
175	 * @alias Fancytree#activateCell
176	 * @requires jquery.fancytree.ariagrid.js
177	 * @since 2.23
178	 */
179	$.ui.fancytree._FancytreeClass.prototype.activateCell = function (
180		$td,
181		orgEvent
182	) {
183		var colIdx,
184			$input,
185			$tr,
186			res,
187			tree = this,
188			$prevTd = this.$activeTd || null,
189			newNode = $td ? FT.getNode($td) : null,
190			prevNode = $prevTd ? FT.getNode($prevTd) : null,
191			anyNode = newNode || prevNode,
192			$prevTr = $prevTd ? $prevTd.closest("tr") : null;
193
194		anyNode.debug(
195			"activateCell(" +
196				($prevTd ? $prevTd.text() : "null") +
197				") -> " +
198				($td ? $td.text() : "OFF")
199		);
200
201		// Make available as event
202
203		if ($td) {
204			FT.assert($td.length, "Invalid active cell");
205			colIdx = getColIdx($(newNode.tr), $td);
206			res = this._triggerNodeEvent("activateCell", newNode, orgEvent, {
207				activeTd: tree.$activeTd,
208				colIdx: colIdx,
209				mode: null, // editMode ? "cell-edit" : "cell-nav"
210			});
211			if (res === false) {
212				return false;
213			}
214			this.$container.addClass(clsFancytreeCellMode);
215			this.$container.toggleClass(
216				clsFancytreeCellNavMode,
217				!!this.forceNavMode
218			);
219			$tr = $td.closest("tr");
220			if ($prevTd) {
221				// cell-mode => cell-mode
222				if ($prevTd.is($td)) {
223					return;
224				}
225				$prevTd
226					.removeAttr("tabindex")
227					.removeClass(clsFancytreeActiveCell);
228
229				if (!$prevTr.is($tr)) {
230					// We are moving to a different row: only the inputs in the
231					// active row should be tabbable
232					$prevTr.find(">td :input,a").attr("tabindex", "-1");
233				}
234			}
235			$tr.find(">td :input:enabled,a").attr("tabindex", "0");
236			newNode.setActive();
237			$td.addClass(clsFancytreeActiveCell);
238			this.$activeTd = $td;
239
240			$input = $td.find(":input:enabled,a");
241			this.debug("Focus input", $input);
242			if ($input.length) {
243				$input.focus();
244				setActiveDescendant(this, $input);
245			} else {
246				$td.attr("tabindex", "-1").focus();
247				setActiveDescendant(this, $td);
248			}
249		} else {
250			res = this._triggerNodeEvent("activateCell", prevNode, orgEvent, {
251				activeTd: null,
252				colIdx: null,
253				mode: "row",
254			});
255			if (res === false) {
256				return false;
257			}
258			// $td == null: switch back to row-mode
259			this.$container.removeClass(
260				clsFancytreeCellMode + " " + clsFancytreeCellNavMode
261			);
262			// console.log("activateCell: set row-mode for " + this.activeNode, $prevTd);
263			if ($prevTd) {
264				// cell-mode => row-mode
265				$prevTd
266					.removeAttr("tabindex")
267					.removeClass(clsFancytreeActiveCell);
268				// In row-mode, only embedded inputs of the active row are tabbable
269				$prevTr
270					.find("td")
271					.blur() // we need to blur first, because otherwise the focus frame is not reliably removed(?)
272					.removeAttr("tabindex");
273				$prevTr.find(">td :input,a").attr("tabindex", "-1");
274				this.$activeTd = null;
275				// The cell lost focus, but the tree still needs to capture keys:
276				this.activeNode.setFocus();
277				setActiveDescendant(this, $tr);
278			} else {
279				// row-mode => row-mode (nothing to do)
280			}
281		}
282	};
283
284	/*******************************************************************************
285	 * Extension code
286	 */
287	$.ui.fancytree.registerExtension({
288		name: "ariagrid",
289		version: "2.38.3",
290		// Default options for this extension.
291		options: {
292			// Internal behavior flags
293			activateCellOnDoubelclick: true,
294			cellFocus: "allow",
295			// TODO: use a global tree option `name` or `title` instead?:
296			label: "Tree Grid", // Added as `aria-label` attribute
297		},
298
299		treeInit: function (ctx) {
300			var tree = ctx.tree,
301				treeOpts = ctx.options,
302				opts = treeOpts.ariagrid;
303
304			// ariagrid requires the table extension to be loaded before itself
305			if (tree.ext.grid) {
306				this._requireExtension("grid", true, true);
307			} else {
308				this._requireExtension("table", true, true);
309			}
310			if (!treeOpts.aria) {
311				$.error("ext-ariagrid requires `aria: true`");
312			}
313			if ($.inArray(opts.cellFocus, VALID_MODES) < 0) {
314				$.error("Invalid `cellFocus` option");
315			}
316			this._superApply(arguments);
317
318			// The combination of $activeTd and forceNavMode determines the current
319			// navigation mode:
320			this.$activeTd = null; // active cell (null in row-mode)
321			this.forceNavMode = true;
322
323			this.$container
324				.addClass("fancytree-ext-ariagrid")
325				.toggleClass(clsFancytreeCellNavMode, !!this.forceNavMode)
326				.attr("aria-label", "" + opts.label);
327			this.$container
328				.find("thead > tr > th")
329				.attr("role", "columnheader");
330
331			// Store table options for easier evaluation of default actions
332			// depending of active cell column
333			this.nodeColumnIdx = treeOpts.table.nodeColumnIdx;
334			this.checkboxColumnIdx = treeOpts.table.checkboxColumnIdx;
335			if (this.checkboxColumnIdx == null) {
336				this.checkboxColumnIdx = this.nodeColumnIdx;
337			}
338
339			this.$container
340				.on("focusin", function (event) {
341					// Activate node if embedded input gets focus (due to a click)
342					var node = FT.getNode(event.target),
343						$td = $(event.target).closest("td");
344
345					// tree.debug( "focusin: " + ( node ? node.title : "null" ) +
346					// 	", target: " + ( $td ? $td.text() : null ) +
347					// 	", node was active: " + ( node && node.isActive() ) +
348					// 	", last cell: " + ( tree.$activeTd ? tree.$activeTd.text() : null ) );
349					// tree.debug( "focusin: target", event.target );
350
351					// TODO: add ":input" as delegate filter instead of testing here
352					if (
353						node &&
354						!$td.is(tree.$activeTd) &&
355						$(event.target).is(":input")
356					) {
357						node.debug("Activate cell on INPUT focus event");
358						tree.activateCell($td);
359					}
360				})
361				.on("fancytreeinit", function (event, data) {
362					if (
363						opts.cellFocus === "start" ||
364						opts.cellFocus === "force"
365					) {
366						tree.debug("Enforce cell-mode on init");
367						tree.debug(
368							"init",
369							tree.getActiveNode() || tree.getFirstChild()
370						);
371						(
372							tree.getActiveNode() || tree.getFirstChild()
373						).setActive(true, { cell: tree.nodeColumnIdx });
374						tree.debug(
375							"init2",
376							tree.getActiveNode() || tree.getFirstChild()
377						);
378					}
379				})
380				.on("fancytreefocustree", function (event, data) {
381					// Enforce cell-mode when container gets focus
382					if (opts.cellFocus === "force" && !tree.$activeTd) {
383						var node = tree.getActiveNode() || tree.getFirstChild();
384						tree.debug("Enforce cell-mode on focusTree event");
385						node.setActive(true, { cell: 0 });
386					}
387				})
388				// .on("fancytreeupdateviewport", function(event, data) {
389				// 	tree.debug(event.type, data);
390				// })
391				.on("fancytreebeforeupdateviewport", function (event, data) {
392					// When scrolling, the TR may be re-used by another node, so the
393					// active cell marker an
394					// tree.debug(event.type, data);
395					if (tree.viewport && tree.$activeTd) {
396						tree.info("Cancel cell-mode due to scroll event.");
397						tree.activateCell(null);
398					}
399				});
400		},
401		nodeClick: function (ctx) {
402			var targetType = ctx.targetType,
403				tree = ctx.tree,
404				node = ctx.node,
405				event = ctx.originalEvent,
406				$target = $(event.target),
407				$td = $target.closest("td");
408
409			tree.debug(
410				"nodeClick: node: " +
411					(node ? node.title : "null") +
412					", targetType: " +
413					targetType +
414					", target: " +
415					($td.length ? $td.text() : null) +
416					", node was active: " +
417					(node && node.isActive()) +
418					", last cell: " +
419					(tree.$activeTd ? tree.$activeTd.text() : null)
420			);
421
422			if (tree.$activeTd) {
423				// If already in cell-mode, activate new cell
424				tree.activateCell($td);
425				if ($target.is(":input")) {
426					return;
427				} else if (
428					$target.is(".fancytree-checkbox") ||
429					$target.is(".fancytree-expander")
430				) {
431					return this._superApply(arguments);
432				}
433				return false;
434			}
435			return this._superApply(arguments);
436		},
437		nodeDblclick: function (ctx) {
438			var tree = ctx.tree,
439				treeOpts = ctx.options,
440				opts = treeOpts.ariagrid,
441				event = ctx.originalEvent,
442				$td = $(event.target).closest("td");
443
444			// console.log("nodeDblclick", tree.$activeTd, ctx.options.ariagrid.cellFocus)
445			if (
446				opts.activateCellOnDoubelclick &&
447				!tree.$activeTd &&
448				opts.cellFocus === "allow"
449			) {
450				// If in row-mode, activate new cell
451				tree.activateCell($td);
452				return false;
453			}
454			return this._superApply(arguments);
455		},
456		nodeRenderStatus: function (ctx) {
457			// Set classes for current status
458			var res,
459				node = ctx.node,
460				$tr = $(node.tr);
461
462			res = this._super(ctx);
463
464			if (node.parent) {
465				$tr.attr("aria-level", node.getLevel())
466					.attr("aria-setsize", node.parent.children.length)
467					.attr("aria-posinset", node.getIndex() + 1);
468
469				// 2018-06-24: not required according to
470				// https://github.com/w3c/aria-practices/issues/132#issuecomment-397698250
471				// if ( $tr.is( ":hidden" ) ) {
472				// 	$tr.attr( "aria-hidden", true );
473				// } else {
474				// 	$tr.removeAttr( "aria-hidden" );
475				// }
476
477				// this.debug("nodeRenderStatus: " + this.$activeTd + ", " + $tr.attr("aria-expanded"));
478				// In cell-mode, move aria-expanded attribute from TR to first child TD
479				if (this.$activeTd && $tr.attr("aria-expanded") != null) {
480					$tr.remove("aria-expanded");
481					$tr.find("td")
482						.eq(this.nodeColumnIdx)
483						.attr("aria-expanded", node.isExpanded());
484				} else {
485					$tr.find("td")
486						.eq(this.nodeColumnIdx)
487						.removeAttr("aria-expanded");
488				}
489			}
490			return res;
491		},
492		nodeSetActive: function (ctx, flag, callOpts) {
493			var $td,
494				node = ctx.node,
495				tree = ctx.tree,
496				$tr = $(node.tr);
497
498			flag = flag !== false;
499			node.debug("nodeSetActive(" + flag + ")", callOpts);
500			// Support custom `cell` option
501			if (flag && callOpts && callOpts.cell != null) {
502				// `cell` may be a col-index, <td>, or `$(td)`
503				if (typeof callOpts.cell === "number") {
504					$td = findTdAtColIdx($tr, callOpts.cell);
505				} else {
506					$td = $(callOpts.cell);
507				}
508				tree.activateCell($td);
509				return;
510			}
511			// tree.debug( "nodeSetActive: activeNode " + this.activeNode );
512			return this._superApply(arguments);
513		},
514		nodeKeydown: function (ctx) {
515			var handleKeys,
516				inputType,
517				res,
518				$td,
519				$embeddedCheckbox = null,
520				tree = ctx.tree,
521				node = ctx.node,
522				treeOpts = ctx.options,
523				opts = treeOpts.ariagrid,
524				event = ctx.originalEvent,
525				eventString = FT.eventToString(event),
526				$target = $(event.target),
527				$activeTd = this.$activeTd,
528				$activeTr = $activeTd ? $activeTd.closest("tr") : null,
529				colIdx = $activeTd ? getColIdx($activeTr, $activeTd) : -1,
530				forceNav =
531					$activeTd &&
532					tree.forceNavMode &&
533					$.inArray(eventString, NAV_KEYS) >= 0;
534
535			if (opts.cellFocus === "off") {
536				return this._superApply(arguments);
537			}
538
539			if ($target.is(":input:enabled")) {
540				inputType = $target.prop("type");
541			} else if ($target.is("a")) {
542				inputType = "link";
543			}
544			if ($activeTd && $activeTd.find(":checkbox:enabled").length === 1) {
545				$embeddedCheckbox = $activeTd.find(":checkbox:enabled");
546				inputType = "checkbox";
547			}
548			tree.debug(
549				"nodeKeydown(" +
550					eventString +
551					"), activeTd: '" +
552					($activeTd && $activeTd.text()) +
553					"', inputType: " +
554					inputType
555			);
556
557			if (inputType && eventString !== "esc" && !forceNav) {
558				handleKeys = INPUT_KEYS[inputType];
559				if (handleKeys && $.inArray(eventString, handleKeys) >= 0) {
560					return; // Let input control handle the key
561				}
562			}
563
564			switch (eventString) {
565				case "right":
566					if ($activeTd) {
567						// Cell mode: move to neighbour (stop on right border)
568						$td = findNeighbourTd(tree, $activeTd, eventString);
569						if ($td) {
570							tree.activateCell($td);
571						}
572					} else if (
573						node &&
574						!node.isExpanded() &&
575						node.hasChildren() !== false
576					) {
577						// Row mode and current node can be expanded:
578						// default handling will expand.
579						break;
580					} else {
581						// Row mode: switch to cell-mode
582						$td = $(node.tr).find(">td").first();
583						tree.activateCell($td);
584					}
585					return false; // no default handling
586
587				case "left":
588				case "home":
589				case "end":
590				case "ctrl+home":
591				case "ctrl+end":
592				case "up":
593				case "down":
594					if ($activeTd) {
595						// Cell mode: move to neighbour
596						$td = findNeighbourTd(tree, $activeTd, eventString);
597						// Note: $td may be null if we move outside bounds. In this case
598						// we switch back to row-mode (i.e. call activateCell(null) ).
599						if (!$td && "left right".indexOf(eventString) < 0) {
600							// Only switch to row-mode if left/right hits the bounds
601							return false;
602						}
603						if ($td || opts.cellFocus !== "force") {
604							tree.activateCell($td);
605						}
606						return false;
607					}
608					break;
609
610				case "esc":
611					if ($activeTd && !tree.forceNavMode) {
612						// Switch from cell-edit-mode to cell-nav-mode
613						// $target.closest( "td" ).focus();
614						tree.forceNavMode = true;
615						tree.debug("Enter cell-nav-mode");
616						tree.$container.toggleClass(
617							clsFancytreeCellNavMode,
618							!!tree.forceNavMode
619						);
620						return false;
621					} else if ($activeTd && opts.cellFocus !== "force") {
622						// Switch back from cell-mode to row-mode
623						tree.activateCell(null);
624						return false;
625					}
626					// tree.$container.toggleClass( clsFancytreeCellNavMode, !!tree.forceNavMode );
627					break;
628
629				case "return":
630					// Let user override the default action.
631					// This event is triggered in row-mode and cell-mode
632					res = tree._triggerNodeEvent(
633						"defaultGridAction",
634						node,
635						event,
636						{
637							activeTd: tree.$activeTd ? tree.$activeTd[0] : null,
638							colIdx: colIdx,
639							mode: getGridNavMode(tree),
640						}
641					);
642					if (res === false) {
643						return false;
644					}
645					// Implement default actions (for cell-mode only).
646					if ($activeTd) {
647						// Apply 'default action' for embedded cell control
648						if (colIdx === this.nodeColumnIdx) {
649							node.toggleExpanded();
650						} else if (colIdx === this.checkboxColumnIdx) {
651							// TODO: only in checkbox mode!
652							node.toggleSelected();
653						} else if ($embeddedCheckbox) {
654							// Embedded checkboxes are always toggled (ignoring `autoFocusInput`)
655							$embeddedCheckbox.prop(
656								"checked",
657								!$embeddedCheckbox.prop("checked")
658							);
659						} else if (tree.forceNavMode && $target.is(":input")) {
660							tree.forceNavMode = false;
661							tree.$container.removeClass(
662								clsFancytreeCellNavMode
663							);
664							tree.debug("enable cell-edit-mode");
665						} else if ($activeTd.find("a").length === 1) {
666							activateEmbeddedLink($activeTd);
667						}
668					} else {
669						// ENTER in row-mode: Switch from row-mode to cell-mode
670						// TODO: it was also suggested to expand/collapse instead
671						//    https://github.com/w3c/aria-practices/issues/132#issuecomment-407634891
672						$td = $(node.tr).find(">td").nth(this.nodeColumnIdx);
673						tree.activateCell($td);
674					}
675					return false; // no default handling
676
677				case "space":
678					if ($activeTd) {
679						if (colIdx === this.checkboxColumnIdx) {
680							node.toggleSelected();
681						} else if ($embeddedCheckbox) {
682							$embeddedCheckbox.prop(
683								"checked",
684								!$embeddedCheckbox.prop("checked")
685							);
686						}
687						return false; // no default handling
688					}
689					break;
690
691				default:
692				// Allow to focus input by typing alphanum keys
693			}
694			return this._superApply(arguments);
695		},
696		treeSetOption: function (ctx, key, value) {
697			var tree = ctx.tree,
698				opts = tree.options.ariagrid;
699
700			if (key === "ariagrid") {
701				// User called `$().fancytree("option", "ariagrid.SUBKEY", VALUE)`
702				if (value.cellFocus !== opts.cellFocus) {
703					if ($.inArray(value.cellFocus, VALID_MODES) < 0) {
704						$.error("Invalid `cellFocus` option");
705					}
706					// TODO: fix current focus and mode
707				}
708			}
709			return this._superApply(arguments);
710		},
711	});
712	// Value returned by `require('jquery.fancytree..')`
713	return $.ui.fancytree;
714}); // End of closure
715