@progress/kendo-angular-grid
Version:
Kendo UI Grid for Angular - high performance data grid with paging, filtering, virtualization, CRUD, and more.
476 lines (469 loc) • 23.1 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 { ChangeDetectionStrategy, Component, EventEmitter, HostBinding, Input, Output, QueryList, ViewChildren, ChangeDetectorRef, NgZone, ViewChild, TemplateRef, Renderer2 } from '@angular/core';
import { Subscription, from, fromEvent, merge } from "rxjs";
import { and, isNullOrEmptyString, isUniversal, observe, or } from '../utils';
import { GroupInfoService } from './group-info.service';
import { DropTargetDirective } from '../dragdrop/drop-target.directive';
import { DragHintService } from '../dragdrop/drag-hint.service';
import { DropCueService } from '../dragdrop/drop-cue.service';
import { position, isTargetBefore } from '../dragdrop/common';
import { tap, filter, switchMapTo, takeUntil } from 'rxjs/operators';
import { arrowLeftIcon, arrowRightIcon, sortAscSmallIcon, sortDescSmallIcon } from '@progress/kendo-svg-icons';
import { ContextService } from '../common/provider.service';
import { PopupService } from '@progress/kendo-angular-popup';
import { ChipComponent, ChipListComponent } from '@progress/kendo-angular-buttons';
import { closest } from '../rendering/common/dom-queries';
import { DraggableDirective, EventsOutsideAngularDirective, Keys } from '@progress/kendo-angular-common';
import { IconWrapperComponent } from '@progress/kendo-angular-icons';
import { DraggableColumnDirective } from '../dragdrop/draggable-column.directive';
import { NgIf, NgFor } from '@angular/common';
import * as i0 from "@angular/core";
import * as i1 from "../dragdrop/drag-hint.service";
import * as i2 from "../dragdrop/drop-cue.service";
import * as i3 from "./group-info.service";
import * as i4 from "../common/provider.service";
import * as i5 from "@progress/kendo-angular-popup";
const withoutField = ({ field }) => isNullOrEmptyString(field);
const alreadyGrouped = ({ groups, field }) => groups.some(group => group.field === field);
const overSameTarget = ({ target, field }) => target.field === field;
const overLastTarget = ({ target }) => target.lastTarget;
const isLastGroup = ({ groups, field }) => groups.map(group => group.field).indexOf(field) === groups.length - 1;
const isNotGroupable = (groupsService) => ({ field }) => !groupsService.isGroupable(field);
const columnRules = (groupService) => or(withoutField, alreadyGrouped, isNotGroupable(groupService));
const indicatorRules = or(overSameTarget, and(overLastTarget, isLastGroup));
/**
* @hidden
*/
export class GroupPanelComponent {
hint;
cue;
groupInfoService;
ctx;
cd;
popupService;
ngZone;
renderer;
change = new EventEmitter();
get groupHeaderClass() {
return true;
}
set text(value) {
this.emptyText = value;
}
get text() {
return this.emptyText ? this.emptyText : this.ctx.localization.get('groupPanelEmpty');
}
navigable;
groups = [];
dropTargets = new QueryList();
defaultTemplate;
groupTitles = [];
isChipMenuOpen = false;
get gridId() {
return this.ctx.grid?.ariaRootId;
}
rtl = false;
first;
last;
arrowLeftIcon = arrowLeftIcon;
arrowRightIcon = arrowRightIcon;
emptyText;
subscription;
targetSubscription;
popupSubs;
popupRef;
activeItem;
constructor(hint, cue, groupInfoService, ctx, cd, popupService, ngZone, renderer) {
this.hint = hint;
this.cue = cue;
this.groupInfoService = groupInfoService;
this.ctx = ctx;
this.cd = cd;
this.popupService = popupService;
this.ngZone = ngZone;
this.renderer = renderer;
}
ngAfterViewInit() {
this.subscription = this.ctx.localization.changes.subscribe(({ rtl }) => {
this.rtl = rtl;
this.cd.markForCheck();
});
this.subscription.add(observe(this.dropTargets)
.subscribe(this.attachTargets.bind(this)));
}
ngDoCheck() {
const currentTitles = this.groups.map(group => this.groupInfoService.groupTitle(group));
if (currentTitles.length !== this.groupTitles.length || currentTitles.some((current, idx) => current !== this.groupTitles[idx])) {
this.groupTitles = currentTitles;
this.cd.markForCheck();
}
}
ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
if (this.targetSubscription) {
this.targetSubscription.unsubscribe();
}
this.destroyMenu();
}
messageFor(token) {
return this.ctx.localization.get(token);
}
getTitle(group) {
return this.messageFor(group.dir === 'desc' ? 'sortedDescending' : 'sortedAscending');
}
getDirectionIcon(group) {
return group.dir === 'desc' ? 'sort-desc-sm' : 'sort-asc-sm';
}
getDirectionSvgIcon(group) {
return group.dir === 'desc' ? sortDescSmallIcon : sortAscSmallIcon;
}
directionChange(group) {
group.dir = group.dir ? group.dir : "asc";
group.dir = group.dir === 'asc' ? 'desc' : 'asc';
const index = this.groups.findIndex(x => x.field === group.field);
const groups = [...this.groups.slice(0, index), group, ...this.groups.slice(index + 1)];
this.change.emit(groups);
}
insert(field, index) {
const groups = this.groups.filter(x => x.field !== field);
if (groups.length || this.groups.length === 0) {
this.change.emit([...groups.slice(0, index), { field: field }, ...groups.slice(index)]);
}
}
remove(group) {
this.destroyMenu();
this.change.emit(this.groups.filter(x => x.field !== group.field));
}
toggleMenu(chip, first, last, field) {
const anchor = chip.element.nativeElement.querySelector('.k-chip-action');
if (this.popupRef) {
const popupAnchor = this.popupRef.popup.instance.anchor;
this.destroyMenu();
if (anchor === popupAnchor) {
return;
}
}
this.first = first;
this.last = last;
const direction = this.ctx.localization.rtl ? 'right' : 'left';
this.popupRef = this.popupService.open({
anchor: anchor,
content: this.defaultTemplate,
anchorAlign: { vertical: 'bottom', horizontal: direction },
popupAlign: { vertical: 'top', horizontal: direction },
positionMode: 'absolute'
});
this.activeItem = this.dropTargets.find(dt => dt.context.field === field);
this.renderer.setAttribute(this.popupRef.popupElement, 'dir', this.ctx.localization.rtl ? 'rtl' : 'ltr');
const menuItems = Array.from(this.popupRef.popupElement.querySelectorAll('.k-menu-item'));
this.activateMenuItem(menuItems[1], 'previous');
this.popupSubs = this.popupRef.popupAnchorViewportLeave.subscribe(() => {
this.destroyMenu(true);
});
if (isUniversal()) {
return;
}
this.ngZone.runOutsideAngular(() => {
this.popupSubs.add(fromEvent(document, 'click')
.pipe(filter((event) => !closest(event.target, (node) => node === this.popupRef.popupElement || (node.matches && node.matches('.k-chip-action'))))).subscribe(() => {
this.destroyMenu();
}));
});
}
handleKeyDown = (e) => {
if (e.keyCode === Keys.ArrowDown || e.keyCode === Keys.ArrowUp) {
e.preventDefault();
const relatedItemType = e.target.matches(':first-child') ? 'next' : 'previous';
this.activateMenuItem(e.target, relatedItemType);
}
else if (e.keyCode === Keys.Escape) {
this.destroyMenu(true);
}
else if (e.keyCode === Keys.Tab) {
this.destroyMenu(true);
}
else if (e.keyCode === Keys.Space || e.keyCode === Keys.Enter) {
this.handleMenuClick(e);
}
};
handleClick = (e) => {
e.preventDefault();
const menuItemEl = e.target.closest('.k-menu-item');
if (!menuItemEl.matches('[aria-disabled="true"]')) {
this.handleMenuClick(e);
return;
}
if (menuItemEl.getAttribute('tabindex') === '0') {
return;
}
const activeMenuItem = menuItemEl.closest('.k-menu-group').querySelector('[tabindex="0"]');
const relatedItemType = activeMenuItem.matches(':first-child') ? 'next' : 'previous';
this.activateMenuItem(activeMenuItem, relatedItemType);
};
canDrop(draggable, target) {
const isIndicator = draggable.type === 'groupIndicator';
const rules = isIndicator
? indicatorRules
: columnRules(this.groupInfoService);
return !rules({
field: draggable.field,
groups: this.groups,
target
});
}
attachTargets() {
if (this.targetSubscription) {
this.targetSubscription.unsubscribe();
}
this.targetSubscription = new Subscription();
const enterStream = this.dropTargets
.reduce((acc, target) => merge(acc, target.enter), from([]));
const leaveStream = this.dropTargets
.reduce((acc, target) => merge(acc, target.leave), from([]));
const dropStream = this.dropTargets
.reduce((acc, target) => merge(acc, target.drop), from([]));
this.targetSubscription.add(enterStream.pipe(tap(() => {
this.hint.removeLock();
this.destroyMenu();
}), filter(({ draggable, target }) => this.canDrop(draggable.context, target.context)), tap(this.enter.bind(this)), switchMapTo(dropStream.pipe(takeUntil(leaveStream.pipe(tap(this.leave.bind(this))))))).subscribe(this.drop.bind(this)));
}
enter({ draggable, target }) {
this.hint.enable();
let before = target.context.lastTarget || isTargetBefore(draggable.element.nativeElement, target.element.nativeElement);
if (this.ctx.localization.rtl) {
before = !before;
}
this.cue.position(position(target.element.nativeElement, before));
}
leave() {
this.hint.disable();
this.cue.hide();
}
drop({ target, draggable }) {
const field = draggable.context.field;
const index = this.dropTargets.toArray().indexOf(target);
this.insert(field, index);
}
destroyMenu(focusAnchor) {
if (this.popupRef) {
this.popupRef.close();
this.popupRef = null;
this.popupSubs && this.popupSubs.unsubscribe();
focusAnchor && this.activeItem.context.target.focus();
}
}
activateMenuItem(item, relatedItemType) {
this.renderer.setAttribute(item, 'tabindex', '-1');
this.renderer.removeClass(item, 'k-focus');
const relatedItem = item[`${relatedItemType}ElementSibling`];
this.renderer.setAttribute(relatedItem, 'tabindex', '0');
this.renderer.addClass(relatedItem, 'k-focus');
this.ngZone.runOutsideAngular(() => setTimeout(() => relatedItem.focus()));
}
handleMenuClick(e) {
e.preventDefault();
if (e.target.getAttribute('aria-disabled') !== 'true') {
const chips = this.dropTargets.toArray().slice(0, this.dropTargets.length - 1);
let groupChip, groupChipIndex;
for (let i = 0; i < chips.length; i++) {
if (chips[i].element.nativeElement === this.popupRef.popup.instance.anchor.closest('.k-chip')) {
groupChip = chips[i];
groupChipIndex = i;
break;
}
}
const isPrev = e.target.closest('.k-menu-item').matches(':first-child');
if (isPrev && groupChipIndex > 0) {
this.insert(groupChip.context.field, groupChipIndex - 1);
}
else if (!isPrev && groupChipIndex < chips.length - 1) {
this.insert(groupChip.context.field, groupChipIndex + 1);
}
this.destroyMenu(true);
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: GroupPanelComponent, deps: [{ token: i1.DragHintService }, { token: i2.DropCueService }, { token: i3.GroupInfoService }, { token: i4.ContextService }, { token: i0.ChangeDetectorRef }, { token: i5.PopupService }, { token: i0.NgZone }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.2.12", type: GroupPanelComponent, isStandalone: true, selector: "kendo-grid-group-panel", inputs: { text: "text", navigable: "navigable", groups: "groups" }, outputs: { change: "change" }, host: { properties: { "class.k-grouping-header": "this.groupHeaderClass" } }, viewQueries: [{ propertyName: "defaultTemplate", first: true, predicate: ["defaultTemplate"], descendants: true, read: TemplateRef, static: true }, { propertyName: "dropTargets", predicate: DropTargetDirective, descendants: true }], ngImport: i0, template: `
<div
*ngIf="groups.length === 0"
class="k-grouping-drop-container"
[context]="{
lastTarget: true
}"
kendoDropTarget
>
{{ text }}
</div>
<kendo-chiplist
*ngIf="groups.length !== 0"
[navigable]="navigable"
role="none">
<kendo-chip
*ngFor="let group of groups; let index = index; let first = first; let last = last;"
#chip
kendoDropTarget
kendoDraggableColumn
kendoDraggable
[title]="getTitle(group)"
[enableDrag]="true"
[context]="{
field: group.field,
type: 'groupIndicator',
hint: groupTitles[index],
target: chip
}"
[label]="groupTitles[index]"
[removable]="true"
[hasMenu]="true"
[icon]="getDirectionIcon(group)"
[svgIcon]="getDirectionSvgIcon(group)"
[attr.aria-haspopup]="'menu'"
[attr.aria-expanded]="isChipMenuOpen"
[attr.aria-controls]="gridId"
(contentClick)="directionChange(group)"
(remove)="remove(group)"
(menuToggle)="toggleMenu(chip, first, last, group.field)"
(keydown.alt.arrowdown)="$event.preventDefault(); toggleMenu(chip, first, last, group.field)"
>
</kendo-chip>
</kendo-chiplist>
<div
*ngIf="groups.length !== 0"
class="k-grouping-drop-container"
[context]="{
lastTarget: true
}"
kendoDropTarget
> </div>
<ng-template #defaultTemplate>
<ul unselectable="on" role="menu" class="k-group k-menu-group k-reset k-menu-group-md" [kendoEventsOutsideAngular]="{
keydown: handleKeyDown,
click: handleClick
}">
<li role="menuitem" unselectable="on" class="k-item k-menu-item" [attr.aria-disabled]="first">
<span class="k-link k-menu-link" [class.k-disabled]="first">
<kendo-icon-wrapper
[name]="rtl ? 'arrow-right' : 'arrow-left'"
[svgIcon]="rtl ? arrowRightIcon : arrowLeftIcon"></kendo-icon-wrapper>
<span class="k-menu-link-text">{{messageFor('groupChipMenuPrevious')}}</span>
</span>
</li>
<li role="menuitem" unselectable="on" class="k-item k-menu-item" [attr.aria-disabled]="last">
<span class="k-link k-menu-link" [class.k-disabled]="last">
<kendo-icon-wrapper
[name]="rtl ? 'arrow-left' : 'arrow-right'"
[svgIcon]="rtl ? arrowLeftIcon : arrowRightIcon"></kendo-icon-wrapper>
<span class="k-menu-link-text">{{messageFor('groupChipMenuNext')}}</span>
</span>
</li>
</ul>
</ng-template>
`, isInline: true, dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: DropTargetDirective, selector: "[kendoDropTarget]", inputs: ["context"], outputs: ["enter", "leave", "drop"] }, { kind: "component", type: ChipListComponent, selector: "kendo-chiplist, kendo-chip-list", inputs: ["selection", "size", "role", "navigable"], outputs: ["selectedChange", "remove"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "component", type: ChipComponent, selector: "kendo-chip", inputs: ["label", "icon", "svgIcon", "iconClass", "avatarSettings", "selected", "removable", "removeIcon", "removeSvgIcon", "hasMenu", "menuIcon", "menuSvgIcon", "disabled", "size", "rounded", "fillMode", "themeColor"], outputs: ["remove", "menuToggle", "contentClick"] }, { kind: "directive", type: DraggableColumnDirective, selector: "[kendoDraggableColumn]", inputs: ["context", "enableDrag"], outputs: ["drag"] }, { kind: "directive", type: DraggableDirective, selector: "[kendoDraggable]", inputs: ["enableDrag"], outputs: ["kendoPress", "kendoDrag", "kendoRelease"] }, { kind: "directive", type: EventsOutsideAngularDirective, selector: "[kendoEventsOutsideAngular]", inputs: ["kendoEventsOutsideAngular", "scope"] }, { kind: "component", type: IconWrapperComponent, selector: "kendo-icon-wrapper", inputs: ["name", "svgIcon", "innerCssClass", "customFontClass", "size"], exportAs: ["kendoIconWrapper"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: GroupPanelComponent, decorators: [{
type: Component,
args: [{
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'kendo-grid-group-panel',
template: `
<div
*ngIf="groups.length === 0"
class="k-grouping-drop-container"
[context]="{
lastTarget: true
}"
kendoDropTarget
>
{{ text }}
</div>
<kendo-chiplist
*ngIf="groups.length !== 0"
[navigable]="navigable"
role="none">
<kendo-chip
*ngFor="let group of groups; let index = index; let first = first; let last = last;"
#chip
kendoDropTarget
kendoDraggableColumn
kendoDraggable
[title]="getTitle(group)"
[enableDrag]="true"
[context]="{
field: group.field,
type: 'groupIndicator',
hint: groupTitles[index],
target: chip
}"
[label]="groupTitles[index]"
[removable]="true"
[hasMenu]="true"
[icon]="getDirectionIcon(group)"
[svgIcon]="getDirectionSvgIcon(group)"
[attr.aria-haspopup]="'menu'"
[attr.aria-expanded]="isChipMenuOpen"
[attr.aria-controls]="gridId"
(contentClick)="directionChange(group)"
(remove)="remove(group)"
(menuToggle)="toggleMenu(chip, first, last, group.field)"
(keydown.alt.arrowdown)="$event.preventDefault(); toggleMenu(chip, first, last, group.field)"
>
</kendo-chip>
</kendo-chiplist>
<div
*ngIf="groups.length !== 0"
class="k-grouping-drop-container"
[context]="{
lastTarget: true
}"
kendoDropTarget
> </div>
<ng-template #defaultTemplate>
<ul unselectable="on" role="menu" class="k-group k-menu-group k-reset k-menu-group-md" [kendoEventsOutsideAngular]="{
keydown: handleKeyDown,
click: handleClick
}">
<li role="menuitem" unselectable="on" class="k-item k-menu-item" [attr.aria-disabled]="first">
<span class="k-link k-menu-link" [class.k-disabled]="first">
<kendo-icon-wrapper
[name]="rtl ? 'arrow-right' : 'arrow-left'"
[svgIcon]="rtl ? arrowRightIcon : arrowLeftIcon"></kendo-icon-wrapper>
<span class="k-menu-link-text">{{messageFor('groupChipMenuPrevious')}}</span>
</span>
</li>
<li role="menuitem" unselectable="on" class="k-item k-menu-item" [attr.aria-disabled]="last">
<span class="k-link k-menu-link" [class.k-disabled]="last">
<kendo-icon-wrapper
[name]="rtl ? 'arrow-left' : 'arrow-right'"
[svgIcon]="rtl ? arrowLeftIcon : arrowRightIcon"></kendo-icon-wrapper>
<span class="k-menu-link-text">{{messageFor('groupChipMenuNext')}}</span>
</span>
</li>
</ul>
</ng-template>
`,
standalone: true,
imports: [NgIf, DropTargetDirective, ChipListComponent, NgFor, ChipComponent, DraggableColumnDirective, DraggableDirective, EventsOutsideAngularDirective, IconWrapperComponent]
}]
}], ctorParameters: function () { return [{ type: i1.DragHintService }, { type: i2.DropCueService }, { type: i3.GroupInfoService }, { type: i4.ContextService }, { type: i0.ChangeDetectorRef }, { type: i5.PopupService }, { type: i0.NgZone }, { type: i0.Renderer2 }]; }, propDecorators: { change: [{
type: Output
}], groupHeaderClass: [{
type: HostBinding,
args: ["class.k-grouping-header"]
}], text: [{
type: Input
}], navigable: [{
type: Input
}], groups: [{
type: Input
}], dropTargets: [{
type: ViewChildren,
args: [DropTargetDirective]
}], defaultTemplate: [{
type: ViewChild,
args: ['defaultTemplate', { static: true, read: TemplateRef }]
}] } });