/*
* Copyright 1998-2025 by Northwoods Software Corporation. All Rights Reserved.
*/
/*
* This is an extension and not part of the main GoJS library.
* The source code for this is at extensionsJSM/AriaCommandHandler.ts.
* Note that the API for this class may change with any version, even point releases.
* If you intend to use an extension in production, you should copy the code to your own source directory.
* Extensions can be found in the GoJS kit under the extensions or extensionsJSM folders.
* See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
*/
/**
* This custom CommandHandler is an example of how screen reader accessibility can be added to diagrams with an `aria-live` DIV.
*
* This CommandHandler adds more key commands for a user:
* - Arrow keys: Change selection to a new node, if possible, based on direction/tree realationship/linked nodes. This is added to an internal navigation history.
* - `b`: Give a description of which nodes are adjacent/connected to the currently selected node
* - `x`: Go backwards in the navigation history
* - `c`: Go forwards in the navigation history
*
* This custom CommandHandler is meant as a starting point to create a more individualized CommandHandler for your unique use case.
* Certain data and attributes of nodes that are different between diagrams may be important to the accessibility interpretation.
* When describing a Part, this example calls {@link getPartText}, which uses the `Part.text` if it is specified, otherwise the `Part.key`.
* You'll want to modify this to suit your needs.
*
* If you want to experiment with this extension, try the Accessibility sample.
* @category Extension
*/
class AriaCommandHandler extends go.CommandHandler {
/**
* Creates and sets the Aria live region.
* Defines variables for node selection history and runs setup method.
*
* @param {string} mode the mode of the diagram, 'default', 'tree', or 'links'
*
* Default mode: Arrow keys change selection to a new node, if possible, based on direction.
* Tree mode: Arrow keys change selection to a new node, if possible, based on tree relationships.
* Links mode: Arrow keys change selection to a new node, if possible, based on linked nodes.
*/
constructor(mode = 'default', init) {
super();
this.liveRegion = document.createElement('div');
this.liveRegion.setAttribute('aria-live', 'polite');
this.history = [];
this.index = 0;
this.mode = mode; //'default' 'tree' or 'links'
if (init)
Object.assign(this, init);
}
/**
* Given a Part, return the text that the aria label should read
* By default this uses `Part.text`, unless it is empty, then it uses `Part.key`
* This can be overridden to return arbitrary text based on Part data, etc.
*/
getPartText(part) {
if (!(part instanceof go.Part))
return 'undefined';
if (part.text)
return part.text;
if (part.key)
return part.key.toString();
return 'undefined';
}
/**
* This implements custom behaviors for keyboard events.
* This effects behavior when user types arrow keys, 'b', 'x', and 'c'.
* @this {AriaCommandHandler}
*/
doKeyDown() {
if (this.diagram === null)
return;
const e = this.diagram.lastInput;
const commandKey = e.commandKey;
if (commandKey === 'ArrowUp' ||
commandKey === 'ArrowDown' ||
commandKey === 'ArrowLeft' ||
commandKey === 'ArrowRight') {
if (this.mode === 'tree') {
this._arrowKeySelectTree();
}
else if (this.mode === 'links') {
this._arrowKeySelectLinks();
}
else {
this._arrowKeySelect(commandKey);
}
}
else if (commandKey === 'x') {
this._goBack();
}
else if (commandKey === 'c') {
this._goForward();
}
else if (commandKey === 'KeyB') {
if (this.mode === 'tree') {
this.callFamilyTreeNodes();
}
else if (this.mode === 'links') {
this.callLinkedNodes();
}
else {
this.callSurroundingNodes();
}
}
else {
// otherwise do any standard command
super.doKeyDown();
}
}
/**
* Clears the text on the aria region and sets it to the passed message.
* @param {string} message the string to read
*/
callAria(message) {
this.liveRegion.textContent = '';
this.liveRegion.textContent = message;
}
/**
* @hidden @internal
* @param {number} a angle
* @param {number} dir direction to compare the angle to
* @return {number} returns the difference between the two angles
* @static
*/
static _angleCloseness(a, dir) {
return Math.min(Math.abs(dir - a), Math.min(Math.abs(dir + 360 - a), Math.abs(dir - 360 - a)));
}
/**
* @hidden @internal
* Looks for the closest node to the selection in the given direction and returns it.
* Returns null if there are no nodes found in the direction.
* @param {number} dir direction angle to look for the closest node
* @return {go.Node | null}
*/
_findClosestNode(dir) {
const originalPart = this.diagram.selection.first();
if (originalPart === null)
return null;
const originalPoint = originalPart.actualBounds.center;
const allParts = this._getAllParts();
let closestDistance = Infinity;
let closest = originalPart; // if no parts meet the criteria, the same part remains selected
for (let i = 0; i < allParts.length; i++) {
const nextPart = allParts[i];
if (nextPart === originalPart)
continue; // skips over currently selected part
if (!nextPart.canSelect())
continue;
const nextPoint = nextPart.actualBounds.center;
const angle = originalPoint.directionPoint(nextPoint);
const anglediff = AriaCommandHandler._angleCloseness(angle, dir);
if (anglediff <= 45) {
// if this part's center is within the desired direction's sector,
let distance = originalPoint.distanceSquaredPoint(nextPoint);
distance *= 1 + Math.sin((anglediff * Math.PI) / 180); // the more different from the intended angle, the further it is
if (distance < closestDistance) {
// and if it's closer than any other part,
closestDistance = distance; // remember it as a better choice
closest = nextPart;
}
}
}
if (closest === originalPart) {
return null;
}
return closest;
}
/**
* @hidden @internal
* For default layouts.
* Returns an array of all nodes and parts in the given diagram, but not any links.
* @return {object[]}
*/
_getAllParts() {
const diagram = this.diagram;
if (!diagram)
return [];
const allParts = [];
diagram.nodes.each((node) => {
if (node.isVisible())
allParts.push(node);
});
diagram.parts.each((part) => {
if (part.isVisible())
allParts.push(part);
});
// note that this ignores Links
return allParts;
}
/**
* @hidden @internal
* For default layouts.
* Checks for closest node in the direction of the hit arrow key and selects the node
* Adds the node to the node movement history
* If there is no node is checked direction completes an aria call letting the user know
*/
_arrowKeySelect(ekey) {
let node = this.diagram.selection.first();
//if no node is currently selected it selects one and clears node history
if (node === null) {
node = this.diagram.nodes.first();
if (node instanceof go.Node) {
this.diagram.select(node);
this.history = [];
this.index = this.history.push(node) - 1;
}
return;
}
//with a part selected, arrow keys change the selection
let nextPart = null; // string | Part
const right = this._findClosestNode(0);
const down = this._findClosestNode(90);
const left = this._findClosestNode(180);
const up = this._findClosestNode(270);
if (ekey === 'ArrowUp')
nextPart = up != null ? up : 'No node above';
else if (ekey === 'ArrowDown')
nextPart = down != null ? down : 'No node below';
else if (ekey === 'ArrowLeft')
nextPart = left != null ? left : 'No node to the left';
else if (ekey === 'ArrowRight')
nextPart = right != null ? right : 'No node to the right';
if (!(nextPart instanceof go.Node))
return;
//nextPart is a string it means that there wasn't a node in the direction so the string is called
if (typeof nextPart === 'string') {
this.callAria(nextPart);
}
else {
//removes any nodes in the history ahead of the current index in the history then adds the selected node
this.history = this.history.slice(0, this.index + 1);
this.diagram.select(nextPart);
this.index = this.history.push(nextPart) - 1;
this.callAria(this.getPartText(nextPart));
}
}
/**
* @hidden @internal
* Checks if the currently selected node is the furthest part of the history
* Selects the previous node in the history array
*/
_goBack() {
if (this.index === 0)
return;
this.diagram.select(this.history[this.index - 1]);
this.index--;
}
/**
* @hidden @internal
* Checks if the currently selected node is the closest part of the history
* Selects the next node in the history array
*/
_goForward() {
if (this.history[this.index + 1] === undefined)
return;
this.diagram.select(this.history[this.index + 1]);
this.index++;
}
/**
* For tree layouts.
* Checks for parent, sibling, and child nodes and build a message to be called based on
* if there is and which nodes are around the currently selected one
*/
callFamilyTreeNodes() {
let message = '';
const node = this.diagram.selection.first();
if (node === null || !(node instanceof go.Node)) {
this.callAria('No node selected');
return;
}
const parent = node.findTreeParentNode();
const children = node.findTreeChildrenNodes();
const nextSiblings = this._getNextSiblingNodes(node);
const previousSiblings = this._getPreviousSiblingNodes(node);
message += (parent != null ? this.getPartText(parent) + ' is the parent. ' : 'No parent node.');
children.each(child => message += this.getPartText(child) + ' is a child. ');
if (nextSiblings !== null)
nextSiblings.forEach(sibling => message += this.getPartText(sibling) + ' is a next sibling. ');
if (previousSiblings !== null)
previousSiblings.forEach(sibling => message += this.getPartText(sibling) + ' is a previous sibling. ');
this.callAria(message);
}
/**
* Checks for a node in each direction and build a message to be called based on
* if there is and which nodes are around the currently selected one
*/
callSurroundingNodes() {
let message = '';
const right = this._findClosestNode(0);
const down = this._findClosestNode(90);
const left = this._findClosestNode(180);
const up = this._findClosestNode(270);
message += (right != null ? right.data.key : 'No node') + ' to the right. ';
message += (down != null ? down.data.key : 'No node') + ' below. ';
message += (left != null ? left.data.key : 'No node') + ' to the left. ';
message += (up != null ? up.data.key : 'No node') + ' above.';
this.callAria(message);
}
/**
* @hidden @internal
* For tree layouts.
* Checks for and returns the next sibling nodes of the currently selected node.
* All nodes next from the currently selected node will be in the array and first element is the next node.
* @param {go.Node}
* @return {null || Array} returns null if there is no next sibling node
*/
_getNextSiblingNodes(node) {
if (!(node instanceof go.Node))
return null;
if (node.findTreeParentNode() === null)
return null;
let nodes = [];
node.findTreeParentNode().findTreeChildrenNodes().each(n => nodes.push(n));
nodes = nodes.slice(nodes.indexOf(node) + 1);
return (nodes.length > 0) ? nodes : null;
}
/**
* @hidden @internal
* For tree layouts.
* Checks for and returns the previous sibling nodes of the currently selected node.
* All nodes previous of the currently selected node will be in the array and first element is the previous node.
* @param {go.Node} node
* @return {null || Array} returns null if there is no previous sibling node
*/
_getPreviousSiblingNodes(node) {
if (!(node instanceof go.Node))
return null;
if (node.findTreeParentNode() === null)
return null;
let nodes = [];
node.findTreeParentNode().findTreeChildrenNodes().each(n => nodes.push(n));
nodes = nodes.slice(0, nodes.indexOf(node)).reverse();
return (nodes.length > 0) ? nodes : null;
}
/**
* @hidden @internal
* For tree layouts.
* Checks for closest node in the direction of the hit arrow key and selects the node
* Adds the node to the node movement history
* If there is no node is checked direction does an aria call letting the user know
*/
_arrowKeySelectTree() {
let _a = null;
let _c = null;
let node = this.diagram.selection.first();
const e = this.diagram.lastInput;
if (node === null) {
const first = this.diagram.nodes.first();
if (first === null)
return;
node = first.findTreeRoot();
if (!(node instanceof go.Node))
return;
this.diagram.select(node);
this.history = [];
this.index = this.history.push(node) - 1;
this.callAria('No node selected, selecting root node');
return;
}
if (!(node instanceof go.Node))
return;
let nextPart = null;
if (e.code === 'ArrowUp') {
nextPart = (_a = node.findTreeParentNode()) ? _a : 'No parent node';
}
else if (e.code === 'ArrowDown') {
nextPart = (_a = node.findTreeChildrenNodes().first()) ? _a : 'No child node';
}
else if (e.code === 'ArrowLeft') {
nextPart = (_c = this._getPreviousSiblingNodes(node)) ? _c[0] : 'No previous sibling node';
}
else if (e.code === 'ArrowRight') {
nextPart = (_c = this._getNextSiblingNodes(node)) ? _c[0] : 'No next sibling node';
}
if (nextPart === null)
return;
if (typeof nextPart === 'string')
this.callAria(nextPart);
else {
this.history = this.history.slice(0, this.index + 1);
this.diagram.select(nextPart);
this.index = this.history.push(nextPart) - 1;
this.callAria(this.getPartText(nextPart));
}
}
/**
* @hidden @internal
* For links layouts.
* Checks if selected node is recorded node and if so selects the first linked node
* If already selecting a linked node, gets array of linked nodes and selects the next one
* If there is no next linked node does an aria call letting the user know
*/
_linkSelectionForward() {
const linkedNodes = [];
const node = this.history[this.index];
const selectedNode = this.diagram.selection.first();
if (!(selectedNode instanceof go.Node && node instanceof go.Node))
return;
node.findNodesConnected().each(x => linkedNodes.push(x));
if (linkedNodes.length === 0)
return null;
if (node === selectedNode) {
return linkedNodes[0];
}
else {
const index = linkedNodes.indexOf(selectedNode);
if (index === linkedNodes.length - 1)
return linkedNodes[0];
else
return linkedNodes[index + 1];
}
}
/**
* @hidden @internal
* For links layouts.
* Checks if selected node is recorded node and if so selects the first linked node
* If already selecting a linked node, gets array of linked nodes and selects the previous one
* If there is no previous linked node does an aria call letting the user know
*/
_linkSelectionBack() {
const linkedNodes = [];
const node = this.history[this.index];
const selectedNode = this.diagram.selection.first();
if (!(selectedNode instanceof go.Node))
return;
node.findNodesConnected().each(x => linkedNodes.push(x));
if (linkedNodes.length === 0)
return null;
if (node === selectedNode) {
return linkedNodes[0];
}
else {
const index = linkedNodes.indexOf(selectedNode);
if (index === 0)
return linkedNodes[linkedNodes.length - 1];
else
return linkedNodes[index - 1];
}
}
/**
* For links layouts.
* Checks for linked nodes and build a message to be called based on
* if there is and which nodes are around the currently selected one
*/
callLinkedNodes() {
let message = '';
const node = this.diagram.selection.first();
if (!(node instanceof go.Node)) {
this.callAria('No node selected');
return;
}
if (node.findNodesConnected().count === 0) {
this.callAria('No linked nodes');
return;
}
message += 'Linked to node ' + this.getPartText(node) + ' are: ';
node.findNodesConnected().each(n => message += this.getPartText(n) + ', ');
this.callAria(message);
}
/**
* @hidden @internal
* For links layouts.
* Checks for closest node in the direction of the hit arrow key and selects the node
* Adds the node to the node movement history
* If there is no node is checked direction does an aria call letting the user know
*/
_arrowKeySelectLinks() {
const node = this.diagram.selection.first();
const e = this.diagram.lastInput;
if (!(node instanceof go.Node) || this.history.length === 0) {
const first = this.diagram.nodes.first();
if (!(first instanceof go.Node))
return;
this.diagram.select(first);
this.history = [];
this.index = this.history.push(first) - 1;
this.callAria('Selecting root node');
return;
}
let nextPart = null;
if (e.code === 'ArrowUp') {
if (this.history[this.index] === node)
return;
this.history = this.history.slice(0, this.index + 1);
this.index = this.history.push(node) - 1;
}
else if (e.code === 'ArrowDown') {
this.diagram.select(this.history[this.index]);
}
else if (e.code === 'ArrowLeft') {
nextPart = this._linkSelectionBack();
}
else if (e.code === 'ArrowRight') {
nextPart = this._linkSelectionForward();
}
if (typeof nextPart === 'string')
this.callAria(nextPart);
if (!(nextPart instanceof go.Node))
return;
else {
if (!(this.mode === 'links')) {
this.history = this.history.slice(0, this.index + 1);
this.index = this.history.push(nextPart) - 1;
}
this.diagram.select(nextPart);
this.callAria(this.getPartText(nextPart));
}
}
}