@progress/kendo-angular-scrollview
Version:
A ScrollView Component for Angular
728 lines (721 loc) • 28.3 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
/* eslint-disable @typescript-eslint/no-inferrable-types */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Component, ContentChild, ElementRef, EventEmitter, HostBinding, Input, Output, TemplateRef, NgZone, ViewChild, Renderer2 } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Dir } from './enums';
import { DraggableDirective, Keys } from '@progress/kendo-angular-common';
import { validatePackage } from '@progress/kendo-licensing';
import { packageMetadata } from './package-metadata';
import { DataCollection, DataResultIterator } from './data.collection';
import { LocalizationService, L10N_PREFIX } from '@progress/kendo-angular-l10n';
import { chevronLeftIcon, chevronRightIcon } from '@progress/kendo-svg-icons';
import { Subscription } from 'rxjs';
import { ScrollViewPagerComponent } from './scrollview-pager.component';
import { NgFor, NgStyle, NgTemplateOutlet, NgIf } from '@angular/common';
import { LocalizedMessagesDirective } from './localization/localized-messages.directive';
import { IconWrapperComponent } from '@progress/kendo-angular-icons';
import * as i0 from "@angular/core";
import * as i1 from "@progress/kendo-angular-l10n";
let idx = 0;
/**
* Represents the [Kendo UI ScrollView component for Angular]({% slug overview_scrollview %}).
*
* @example
* ```ts
*
* _@Component({
* selector: 'my-app',
* template: `
* <kendo-scrollview
* [data]="items"
* [width]="width"
* [height]="height"
* [endless]="endless"
* [pageable]="pageable">
* <ng-template let-item="item">
* <h2 class="demo-title">{{item.title}}</h2>
* <img src='{{item.url}}' alt='{{item.title}}' [ngStyle]="{minWidth: width}" draggable="false" />
* </ng-template>
* </kendo-scrollview>
* `,
* styles: [`
* .k-scrollview-wrap {
* margin: 0 auto;
* }
* .demo-title {
* position: absolute;
* top: 0;
* left: 0;
* right: 0;
* margin: 0;
* padding: 15px;
* color: #fff;
* background-color: rgba(0,0,0,.4);
* text-align: center;
* font-size: 24px;
* }
* `]
* })
*
* class AppComponent {
* public width: string = "600px";
* public height: string = "400px";
* public endless: boolean = false;
* public pageable: boolean = false;
* public items: any[] = [
* { title: 'Flower', url: 'https://bit.ly/2cJjYuB' },
* { title: 'Mountain', url: 'https://bit.ly/2cTBNaL' },
* { title: 'Sky', url: 'https://bit.ly/2cJl3Cx' }
* ];
* }
* ```
*/
export class ScrollViewComponent {
element;
localization;
ngZone;
renderer;
/**
* @hidden
*/
chevronLeftIcon = chevronLeftIcon;
/**
* @hidden
*/
chevronRightIcon = chevronRightIcon;
/**
* Provides the data source for the ScrollView ([see example]({% slug datasources_scrollview %})).
*/
data = [];
/**
* Represents the current item index ([see example]({% slug activeitems_scrollview %})).
*/
set activeIndex(value) {
this.index = this._activeIndex = value;
}
get activeIndex() {
return this._activeIndex;
}
/**
* Sets the width of the ScrollView ([see example]({% slug dimensions_scrollview %})).
* By default, the width is not set and you have to explicitly define the `width` value.
*/
width;
/**
* Sets the height of the ScrollView ([see example]({% slug dimensions_scrollview %})).
* By default, the height is not set and you have to explicitly define the `height` value.
*/
height;
/**
* Enables or disables the endless scrolling mode in which the data source items are endlessly looped
* ([see example]({% slug endlessscrolling_scrollview %})). By default, `endless` is set to `false`
* and the endless scrolling mode is disabled.
*/
endless = false;
/**
* Sets `pagerOverlay` to one of three possible values: `dark`, `light` or `none`.
* By default, the pager overlay is set to `none`.
*/
pagerOverlay = 'none';
/**
* Enables or disables the built-in animations ([see example]({% slug animations_scrollview %})).
* By default, `animate` is set to `true` and animations are enabled.
*/
animate = true;
/**
* Enables or disables the built-in pager ([see example]({% slug paging_scrollview %})).
* By default, `pageable` is set to `false` and paging is disabled.
*/
pageable = false;
/**
* Enables or disables the built-in navigation arrows ([see example]({% slug arrows_scrollview %})).
* By default, `arrows` is set to `false` and arrows are disabled.
*/
arrows = false;
/**
* Fires after the current item is changed.
*/
itemChanged = new EventEmitter();
/**
* Fires after the activeIndex has changed. Allows for two-way binding of the activeIndex property.
*/
activeIndexChange = new EventEmitter();
itemTemplateRef;
itemWrapper;
prevButton;
nextButton;
scrollViewClass = true;
scrollViewRole = 'application';
scrollViewRoleDescription = 'carousel';
get scrollViewLightOverlayClass() {
return this.pagerOverlay === 'light';
}
get scrollViewDarkOverlayClass() {
return this.pagerOverlay === 'dark';
}
get hostWidth() { return this.width; }
get hostHeight() { return this.height; }
tabIndex = 0;
ariaLive = 'assertive';
get dir() {
return this.direction;
}
touchAction = 'pan-y pinch-zoom';
animationState = null;
view = new DataCollection(() => new DataResultIterator(this.data, this.activeIndex, this.endless, this.pageIndex, this.isRTL));
/**
* @hidden
*/
scrollviewId;
isDataSourceEmpty = false;
subs = new Subscription();
_activeIndex = 0;
index = 0;
initialTouchCoordinate;
pageIndex = null;
transforms = ['translateX(-100%)', 'translateX(0%)', 'translateX(100%)'];
get direction() {
return this.localization.rtl ? 'rtl' : 'ltr';
}
constructor(element, localization, ngZone, renderer) {
this.element = element;
this.localization = localization;
this.ngZone = ngZone;
this.renderer = renderer;
validatePackage(packageMetadata);
}
ngOnInit() {
this.subs.add(this.renderer.listen(this.element.nativeElement, 'keydown', event => this.keyDown(event)));
if (this.arrows) {
this.scrollviewId = `k-scrollview-wrap-${++idx}`;
}
}
ngOnDestroy() {
this.subs.unsubscribe();
}
ngOnChanges(_) {
if (this.data && this.data.length === 0) {
this.activeIndex = Math.max(Math.min(this.activeIndex, this.view.total - 1), 0);
}
}
/**
* Navigates the ScrollView to the previous item.
*/
prev() {
this.navigate(Dir.Prev);
}
/**
* Navigates the ScrollView to the next item.
*/
next() {
this.navigate(Dir.Next);
}
/**
* @hidden
*/
transitionEndHandler(e) {
this.animationState = null;
if (e.toState === 'left' || e.toState === 'right') {
if (this.pageIndex !== null) {
this.activeIndex = this.pageIndex;
this.pageIndex = null;
}
this.activeIndex = this.index;
this.activeIndexChange.emit(this.activeIndex);
this.itemChanged.emit({ index: this.activeIndex, item: this.view.item(1) });
}
}
/**
* @hidden
*/
handlePress(e) {
this.initialTouchCoordinate = e.pageX;
}
/**
* @hidden
*/
handleDrag(e) {
const deltaX = e.pageX - this.initialTouchCoordinate;
if (!this.animationState && !this.isDragForbidden(deltaX) && this.draggedInsideBounds(deltaX)) {
this.renderer.setStyle(this.itemWrapper.nativeElement, 'transform', `translateX(${deltaX}px)`);
}
}
/**
* @hidden
*/
handleRelease(e) {
const deltaX = e.pageX - this.initialTouchCoordinate;
if (this.isDragForbidden(deltaX)) {
return;
}
this.ngZone.run(() => {
if (this.draggedEnoughToNavigate(deltaX)) {
if (this.isRTL) {
this.changeIndex(deltaX < 0 ? Dir.Prev : Dir.Next);
}
else {
this.changeIndex(deltaX > 0 ? Dir.Prev : Dir.Next);
}
if (!this.animate) {
this.renderer.removeStyle(this.itemWrapper.nativeElement, 'transform');
this.activeIndexChange.emit(this.activeIndex);
this.itemChanged.emit({ index: this.activeIndex, item: this.view.item(1) });
}
}
else {
if (this.animate && deltaX) {
this.animationState = 'center';
}
else {
this.renderer.removeStyle(this.itemWrapper.nativeElement, 'transform');
}
}
});
}
/**
* @hidden
*/
pageChange(idx) {
if (!this.animationState && this.activeIndex !== idx) {
if (this.animate) {
this.pageIndex = idx;
if (this.isRTL) {
this.animationState = (this.pageIndex > this.index ? 'right' : 'left');
}
else {
this.animationState = (this.pageIndex > this.index ? 'left' : 'right');
}
}
else {
this.activeIndex = idx;
}
}
}
/**
* @hidden
*/
inlineListItemStyles(idx) {
return {
'height': this.height,
'transform': this.transforms[idx],
'width': '100%',
'position': 'absolute',
'top': '0',
'left': '0'
};
}
/**
* @hidden
*/
displayLeftArrow() {
let isNotBorderItem;
if (this.isRTL) {
isNotBorderItem = this.activeIndex + 1 < this.view.total;
}
else {
isNotBorderItem = this.activeIndex > 0;
}
return (this.endless || isNotBorderItem) && this.view.total > 0;
}
/**
* @hidden
*/
leftArrowClick() {
if (this.isRTL) {
this.next();
}
else {
this.prev();
}
}
/**
* @hidden
*/
displayRightArrow() {
let isNotBorderItem;
if (this.isRTL) {
isNotBorderItem = this.activeIndex > 0;
}
else {
isNotBorderItem = this.activeIndex + 1 < this.view.total;
}
return (this.endless || isNotBorderItem) && this.view.total > 0;
}
/**
* @hidden
*/
rightArrowClick() {
if (this.isRTL) {
this.prev();
}
else {
this.next();
}
}
draggedInsideBounds(deltaX) {
return Math.abs(deltaX) <= this.element.nativeElement.offsetWidth;
}
draggedEnoughToNavigate(deltaX) {
return Math.abs(deltaX) > (this.element.nativeElement.offsetWidth / 2);
}
isDragForbidden(deltaX) {
let pastEnd;
if (this.isRTL) {
pastEnd = deltaX < 0 && deltaX !== 0;
}
else {
pastEnd = deltaX > 0 && deltaX !== 0;
}
const isEndReached = ((this.activeIndex === 0 && pastEnd) || (this.activeIndex === this.view.total - 1 && !pastEnd));
return !this.endless && isEndReached;
}
keyDown(e) {
const keyCode = e.keyCode;
if (keyCode === Keys.ArrowLeft) {
if (this.isRTL) {
this.next();
return;
}
this.prev();
}
else if (keyCode === Keys.ArrowRight) {
if (this.isRTL) {
this.prev();
return;
}
this.next();
}
if (this.arrows && keyCode === Keys.Space || keyCode === Keys.Enter) {
const prevButton = this.prevButton?.nativeElement;
const nextButton = this.nextButton?.nativeElement;
const activeElement = document.activeElement;
const isPrevButtonFocused = activeElement === prevButton;
const isNextButtonFocused = activeElement === nextButton;
if (isPrevButtonFocused) {
if (this.isRTL) {
this.next();
return;
}
this.prev();
}
else if (isNextButtonFocused) {
if (this.isRTL) {
this.prev();
return;
}
this.next();
}
}
}
navigate(direction) {
if (this.isDataSourceEmpty || this.animationState) {
return;
}
this.changeIndex(direction);
if (!this.animate) {
this.activeIndexChange.emit(this.activeIndex);
this.itemChanged.emit({ index: this.activeIndex, item: this.view.item(1) });
}
}
changeIndex(direction) {
if (direction === Dir.Next && this.view.canMoveNext()) {
this.index = (this.index + 1) % this.view.total;
if (this.animate) {
this.animationState = this.isRTL ? 'right' : 'left';
}
else {
this.activeIndex = this.index;
}
}
else if (direction === Dir.Prev && this.view.canMovePrev()) {
this.index = this.index === 0 ? this.view.total - 1 : this.index - 1;
if (this.animate) {
this.animationState = this.isRTL ? 'left' : 'right';
}
else {
this.activeIndex = this.index;
}
}
}
get isRTL() {
return this.direction === 'rtl';
}
get prevButtonArrowIcon() {
return this.direction === 'ltr' ? 'chevron-left' : 'chevron-right';
}
get nextButtonArrowIcon() {
return this.direction === 'ltr' ? 'chevron-right' : 'chevron-left';
}
get prevButtonArrowSVGIcon() {
return this.direction === 'ltr' ? this.chevronLeftIcon : this.chevronRightIcon;
}
get nextButtonArrowSVGIcon() {
return this.direction === 'ltr' ? this.chevronRightIcon : this.chevronLeftIcon;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ScrollViewComponent, deps: [{ token: i0.ElementRef }, { token: i1.LocalizationService }, { token: i0.NgZone }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.2.12", type: ScrollViewComponent, isStandalone: true, selector: "kendo-scrollview", inputs: { data: "data", activeIndex: "activeIndex", width: "width", height: "height", endless: "endless", pagerOverlay: "pagerOverlay", animate: "animate", pageable: "pageable", arrows: "arrows" }, outputs: { itemChanged: "itemChanged", activeIndexChange: "activeIndexChange" }, host: { properties: { "class.k-scrollview": "this.scrollViewClass", "attr.role": "this.scrollViewRole", "attr.aria-roledescription": "this.scrollViewRoleDescription", "class.k-scrollview-light": "this.scrollViewLightOverlayClass", "class.k-scrollview-dark": "this.scrollViewDarkOverlayClass", "style.width": "this.hostWidth", "style.height": "this.hostHeight", "attr.tabindex": "this.tabIndex", "attr.aria-live": "this.ariaLive", "attr.dir": "this.dir", "style.touch-action": "this.touchAction" } }, providers: [
LocalizationService,
{
provide: L10N_PREFIX,
useValue: 'kendo.scrollview'
}
], queries: [{ propertyName: "itemTemplateRef", first: true, predicate: TemplateRef, descendants: true }], viewQueries: [{ propertyName: "itemWrapper", first: true, predicate: ["itemWrapper"], descendants: true }, { propertyName: "prevButton", first: true, predicate: ["prevButton"], descendants: true }, { propertyName: "nextButton", first: true, predicate: ["nextButton"], descendants: true }], exportAs: ["kendoScrollView"], usesOnChanges: true, ngImport: i0, template: `
<ng-container kendoScrollViewLocalizedMessages
i18n-pagerButtonLabel="kendo.scrollview.pagerButtonLabel|The label for the buttons inside the ScrollView Pager"
pagerButtonLabel="{{ 'Item {itemIndex}' }}">
<ng-container>
<div class="k-scrollview-wrap k-scrollview-animate"
#itemWrapper
role="list"
[id]="scrollviewId"
[ ]="animationState"
( .done)="transitionEndHandler($event)"
kendoDraggable
(kendoDrag)="handleDrag($event)"
(kendoPress)="handlePress($event)"
(kendoRelease)="handleRelease($event)"
>
<div class="k-scrollview-view"
*ngFor="let item of view;let i=index"
role="listitem"
aria-roledescription="slide"
[ngStyle]="inlineListItemStyles(i)"
[attr.aria-hidden]="i !== 1"
>
<ng-template
[ngTemplateOutlet]="itemTemplateRef"
[ngTemplateOutletContext]="{ item: item }">
</ng-template>
</div>
</div>
<div class='k-scrollview-elements'
[ngStyle]="{'height': height}"
*ngIf="!isDataSourceEmpty && (pageable||arrows)">
<span
#prevButton
class="k-scrollview-prev"
role="button"
[attr.aria-controls]="scrollviewId"
aria-label="previous"
*ngIf="arrows && displayLeftArrow()"
(click)="leftArrowClick()">
<kendo-icon-wrapper
size="xxxlarge"
[name]="prevButtonArrowIcon"
[svgIcon]="prevButtonArrowSVGIcon"
>
</kendo-icon-wrapper>
</span>
<span
#nextButton
class="k-scrollview-next"
role="button"
[attr.aria-controls]="scrollviewId"
aria-label="next"
*ngIf="arrows && displayRightArrow()"
(click)="rightArrowClick()">
<kendo-icon-wrapper
size="xxxlarge"
[name]="nextButtonArrowIcon"
[svgIcon]="nextButtonArrowSVGIcon"
>
</kendo-icon-wrapper>
</span>
<kendo-scrollview-pager
class='k-scrollview-nav-wrap'
*ngIf="pageable"
(pagerIndexChange)="pageChange($event)"
[data]="data"
[activeIndex]="activeIndex">
</kendo-scrollview-pager>
</div>
<div class="k-sr-only" aria-live="polite"></div>
`, isInline: true, dependencies: [{ kind: "directive", type: LocalizedMessagesDirective, selector: "[kendoScrollViewLocalizedMessages]" }, { kind: "directive", type: DraggableDirective, selector: "[kendoDraggable]", inputs: ["enableDrag"], outputs: ["kendoPress", "kendoDrag", "kendoRelease"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: IconWrapperComponent, selector: "kendo-icon-wrapper", inputs: ["name", "svgIcon", "innerCssClass", "customFontClass", "size"], exportAs: ["kendoIconWrapper"] }, { kind: "component", type: ScrollViewPagerComponent, selector: "kendo-scrollview-pager", inputs: ["activeIndex", "data"], outputs: ["pagerIndexChange"] }], animations: [
trigger('animateTo', [
state('center, left, right', style({ transform: 'translateX(0)' })),
transition('* => right', [
animate('300ms ease-out', style({ transform: 'translateX(100%)' }))
]),
transition('* => left', [
animate('300ms ease-out', style({ transform: 'translateX(-100%)' }))
]),
transition('* => center', [
animate('300ms ease-out')
])
])
] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ScrollViewComponent, decorators: [{
type: Component,
args: [{
animations: [
trigger('animateTo', [
state('center, left, right', style({ transform: 'translateX(0)' })),
transition('* => right', [
animate('300ms ease-out', style({ transform: 'translateX(100%)' }))
]),
transition('* => left', [
animate('300ms ease-out', style({ transform: 'translateX(-100%)' }))
]),
transition('* => center', [
animate('300ms ease-out')
])
])
],
exportAs: 'kendoScrollView',
providers: [
LocalizationService,
{
provide: L10N_PREFIX,
useValue: 'kendo.scrollview'
}
],
selector: 'kendo-scrollview',
template: `
<ng-container kendoScrollViewLocalizedMessages
i18n-pagerButtonLabel="kendo.scrollview.pagerButtonLabel|The label for the buttons inside the ScrollView Pager"
pagerButtonLabel="{{ 'Item {itemIndex}' }}">
<ng-container>
<div class="k-scrollview-wrap k-scrollview-animate"
#itemWrapper
role="list"
[id]="scrollviewId"
[ ]="animationState"
( .done)="transitionEndHandler($event)"
kendoDraggable
(kendoDrag)="handleDrag($event)"
(kendoPress)="handlePress($event)"
(kendoRelease)="handleRelease($event)"
>
<div class="k-scrollview-view"
*ngFor="let item of view;let i=index"
role="listitem"
aria-roledescription="slide"
[ngStyle]="inlineListItemStyles(i)"
[attr.aria-hidden]="i !== 1"
>
<ng-template
[ngTemplateOutlet]="itemTemplateRef"
[ngTemplateOutletContext]="{ item: item }">
</ng-template>
</div>
</div>
<div class='k-scrollview-elements'
[ngStyle]="{'height': height}"
*ngIf="!isDataSourceEmpty && (pageable||arrows)">
<span
#prevButton
class="k-scrollview-prev"
role="button"
[attr.aria-controls]="scrollviewId"
aria-label="previous"
*ngIf="arrows && displayLeftArrow()"
(click)="leftArrowClick()">
<kendo-icon-wrapper
size="xxxlarge"
[name]="prevButtonArrowIcon"
[svgIcon]="prevButtonArrowSVGIcon"
>
</kendo-icon-wrapper>
</span>
<span
#nextButton
class="k-scrollview-next"
role="button"
[attr.aria-controls]="scrollviewId"
aria-label="next"
*ngIf="arrows && displayRightArrow()"
(click)="rightArrowClick()">
<kendo-icon-wrapper
size="xxxlarge"
[name]="nextButtonArrowIcon"
[svgIcon]="nextButtonArrowSVGIcon"
>
</kendo-icon-wrapper>
</span>
<kendo-scrollview-pager
class='k-scrollview-nav-wrap'
*ngIf="pageable"
(pagerIndexChange)="pageChange($event)"
[data]="data"
[activeIndex]="activeIndex">
</kendo-scrollview-pager>
</div>
<div class="k-sr-only" aria-live="polite"></div>
`,
standalone: true,
imports: [LocalizedMessagesDirective, DraggableDirective, NgFor, NgStyle, NgTemplateOutlet, NgIf, IconWrapperComponent, ScrollViewPagerComponent]
}]
}], ctorParameters: function () { return [{ type: i0.ElementRef }, { type: i1.LocalizationService }, { type: i0.NgZone }, { type: i0.Renderer2 }]; }, propDecorators: { data: [{
type: Input
}], activeIndex: [{
type: Input
}], width: [{
type: Input
}], height: [{
type: Input
}], endless: [{
type: Input
}], pagerOverlay: [{
type: Input
}], animate: [{
type: Input
}], pageable: [{
type: Input
}], arrows: [{
type: Input
}], itemChanged: [{
type: Output
}], activeIndexChange: [{
type: Output
}], itemTemplateRef: [{
type: ContentChild,
args: [TemplateRef]
}], itemWrapper: [{
type: ViewChild,
args: ['itemWrapper']
}], prevButton: [{
type: ViewChild,
args: ['prevButton']
}], nextButton: [{
type: ViewChild,
args: ['nextButton']
}], scrollViewClass: [{
type: HostBinding,
args: ['class.k-scrollview']
}], scrollViewRole: [{
type: HostBinding,
args: ['attr.role']
}], scrollViewRoleDescription: [{
type: HostBinding,
args: ['attr.aria-roledescription']
}], scrollViewLightOverlayClass: [{
type: HostBinding,
args: ['class.k-scrollview-light']
}], scrollViewDarkOverlayClass: [{
type: HostBinding,
args: ['class.k-scrollview-dark']
}], hostWidth: [{
type: HostBinding,
args: ['style.width']
}], hostHeight: [{
type: HostBinding,
args: ['style.height']
}], tabIndex: [{
type: HostBinding,
args: ['attr.tabindex']
}], ariaLive: [{
type: HostBinding,
args: ['attr.aria-live']
}], dir: [{
type: HostBinding,
args: ['attr.dir']
}], touchAction: [{
type: HostBinding,
args: ['style.touch-action']
}] } });