UNPKG

@progress/kendo-angular-sortable

Version:

A Sortable Component for Angular

1,470 lines (1,459 loc) 78.5 kB
/**----------------------------------------------------------------------------------------- * 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, 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 { NgFor, NgClass, NgStyle, NgIf, 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) { 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: 1751463351, version: '19.2.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: "16.2.12", ngImport: i0, type: SortableService, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: SortableService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: SortableService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: function () { return [{ type: i0.NgZone }]; } }); /** * @hidden */ class SortableContainer { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: SortableContainer, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: SortableContainer }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", 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: "16.2.12", 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: "16.2.12", 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: "16.2.12", ngImport: i0, type: DraggableDirective, decorators: [{ type: Directive, args: [{ selector: '[kendoDraggable]', standalone: true }] }], ctorParameters: function () { return [{ 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: "16.2.12", ngImport: i0, type: ItemTemplateDirective, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "16.2.12", type: ItemTemplateDirective, isStandalone: true, selector: "[kendoSortableItemTemplate]", ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ItemTemplateDirective, decorators: [{ type: Directive, args: [{ selector: '[kendoSortableItemTemplate]', standalone: true }] }], ctorParameters: function () { return [{ type: i0.TemplateRef }]; } }); /** * @hidden */ class PlaceholderTemplateDirective { templateRef; constructor(templateRef) { this.templateRef = templateRef; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: PlaceholderTemplateDirective, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "16.2.12", type: PlaceholderTemplateDirective, isStandalone: true, selector: "[kendoSortablePlaceholderTemplate]", ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: PlaceholderTemplateDirective, decorators: [{ type: Directive, args: [{ selector: '[kendoSortablePlaceholderTemplate]', standalone: true }] }], ctorParameters: function () { return [{ 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 = event.keyCode; 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) { if (keyCode >= Keys.ArrowLeft && keyCode <= Keys.ArrowDown) { 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); if (dragStartEvent.isDef