@progress/kendo-angular-gantt
Version:
Kendo UI Angular Gantt
272 lines (271 loc) • 12.9 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
import { Injectable, NgZone, Renderer2 } from '@angular/core';
import { Subject } from 'rxjs';
import { ScrollSyncService } from '../scrolling/scroll-sync.service';
import { fitToRange, getClosestTaskIndex, isClearButton, isPresent, isTask } from '../utils';
import * as i0 from "@angular/core";
import * as i1 from "../scrolling/scroll-sync.service";
/**
* @hidden
*/
export class NavigationService {
zone;
renderer;
scrollSyncService;
/**
* Notifies when the tasks' focused and interactive (tabindex) state has changed.
*
* All tasks are rendered with tabindex="-1".
* When one is clicked, or when some navigation key keyboard key is pressed, it should be focused, assigned the focus class, and its tabindex updated to 0.
* All other tasks should get -1 tabindex and have the focus class removed from them.
*/
taskStatusChanges = new Subject();
/**
* Specifies whether navigation is enabled.
*/
get enabled() {
return this._enabled;
}
/**
* Used to retrieve read-only data about the currently active task.
*/
get activeTask() {
return {
activeIndex: this.activeTimelineIndex,
isFocused: this.isTimelineFocused
};
}
/**
* Persists the expected Timeline focused task index.
* When the cells in the TreeList are navigated through, the expected Timeline focus target should also change,
* in order to allow tabbing from the TreeList to the same row in the Timeline.
*/
set activeTimelineIndex(index) {
this._activeTimelineIndex = index;
}
get activeTimelineIndex() {
const firstAvailableIndex = 0;
const lastAvailableIndex = this.metadata.treeList.view.data.length - 1;
return fitToRange(this._activeTimelineIndex, firstAvailableIndex, lastAvailableIndex);
}
/**
* Persists the expected TreeList focused cell coords.
* When the tasks in the Timeline are navigated through, the expected TreeList focus target should also change,
* in order to allow back-tabbing from the Timeline to the same row in the TreeList.
*/
set activeTreeListCell(cell) {
this._activeTreeListCell = cell;
}
get activeTreeListCell() {
const firstAvailableIndex = 0;
const lastAvailableRowIndex = this.treeListHeaderRowsCount + this.metadata.treeList.view.data.length - 1;
const rowIndex = fitToRange(this._activeTreeListCell.rowIndex, firstAvailableIndex, lastAvailableRowIndex);
const lastAvailableColIndex = this.metadata.columns.length;
const colIndex = fitToRange(this._activeTreeListCell.colIndex, firstAvailableIndex, lastAvailableColIndex);
return { rowIndex, colIndex };
}
/**
* Keeps track of whether the Timeline part is focused.
* Used when the index of the task elements change (tasks are changed, pushed to, spliced from, etc.)
* and their status should be updated accordingly.
*/
isTimelineFocused = false;
/**
* The TreeList row index takes into account the header and filter rows.
* Used when translating Timeline task indices to TreeList row indices.
*/
get treeListHeaderRowsCount() {
// captures nested group header rows + filter row if we start supporting it at some point
return this.metadata.treeListElement.querySelectorAll('.k-grid-header tr').length;
}
/**
* Keeps track of which part has last been focused.
* Used when calling `gantt.focus()` to determine which part of the component should receive focus.
*/
treeListLastActive = false;
/**
* Keeps track of which part has last been focused.
* Used when calling `gantt.focus()` to determine which part of the component should receive focus.
*/
timelineLastActive = false;
metadata;
_enabled = false;
_activeTimelineIndex = 0;
_activeTreeListCell = { rowIndex: 0, colIndex: 0 };
eventListenerDisposers;
constructor(zone, renderer, scrollSyncService) {
this.zone = zone;
this.renderer = renderer;
this.scrollSyncService = scrollSyncService;
}
initialize(metadata) {
// no private property setters in TypeScript, so use a getter and a poorly named private prop for this value
this._enabled = true;
this.metadata = metadata;
// TODO: fix in the splitter package and remove
// move the splitbar HTML element between the two panes to keep the visial tabbing order in tact
const splitbar = this.metadata.host.querySelector('.k-splitbar');
if (isPresent(splitbar) && isPresent(splitbar.previousElementSibling) && isPresent(splitbar.after)) {
splitbar.after(splitbar.previousElementSibling);
}
this.zone.runOutsideAngular(() => {
this.eventListenerDisposers = [
this.renderer.listen(this.metadata.treeListElement, 'mousedown', this.focusTreeList.bind(this)),
this.renderer.listen(this.metadata.treeListElement, 'focusin', this.handleTreeListFocusIn.bind(this)),
this.renderer.listen(this.metadata.timelineElement, 'mousedown', this.handleTimelineMousedown.bind(this)),
this.renderer.listen(this.metadata.timelineElement, 'focusin', this.handleTimelineFocusIn.bind(this)),
this.renderer.listen(this.metadata.timelineElement, 'focusout', this.handleTimelineFocusOut.bind(this))
];
});
}
ngOnDestroy() {
if (isPresent(this.eventListenerDisposers)) {
this.eventListenerDisposers.forEach(removeListener => removeListener());
this.eventListenerDisposers = null;
}
this.metadata = null;
}
/**
* Focuses either the last active TreeList cell, or the last active Timeline task,
* dependening on which of the two last held focus.
*
* Focuses the first TreeList cell by default.
*/
focusLastActiveItem() {
if (this.metadata.data.length === 0 || (!this.treeListLastActive && !this.timelineLastActive)) {
this.focusCell(0, 0);
}
else if (this.treeListLastActive) {
const { rowIndex, colIndex } = this.activeTreeListCell;
this.metadata.treeList.focusCell(rowIndex, colIndex);
}
else if (this.timelineLastActive) {
this.focusTask(this.activeTimelineIndex);
}
}
/**
* Focuses the targeted TreeList cell regardless of the last peresisted target.
*/
focusCell(rowIndex, colIndex) {
this.activeTreeListCell = { rowIndex, colIndex };
this.activeTimelineIndex = rowIndex - this.treeListHeaderRowsCount;
this.metadata.treeList.focusCell(this.activeTreeListCell.rowIndex, this.activeTreeListCell.colIndex);
}
/**
* Focuses the targeted Timeline task regardless of the last peresisted target.
*/
focusTask(index) {
this.activeTimelineIndex = index;
this.isTimelineFocused = true;
this.activeTreeListCell = {
rowIndex: index + this.treeListHeaderRowsCount,
colIndex: this.activeTreeListCell.colIndex
};
this.notifyTaskStatusChange();
}
/**
* Updates the focus target flags and notifies the active task to update its focused state.
*/
handleTimelineFocusIn({ target }) {
this.treeListLastActive = false;
this.timelineLastActive = true;
this.isTimelineFocused = true;
if (isTask(target, this.metadata.timelineElement)) {
this.notifyTaskStatusChange();
}
}
/**
* Updates the timeline focus state flag and notifies the active task to update its focused state.
*/
handleTimelineFocusOut({ relatedTarget }) {
this.isTimelineFocused = this.metadata.timelineElement.contains(relatedTarget);
// update the task element only if the new focus target is not in the Timeline - focus change between tasks is handled in the focusin handler
if (!isTask(relatedTarget, this.metadata.timelineElement)) {
this.notifyTaskStatusChange();
}
}
/**
* Updates the focus target flags and corrects the TreeList focus target if needed.
* As the TreeList will keep its last focused cell with tabindex="0",
* this methods forcefully focuses the correct cell,
* when navigating in the Timeline has updated the expected TreeList focus target.
*/
handleTreeListFocusIn(event) {
this.treeListLastActive = true;
this.timelineLastActive = false;
// if the previous focus target was in the TreeList, rely on its component navigation and just record the focused item index
if (this.metadata.treeListElement.contains(event.relatedTarget)) {
const { colIndex, rowIndex } = this.metadata.treeList.activeCell;
this.activeTreeListCell = { colIndex, rowIndex };
}
else {
// if the previous focus target was outside the TreeList, ensure the expected focus coords are used
const { rowIndex, colIndex } = this.activeTreeListCell;
this.metadata.treeList.focusCell(rowIndex, colIndex); // activates the target cell even if it has tabindex="-1"
}
this.activeTimelineIndex = this.metadata.treeList.activeCell.dataRowIndex;
this.notifyTaskStatusChange();
if (this.metadata.treeList.activeCell.dataRowIndex >= 0) {
this.scrollHorizontallyToTask();
this.scrollSyncService.syncScrollTop('treelist', 'timeline');
}
}
updateActiveTimeLineIndex(index) {
this.activeTimelineIndex = index;
}
updateActiveTreeListCell() {
this.activeTreeListCell = {
rowIndex: this.activeTimelineIndex + this.treeListHeaderRowsCount,
colIndex: this.activeTreeListCell.colIndex
};
}
/**
* Fires the `taskStatusChanges` event with active and focused status retrieved from
* `this.activeTimelineIndex` and `this.isTimelineFocused`.
*/
notifyTaskStatusChange() {
this.taskStatusChanges.next(this.activeTask);
}
/**
* Scrolls horizontally to the beginning of the target task if the beginning of its content is not in the viewport.
*/
scrollHorizontallyToTask() {
const index = this.activeTimelineIndex;
const task = this.metadata.timelineElement.querySelectorAll('.k-task-wrap').item(index);
if (!isPresent(task)) {
return;
}
// scroll horizontally to the item if less than 200px from the beginning of its content are visible
const targetVisibleWidth = 200;
const isScrollBeforeTask = (this.metadata.timelineElement.clientWidth + this.metadata.timelineElement.scrollLeft) < (task.offsetLeft + targetVisibleWidth);
const isScrollAfterTask = this.metadata.timelineElement.scrollLeft > task.offsetLeft;
if (isScrollBeforeTask || isScrollAfterTask) {
this.metadata.timelineElement.scrollLeft = task.offsetLeft;
}
}
/**
* Filters for task mousedown in the Timeline.
*/
handleTimelineMousedown({ target }) {
if (isTask(target, this.metadata.host) && !isClearButton(target, this.metadata.host)) {
const taskIndex = getClosestTaskIndex(target, this.metadata.host);
this.focusTask(taskIndex);
}
}
/**
* Focus the TreeList on TreeList mousedown.
* A nasty hack to trick `handleTreeListFocusIn` into regarding the previous focus target as again the TreeList.
* Otherwise cell clicks are wrongly overwritten in `handleTreeListFocusIn` and the click focus target is not respected.
*/
focusTreeList() {
this.metadata.treeList.focus();
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NavigationService, deps: [{ token: i0.NgZone }, { token: i0.Renderer2 }, { token: i1.ScrollSyncService }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NavigationService });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NavigationService, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: i0.NgZone }, { type: i0.Renderer2 }, { type: i1.ScrollSyncService }] });