wunderbaum
Version:
JavaScript tree/grid/treegrid control.
388 lines (366 loc) • 12.9 kB
text/typescript
/*!
* Wunderbaum - ext-keynav
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* @VERSION, @DATE (https://github.com/mar10/wunderbaum)
*/
import { KeynavOptionsType, NavModeEnum } from "./types";
import { eventToString } from "./util";
import { Wunderbaum } from "./wunderbaum";
import { WunderbaumNode } from "./wb_node";
import { WunderbaumExtension } from "./wb_extension_base";
const QUICKSEARCH_DELAY = 500;
export class KeynavExtension extends WunderbaumExtension<KeynavOptionsType> {
constructor(tree: Wunderbaum) {
super(tree, "keynav", {});
}
protected _getEmbeddedInputElem(elem: any): HTMLInputElement | null {
let input = null;
if (elem && elem.type != null) {
input = elem;
} else {
// ,[contenteditable]
const ace = this.tree.getActiveColElem()?.querySelector("input,select");
if (ace) {
input = ace as HTMLInputElement;
}
}
return input;
}
// /* Return the current cell's embedded input that has keyboard focus. */
// protected _getFocusedInputElem(): HTMLInputElement | null {
// const ace = this.tree
// .getActiveColElem()
// ?.querySelector<HTMLInputElement>("input:focus,select:focus");
// return ace || null;
// }
/* Return true if the current cell's embedded input has keyboard focus. */
protected _isCurInputFocused(): boolean {
const ace = this.tree
.getActiveColElem()
?.querySelector("input:focus,select:focus");
return !!ace;
}
onKeyEvent(data: any): boolean | undefined {
const event = data.event;
const tree = this.tree;
const opts = data.options;
const activate = !event.ctrlKey || opts.autoActivate;
const curInput = this._getEmbeddedInputElem(event.target);
const inputHasFocus = curInput && this._isCurInputFocused();
const navModeOption = opts.navigationModeOption as NavModeEnum;
let focusNode,
eventName = eventToString(event),
node = data.node as WunderbaumNode,
handled = true;
// tree.log(`onKeyEvent: ${eventName}, curInput`, curInput);
if (!tree.isEnabled()) {
// tree.logDebug(`onKeyEvent ignored for disabled tree: ${eventName}`);
return false;
}
// Let callback prevent default processing
if (tree._callEvent("keydown", data) === false) {
return false;
}
// Let ext-edit trigger editing
if (tree._callMethod("edit._preprocessKeyEvent", data) === false) {
return false;
}
// Set focus to active (or first node) if no other node has the focus yet
if (!node) {
const currentNode = tree.getFocusNode() || tree.getActiveNode();
const firstNode = tree.getFirstChild();
if (!currentNode && firstNode && eventName === "ArrowDown") {
firstNode.logInfo("Keydown: activate first node.");
firstNode.setActive();
return;
}
focusNode = currentNode || firstNode;
if (focusNode) {
focusNode.setFocus();
node = tree.getFocusNode()!;
node.logInfo("Keydown: force focus on active node.");
}
}
const isColspan = node.isColspan();
if (tree.isRowNav()) {
// -----------------------------------------------------------------------
// --- Row Mode ---
// -----------------------------------------------------------------------
if (inputHasFocus) {
// If editing an embedded input control, let the control handle all
// keys. Only Enter and Escape should apply / discard, but keep the
// keyboard focus.
switch (eventName) {
case "Enter":
curInput.blur();
tree.setFocus();
break;
case "Escape":
node._render();
tree.setFocus();
break;
}
return;
}
// --- Quick-Search
if (
opts.quicksearch &&
eventName.length === 1 &&
/^\w$/.test(eventName) &&
!curInput
) {
// Allow to search for longer streaks if typed in quickly
const stamp = Date.now();
if (stamp - tree.lastQuicksearchTime > QUICKSEARCH_DELAY) {
tree.lastQuicksearchTerm = "";
}
tree.lastQuicksearchTime = stamp;
tree.lastQuicksearchTerm += eventName;
const matchNode = tree.findNextNode(
tree.lastQuicksearchTerm,
tree.getActiveNode()
);
if (matchNode) {
matchNode.setActive(true, { event: event });
}
event.preventDefault();
return;
}
// Pre-Evaluate expand/collapse action for LEFT/RIGHT
switch (eventName) {
case "Enter":
if (node.isActive()) {
if (node.isExpanded()) {
eventName = "Subtract"; // callapse
} else if (node.isExpandable(true)) {
eventName = "Add"; // expand
}
}
break;
case "ArrowLeft":
if (node.expanded) {
eventName = "Subtract"; // collapse
}
break;
case "ArrowRight":
if (!node.expanded && node.isExpandable(true)) {
eventName = "Add"; // expand
} else if (
navModeOption === NavModeEnum.startCell ||
navModeOption === NavModeEnum.startRow
) {
event.preventDefault();
tree.setCellNav();
return false;
}
break;
}
// Standard navigation (row mode)
switch (eventName) {
case "+":
case "Add":
// case "=": // 187: '+' @ Chrome, Safari
node.setExpanded(true);
break;
case "-":
case "Subtract":
node.setExpanded(false);
break;
case " ": // Space
// if (node.isPagingNode()) {
// tree._triggerNodeEvent("clickPaging", ctx, event);
// } else
if (node.getOption("checkbox")) {
node.toggleSelected();
} else {
node.setActive(true, { event: event });
}
break;
case "Enter":
node.setActive(true, { event: event });
break;
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight":
case "ArrowUp":
case "Backspace":
case "End":
case "Home":
case "Control+End":
case "Control+Home":
case "Meta+ArrowDown":
case "Meta+ArrowUp":
case "PageDown":
case "PageUp":
node.navigate(eventName, { activate: activate, event: event });
break;
default:
handled = false;
}
} else {
// -----------------------------------------------------------------------
// --- Cell Mode ---
// -----------------------------------------------------------------------
// // Standard navigation (cell mode)
// if (isCellEditMode && INPUT_BREAKOUT_KEYS.has(eventName)) {
// }
// const curInput = this._getEmbeddedInputElem(null);
const curInputType = curInput ? curInput.type || curInput.tagName : "";
// const inputHasFocus = curInput && this._isCurInputFocused();
const inputCanFocus = curInput && curInputType !== "checkbox";
if (inputHasFocus) {
if (eventName === "Escape") {
node.logDebug(`Reset focused input on Escape`);
// Discard changes and reset input validation state
curInput.setCustomValidity("");
node._render();
// Keep cell-nav mode
tree.setFocus();
tree.setColumn(tree.activeColIdx);
return;
// } else if (!INPUT_BREAKOUT_KEYS.has(eventName)) {
} else if (eventName !== "Enter") {
if (curInput && curInput.checkValidity && !curInput.checkValidity()) {
// Invalid input: ignore all keys except Enter and Escape
node.logDebug(`Ignored ${eventName} inside invalid input`);
return false;
}
// Let current `<input>` handle it
node.logDebug(`Ignored ${eventName} inside focused input`);
return;
}
// const curInputType = curInput.type || curInput.tagName;
// const breakoutKeys = INPUT_KEYS[curInputType];
// if (!breakoutKeys.includes(eventName)) {
// node.logDebug(`Ignored ${eventName} inside ${curInputType} input`);
// return;
// }
} else if (curInput) {
// On a cell that has an embedded, unfocused <input>
if (eventName.length === 1 && inputCanFocus) {
// Typing a single char
curInput.focus();
curInput.value = "";
node.logDebug(`Focus input: ${eventName}`);
return false;
}
}
if (eventName === "Tab") {
eventName = "ArrowRight";
handled = true;
} else if (eventName === "Shift+Tab") {
eventName = tree.activeColIdx > 0 ? "ArrowLeft" : "";
handled = true;
}
switch (eventName) {
case "+":
case "Add":
// case "=": // 187: '+' @ Chrome, Safari
node.setExpanded(true);
break;
case "-":
case "Subtract":
node.setExpanded(false);
break;
case " ": // Space
if (tree.activeColIdx === 0 && node.getOption("checkbox")) {
node.toggleSelected();
handled = true;
} else if (curInput && curInputType === "checkbox") {
curInput.click();
// toggleCheckbox(curInput)
// new Event("change")
// curInput.change
handled = true;
}
break;
case "F2":
if (curInput && !inputHasFocus && inputCanFocus) {
curInput.focus();
handled = true;
}
break;
case "Enter":
tree.setFocus(); // Blur prev. input if any
if ((tree.activeColIdx === 0 || isColspan) && node.isExpandable()) {
node.setExpanded(!node.isExpanded());
handled = true;
} else if (curInput && !inputHasFocus && inputCanFocus) {
curInput.focus();
handled = true;
}
break;
case "Escape":
tree.setFocus(); // Blur prev. input if any
node.log(`keynav: focus tree...`);
if (tree.isCellNav() && navModeOption !== NavModeEnum.cell) {
node.log(`keynav: setCellNav(false)`);
tree.setCellNav(false); // row-nav mode
tree.setFocus(); //
handled = true;
}
break;
case "ArrowLeft":
tree.setFocus(); // Blur prev. input if any
if (isColspan && node.isExpanded()) {
node.setExpanded(false);
} else if (!isColspan && tree.activeColIdx > 0) {
tree.setColumn(tree.activeColIdx - 1);
} else if (navModeOption !== NavModeEnum.cell) {
tree.setCellNav(false); // row-nav mode
}
handled = true;
break;
case "ArrowRight":
tree.setFocus(); // Blur prev. input if any
if (isColspan && !node.isExpanded()) {
node.setExpanded();
} else if (
!isColspan &&
tree.activeColIdx < tree.columns.length - 1
) {
tree.setColumn(tree.activeColIdx + 1);
}
handled = true;
break;
case "Home": // Generated by [Fn] + ArrowLeft on Mac
// case "Meta+ArrowLeft":
tree.setFocus(); // Blur prev. input if any
if (!isColspan && tree.activeColIdx > 0) {
tree.setColumn(0);
}
handled = true;
break;
case "End": // Generated by [Fn] + ArrowRight on Mac
// case "Meta+ArrowRight":
tree.setFocus(); // Blur prev. input if any
if (!isColspan && tree.activeColIdx < tree.columns.length - 1) {
tree.setColumn(tree.columns.length - 1);
}
handled = true;
break;
case "ArrowDown":
case "ArrowUp":
case "Backspace":
case "Control+End": // Generated by Control + [Fn] + ArrowRight on Mac
case "Control+Home": // Generated by Control + [Fn] + Arrowleft on Mac
case "Meta+ArrowDown": // [⌘] + ArrowDown on Mac
case "Meta+ArrowUp": // [⌘] + ArrowUp on Mac
case "PageDown": // Generated by [Fn] + ArrowDown on Mac
case "PageUp": // Generated by [Fn] + ArrowUp on Mac
node.navigate(eventName, { activate: activate, event: event });
// if (isCellEditMode) {
// this._getEmbeddedInputElem(null, true); // set focus to input
// }
handled = true;
break;
default:
handled = false;
}
}
if (handled) {
event.preventDefault();
}
return;
}
}