diff --git a/packages/core/src/Render/Dialog/index.ts b/packages/core/src/Render/Dialog/index.ts index 396d272d..b4b70476 100644 --- a/packages/core/src/Render/Dialog/index.ts +++ b/packages/core/src/Render/Dialog/index.ts @@ -7,6 +7,8 @@ import { OlliRuntime } from '../../Runtime/OlliRuntime'; import './dialog.css'; import { makeSelectionMenu } from './selectionMenu'; import { makeTargetedNavMenu } from './targetedNavMenu'; +import { renderHelpDialog } from '../Help'; +import { getOlliGlobalState } from '../../util/globalState'; import { getSpecForNode } from '../../Structure'; export function makeDialog( @@ -95,6 +97,14 @@ function openDialog(dialog: HTMLElement, renderContainer: HTMLElement) { }); } +export function openHelpDialog(tree: OlliRuntime) { + const { keyboardManager } = getOlliGlobalState(); + const table = renderHelpDialog(keyboardManager); + const dialog = makeDialog(tree, 'Olli Help Menu', 'Below are the controls to navigate the Olli tree.', table); + + openDialog(dialog, tree.renderContainer) +} + export function openTableDialog(olliNode: ElaboratedOlliNode, tree: OlliRuntime) { const olliSpec: UnitOlliSpec = getSpecForNode(olliNode, tree.olliSpec); const table = renderTable(selectionTest(olliSpec.data, olliNode.fullPredicate), olliSpec.fields); diff --git a/packages/core/src/Render/Help/index.ts b/packages/core/src/Render/Help/index.ts new file mode 100644 index 00000000..67a2494e --- /dev/null +++ b/packages/core/src/Render/Help/index.ts @@ -0,0 +1,31 @@ +import { KeyboardManager } from "../../Runtime/KeyboardManager"; + + /** + * Build a help dialog + */ + export function renderHelpDialog(keyboardManager: KeyboardManager): HTMLElement { + const table = document.createElement("table"); + const tbody = document.createElement("tbody"); + + Object.entries(keyboardManager.getActions()).forEach(([keystroke, details]) => { + const tr = document.createElement("tr"); + const th = document.createElement("th"); + th.style.textAlign = 'left'; + th.scope = "row"; + th.textContent = details.keyDescription ?? keystroke; + tr.appendChild(th); + + const tdKey = document.createElement("td"); + tdKey.textContent = details.description; + tr.appendChild(tdKey); + + const tdTitle = document.createElement("td"); + tdTitle.textContent = details.title; + tr.appendChild(tdTitle); + + tbody.appendChild(tr); + }); + table.appendChild(tbody); + + return table; + } \ No newline at end of file diff --git a/packages/core/src/Runtime/KeyboardManager.ts b/packages/core/src/Runtime/KeyboardManager.ts new file mode 100644 index 00000000..7c932b98 --- /dev/null +++ b/packages/core/src/Runtime/KeyboardManager.ts @@ -0,0 +1,248 @@ +import { openHelpDialog, openSelectionDialog, openTableDialog } from "../Render/Dialog"; +import { OlliRuntime } from "./OlliRuntime"; +import { OlliRuntimeTreeItem } from "./OlliRuntimeTreeItem"; + +export type KeyboardAction = { + action: (treeItem: OlliRuntimeTreeItem) => void; + title?: string; + keyDescription?: string; + description?: string; + force?: boolean; + caseSensitive?: boolean; + shiftKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +export type KeyRegistration = { + key: string; +} & KeyboardAction; + +export const checkKeys = (e: KeyboardEvent, action: KeyboardAction) => { + let hasKeys = { + altKey: true, + shiftKey: true, + ctrlKEy: true, + metaKey: true, + } + + Object.keys(hasKeys).forEach((key: string) => { + if (action[key]) { + hasKeys[key] = e[key] === action[key]; + } + }) + + return Object.keys(hasKeys).every((key: string) => hasKeys[key]); +}; + +/** + * The KeyboardManager handles adding new keyboard events and controls, + * and displaying a help documentation modal + */ +export class KeyboardManager { + private actions: { + [event: string]: KeyboardAction; + }; + private target: HTMLElement; + private helpModal: HTMLDialogElement | null; + + constructor(target: HTMLElement) { + this.target = target; + if (!this.target.hasAttribute("tabIndex")) { + this.target.setAttribute("tabIndex", "0"); + } + this.helpModal = null; + this.actions = {}; + } + + public handleEvents(e: KeyboardEvent, tree: OlliRuntimeTreeItem): void { + const keyPress = e.key; + let keyboardAction: KeyboardAction; + + if (keyPress in this.actions) { + keyboardAction = this.actions[keyPress]; + } else if (keyPress.toUpperCase() in this.actions) { + keyboardAction = this.actions[keyPress.toUpperCase()]; + } + + if (checkKeys(e, keyboardAction)) { + keyboardAction.action(tree); + e.stopPropagation(); + e.preventDefault(); + } + } + + public addAction({ + key, + action, + caseSensitive, + description, + force, + keyDescription, + title, + shiftKey, + ctrlKey, + altKey, + metaKey, + }: KeyRegistration): void { + const checkKey = caseSensitive ? key : key.toUpperCase(); + if (!force && checkKey in this.actions) { + return; + } + this.actions[checkKey] = { + title, + description, + action, + keyDescription, + shiftKey, + ctrlKey, + altKey, + metaKey, + }; + } + + public addActions(keyList: KeyRegistration[]): void { + keyList.forEach((key: KeyRegistration) => { + this.addAction(key); + }) + } + + /** + * Return list of possible actions + */ + public getActions(): { [event: string]: KeyboardAction; } { + return this.actions; + } +} + +export const initKeyboardManager = (olliInstance: OlliRuntime) => { + + const kb = new KeyboardManager(olliInstance.rootDomNode); + + kb.addActions([ + { + key: 'h', + title: 'Display help documentation modal', + action: (treeItem) => { + openHelpDialog(treeItem.tree); + }, + }, + { + key: 'Enter', + title: 'Expand and collapse the current layer of the tree', + action: (treeItem) => { + if (treeItem.isExpandable) { + if (treeItem.isExpanded()) { + treeItem.tree.collapseTreeItem(treeItem); + } else { + treeItem.tree.expandTreeItem(treeItem); + } + } + }, + }, + { + key: ' ', + keyDescription: 'Space', + title: 'Expand and collapse the current layer of the tree', + action: (treeItem) => { + if (treeItem.tree.lastFocusedTreeItem.isExpandable) { + if (treeItem.tree.lastFocusedTreeItem.isExpanded()) { + treeItem.tree.collapseTreeItem(treeItem); + } else { + treeItem.tree.expandTreeItem(treeItem); + } + } + }, + }, + { + key: 'ArrowDown', + title: 'Focus on the next layer of the tree', + action: (treeItem) => { + if (treeItem.children.length > 0 && treeItem.isExpandable) { + treeItem.tree.setFocusToNextLayer(treeItem); + } + }, + }, + { + key: 'ArrowUp', + title: 'Focus on the previous layer of the tree', + action: (treeItem) => { + if (treeItem.inGroup) { + treeItem.tree.setFocusToParentItem(treeItem); + } + }, + }, + { + key: 'Escape', + title: 'Focus on the previous layer of the tree', + action: (treeItem) => { + if (treeItem.inGroup) { + treeItem.tree.setFocusToParentItem(treeItem); + } + }, + }, + { + key: 'ArrowLeft', + title: 'Focus on the previous child element of the tree', + action: (treeItem) => { + treeItem.tree.setFocusToPreviousItem(treeItem); + }, + }, + { + key: 'ArrowRight', + title: 'Focus on the next child element of the tree', + action: (treeItem) => { + treeItem.tree.setFocusToNextItem(treeItem); + }, + }, + { + key: 'Home', + title: 'Focus top of the tree', + action: (treeItem) => { + if (treeItem.parent) { + treeItem.tree.setFocusToFirstInLayer(treeItem); + } + }, + }, + { + key: 'x', + title: 'Navigate to the x-axis of the graph', + action: (treeItem) => { + treeItem.tree.focusOnNodeType('xAxis', treeItem); + }, + }, + { + key: 'y', + title: 'Navigate to the y-axis of the graph', + action: (treeItem) => { + treeItem.tree.focusOnNodeType('yAxis', treeItem); + }, + }, + { + key: 'l', + title: 'Navigate to the legend of the graph', + action: (treeItem) => { + treeItem.tree.focusOnNodeType('legend', treeItem); + }, + }, + { + key: 't', + title: 'Open table dialog', + action: (treeItem) => { + if ('predicate' in treeItem.olliNode || treeItem.olliNode.nodeType === 'root') { + openTableDialog(treeItem.olliNode, treeItem.tree); + } + }, + }, + { + key: 'f', + title: 'Open selection dialog', + action: (treeItem) => { + openSelectionDialog(treeItem.tree); + }, + }, + ]) + + return kb; +} diff --git a/packages/core/src/Runtime/OlliRuntimeTreeItem.ts b/packages/core/src/Runtime/OlliRuntimeTreeItem.ts index eccd75be..eaeb5d87 100644 --- a/packages/core/src/Runtime/OlliRuntimeTreeItem.ts +++ b/packages/core/src/Runtime/OlliRuntimeTreeItem.ts @@ -10,6 +10,8 @@ import { openSelectionDialog, openTableDialog, openTargetedNavigationDialog } from '../Render/Dialog'; import { ElaboratedOlliNode } from '../Structure/Types'; +import { getOlliGlobalState } from '../util/globalState'; +import { KeyboardManager } from './KeyboardManager'; import { OlliRuntime } from './OlliRuntime'; /* @@ -29,6 +31,7 @@ export class OlliRuntimeTreeItem { olliNode: ElaboratedOlliNode; isExpandable: boolean; inGroup: boolean; + keyboardManager: KeyboardManager; parent?: OlliRuntimeTreeItem; children: OlliRuntimeTreeItem[]; @@ -65,7 +68,7 @@ export class OlliRuntimeTreeItem { init() { this.domNode.tabIndex = -1; - this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)); + this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)) this.domNode.addEventListener('click', this.handleClick.bind(this)); this.domNode.addEventListener('focus', this.handleFocus.bind(this)); this.domNode.addEventListener('blur', this.handleBlur.bind(this)); @@ -83,92 +86,14 @@ export class OlliRuntimeTreeItem { return false; } - /* EVENT HANDLERS */ + // /* EVENT HANDLERS */ handleKeydown(event: KeyboardEvent) { + const { keyboardManager } = getOlliGlobalState(); if (event.altKey || event.ctrlKey || event.metaKey) { return; } - this.checkBaseKeys(event); - } - - checkBaseKeys(event: KeyboardEvent) { - switch (event.key) { - case 'Enter': - case ' ': - if (this.isExpandable) { - if (this.isExpanded()) { - this.tree.collapseTreeItem(this); - } else { - this.tree.expandTreeItem(this); - } - } - break; - case 'ArrowDown': - if (this.children.length > 0 && this.isExpandable) { - this.tree.setFocusToNextLayer(this); - } - break; - case 'Escape': - case 'ArrowUp': - if (this.inGroup) { - this.tree.setFocusToParentItem(this); - } - break; - case 'ArrowLeft': - if (event.shiftKey) { - if (this.tree.isLateralPossible()) { - this.tree.setFocusToLateralItem(this, 'left'); - } - } else { - this.tree.setFocusToPreviousItem(this); - } - break; - case 'ArrowRight': - if (event.shiftKey) { - if (this.tree.isLateralPossible()) { - this.tree.setFocusToLateralItem(this, 'right'); - } - } else { - this.tree.setFocusToNextItem(this); - } - break; - case 'Home': - if (this.parent) { - this.tree.setFocusToFirstInLayer(this); - } - break; - - case 'End': - if (this.parent) { - this.tree.setFocusToLastInLayer(this); - } - break; - case 'x': - this.tree.focusOnNodeType('xAxis', this); - break; - case 'y': - this.tree.focusOnNodeType('yAxis', this); - break; - case 'l': - this.tree.focusOnNodeType('legend', this); - break; - case 't': - if ('predicate' in this.olliNode || this.olliNode.nodeType === 'root') { - openTableDialog(this.olliNode, this.tree); - } - break; - case 'f': - openSelectionDialog(this.olliNode, this.tree); - break; - case 'r': - openTargetedNavigationDialog(this.tree); - break; - default: - // return to avoid preventing default event action - return; - } - + keyboardManager.handleEvents(event, this); event.stopPropagation(); event.preventDefault(); } diff --git a/packages/core/src/util/globalState.ts b/packages/core/src/util/globalState.ts index d3178dae..c7b5e1fc 100644 --- a/packages/core/src/util/globalState.ts +++ b/packages/core/src/util/globalState.ts @@ -1,3 +1,4 @@ +import { KeyboardManager, initKeyboardManager } from '../Runtime/KeyboardManager'; import { OlliRuntime } from '../Runtime/OlliRuntime'; import { nodeIsTextInput } from './events'; @@ -5,6 +6,7 @@ export interface OlliGlobalState { keyListenerAttached: boolean; lastVisitedInstance: OlliRuntime; instancesOnPage: OlliRuntime[]; + keyboardManager: KeyboardManager; } export const getOlliGlobalState = (): OlliGlobalState => { @@ -71,6 +73,6 @@ export const updateGlobalStateOnInitialRender = (t: OlliRuntime) => { } } }); - setOlliGlobalState({ keyListenerAttached: true }); + setOlliGlobalState({ keyListenerAttached: true, keyboardManager: initKeyboardManager(t) }); } };