@progress/kendo-angular-sortable
Version:
A Sortable Component for Angular
1,471 lines (1,459 loc) • 78.5 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 * as i0 from '@angular/core';
import { Injectable, Directive, Input, HostBinding, QueryList, EventEmitter, forwardRef, TemplateRef, Component, ContentChildren, ViewChildren, ViewChild, Output, NgModule } from '@angular/core';
import { Subject, merge } from 'rxjs';
import { isDocumentAvailable, focusableSelector, isChanged, Keys, normalizeKeys, EventsOutsideAngularDirective } from '@progress/kendo-angular-common';
import { filter, tap, take, switchMap } from 'rxjs/operators';
import * as i1 from '@progress/kendo-angular-l10n';
import { LocalizationService, L10N_PREFIX } from '@progress/kendo-angular-l10n';
import { validatePackage } from '@progress/kendo-licensing';
import { Draggable } from '@progress/kendo-draggable';
import { NgClass, NgStyle, NgTemplateOutlet } from '@angular/common';
const NODE_NAME_PREDICATES = {};
const NODE_ATTR_PREDICATES = {};
const focusableRegex = /^(?:a|input|select|option|textarea|button|object)$/i;
/**
* @hidden
*/
const MINIMAL_DRAG_DISTANCE = 5;
/**
* @hidden
*/
const matchesNodeName = (nodeName) => {
if (!NODE_NAME_PREDICATES[nodeName]) {
NODE_NAME_PREDICATES[nodeName] = (element) => String(element.nodeName).toLowerCase() === nodeName.toLowerCase();
}
return NODE_NAME_PREDICATES[nodeName];
};
/**
* @hidden
*/
const matchesNodeAttr = (nodeAttr) => {
if (!NODE_ATTR_PREDICATES[nodeAttr]) {
NODE_ATTR_PREDICATES[nodeAttr] = (element) => element.hasAttribute ? element.hasAttribute(nodeAttr) : false;
}
return NODE_ATTR_PREDICATES[nodeAttr];
};
/**
* @hidden
*/
const closest = (node, predicate) => {
while (node && !predicate(node)) {
node = node.parentNode;
}
return node;
};
/**
* Returns an object specifying whether there is a `DraggableDirective` under the cursor.
* @hidden
*/
const draggableFromPoint = (x, y) => {
if (!isDocumentAvailable()) {
return;
}
const el = document.elementFromPoint(x, y);
if (!el) {
return;
}
const isDraggable = el.hasAttribute("kendoDraggable");
const isChild = closest(el, matchesNodeAttr("kendoDraggable")) !== null;
const parentDraggable = closest(el, matchesNodeAttr("data-sortable-index"));
const index = parentDraggable ? parseInt(parentDraggable.getAttribute("data-sortable-index"), 10) : -1;
return {
element: el,
index: index,
isDraggable: isDraggable,
isDraggableChild: isChild,
parentDraggable: parentDraggable,
rect: el.getBoundingClientRect()
};
};
/**
* Returns the DraggableDirective under the cursor.
* @hidden
*/
const draggableFromEvent = (event, sortable) => {
let target;
if (event.changedTouches) {
const touch = event.changedTouches[0];
target = draggableFromPoint(touch.clientX, touch.clientY);
}
else {
target = draggableFromPoint(event.clientX, event.clientY);
}
// TODO: refactor sortable. Add draggable getter
return sortable.draggables.toArray()[target ? target.index : -1];
};
/**
* @hidden
*/
const getAllFocusableChildren = (parent) => {
return Array.from(parent.querySelectorAll(focusableSelector)).filter((element) => element.offsetParent !== null);
};
/**
* @hidden
*/
const getFirstAndLastFocusable = (parent) => {
const all = getAllFocusableChildren(parent);
const firstFocusable = all.length > 0 ? all[0] : parent;
const lastFocusable = all.length > 0 ? all[all.length - 1] : parent;
return [firstFocusable, lastFocusable];
};
/**
* @hidden
*/
const keepFocusWithinComponent = (event, wrapper) => {
const [firstFocusable, lastFocusable] = getFirstAndLastFocusable(wrapper);
const tabAfterLastFocusable = !event.shiftKey && event.target === lastFocusable;
const shiftTabAfterFirstFocusable = event.shiftKey && event.target === firstFocusable;
if (tabAfterLastFocusable) {
event.preventDefault();
firstFocusable.focus();
wrapper.blur();
}
if (shiftTabAfterFirstFocusable) {
event.preventDefault();
lastFocusable.focus();
}
};
/**
* @hidden
*/
const isFocusable = (element) => {
if (element.tagName) {
const tagName = element.tagName.toLowerCase();
const tabIndex = element.getAttribute('tabIndex');
const skipTab = tabIndex === '-1';
let focusable = tabIndex !== null && !skipTab;
if (focusableRegex.test(tagName)) {
focusable = !element.disabled && !skipTab;
}
return focusable;
}
return false;
};
const toClassList = (classNames) => String(classNames).trim().split(' ');
/**
* @hidden
*/
const hasClasses = (element, classNames) => {
const namesList = toClassList(classNames);
return Boolean(toClassList(element.className).find((className) => namesList.indexOf(className) >= 0));
};
const isSortable = matchesNodeName('kendo-sortable');
/**
* @hidden
*/
const widgetTarget = (target) => {
const element = closest(target, node => hasClasses(node, 'k-widget') || isSortable(node));
return element && !isSortable(element);
};
const hasRelativeStackingContext = () => {
if (!isDocumentAvailable()) {
return false;
}
const top = 10;
const parent = document.createElement("div");
parent.style.transform = "matrix(10, 0, 0, 10, 0, 0)";
const innerDiv = document.createElement('div');
innerDiv.style.position = 'fixed';
innerDiv.style.top = `${top}px;`;
parent.appendChild(innerDiv);
document.body.appendChild(parent);
const isDifferent = parent.children[0].getBoundingClientRect().top !== top;
document.body.removeChild(parent);
return isDifferent;
};
const HAS_RELATIVE_STACKING_CONTEXT = hasRelativeStackingContext();
/**
* @hidden
*/
const relativeContextElement = (element) => {
if (!element || !HAS_RELATIVE_STACKING_CONTEXT || !isDocumentAvailable()) {
return null;
}
let node = element.parentElement;
while (node) {
if (window.getComputedStyle(node).transform !== 'none') {
return node;
}
node = node.parentElement;
}
};
/**
* @hidden
*/
const packageMetadata = {
name: '@progress/kendo-angular-sortable',
productName: 'Kendo UI for Angular',
productCode: 'KENDOUIANGULAR',
productCodes: ['KENDOUIANGULAR'],
publishDate: 1765468190,
version: '21.3.0',
licensingDocsUrl: 'https://www.telerik.com/kendo-angular-ui/my-license/'
};
const allowDrag = (e) => {
const target = e.originalEvent.target;
return target.hasAttribute('data-sortable-item') || !(isFocusable(target) || widgetTarget(target));
};
/**
* The `SortableService` is a service that manages the drag-and-drop functionality
* for transferring items between Sortable components.
*/
class SortableService {
ngZone;
/**
* Specifies the Draggable item that is currently being moved.
*/
activeDraggable = null;
/**
* Specifies the Draggable item from which the dragging started.
*/
originDraggable = null;
/**
* @hidden
*/
originIndex;
/**
* @hidden
*/
targetSortable = null;
/**
* Specifies the Draggable item that last emitted an event.
*/
lastDraggable = null;
/**
* @hidden
*/
onPressSubject = new Subject();
/**
* @hidden
*/
onDragSubject = new Subject();
/**
* @hidden
*/
onReleaseSubject = new Subject();
subscriptions;
source = null;
_target = null;
sortableCounter = 0;
sortableRegister = {};
pressArgs;
/**
* Specifies the `SortableComponent` instance under the currently dragged item.
*/
set target(target) {
this._target = target;
}
get target() {
return this._target;
}
/**
* @hidden
*/
constructor(ngZone) {
this.ngZone = ngZone;
if (!isDocumentAvailable()) {
return;
}
this.subscriptions = this.onPressSubject.pipe(filter(allowDrag), tap(press => {
this.targetSortable = this.getSortableComponentFromTouch(press);
}), filter(_ => Boolean(this.targetSortable)), tap(press => {
this.onReleaseSubject.pipe(take(1)).subscribe(event => this.release(event));
this.pressArgs = press;
if (press.isTouch) {
press.originalEvent.preventDefault();
}
}), switchMap(_drag => this.onDragSubject.pipe(filter(_ => Boolean(this.targetSortable)), //stop further events if dragStart is prevented
tap((e) => this.drag(e))))).subscribe();
}
/**
* @hidden
*/
onPress(e) {
this.onPressSubject.next(e);
}
/**
* @hidden
*/
onDrag(e) {
this.onDragSubject.next(e);
}
/**
* @hidden
*/
onRelease(e) {
this.onReleaseSubject.next(e);
}
/**
* @hidden
*/
ngOnDestroy() {
if (this.subscriptions) {
this.subscriptions.unsubscribe();
}
}
/**
* Registers a `SortableComponent` with the `SortableService` so that it can be managed by the service.
*
* @param sortableComponent - The `SortableComponent`.
* @return - The unique key that the current `SortableComponent` gets when registered.
*/
registerComponent(sortableComponent) {
const id = this.sortableCounter.toString();
this.sortableRegister[id] = sortableComponent;
this.sortableCounter++;
return id;
}
/**
* Removes a `SortableComponent` from the registered `SortableComponents` with which the service operates.
*
* @param key - The key of the `SortableComponent` which will be removed from the register.
* Obtained when `registerComponent` is called.
*/
unregisterComponent(key) {
this.sortableRegister[key] = null;
}
/**
* Sets the `SortableComponent` as a source component. When dragging an item from one Sortable to another,
* the source component is the one from which the item originates.
*
* @param sortable - The `SortableComponent`.
*/
setSource(sortable) {
this.source = sortable;
}
/**
* Returns the source `SortableComponent` from which
* an item is dragged to other Sortable components.
*
* @return - The `SourceComponent`.
*/
getSource() {
return this.source;
}
/**
* The method that finds the `SortableComponent` which is registered to
* the `SortableService` by using the arguments of the `touch` event.
*
* @param touch - A Touch-Object of the `Touch` type interface.
* Represents a single contact point (finger or stylus)
* on a touch-sensitive device (touchscreen or trackpad).
*
* @return { component: SortableComponent, index: number } - An object where the component is the `SortableComponent` that owns the item and the index is the index of the touched item.
*/
getSortableComponentFromTouch(touch) {
if (!isDocumentAvailable()) {
return { component: undefined, index: undefined };
}
let realTarget = document.elementFromPoint(touch.clientX, touch.clientY);
while (realTarget) {
const id = realTarget.getAttribute('data-sortable-id');
const index = realTarget.getAttribute('data-sortable-index');
if (id) {
const targetSortable = this.sortableRegister[id];
if (targetSortable) {
return { component: targetSortable, index: parseInt(index, 10) };
}
}
realTarget = realTarget.parentElement;
}
}
start() {
const pressArgs = this.pressArgs;
if (pressArgs) {
this.pressArgs = null;
const startTarget = draggableFromEvent(pressArgs, this.targetSortable.component);
if (this.targetSortable.component.startDrag({ target: startTarget, originalEvent: pressArgs })) {
this.targetSortable = null;
return true;
}
}
}
release(event) {
if (this.source) {
this.ngZone.run(() => {
if (this.targetSortable) {
const dropTarget = draggableFromEvent(event, this.targetSortable.component);
this.source.endDrag({ target: dropTarget, originalEvent: event });
}
this.source.positionHintFromEvent(null);
this.source.markForCheck();
});
}
this.targetSortable = null;
this.pressArgs = null;
}
drag(event) {
const distance = this.pressArgs && Math.sqrt((event.pageX - this.pressArgs.pageX) ** 2 + (event.pageY - this.pressArgs.pageY) ** 2);
if (distance && distance < MINIMAL_DRAG_DISTANCE) {
return;
}
this.ngZone.run(() => {
if (this.start()) {
return;
}
this.source.positionHintFromEvent(event);
const sortable = this.getSortableComponentFromTouch(event);
if (!sortable || sortable && sortable.component !== this.target) {
if (this.target) {
this.target.leave({ target: undefined, originalEvent: event });
}
else if (this.source !== this.target) {
this.source.leave({ target: undefined, originalEvent: event });
}
}
if (sortable && sortable.component) {
const draggable = draggableFromEvent(event, sortable.component);
sortable.component.drag({ target: draggable, originalEvent: event });
}
this.source.markForCheck();
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SortableService, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SortableService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SortableService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}], ctorParameters: () => [{ type: i0.NgZone }] });
/**
* @hidden
*/
class SortableContainer {
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SortableContainer, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SortableContainer });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: SortableContainer, decorators: [{
type: Injectable
}] });
/**
* @hidden
*/
class DraggableDirective {
parent;
el;
renderer;
index;
disabled;
set hidden(value) {
this._hidden = value;
this.updateDisplayStyle();
}
get hidden() {
return this._hidden;
}
_hidden;
get _focused() {
return this.disabled ? false : (this.index === this.parent.activeIndex);
}
get _disabled() {
return this.disabled;
}
get display() {
return this.hidden ? "none" : this._display;
}
set display(display) {
this._display = display;
this.updateDisplayStyle();
}
_display;
constructor(parent, el, renderer) {
this.parent = parent;
this.el = el;
this.renderer = renderer;
}
ngOnInit() {
const nativeElement = this.el.nativeElement;
this.display = nativeElement.style.display;
if (nativeElement) { // Remove the inline styles after a few releases of the themes with the style.
this.renderer.setStyle(nativeElement, 'user-select', 'none');
this.renderer.setStyle(nativeElement, '-ms-user-select', 'none');
this.renderer.setStyle(nativeElement, '-moz-user-select', 'none');
this.renderer.setStyle(nativeElement, '-webkit-user-select', 'none');
}
}
updateDisplayStyle() {
this.renderer.setStyle(this.el.nativeElement, 'display', this.display);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DraggableDirective, deps: [{ token: SortableContainer }, { token: i0.ElementRef }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.14", type: DraggableDirective, isStandalone: true, selector: "[kendoDraggable]", inputs: { index: "index", disabled: "disabled", hidden: "hidden" }, host: { properties: { "class.k-focus": "this._focused", "attr.aria-disabled": "this._disabled" } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DraggableDirective, decorators: [{
type: Directive,
args: [{
selector: '[kendoDraggable]',
standalone: true
}]
}], ctorParameters: () => [{ type: SortableContainer }, { type: i0.ElementRef }, { type: i0.Renderer2 }], propDecorators: { index: [{
type: Input
}], disabled: [{
type: Input
}], hidden: [{
type: Input
}], _focused: [{
type: HostBinding,
args: ['class.k-focus']
}], _disabled: [{
type: HostBinding,
args: ['attr.aria-disabled']
}] } });
//TODO: RENAME FILE AND UPDATE EXPORTS AND MODULES
/**
* @hidden
*/
class ItemTemplateDirective {
templateRef;
constructor(templateRef) {
this.templateRef = templateRef;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ItemTemplateDirective, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.14", type: ItemTemplateDirective, isStandalone: true, selector: "[kendoSortableItemTemplate]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ItemTemplateDirective, decorators: [{
type: Directive,
args: [{
selector: '[kendoSortableItemTemplate]',
standalone: true
}]
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
/**
* @hidden
*/
class PlaceholderTemplateDirective {
templateRef;
constructor(templateRef) {
this.templateRef = templateRef;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PlaceholderTemplateDirective, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.14", type: PlaceholderTemplateDirective, isStandalone: true, selector: "[kendoSortablePlaceholderTemplate]", ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PlaceholderTemplateDirective, decorators: [{
type: Directive,
args: [{
selector: '[kendoSortablePlaceholderTemplate]',
standalone: true
}]
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
/**
* Defines an event whose default action can be prevented
* by calling the `preventDefault` method.
*
* @hidden
*/
class PreventableEvent {
prevented = false;
/**
* Prevents the default action for a specified event.
* In this way, the source component suppresses
* the built-in behavior that follows the event.
*/
preventDefault() {
this.prevented = true;
}
/**
* If the event was prevented
* by any of its subscribers, returns `true`.
*
* @returns `true` if the default action was prevented. Otherwise, returns `false`.
*/
isDefaultPrevented() {
return this.prevented;
}
}
/**
* Represents the event arguments for the `navigate` event, which is emitted when you use the keyboard arrows.
*/
class NavigateEvent extends PreventableEvent {
/**
* Specifies the index of the draggable item.
*/
index;
/**
* Specifies the old index of the draggable item.
*/
oldIndex;
/**
* Indicates whether the `Ctrl` or meta keys are pressed.
*/
ctrlKey;
/**
* @hidden
*/
constructor(options) {
super();
Object.assign(this, options);
}
}
/**
* Represents the arguments for the `DraggableDirective` events.
* @hidden
*/
class DraggableEvent extends PreventableEvent {
/**
* Specifies the target `DraggableDirective` instance.
*/
target;
/**
* Specifies the browser event emitted by the target's native element.
*/
originalEvent; //DragEvent | TouchEvent;
/**
* @hidden
*/
constructor(options) {
super();
Object.assign(this, options);
}
}
/**
* Represents the arguments for the `dragStart` event.
*/
class DragStartEvent extends PreventableEvent {
/**
* Specifies the index of the draggable item.
*/
index;
/**
* Specifies the previous index of the draggable item.
* This property is used for the `dragEnd and `dragOver` events to determine the original position of the item before dragging.
* It doesn't apply to the `dragStart` event as there is no previous position at that point.
*/
oldIndex;
/**
* @hidden
*/
constructor(options) {
super();
Object.assign(this, options);
}
}
/**
* Represents the arguments for the `dragOver` event.
*/
class DragOverEvent extends DragStartEvent {
}
/**
* Represents the arguments for the `dragEnd` event.
*/
class DragEndEvent extends DragOverEvent {
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const KEY_SHORTCUTS = 'Control+ArrowLeft Control+ArrowRight Meta+ArrowLeft Meta+ArrowRight';
/**
* Represents the [Kendo UI Sortable component for Angular]({% slug overview_sortable %}).
*
* @example
* ```html
* <kendo-sortable [data]="['Item 1', 'Item 2', 'Item 3']"></kendo-sortable>
* ```
*/
/**
* Represents the Kendo UI Sortable component for Angular.
*/
class SortableComponent {
ngZone;
renderer;
changeDetector;
localization;
cdr;
/**
* Specifies the tab index of the Sortable component.
*/
tabIndex = null;
/**
* Configures how the Sortable component tracks changes in its items collection.
*/
trackBy = (_, idx) => idx;
/**
* Sets an array of any data that is used as a data source for the Sortable.
*/
set data(data) {
this._data = data;
//Cache each _data item instance locally to avoid repaint due to the ngTemplateOutletContext (generated by itemData)
//This prevents destroying the kendoDraggable instance, which otherwise leads to losing the dragEnd event
//due to non-exisitng HTML element
this.cacheData();
}
get data() {
return this._data;
}
/**
* Sets a boolean value that determines whether the Sortable items are navigable using the keyboard. [See example]({% slug keyboard_navigation_sortable %}).
* @default true
*/
navigable = true;
/**
* Enables or disables built-in animations.
* @default false
*/
animation = false;
/**
* Sets an array of integers that represent the indexes of the disabled items from the data array. [See example](slug:items_sortable#toc-disabling-items).
*/
disabledIndexes = [];
/**
* Sets a string that represents the name of the zone to which the Sortable belongs
* ([see example](slug:items_sortable#toc-transferring-of-items)). Items can be transferred
* between Sortables in the same zone.
*/
zone = undefined;
/**
* Defines the zones from which items can be transferred onto the current Sortable component
* ([see example](slug:items_sortable#toc-transferring-of-items)). If the `acceptZones` property
* of the target Sortable is set, you can transfer items between Sortables in different zones.
*/
acceptZones = undefined;
/**
* Represents the CSS styles applied to each Sortable item.
*/
itemStyle = {};
/**
* Defines the CSS styles applied to an empty item ([see example]({% slug templates_sortable %})).
*/
emptyItemStyle = undefined;
/**
* Defines the CSS styles which are applied to the currently dragged item ([see example]({% slug templates_sortable %})).
*/
activeItemStyle = undefined;
/**
* Defines the CSS styles which are applied to all disabled items.
*/
disabledItemStyle = undefined;
/**
* Defines the class which is applied to each Sortable item.
*/
itemClass = "";
/**
* Defines the class which is applied to the active Sortable item.
*/
activeItemClass = null;
/**
* Defines the class which is applied to the empty item when the Sortable has empty data.
*/
emptyItemClass = null;
/**
* Defines the class which is applied to each disabled Sortable item.
*/
disabledItemClass = null;
/**
* Sets the text message that will be displayed when the Sortable has no items.
*/
emptyText = "Empty";
/**
* @hidden
*/
defaultTemplateRef = null;
/**
* Defines the template that will be used for rendering the items.
* @hidden
*/
itemTemplateDirectiveRef = null;
/**
* Defines the template that will be used for rendering the placeholder.
* @hidden
*/
placeholderTemplateDirectiveRef = null;
itemWrappers = new QueryList();
draggables;
noDataContainer;
hint;
/**
* Fires when the dragging of an item is started.
*/
dragStart = new EventEmitter();
/**
* Fires when the dragging of an item is completed.
*/
dragEnd = new EventEmitter();
/**
* Fires while the dragging of an item is in progress.
*/
dragOver = new EventEmitter();
/**
* Fires when dragging an item outside of the component.
*/
dragLeave = new EventEmitter();
/**
* Fires while the moving an item from one position to another.
*/
dataMove = new EventEmitter();
/**
* Fires when a new item is added to the Sortable.
*/
dataAdd = new EventEmitter();
/**
* Fires when an item is removed from the Sortable.
*/
dataRemove = new EventEmitter();
/**
* Fires when navigating using the keyboard.
*/
navigate = new EventEmitter();
/**
* The index of the currently focused item.
* If no item is focused, set to `-1`.
*/
activeIndex = -1;
get touchAction() {
return "none";
}
get dir() {
return this.direction;
}
hostRole = 'list';
/**
* Flag indicating if the component is currently playing animations.
* @hidden
*/
animating = false;
/**
* The index of the currently dragged item.
*/
dragIndex = -1;
/**
* The index of the item above which the dragged item is.
*/
dragOverIndex = -1;
onDragStartSubject = new Subject();
onDragOverSubject = new Subject();
onDragLeaveSubject = new Subject();
onDragEndSubject = new Subject();
/**
* The SortableComponent's HTMLElement.
*/
wrapper;
/**
* The location of the hint indicator when dragging on mobile devices.
*/
hintLocation = null;
id;
itemTemplateRef;
placeholderTemplateRef;
_data;
_localData = [];
/**
* @hidden
*/
ariaKeyShortcuts = KEY_SHORTCUTS;
localizationChangeSubscription;
dragStartSubscription;
dragOverSubscription;
dragLeaveSubscription;
dragEndSubscription;
childrenTabindexSubscription;
focusableItems = [];
animationDuration = 300;
afterKeyPress = false;
sortableService = null;
_hideActiveItem = false;
prevActiveIndex = 0;
direction;
_animating;
draggable;
offsetParent;
setItemData(data, i) {
this._localData[i].item = data.item;
this._localData[i].index = data.index;
this._localData[i].hidden = data.hidden;
}
/**
* @hidden
*/
itemTemplate(index) {
let template = this.itemTemplateRef;
if (index === this.dragOverIndex) {
template = this.placeholderTemplateRef;
}
else if (index === this.dragIndex) {
template = this.itemTemplateRef;
}
return template;
}
constructor(ngZone, renderer, changeDetector, localization, wrapper, sortableService, cdr) {
this.ngZone = ngZone;
this.renderer = renderer;
this.changeDetector = changeDetector;
this.localization = localization;
this.cdr = cdr;
validatePackage(packageMetadata);
this.wrapper = wrapper.nativeElement;
this.direction = localization.rtl ? 'rtl' : 'ltr';
this.sortableService = sortableService;
this.subscribeEvents();
}
ngOnInit() {
if (!this.data) {
this.data = [];
}
this.id = this.sortableService.registerComponent(this);
this.dragIndex = -1;
const display = "display";
if (this.activeItemStyle && !this.activeItemStyle[display]) {
this.activeItemStyle[display] = "";
}
if (!this.itemStyle[display]) {
this.itemStyle[display] = "";
}
if (this.wrapper) {
this.draggable = new Draggable({
press: (e) => this.sortableService.onPress(e),
drag: (e) => this.sortableService.onDrag(e),
release: (e) => this.sortableService.onRelease(e)
});
this.ngZone.runOutsideAngular(() => {
this.draggable.bindTo(this.wrapper);
});
}
}
ngAfterViewInit() {
if (this.navigable) {
this.setInitialItemTabindex();
this.setFocusableChildren();
}
this.childrenTabindexSubscription = this.itemWrappers.changes.subscribe(() => {
if (this.navigable) {
this.setInitialItemTabindex();
this.setFocusableChildren();
}
});
}
ngOnChanges(changes) {
if (this.data && isChanged('disabledIndexes', changes, false)) {
this.cacheData();
}
}
ngOnDestroy() {
this.unsubscribeEvents();
this.sortableService.unregisterComponent(this.id);
if (this.draggable) {
this.draggable.destroy();
}
}
ngAfterContentInit() {
this.itemTemplateRef = this.itemTemplateDirectiveRef.first || this.defaultTemplateRef.first;
this.placeholderTemplateRef = this.placeholderTemplateDirectiveRef.first || this.defaultTemplateRef.first;
}
ngAfterViewChecked() {
if (this.navigable) {
if (this.afterKeyPress) {
const elems = this.itemWrappers.toArray();
if (elems && elems.length > 0 && this.activeIndex > -1) {
const currentItem = elems[this.activeIndex].nativeElement;
const prevItem = elems[this.prevActiveIndex].nativeElement;
this.renderer.setAttribute(prevItem, 'tabindex', '-1');
this.renderer.setAttribute(currentItem, 'tabindex', '0');
currentItem.focus();
}
}
this.afterKeyPress = false;
}
}
/**
* @hidden
*/
setFocusableChildren() {
this.itemWrappers.toArray().forEach((item) => {
const itemEl = item.nativeElement;
const focusableChildren = getAllFocusableChildren(itemEl);
if (focusableChildren.length > 0) {
this.focusableItems.push(focusableChildren);
focusableChildren.forEach(focusableChild => {
this.renderer.setAttribute(focusableChild, 'tabindex', '-1');
});
}
});
}
/**
* @hidden
*/
updateCacheIndices() {
this._localData.forEach((item, index) => {
item.index = index;
});
}
/**
* @hidden
*/
cacheData() {
this._localData = [];
this._data.forEach((item, index) => {
this._localData.push({ item: item, active: false, disabled: !this.itemEnabled(index), index: index, hidden: false });
});
}
/**
* @hidden
*/
startDrag(event) {
const startEvent = new DraggableEvent(event);
this.onDragStartSubject.next(startEvent);
const prevented = startEvent.isDefaultPrevented();
if (!prevented) {
this.offsetParent = relativeContextElement(this.wrapper);
}
return prevented;
}
/**
* @hidden
*/
setInitialItemTabindex() {
this.itemWrappers.toArray().forEach((item, index) => {
if (this.itemEnabled(index)) {
const isFirstItem = index === 0 ? 0 : -1;
const tabIndexValue = `${this.navigable ? this.tabIndex || isFirstItem : this.tabIndex}`;
const hasItemTabindex = item.nativeElement.getAttribute('tabindex');
if (!hasItemTabindex) {
this.renderer.setAttribute(item.nativeElement, 'tabindex', tabIndexValue);
}
}
});
}
/**
* @hidden
*/
drag(event) {
const dragEvent = new DraggableEvent(event);
this.onDragOverSubject.next(dragEvent);
return dragEvent.isDefaultPrevented();
}
/**
* @hidden
*/
leave(event) {
const leaveEvent = new DraggableEvent(event);
this.onDragLeaveSubject.next(leaveEvent);
return leaveEvent.isDefaultPrevented();
}
/**
* @hidden
*/
endDrag(event) {
const endEvent = new DraggableEvent(event);
this.onDragEndSubject.next(endEvent);
return endEvent.isDefaultPrevented();
}
/**
* @hidden
*/
hintVisible() {
return this.dragIndex >= 0 && this.hintLocation && this === this.sortableService.getSource();
}
/**
* @hidden
*/
currentItemStyle(index) {
if (index === -1) {
return this.emptyItemStyle ? this.emptyItemStyle : this.itemStyle;
}
if (!this.itemEnabled(index) && this.disabledItemStyle) {
return this.disabledItemStyle;
}
if (index === this.dragIndex || (this.dragIndex === -1 && index === this.activeIndex)) {
if (this.hideActiveItem) {
return { "display": "none" };
}
if (this.activeItemStyle) {
return this.activeItemStyle;
}
}
return this.itemStyle;
}
/**
* @hidden
*/
currentItemClass(index) {
if (index === -1) {
return this.emptyItemClass ? this.emptyItemClass : this.itemClass;
}
if (!this.itemEnabled(index) && this.disabledItemClass) {
return this.disabledItemClass;
}
if ((index === this.dragIndex || this.dragIndex === -1 && index === this.activeIndex) && this.activeItemClass) {
return this.activeItemClass;
}
return this.itemClass;
}
/**
* @hidden
*/
hintStyle() {
const position = {
"left": this.hintLocation.x + 10 + "px",
"position": "fixed",
"top": this.hintLocation.y + 10 + "px"
};
const style = {};
Object.assign(style, this.currentItemStyle(this.dragIndex), position);
return style;
}
/**
* @hidden
*/
itemEnabled(index) {
return this.disabledIndexes.indexOf(index) === -1;
}
/**
* @hidden
*/
acceptDragFrom(sortableComponent) {
if (this.acceptZones === undefined) {
return (this.zone === sortableComponent.zone);
}
else if (sortableComponent.zone !== undefined) {
return (this.acceptZones.indexOf(sortableComponent.zone) !== -1);
}
return false;
}
/**
* @hidden
*/
ariaDropEffect(index) {
return this.itemEnabled(index) ? "move" : "none";
}
/**
* @hidden
*/
focusHandler(index) {
if (this.navigable) {
this.activeIndex = index;
}
}
/**
* @hidden
*/
blurHandler() {
if (this.navigable && !this.afterKeyPress) {
this.prevActiveIndex = this.activeIndex;
this.activeIndex = -1;
}
}
/**
* @hidden
*/
onArrowHandler(event, keyCode) {
const leftKey = this.direction === 'rtl' ? Keys.ArrowRight : Keys.ArrowLeft;
const dir = keyCode === Keys.ArrowUp || keyCode === leftKey ? -1 : 1;
const limit = this.data.length - 1;
let targetIndex = this.activeIndex + dir;
while (!this.itemEnabled(targetIndex) && targetIndex <= limit) {
targetIndex += dir;
}
targetIndex = Math.min(Math.max(targetIndex, 0), limit);
this.prevActiveIndex = this.activeIndex;
if (!this.itemEnabled(targetIndex)) {
return;
}
const ctrl = event.ctrlKey || event.metaKey;
const navigateEvent = new NavigateEvent({ index: targetIndex, oldIndex: this.activeIndex, ctrlKey: ctrl });
this.navigate.emit(navigateEvent);
if (!navigateEvent.isDefaultPrevented()) {
this.activeIndex = targetIndex;
}
this.dragIndex = -1;
this.dragOverIndex = -1;
event.stopPropagation();
event.preventDefault();
this.afterKeyPress = true;
}
/**
* @hidden
*/
onEnterHandler(item) {
const focusableItems = this.focusableItems[this.activeIndex];
focusableItems.forEach(focusableItem => {
this.renderer.setAttribute(focusableItem, 'tabindex', '0');
});
this.renderer.setAttribute(item, 'tabindex', '-1');
focusableItems[0].focus();
}
/**
* @hidden
*/
onEscapeHandler(event) {
const focusableItems = this.focusableItems[this.prevActiveIndex];
const item = (event?.target).closest('[data-sortable-item]');
focusableItems.forEach(focusableItem => {
this.renderer.setAttribute(focusableItem, 'tabindex', '-1');
});
this.renderer.setAttribute(item, 'tabindex', '0');
item.focus();
}
/**
* @hidden
*/
keydownHandler = (event) => {
if (!this.navigable) {
return;
}
this.cdr.markForCheck();
const targetIsWrapper = this.itemWrappers.toArray().some((item) => item.nativeElement === event.target);
const index = this.activeIndex === -1 ? this.prevActiveIndex : this.activeIndex;
const item = this.itemWrappers.toArray()[index]?.nativeElement;
const isItemFocused = document.activeElement === item;
const hasFocus = this.activeIndex !== -1;
const keyCode = normalizeKeys(event);
if (keyCode === Keys.Tab && !isItemFocused) {
keepFocusWithinComponent(event, item);
return;
}
if (keyCode === Keys.Escape && this.focusableItems.length > 0 && this.activeIndex === -1) {
this.onEscapeHandler(event);
return;
}
if (!targetIsWrapper) {
return;
}
if (this.navigable && hasFocus) {
const isArrowKey = [Keys.ArrowUp, Keys.ArrowDown, Keys.ArrowLeft, Keys.ArrowRight].includes(keyCode);
if (isArrowKey) {
this.ngZone.run(() => this.onArrowHandler(event, keyCode));
}
else if (keyCode === Keys.Enter && isItemFocused && this.focusableItems.length > 0) {
this.onEnterHandler(item);
}
}
};
/**
* Removes the currently active item from the Data collection that the Sortable uses.
*/
removeDataItem(index) {
this.dragIndex = -1;
this.dragOverIndex = -1;
this._localData.splice(index, 1);
this.data.splice(index, 1);
this.updateCacheIndices();
}
/**
* Sets a boolean value that indicates whether the item will be hidden or not.
* @hidden
*/
hideItem(index, hidden = true) {
this._localData[index].hidden = hidden;
}
/**
* Gets or sets a boolean value that indicates whether the currently dragged item will be hidden.
*
* If the currently dragged item is hidden, returns `true`.
* If the currently dragged item is visible, returns `false`.
*/
get hideActiveItem() {
return this._hideActiveItem;
}
set hideActiveItem(value) {
this.activeIndex = -1;
this._hideActiveItem = value;
}
/**
* Clears the active item.
* An active item is the one that is currently focused when the user navigates with the keyboard.
*/
clearActiveItem() {
if (this.navigable) {
this.fixFocus();
}
else {
this.activeIndex = -1;
}
this.dragIndex = -1;
}
/**
* Returns the currently active item when the user navigates with the keyboard.
* @return - The data item which is currently active.
*/
getActiveItem() {
if (this.data && this.dragIndex >= 0 && this.dragIndex < this.data.length) {
return this.data[this.dragIndex];
}
}
/**
* Inserts a new data item at a particular index in the Sortable component.
* @param dataItem - The data item.
* @param index - The index at which the data item is inserted.
*/
addDataItem(dataItem, index) {
const originDraggable = this.sortableService.originDraggable;
if (originDraggable && originDraggable.parent === this) {
const animation = this.animation;
this.hideItem(originDraggable.index, false);
this.animation = false;
this.moveItem(originDraggable.index, index);
this.animation = animation;
}
else {
this.data.splice(index, 0, dataItem);
this._localData.splice(index, 0, { item: dataItem, active: false, disabled: !this.itemEnabled(index), index: index, hidden: false });
this.updateCacheIndices();
}
this.dragIndex = index;
this.dragOverIndex = index;
this.ngZone.onStable.pipe(take(1)).subscribe(() => {
this.sortableService.target = this;
this.sortableService.setSource(this);
this.sortableService.activeDraggable = this.draggables.toArray()[index];
this.sortableService.lastDraggable = null;
});
}
/**
* Moves a data item from one index to another in the Sortable component.
* @param fromIndex - The data item's index.
* @param toIndex - The index which the data item should be moved to. Item currently sitting at that index is pushed back one position.
*/
moveItem(fromIndex, toIndex) {
if (toIndex === fromIndex) {
return;
}
let dragIndex = fromIndex;
const d = toIndex > dragIndex ? 1 : -1;
const originalIndexAnimate = dragIndex;
const toAnimate = [];
let prevIndex = dragIndex;
let tmp;
while (dragIndex !== toIndex) {
dragIndex += d;
if (this.itemEnabled(dragIndex) || dragIndex === toIndex) {
if (this.animation) {
toAnimate.push({ next: dragIndex, prev: prevIndex });
}
tmp = this._localData[prevIndex].index;
this._localData[prevIndex].index = this._localData[dragIndex].index;
this._localData[dragIndex].index = tmp;
tmp = this._localData[prevIndex];
this._localData[prevIndex] = this._localData[dragIndex];
this._localData[dragIndex] = tmp;
tmp = this.data[prevIndex];
this.data[prevIndex] = this.data[dragIndex];
this.data[dragIndex] = tmp;
prevIndex = dragIndex;
}
}
this.dragIndex = dragIndex;
this.dragOverIndex = dragIndex;
this.activeIndex = dragIndex;
if (this.focusableItems.length > 0) {
this.swapFocusableChildren(fromIndex, toIndex);
}
if (this.animation) {
setTimeout(() => {
toAnimate.push({ next: originalIndexAnimate, prev: dragIndex });
this.animating = true;
this.animate(toAnimate);
});
}
this.ngZone.onStable.pipe(take(1)).subscribe(() => {
this.sortableService.activeDraggable = this.draggables.toArray()[dragIndex];
this.sortableService.lastDraggable = null;
});
}
/**
* @hidden
*/
animate(draggables) {
const itemArray = this.itemWrappers.toArray();
const prevClientRect = [];
const nextClientRect = [];
clearTimeout(this._animating);
for (let i = 0; i < draggables.length; i++) {
prevClientRect.push(itemArray[draggables[i].prev].nativeElement.getBoundingClientRect());
nextClientRect.push(itemArray[draggables[i].next].nativeElement.getBoundingClientRect());
}
for (let i = 0; i < draggables.length; i++) {
const nextIndex = draggables[i].prev;
const targetRect = nextClientRect[i];
const currentRect = prevClientRect[i];
const target = itemArray[nextIndex].nativeElement;
this.applyAnimationStyle(target, 'transition', 'none');
this.applyAnimationStyle(target, 'transform', 'translate3d('
+ (targetRect.left - currentRect.left).toString() + 'px,'
+ (targetRect.top - currentRect.top).toString() + 'px,0)');
this.reflow(target);
}
for (let i = 0; i < draggables.length; i++) {
const nextIndex = draggables[i].prev;
const target = itemArray[nextIndex].nativeElement;
this.applyAnimationStyle(target, 'transition', 'all ' + this.animationDuration + 'ms');
this.applyAnimationStyle(target, 'transform', 'translate3d(0,0,0)');
clearTimeout(target.animated);
target.animated = setTimeout(() => {
this.applyAnimationStyle(target, 'transition', '');
this.applyAnimationStyle(target, 'transform', '');
target.animated = false;
}, this.animationDuration);
}
this._animating = setTimeout(() => {
this.animating = false;
}, this.animationDuration);
}
/**
* @hidden
*/
positionHintFromEvent(event) {
const offset = this.parentOffset();
this.hintLocation = event ? { x: event.clientX - offset.left, y: event.clientY - offset.top } : null;
}
/**
* @hidden
*/
parentOffset() {
const offsetParent = this.offsetParent;
if (offsetParent) {
const rect = offsetParent.getBoundingClientRect();
return {
left: rect.left - offsetParent.scrollLeft,
top: rect.top - offsetParent.scrollTop
};
}
return { left: 0, top: 0 };
}
/**
* @hidden
*/
markForCheck() {
this.changeDetector.markForCheck();
}
/**
* @hidden
*/
reflow(element) {
return element.offsetWidth;
}
/**
* @hidden
*/
swapFocusableChildren(firstItemIndex, secondItemIndex) {
[this.focusableItems[firstItemIndex], this.focusableItems[secondItemIndex]] = [this.focusableItems[secondItemIndex], this.focusableItems[firstItemIndex]];
}
/**
* @hidden
*/
applyAnimationStyle(el, prop, val) {
const style = el && el.style;
if (style) {
if (!(prop in style)) {
prop = '-webkit-' + prop;
}
style[prop] = val;
}
}
subscribeEvents() {
this.localizationChangeSubscription = this.localization
.changes
.subscribe(({ rtl }) => this.direction = rtl ? 'rtl' : 'ltr');
this.dragStartSubscription = this.onDragStartSubject
.subscribe((event) => {
if (!event.target) {
return;
}
this.sortableService.originDraggable = event.target;
this.sortableService.originIndex = event.target.index;
this.sortableService.activeDraggable = event.target;
this.sortableService.lastDraggable = event.target;
this.sortableService.target = this;
this.sortableService.setSource(this);
const dragStartEvent = new DragStartEvent({ index: event.target.index });
this.dragStart.emit(dragStartEvent);