/// /// Copyright 2015-2026 Micro Focus or one of its affiliates. /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. /// You may obtain a copy of the License at /// /// http://www.apache.org/licenses/LICENSE-2.0 /// /// Unless required by applicable law or agreed to in writing, software /// distributed under the License is distributed on an "AS IS" BASIS, /// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. /// See the License for the specific language governing permissions and /// limitations under the License. /// import { DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW } from '@angular/cdk/keycodes'; import { Injectable, OnDestroy } from '@angular/core'; import { BehaviorSubject, Subject } from 'rxjs'; import { SankeyNodeLink } from './interfaces/node-link.interface'; @Injectable() export class SankeyFocusManager implements OnDestroy { /** Store the node that can currently be tabbed to */ active$ = new BehaviorSubject>(null); /** Emit whenever an item should receive focus */ focused$ = new Subject>(); /** Store the nodes */ private _nodes: ReadonlyArray> = []; /** Get the current active item */ private get _active(): SankeyNodeLink { return this.active$.value; } ngOnDestroy(): void { this.active$.complete(); this.focused$.complete(); } /** Update the list of possible nodes */ setNodes(nodes: ReadonlyArray>): void { this._nodes = nodes; // check if there is currently a tabbable node, if not we should make the first node tabbable if (!this.hasActiveNode()) { this.setActiveItem(this._nodes[0]); } } /** Set the current active item */ setActiveItem(node: SankeyNodeLink): void { this.active$.next(node); } /** Handle keyboard input from nodes */ onKeydown(event: KeyboardEvent): void { switch (event.which) { case UP_ARROW: this.shiftFocusVertically(-1); event.preventDefault(); break; case DOWN_ARROW: this.shiftFocusVertically(1); event.preventDefault(); break; case LEFT_ARROW: this.shiftFocusHorizontally(-1); event.preventDefault(); break; case RIGHT_ARROW: this.shiftFocusHorizontally(1); event.preventDefault(); break; } } private setFocusedItem(item: SankeyNodeLink): void { this.setActiveItem(item); this.focused$.next(item); } private shiftFocusVertically(delta: number): void { const nodes = this.getNodesInColumn(this._active.column); // get the node below or above the active node const target = nodes[nodes.findIndex(node => node.node.id === this._active.node.id) + delta]; if (target) { this.setFocusedItem(target); } } /** Shift the focus to a node in a sibling column */ private shiftFocusHorizontally(delta: number): void { // get nodes in the sibling column in the desired direction const nodes = this.getNodesInColumn(this._active.column + delta); // if there are no nodes then do nothing as we cannot reduce an empty array if (nodes.length === 0) { return; } // get the node with the most similar y position const target = nodes.reduce((closest, node) => { const closestDiff = Math.max(closest.y, this._active.y) - Math.min(closest.y, this._active.y); const currentDiff = Math.max(node.y, this._active.y) - Math.min(node.y, this._active.y); return closestDiff < currentDiff ? closest : node; }); if (target) { this.setFocusedItem(target); } } /** Get a list of nodes that are in a given column */ private getNodesInColumn(column: number): ReadonlyArray> { return this.getNodesInOrder(this._nodes.filter(node => node.column === column)); } /** Sort the nodes based on the Y position */ private getNodesInOrder( nodes: ReadonlyArray> ): ReadonlyArray> { return [...nodes].sort((nodeOne, nodeTwo) => nodeOne.y - nodeTwo.y); } /** Determine whether or not there is a not that is tabbable */ private hasActiveNode(): boolean { return ( !!this.active$.value && !!this._nodes.find(node => node.node.id === this.active$.value.node.id) ); } }