UNPKG

@progress/kendo-angular-treelist

Version:

Kendo UI TreeList for Angular - Display hierarchical data in an Angular tree grid view that supports sorting, filtering, paging, and much more.

645 lines (644 loc) 26.8 kB
/**----------------------------------------------------------------------------------------- * Copyright © 2025 Progress Software Corporation. All rights reserved. * Licensed under commercial license. See LICENSE.md in the project root for more information *-------------------------------------------------------------------------------------------*/ import { ChangeDetectorRef, EventEmitter, Injectable, NgZone, Optional } from '@angular/core'; import { isDocumentAvailable, Keys, normalizeNumpadKeys } from '@progress/kendo-angular-common'; import { LocalizationService } from '@progress/kendo-angular-l10n'; import { from, interval, Subscription } from 'rxjs'; import { filter, map, switchMap, switchMapTo, take, takeUntil } from 'rxjs/operators'; import { FocusRoot } from './focus-root'; import { FocusableDirective } from './focusable.directive'; import { NavigationCursor } from './navigation-cursor'; import { NavigationModel } from './navigation-model'; import { TreeListFocusableElement } from './treelist-focusable-element'; import { DomEventsService } from '../common/dom-events.service'; import { CellCloseEvent } from '../editing/cell-close-event'; import { EditService } from '../editing/edit.service'; import { ExpandStateService } from '../expand-state/expand-state.service'; import { closest, contains, findFocusableChild, isVisible, matchesNodeName } from '../rendering/common/dom-queries'; import { ScrollRequestService } from '../scrolling/scroll-request.service'; import { SelectionService } from '../selection/selection.service'; import { PagerContextService } from '@progress/kendo-angular-pager'; import * as i0 from "@angular/core"; import * as i1 from "../common/dom-events.service"; import * as i2 from "@progress/kendo-angular-pager"; import * as i3 from "../scrolling/scroll-request.service"; import * as i4 from "./focus-root"; import * as i5 from "../editing/edit.service"; import * as i6 from "@progress/kendo-angular-l10n"; import * as i7 from "../expand-state/expand-state.service"; import * as i8 from "../selection/selection.service"; import * as i9 from "./focusable.directive"; const isInSameTreeList = (element, treelistElement) => closest(element, matchesNodeName('kendo-treelist')) === treelistElement; const matchHeaderCell = matchesNodeName('th'); const matchDataCell = matchesNodeName('td'); const matchCell = (element) => matchDataCell(element) || matchHeaderCell(element); const treelistCell = (element, treelistElement) => { let target = closest(element, matchCell); while (target && !isInSameTreeList(target, treelistElement)) { target = closest(target.parentElement, matchCell); } return target; }; const targetCell = (target, treelistElement) => { const cell = treelistCell(target, treelistElement); const row = closest(cell, matchesNodeName('tr')); if (cell && row) { let rowIndex = row.getAttribute('aria-rowindex') || row.getAttribute('data-kendo-treelist-row-index'); rowIndex = rowIndex ? parseInt(rowIndex, 10) - 1 : null; let colIndex = cell.getAttribute('aria-colindex'); colIndex = colIndex ? parseInt(colIndex, 10) - 1 : null; if (rowIndex !== null && colIndex !== null) { return { colIndex, rowIndex, element: cell }; } } }; const isArrowKey = keyCode => keyCode === Keys.ArrowLeft || keyCode === Keys.ArrowRight || keyCode === Keys.ArrowUp || keyCode === Keys.ArrowDown; const isNavigationKey = keyCode => isArrowKey(keyCode) || keyCode === Keys.PageUp || keyCode === Keys.PageDown || keyCode === Keys.Home || keyCode === Keys.End; const isInput = matchesNodeName('input'); const isTextInput = element => element && isInput(element) && element.type.toLowerCase() === 'text'; const isPrintableCharacter = (str) => str.length === 1 && str.match(/\S/); /** * @hidden */ export class NavigationViewport { firstItemIndex; lastItemIndex; constructor(firstItemIndex, lastItemIndex) { this.firstItemIndex = firstItemIndex; this.lastItemIndex = lastItemIndex; } containsRow(dataRowIndex) { const headerRow = dataRowIndex < 0; return headerRow || (dataRowIndex >= this.firstItemIndex && dataRowIndex <= this.lastItemIndex); } intersects(start, end) { return (start <= this.firstItemIndex && this.lastItemIndex <= end) || (this.firstItemIndex <= start && start <= this.lastItemIndex) || (this.firstItemIndex <= end && end <= this.lastItemIndex); } } /** * @hidden */ export class NavigationService { zone; domEvents; pagerContextService; scrollRequestService; focusRoot; editService; localization; expandState; selectionService; changeDetector; focusableParent; changes; cellKeydown = new EventEmitter(); set metadata(value) { this.meta = value; this.cursor.metadata = value; } get metadata() { return this.meta; } get enabled() { return this.alive; } get activeCell() { if (this.mode !== 0 /* NavigationMode.Standby */) { return this.cursor.cell; } } get activeRow() { if (this.mode !== 0 /* NavigationMode.Standby */) { return Object.assign({}, this.cursor.row, { cells: this.cursor.row.cells.toArray() }); } } viewport; columnViewport; activeRowIndex = 0; alive = false; active = true; mode = 0 /* NavigationMode.Standby */; model = new NavigationModel(); cursor = new NavigationCursor(this.model); meta; subs; pendingRowIndex; virtualCell; get activeDataRow() { return Math.max(0, this.activeRowIndex - this.meta.headerRows); } constructor(zone, domEvents, pagerContextService, scrollRequestService, focusRoot, editService, localization, expandState, selectionService, changeDetector, focusableParent) { this.zone = zone; this.domEvents = domEvents; this.pagerContextService = pagerContextService; this.scrollRequestService = scrollRequestService; this.focusRoot = focusRoot; this.editService = editService; this.localization = localization; this.expandState = expandState; this.selectionService = selectionService; this.changeDetector = changeDetector; this.focusableParent = focusableParent; this.changes = this.cursor.changes; } init(meta) { this.alive = true; this.focusRoot.active = true; this.metadata = meta; const onStableSubscriber = (...operators) => (args) => this.zone.isStable ? from([true]).pipe(map(() => args)) : this.zone.onStable.pipe(take(1), map(() => args), ...operators); const onStable = onStableSubscriber(); this.subs = new Subscription(); this.subs.add(this.cursor.changes .subscribe(args => this.onCursorChanges(args))); this.subs.add(this.domEvents.focus.pipe(switchMap(onStable)) .subscribe((args) => this.navigateTo(args.target))); this.subs.add(this.domEvents.focusOut.pipe(filter(() => this.mode !== 0 /* NavigationMode.Standby */), switchMap(onStableSubscriber(takeUntil(this.domEvents.focus)))) .subscribe(args => this.onFocusOut(args))); this.subs.add(this.domEvents.windowBlur.pipe(filter(() => this.mode !== 0 /* NavigationMode.Standby */)) .subscribe(() => this.onWindowBlur())); this.subs.add( // Closing the editor will not always trigger focusout in Firefox. // To get around this, we ensure that the cell is closed after editing. this.editService.changes.pipe(filter((e) => { if (e instanceof CellCloseEvent) { return !e.isDefaultPrevented(); } return e.action !== 'edit' && this.mode === 2 /* NavigationMode.Content */; }), switchMap(onStable)) .subscribe(() => this.leaveCell())); this.subs.add(this.pagerContextService.pageChange .subscribe(() => this.cursor.reset(0, 0))); this.subs.add(this.domEvents.keydown .subscribe(args => this.onKeydown(args))); this.subs.add(this.domEvents.keydown.pipe(filter(args => args.code === Keys.Tab && this.mode === 2 /* NavigationMode.Content */), switchMapTo(this.domEvents.focusOut.pipe(takeUntil( // Timeout if focusOut doesn't fire very soon interval(0).pipe(take(1)))))) .subscribe(() => this.onTabout())); if (this.focusableParent) { const element = new TreeListFocusableElement(this); this.focusableParent.registerElement(element); } this.deactivateElements(); } ngOnDestroy() { if (this.subs) { this.subs.unsubscribe(); } this.alive = false; } registerCell(cell) { if (cell.logicalRowIndex !== this.pendingRowIndex) { const modelCell = this.model.registerCell(cell); if (this.virtualCell && this.cursor.activateVirtualCell(modelCell)) { this.virtualCell = false; } } } registerCellOnCurrentRow(cell) { if (cell.logicalRowIndex === this.pendingRowIndex) { this.model.registerCell(cell); } } unregisterCell(index, rowIndex, cell) { this.model.unregisterCell(index, rowIndex, cell); } registerRow(row) { this.model.registerRow(row); this.pendingRowIndex = row.logicalRowIndex; } updateRow(row) { this.model.updateRow(row); } unregisterRow(index, row) { this.model.unregisterRow(index, row); } isCellFocusable(cell) { return this.alive && this.active && this.mode !== 2 /* NavigationMode.Content */ && this.cursor.isActive(cell.logicalRowIndex, cell.logicalColIndex); } isCellFocused(cell) { return this.mode === 1 /* NavigationMode.Cursor */ && this.isCellFocusable(cell); } navigateTo(el) { if (!this.alive || !isDocumentAvailable()) { return; } const cell = targetCell(el, this.meta.treelistElement.nativeElement); if (!cell) { return; } const oldMode = this.mode; const focusInCell = contains(cell.element, document.activeElement); const focusInActiveRowContent = this.mode === 2 /* NavigationMode.Content */ && this.activeRowIndex === cell.rowIndex && el !== cell.element; if (focusInCell) { this.mode = 2 /* NavigationMode.Content */; this.cursor.reset(cell.rowIndex, cell.colIndex); this.activateRow(); } else if (!focusInActiveRowContent) { this.mode = 1 /* NavigationMode.Cursor */; this.deactivateElements(); const alreadyActive = this.cursor.isActive(cell.rowIndex, cell.colIndex); const isCursor = oldMode === 1 /* NavigationMode.Cursor */ && alreadyActive; if (!isCursor) { this.cursor.reset(cell.rowIndex, cell.colIndex); } } } tryFocus(el) { this.activateElements(); const focusable = findFocusableChild(el); if (focusable) { const cell = targetCell(focusable, this.meta.treelistElement.nativeElement); if (cell) { this.cursor.reset(cell.rowIndex, cell.colIndex); this.deactivateElements(); this.enterCell(); } focusable.focus(); } else { this.deactivateElements(); } return !!focusable; } needsViewport() { return this.meta && this.meta.isVirtual; } setViewport(firstItemIndex, lastItemIndex) { this.viewport = new NavigationViewport(firstItemIndex, lastItemIndex); if (this.enabled && this.meta && this.meta.isVirtual && this.activeDataRow > -1) { const dataRowIndex = this.activeDataRow; const ahead = firstItemIndex - dataRowIndex; const behind = dataRowIndex - lastItemIndex; if (ahead > 0) { this.cursor.reset(firstItemIndex + this.meta.headerRows); } else if (behind > 0) { this.cursor.reset(lastItemIndex - this.meta.headerRows); } } } setColumnViewport(firstItemIndex, lastItemIndex) { this.columnViewport = new NavigationViewport(firstItemIndex, lastItemIndex); } focusCell(rowIndex = undefined, colIndex = undefined) { this.mode = 1 /* NavigationMode.Cursor */; this.cursor.reset(rowIndex, colIndex); return this.activeCell; } focusNextCell(wrap = true) { return this.focusAdjacentCell(true, wrap); } focusPrevCell(wrap = true) { return this.focusAdjacentCell(false, wrap); } toggle(active) { this.active = active; this.cursor.announce(); } hasFocus() { return this.mode === 1 /* NavigationMode.Cursor */ || this.mode === 2 /* NavigationMode.Content */; } autoFocusCell(start, end) { return !this.meta.virtualColumns || end < this.meta.columns.lockedLeafColumns.length || this.columnViewport.intersects(start, end); } focusAdjacentCell(fwd, wrap) { this.focusCell(); let success = fwd ? this.moveCursorFwd() : this.moveCursorBwd(); if (wrap && !success) { success = fwd ? this.cursor.moveDown(1) : this.cursor.moveUp(1); if (success) { const row = this.cursor.row; const colIdx = fwd ? 0 : this.cursor.lastCellIndex(); this.cursor.reset(row.index, colIdx); } } if (success) { return this.activeCell; } else { this.mode = 0 /* NavigationMode.Standby */; this.cursor.announce(); } return null; } enterCell() { const cell = this.cursor.cell; if (!cell) { return; } const group = cell.focusGroup; const focusable = group && group.canFocus(); this.mode = focusable ? 2 /* NavigationMode.Content */ : 1 /* NavigationMode.Cursor */; this.cursor.announce(); if (focusable) { this.activateRow(); group.focus(); } } leaveCell() { const cell = this.cursor.cell; if (!cell) { return; } const group = cell.focusGroup; const focusable = group && group.canFocus(); if (!focusable) { this.deactivateElements(); } this.mode = 1 /* NavigationMode.Cursor */; this.cursor.announce(); } activateElements() { this.focusRoot.activate(); } deactivateElements() { this.focusRoot.deactivate(); } activateRow() { this.cursor.row.cells .forEach(cell => cell.focusGroup && cell.focusGroup.activate()); } moveCursorFwd() { return this.localization.rtl ? this.cursor.moveLeft() : this.cursor.moveRight(); } moveCursorBwd() { return this.localization.rtl ? this.cursor.moveRight() : this.cursor.moveLeft(); } updateSelection(args) { if (this.selectionService.enabled && this.cursor.row.dataItem) { this.selectionService.click({ dataItem: this.cursor.row.dataItem, column: this.cursor.cell.column, columnIndex: this.cursor.cell.colIndex, originalEvent: args }); } } onCursorKeydown(args) { let preventDefault = false; const modifier = args.ctrlKey || args.metaKey; const step = modifier ? 5 : 1; if (!this.onCellKeydown(args)) { return; } // on some keyboards arrow keys, PageUp/Down, and Home/End are mapped to Numpad keys const code = normalizeNumpadKeys(args); const row = this.cursor.row; switch (code) { case Keys.Space: this.updateSelection(args); preventDefault = this.selectionService.enabled; break; case Keys.KeyA: if (modifier && this.selectionService.enabled) { this.zone.run(() => this.selectionService.toggleAll(true)); preventDefault = true; } break; case Keys.ArrowDown: preventDefault = this.cursor.moveDown(step); if (preventDefault && args.shiftKey) { this.updateSelection(args); } break; case Keys.ArrowUp: preventDefault = this.cursor.moveUp(step); if (preventDefault && args.shiftKey) { this.updateSelection(args); } break; case Keys.ArrowRight: if (args.altKey) { this.zone.run(() => { this.expandState.expand(row.dataItem); }); preventDefault = true; } else { preventDefault = this.moveCursorFwd(); if (preventDefault && args.shiftKey) { this.updateSelection(args); } } break; case Keys.ArrowLeft: if (args.altKey) { this.zone.run(() => { this.expandState.collapse(row.dataItem); }); preventDefault = true; } else { preventDefault = this.moveCursorBwd(); if (preventDefault && args.shiftKey) { this.updateSelection(args); } } break; case Keys.PageDown: if (this.metadata.isVirtual && this.viewport) { let nextItemIndex = this.meta.headerRows + this.viewport.lastItemIndex + 1; nextItemIndex = Math.min(this.meta.maxLogicalRowIndex, nextItemIndex); this.cursor.reset(nextItemIndex); preventDefault = true; } else if (this.metadata.hasPager) { this.zone.run(() => this.pagerContextService.nextPage()); preventDefault = true; } break; case Keys.PageUp: if (this.metadata.isVirtual && this.viewport) { const viewportSize = this.viewport.lastItemIndex - this.viewport.firstItemIndex; const firstItemIndex = this.viewport.firstItemIndex; const nextItemIndex = Math.max(this.meta.headerRows, firstItemIndex - viewportSize - 1); this.cursor.reset(nextItemIndex); preventDefault = true; } else if (this.metadata.hasPager) { this.zone.run(() => this.pagerContextService.prevPage()); preventDefault = true; } break; case Keys.Home: if (modifier) { if (this.meta.isVirtual) { this.cursor.reset(this.meta.headerRows, 0, false); } else { this.cursor.reset(this.model.firstRow.index, 0, false); } } else { this.cursor.reset(row.index, 0, false); } preventDefault = true; break; case Keys.End: if (modifier) { if (this.meta.isVirtual) { const lastRowIndex = this.meta.maxLogicalRowIndex; this.cursor.reset(lastRowIndex, this.cursor.lastCellIndex(), false); } else { this.cursor.reset(this.model.lastRow.index, this.cursor.lastCellIndex(), false); } } else { const lastIndex = this.cursor.lastCellIndex(); const cell = this.model.findCell(lastIndex, row); if (cell) { this.cursor.reset(cell.rowIndex, cell.colIndex); } else { this.cursor.reset(row.index, lastIndex); } } preventDefault = true; break; case Keys.Enter: if (!modifier && !args.shiftKey) { const editing = this.editService.isEditingCell(); const cell = this.cursor.cell; if (!editing && cell.expandable === true) { this.zone.run(() => { this.expandState.toggleState(row.dataItem); }); } else { this.enterCell(); if (!cell.focusGroup.isNavigable()) { preventDefault = true; } } } break; default: if (!modifier && !args.altKey && isPrintableCharacter(args.key)) { this.enterCell(); } } if (preventDefault) { args.preventDefault(); } } onContentKeydown(args) { if (!this.onCellKeydown(args)) { return; } // on some keyboards arrow keys, PageUp/Down, and Home/End are mapped to Numpad keys const code = normalizeNumpadKeys(args); const confirm = !args.defaultPrevented && code === Keys.Enter && isTextInput(args.target); if (code === Keys.Escape || code === Keys.F2 || confirm) { this.leaveCell(); this.cursor.reset(); args.stopPropagation(); } else if (isNavigationKey(code) && this.cursor.cell.focusGroup.isNavigable()) { this.onCursorKeydown(args); if (args.defaultPrevented) { this.leaveCell(); } } } onCellKeydown(args) { // on some keyboards arrow keys, PageUp/Down, and Home/End are mapped to Numpad keys const code = normalizeNumpadKeys(args); if (this.editService.isEditingCell()) { const confirm = code === Keys.Enter || code === Keys.NumpadEnter; const cancel = code === Keys.Escape; const navigate = isNavigationKey(code); if (confirm) { return !this.editService.closeCell(args); } else if (cancel) { this.editService.cancelCell(); this.meta.view.updateEditedState(); this.changeDetector.detectChanges(); } else if (navigate) { return false; } } this.cellKeydown.emit(args); return true; } onCursorChanges(args) { this.activeRowIndex = args.rowIndex; const dataRowIndex = this.activeDataRow; if (this.meta && (this.meta.isVirtual && this.viewport && !this.viewport.containsRow(dataRowIndex) && dataRowIndex > -1)) { this.scrollRequestService.scrollTo({ row: dataRowIndex }); } if (this.meta.virtualColumns && args.colIndex >= this.meta.columns.lockedLeafColumns.length) { const cell = this.activeCell; const { start, end } = this.model.cellRange(cell); if (!cell) { this.virtualCell = true; } if ((!cell && this.mode !== 0 /* NavigationMode.Standby */) || (cell && !this.columnViewport.intersects(start, end))) { this.scrollRequestService.scrollTo({ column: args.colIndex }); } } } onFocusOut(args) { if (isVisible(args.target)) { this.mode = 0 /* NavigationMode.Standby */; } else { // Focused target is no longer visible, // reset to cursor mode and recapture focus. this.mode = 1 /* NavigationMode.Cursor */; } this.deactivateElements(); this.cursor.announce(); } onWindowBlur() { this.mode = 0 /* NavigationMode.Standby */; this.deactivateElements(); this.cursor.announce(); } onKeydown(args) { if (this.mode === 1 /* NavigationMode.Cursor */) { this.onCursorKeydown(args); } else if (this.mode === 2 /* NavigationMode.Content */) { this.onContentKeydown(args); } } onTabout() { // Tabbed out of the last focusable content element // reset to cursor mode and recapture focus. if (this.cursor.cell.focusGroup.isNavigable()) { // Unless the cell has a single focusable element, // otherwise we'd return to Content mode and enter an endless loop return; } this.leaveCell(); this.cursor.reset(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: NavigationService, deps: [{ token: i0.NgZone }, { token: i1.DomEventsService }, { token: i2.PagerContextService }, { token: i3.ScrollRequestService }, { token: i4.FocusRoot }, { token: i5.EditService }, { token: i6.LocalizationService }, { token: i7.ExpandStateService }, { token: i8.SelectionService }, { token: i0.ChangeDetectorRef }, { token: i9.FocusableDirective, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: NavigationService }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: NavigationService, decorators: [{ type: Injectable }], ctorParameters: function () { return [{ type: i0.NgZone }, { type: i1.DomEventsService }, { type: i2.PagerContextService }, { type: i3.ScrollRequestService }, { type: i4.FocusRoot }, { type: i5.EditService }, { type: i6.LocalizationService }, { type: i7.ExpandStateService }, { type: i8.SelectionService }, { type: i0.ChangeDetectorRef }, { type: i9.FocusableDirective, decorators: [{ type: Optional }] }]; } });