diff --git a/demo/app/examples/groups.component.ts b/demo/app/examples/groups.component.ts index c2e366ff2..a87c77fdc 100644 --- a/demo/app/examples/groups.component.ts +++ b/demo/app/examples/groups.component.ts @@ -14,7 +14,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; bindLabel="name" bindValue="name" groupBy="country" - [multiple]="true" [(ngModel)]="selectedAccount"> {{item.country || 'Unnamed group'}} @@ -149,7 +148,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; ` }) export class SelectGroupsComponent { - selectedAccount = ['Samantha']; + selectedAccount = 'Michael'; accounts = [ { name: 'Jill', email: 'jill@email.com', age: 15, country: undefined, child: { state: 'Active' } }, { name: 'Henry', email: 'henry@email.com', age: 10, country: undefined, child: { state: 'Active' } }, diff --git a/demo/app/examples/virtual-scroll.component.ts b/demo/app/examples/virtual-scroll.component.ts index a74b426c1..55a0221f2 100644 --- a/demo/app/examples/virtual-scroll.component.ts +++ b/demo/app/examples/virtual-scroll.component.ts @@ -16,6 +16,7 @@ import { HttpClient } from '@angular/common/http'; bindLabel="title" bindValue="thumbnailUrl" placeholder="Select photo" + appendTo="body" (scroll)="onScroll($event)" (scrollToEnd)="onScrollToEnd()"> diff --git a/src/ng-select/items-list.spec.ts b/src/ng-select/items-list.spec.ts index 823907c08..465998f54 100644 --- a/src/ng-select/items-list.spec.ts +++ b/src/ng-select/items-list.spec.ts @@ -1,7 +1,7 @@ -import { NgSelectComponent } from './ng-select.component'; +import { NgSelectConfig } from './config.service'; import { ItemsList } from './items-list'; +import { NgSelectComponent } from './ng-select.component'; import { DefaultSelectionModel } from './selection-model'; -import { NgSelectConfig } from './config.service'; describe('ItemsList', () => { describe('select', () => { @@ -427,6 +427,35 @@ describe('ItemsList', () => { }); }); + describe('markSelectedOrDefault', () => { + let list: ItemsList; + let cmp: NgSelectComponent; + + beforeEach(() => { + cmp = ngSelectFactory(); + list = itemsListFactory(cmp); + const items = Array.from(Array(30)).map((_, index) => (`item-${index}`)); + list.setItems(items); + }); + + it('should mark first item', () => { + list.markSelectedOrDefault(true); + expect(list.markedIndex).toBe(0); + }); + + it('should keep marked item if it is above last selected item', () => { + list.select(list.items[10]); + list.markSelectedOrDefault(); + expect(list.markedIndex).toBe(10); + + list.markNextItem(); + list.markNextItem(); + list.markNextItem(); + list.markSelectedOrDefault(); + expect(list.markedIndex).toBe(13); + }); + }); + function itemsListFactory(cmp: NgSelectComponent): ItemsList { return new ItemsList(cmp, new DefaultSelectionModel()); } diff --git a/src/ng-select/items-list.ts b/src/ng-select/items-list.ts index 8d0303292..22b5aef0d 100644 --- a/src/ng-select/items-list.ts +++ b/src/ng-select/items-list.ts @@ -201,9 +201,10 @@ export class ItemsList { if (this._filteredItems.length === 0) { return; } - const indexOfLastSelected = this._ngSelect.hideSelected ? -1 : this._filteredItems.indexOf(this.lastSelectedItem); - if (this.lastSelectedItem && indexOfLastSelected > -1) { - this._markedIndex = indexOfLastSelected; + + const lastMarkedIndex = this._getLastMarkedIndex(); + if (lastMarkedIndex > -1) { + this._markedIndex = lastMarkedIndex; } else { if (this._ngSelect.excludeGroupsFromDefaultSelection) { this._markedIndex = markDefault ? this.filteredItems.findIndex(x => !x.disabled && !x.children) : -1; @@ -310,6 +311,19 @@ export class ItemsList { } } + private _getLastMarkedIndex() { + if (this._ngSelect.hideSelected) { + return -1; + } + + const selectedIndex = this._filteredItems.indexOf(this.lastSelectedItem); + if (this.lastSelectedItem && selectedIndex < 0) { + return -1; + } + + return Math.max(this.markedIndex, selectedIndex); + } + private _groupBy(items: NgOption[], prop: string | Function): OptionGroups { const groups = new Map(); if (items.length === 0) { diff --git a/src/ng-select/ng-dropdown-panel.component.ts b/src/ng-select/ng-dropdown-panel.component.ts index 41b501ec7..3b6cb987c 100644 --- a/src/ng-select/ng-dropdown-panel.component.ts +++ b/src/ng-select/ng-dropdown-panel.component.ts @@ -1,35 +1,35 @@ +import { DOCUMENT } from '@angular/common'; import { + ChangeDetectionStrategy, Component, - OnDestroy, - Renderer2, ElementRef, - Input, EventEmitter, - Output, - ViewChild, - SimpleChanges, + HostListener, + Inject, + Input, NgZone, - TemplateRef, - ViewEncapsulation, - ChangeDetectionStrategy, - AfterContentInit, - OnInit, OnChanges, - HostListener, + OnDestroy, + OnInit, Optional, - Inject + Output, + Renderer2, + SimpleChanges, + TemplateRef, + ViewChild, + ViewEncapsulation } from '@angular/core'; -import { DOCUMENT } from '@angular/common'; +import { animationFrameScheduler, asapScheduler, fromEvent, merge, Subject } from 'rxjs'; +import { auditTime, takeUntil } from 'rxjs/operators'; +import { NgDropdownPanelService, PanelDimensions } from './ng-dropdown-panel.service'; -import { NgOption } from './ng-select.types'; import { DropdownPosition } from './ng-select.component'; -import { WindowService } from './window.service'; -import { VirtualScrollService } from './virtual-scroll.service'; -import { takeUntil } from 'rxjs/operators'; -import { Subject, fromEvent, merge } from 'rxjs'; +import { NgOption } from './ng-select.types'; +import { isDefined } from './value-utils'; const TOP_CSS_CLASS = 'ng-select-top'; const BOTTOM_CSS_CLASS = 'ng-select-bottom'; +const SCROLL_SCHEDULER = typeof requestAnimationFrame !== 'undefined' ? animationFrameScheduler : asapScheduler; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -41,7 +41,7 @@ const BOTTOM_CSS_CLASS = 'ng-select-bottom';
-
+
@@ -50,13 +50,13 @@ const BOTTOM_CSS_CLASS = 'ng-select-bottom';
` }) -export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, AfterContentInit { +export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy { @Input() items: NgOption[] = []; @Input() markedItem: NgOption; @Input() position: DropdownPosition = 'auto'; @Input() appendTo: string; - @Input() bufferAmount = 4; + @Input() bufferAmount; @Input() virtualScroll = false; @Input() headerTemplate: TemplateRef; @Input() footerTemplate: TemplateRef; @@ -73,31 +73,44 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A private readonly _destroy$ = new Subject(); private readonly _dropdown: HTMLElement; + private _virtualPadding: HTMLElement; + private _scrollablePanel: HTMLElement; + private _contentPanel: HTMLElement; private _select: HTMLElement; - private _previousStart: number; - private _previousEnd: number; - private _startupLoop = true; - private _isScrolledToMarked = false; + private _parent: HTMLElement; private _scrollToEndFired = false; - private _currentPosition: DropdownPosition; - private _disposeScrollListener = () => { }; - private _disposeDocumentResizeListener = () => { }; + private _updateScrollHeight = false; + private _lastScrollPosition = 0; constructor( private _renderer: Renderer2, private _zone: NgZone, - private _virtualScrollService: VirtualScrollService, - private _window: WindowService, + private _panelService: NgDropdownPanelService, _elementRef: ElementRef, @Optional() @Inject(DOCUMENT) private _document: any ) { this._dropdown = _elementRef.nativeElement; } + private _currentPosition: DropdownPosition; + get currentPosition(): DropdownPosition { return this._currentPosition; } + private _itemsLength: number; + + private get itemsLength() { + return this._itemsLength; + } + + private set itemsLength(value: number) { + if (value !== this._itemsLength) { + this._itemsLength = value; + this._onItemsLengthChanged(); + } + } + @HostListener('mousedown', ['$event']) handleMousedown($event: MouseEvent) { const target = $event.target as HTMLElement; @@ -109,27 +122,22 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A ngOnInit() { this._select = this._dropdown.parentElement; + this._virtualPadding = this.paddingElementRef.nativeElement; + this._scrollablePanel = this.scrollElementRef.nativeElement; + this._contentPanel = this.contentElementRef.nativeElement; this._handleScroll(); - if (this._document) { - merge( - fromEvent(this._document, 'touchstart', { capture: true }), - fromEvent(this._document, 'mousedown', { capture: true }) - ) - .pipe(takeUntil(this._destroy$)) - .subscribe(($event) => this._handleOutsideClick($event)); - } + this._handleOutsideClick(); + this._appendDropdown(); } ngOnChanges(changes: SimpleChanges) { if (changes.items) { - this._isScrolledToMarked = false; - this._handleItemsChange(changes.items); + const change = changes.items; + this._onItemsChange(change.currentValue, change.firstChange); } } ngOnDestroy() { - this._disposeDocumentResizeListener(); - this._disposeScrollListener(); this._destroy$.next(); this._destroy$.complete(); this._destroy$.unsubscribe(); @@ -138,85 +146,91 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A } } - ngAfterContentInit() { - this._whenContentReady().then(() => { - if (this._destroy$.closed) { - return; - } - if (this.appendTo) { - this._appendDropdown(); - this._handleDocumentResize(); - } - this.updateDropdownPosition(); - }); - } - - refresh(): Promise { - return new Promise(resolve => { - this._zone.runOutsideAngular(() => { - this._window.requestAnimationFrame(() => { - this._updateItems().then(resolve); - }); - }); - }) - } - - scrollInto(item: NgOption) { - if (!item) { + scrollTo(option: NgOption, startFromOption = false) { + if (!option) { return; } - const index = this.items.indexOf(item); - if (index < 0 || index >= this.items.length) { + + const index = this.items.indexOf(option); + if (index < 0 || index >= this.itemsLength) { return; } - const d = this._calculateDimensions(this.virtualScroll ? 0 : index); - const scrollEl: Element = this.scrollElementRef.nativeElement; - const buffer = Math.floor(d.viewHeight / d.childHeight) - 1; + let scrollTo; if (this.virtualScroll) { - scrollEl.scrollTop = (index * d.childHeight) - (d.childHeight * Math.min(index, buffer)); + const itemHeight = this._panelService.dimensions.itemHeight; + scrollTo = this._panelService.getScrollTo(index * itemHeight, itemHeight, this._lastScrollPosition); } else { - const contentEl: HTMLElement = this.contentElementRef.nativeElement; - const childrenHeight = Array.from(contentEl.children).slice(0, index).reduce((c, n) => c + n.clientHeight, 0); - scrollEl.scrollTop = childrenHeight - (d.childHeight * Math.min(index, buffer)); + const item: HTMLElement = this._dropdown.querySelector(`#${option.htmlId}`); + const lastScroll = startFromOption ? item.offsetTop : this._lastScrollPosition; + scrollTo = this._panelService.getScrollTo(item.offsetTop, item.clientHeight, lastScroll); + } + + if (isDefined(scrollTo)) { + this._scrollablePanel.scrollTop = scrollTo; } } - scrollIntoTag() { - const el: Element = this.scrollElementRef.nativeElement; - const d = this._calculateDimensions(); - el.scrollTop = d.childHeight * (d.itemsLength + 1); + scrollToTag() { + const panel = this._scrollablePanel; + panel.scrollTop = panel.scrollHeight - panel.clientHeight; } - updateDropdownPosition() { - this._window.setTimeout(() => { - this._currentPosition = this._calculateCurrentPosition(this._dropdown); - if (this._currentPosition === 'top') { - this._renderer.addClass(this._dropdown, TOP_CSS_CLASS); - this._renderer.removeClass(this._dropdown, BOTTOM_CSS_CLASS); - this._renderer.addClass(this._select, TOP_CSS_CLASS); - this._renderer.removeClass(this._select, BOTTOM_CSS_CLASS) - } else { - this._renderer.addClass(this._dropdown, BOTTOM_CSS_CLASS); - this._renderer.removeClass(this._dropdown, TOP_CSS_CLASS); - this._renderer.addClass(this._select, BOTTOM_CSS_CLASS); - this._renderer.removeClass(this._select, TOP_CSS_CLASS); - } + adjustPosition() { + if (this._currentPosition === 'top') { + return; + } - if (this.appendTo) { - this._updateAppendedDropdownPosition(); - } + const parent = this._parent.getBoundingClientRect(); + const select = this._select.getBoundingClientRect(); + this._setOffset(parent, select); + } - this._dropdown.style.opacity = '1'; - }, 0); + private _handleDropdownPosition() { + this._currentPosition = this._calculateCurrentPosition(this._dropdown); + if (this._currentPosition === 'top') { + this._renderer.addClass(this._dropdown, TOP_CSS_CLASS); + this._renderer.removeClass(this._dropdown, BOTTOM_CSS_CLASS); + this._renderer.addClass(this._select, TOP_CSS_CLASS); + this._renderer.removeClass(this._select, BOTTOM_CSS_CLASS) + } else { + this._renderer.addClass(this._dropdown, BOTTOM_CSS_CLASS); + this._renderer.removeClass(this._dropdown, TOP_CSS_CLASS); + this._renderer.addClass(this._select, BOTTOM_CSS_CLASS); + this._renderer.removeClass(this._select, TOP_CSS_CLASS); + } + + if (this.appendTo) { + this._updatePosition(); + } + + this._dropdown.style.opacity = '1'; + } + + private _handleScroll() { + this._zone.runOutsideAngular(() => { + fromEvent(this.scrollElementRef.nativeElement, 'scroll') + .pipe(takeUntil(this._destroy$), auditTime(0, SCROLL_SCHEDULER)) + .subscribe((e: Event) => this._onContentScrolled(e.srcElement.scrollTop)); + }); } - private _handleOutsideClick($event: any) { - if (this._select.contains($event.target)) { + private _handleOutsideClick() { + if (!this._document) { return; } - if (this._dropdown.contains($event.target)) { + this._zone.runOutsideAngular(() => { + merge( + fromEvent(this._document, 'touchstart', { capture: true }), + fromEvent(this._document, 'mousedown', { capture: true }) + ).pipe(takeUntil(this._destroy$)) + .subscribe($event => this._checkToClose($event)); + }); + } + + private _checkToClose($event: any) { + if (this._select.contains($event.target) || this._dropdown.contains($event.target)) { return; } @@ -228,107 +242,121 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A this.outsideClick.emit(); } - private _handleScroll() { - this._disposeScrollListener = this._renderer.listen(this.scrollElementRef.nativeElement, 'scroll', () => { - this.refresh(); - this._fireScrollToEnd(); - }); - } - - private _handleItemsChange(items: { previousValue: NgOption[], currentValue: NgOption[] }) { + private _onItemsChange(items: NgOption[], firstChange: boolean) { + this.items = items || []; this._scrollToEndFired = false; - this._previousStart = undefined; - this._previousEnd = undefined; - if (items !== undefined && items.previousValue === undefined || - (items.previousValue !== undefined && items.previousValue.length === 0)) { - this._startupLoop = true; + this.itemsLength = items.length; + + if (this.virtualScroll) { + this._updateItemsRange(firstChange); + } else { + this._updateItems(firstChange); } - this.items = items.currentValue || []; - this.refresh().then(() => { - if (this.appendTo && this._currentPosition === 'top') { - this._updateAppendedDropdownPosition(); - } - }); } - private _updateItems(): Promise { - NgZone.assertNotInAngularZone(); + private _updateItems(firstChange: boolean) { + this.update.emit(this.items); - if (!this.virtualScroll) { - this._zone.run(() => { - this.update.emit(this.items.slice()); - this._scrollToMarked(); - }); - return Promise.resolve(); + if (firstChange === false) { + return; } - const loop = (resolve) => { - const d = this._calculateDimensions(); - const res = this._virtualScrollService.calculateItems(d, this.scrollElementRef.nativeElement, this.bufferAmount || 0); - - (this.paddingElementRef.nativeElement).style.height = `${res.scrollHeight}px`; - (this.contentElementRef.nativeElement).style.transform = 'translateY(' + res.topPadding + 'px)'; + this._zone.runOutsideAngular(() => { + Promise.resolve().then(() => { + const panelHeight = this._scrollablePanel.clientHeight; + this._panelService.setDimensions(0, panelHeight); + this._handleDropdownPosition(); + this.scrollTo(this.markedItem, firstChange); + }); + }); + } - if (res.start !== this._previousStart || res.end !== this._previousEnd) { - this._zone.run(() => { - this.update.emit(this.items.slice(res.start, res.end)); - this.scroll.emit({ start: res.start, end: res.end }); + private _updateItemsRange(firstChange: boolean) { + this._zone.runOutsideAngular(() => { + if (firstChange) { + this._measureDimensions().then((d: PanelDimensions) => { + const index = this.markedItem ? this.markedItem.index : 0; + this._renderItemsRange(index * d.itemHeight); + this._handleDropdownPosition(); }); - this._previousStart = res.start; - this._previousEnd = res.end; - - if (this._startupLoop === true) { - loop(resolve) - } - - } else if (this._startupLoop === true) { - this._startupLoop = false; - this._scrollToMarked(); - resolve(); + } else { + this._renderItemsRange(); } - }; - return new Promise((resolve) => loop(resolve)) + }); } - private _fireScrollToEnd() { - if (this._scrollToEndFired) { - return; + private _onContentScrolled(scrollTop: number) { + if (this.virtualScroll) { + this._renderItemsRange(scrollTop); } - const scroll: HTMLElement = this.scrollElementRef.nativeElement; - const padding: HTMLElement = this.virtualScroll ? - this.paddingElementRef.nativeElement : - this.contentElementRef.nativeElement; + this._lastScrollPosition = scrollTop; + this._fireScrollToEnd(scrollTop); + } - if (scroll.scrollTop + this._dropdown.clientHeight >= padding.clientHeight) { - this.scrollToEnd.emit(); - this._scrollToEndFired = true; + private _updateVirtualHeight(height: number) { + if (this._updateScrollHeight) { + this._virtualPadding.style.height = `${height}px`; + this._updateScrollHeight = false; } } - private _calculateDimensions(index = 0) { - return this._virtualScrollService.calculateDimensions( - this.items.length, - index, - this.scrollElementRef.nativeElement, - this.contentElementRef.nativeElement - ) + private _onItemsLengthChanged() { + this._updateScrollHeight = true; } - private _handleDocumentResize() { - if (!this.appendTo) { + private _renderItemsRange(scrollTop = null) { + if (scrollTop && this._lastScrollPosition === scrollTop) { return; } - this._disposeDocumentResizeListener = this._renderer.listen('window', 'resize', () => { - this._updateAppendedDropdownPosition(); + + scrollTop = scrollTop || this._scrollablePanel.scrollTop; + const range = this._panelService.calculateItems(scrollTop, this.itemsLength, this.bufferAmount); + this._updateVirtualHeight(range.scrollHeight); + this._contentPanel.style.transform = `translateY(${range.topPadding}px)`; + + this._zone.run(() => { + this.update.emit(this.items.slice(range.start, range.end)); + this.scroll.emit({ start: range.start, end: range.end }); }); + + if (isDefined(scrollTop) && this._lastScrollPosition === 0) { + this._scrollablePanel.scrollTop = scrollTop; + this._lastScrollPosition = scrollTop; + } } - private _scrollToMarked() { - if (this._isScrolledToMarked || !this.markedItem) { + private _measureDimensions(): Promise { + if (this._panelService.dimensions) { + return Promise.resolve(this._panelService.dimensions); + } + + const [first] = this.items; + this.update.emit([first]); + + return Promise.resolve().then(() => { + const option = this._dropdown.querySelector(`#${first.htmlId}`); + const optionHeight = option.clientHeight; + this._virtualPadding.style.height = `${optionHeight * this.itemsLength}px`; + const panelHeight = this._scrollablePanel.clientHeight; + this._panelService.setDimensions(optionHeight, panelHeight); + + return this._panelService.dimensions; + }); + } + + private _fireScrollToEnd(scrollTop: number) { + if (this._scrollToEndFired || scrollTop === 0) { return; } - this._isScrolledToMarked = true; - this.scrollInto(this.markedItem); + + const padding = this.virtualScroll ? + this._virtualPadding : + this._contentPanel; + + if (scrollTop + this._dropdown.clientHeight >= padding.clientHeight) { + this._zone.run(() => this.scrollToEnd.emit()); + this._scrollToEndFired = true; + } } private _calculateCurrentPosition(dropdownEl: HTMLElement) { @@ -348,43 +376,40 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A } private _appendDropdown() { - const parent = document.querySelector(this.appendTo); + if (!this.appendTo) { + return; + } + + this._parent = document.querySelector(this.appendTo); if (!parent) { - throw new Error(`appendTo selector ${this.appendTo} did not found any parent element`) + throw new Error(`appendTo selector ${this.appendTo} did not found any parent element`); } - parent.appendChild(this._dropdown); + this._parent.appendChild(this._dropdown); } - private _updateAppendedDropdownPosition() { - const parent = document.querySelector(this.appendTo) || document.body; - this._dropdown.style.display = 'none'; - const selectRect: ClientRect = this._select.getBoundingClientRect(); - const boundingRect = parent.getBoundingClientRect(); - this._dropdown.style.display = ''; - const offsetTop = selectRect.top - boundingRect.top; - const offsetLeft = selectRect.left - boundingRect.left; - const topDelta = this._currentPosition === 'bottom' ? selectRect.height : -this._dropdown.clientHeight; - this._dropdown.style.top = offsetTop + topDelta + 'px'; - this._dropdown.style.bottom = 'auto'; + private _updatePosition() { + const select = this._select.getBoundingClientRect(); + const parent = this._parent.getBoundingClientRect(); + const offsetLeft = select.left - parent.left; + + this._setOffset(parent, select); + this._dropdown.style.left = offsetLeft + 'px'; - this._dropdown.style.width = selectRect.width + 'px'; - this._dropdown.style.minWidth = selectRect.width + 'px'; + this._dropdown.style.width = select.width + 'px'; + this._dropdown.style.minWidth = select.width + 'px'; } - private _whenContentReady(): Promise { - if (this.items.length === 0) { - return Promise.resolve(); + private _setOffset(parent: ClientRect, select: ClientRect) { + const delta = select.height; + + if (this._currentPosition === 'top') { + const offsetBottom = parent.bottom - select.bottom; + this._dropdown.style.bottom = offsetBottom + delta + 'px'; + this._dropdown.style.top = 'auto'; + } else if (this._currentPosition === 'bottom') { + const offsetTop = select.top - parent.top; + this._dropdown.style.top = offsetTop + delta + 'px'; + this._dropdown.style.bottom = 'auto'; } - const ready = (resolve) => { - const ngOption = this._dropdown.querySelector('.ng-option'); - if (ngOption) { - resolve(); - return; - } - this._zone.runOutsideAngular(() => { - setTimeout(() => ready(resolve), 5); - }); - }; - return new Promise((resolve) => ready(resolve)) } } diff --git a/src/ng-select/ng-dropdown-panel.service.spec.ts b/src/ng-select/ng-dropdown-panel.service.spec.ts new file mode 100644 index 000000000..6792aa64f --- /dev/null +++ b/src/ng-select/ng-dropdown-panel.service.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; +import { NgDropdownPanelService } from './ng-dropdown-panel.service'; + +describe('NgDropdownPanelService', () => { + + let service: NgDropdownPanelService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [NgDropdownPanelService] + }); + + service = TestBed.get(NgDropdownPanelService); + }); + + describe('calculate items', () => { + it('should calculate items from start', () => { + const itemsLength = 100; + const buffer = 4; + + service.setDimensions(25, 100); + const res = service.calculateItems(0, itemsLength, buffer); + + expect(res).toEqual({ + start: 0, + end: 9, + topPadding: 0, + scrollHeight: 2500 + }) + }); + + it('should calculate items when scrolled', () => { + const itemsLength = 100; + const buffer = 4; + + service.setDimensions(25, 100); + const res = service.calculateItems(1250, itemsLength, buffer); + + expect(res).toEqual({ + start: 46, + end: 59, + topPadding: 1150, + scrollHeight: 2500 + }) + }); + }); + + describe('scroll to', () => { + beforeEach(() => { + service.setDimensions(40, 240); + }); + + it('should not scroll if item is in visible area', () => { + expect(service.getScrollTo(0, 40, 0)).toBe(0); + expect(service.getScrollTo(200, 40, 0)).toBeNull(); + }); + + it('should scroll by item height', () => { + expect(service.getScrollTo(240, 40, 0)).toBe(40); + }); + + it('should start from top when reached bottom', () => { + expect(service.getScrollTo(0, 40, 400)).toBe(0); + }); + + it('should move to bottom when reached top', () => { + expect(service.getScrollTo(600, 40, 0)).toBe(400); + }); + }); +}) +; diff --git a/src/ng-select/ng-dropdown-panel.service.ts b/src/ng-select/ng-dropdown-panel.service.ts new file mode 100644 index 000000000..6cb0498b0 --- /dev/null +++ b/src/ng-select/ng-dropdown-panel.service.ts @@ -0,0 +1,73 @@ +export interface ItemsRangeResult { + scrollHeight: number; + topPadding: number; + start: number; + end: number; +} + +export interface PanelDimensions { + itemHeight: number; + panelHeight: number; + itemsPerViewport: number; +} + +export class NgDropdownPanelService { + + private _dimensions: PanelDimensions; + + get dimensions() { + return this._dimensions; + } + + calculateItems(scrollPos: number, itemsLength: number, buffer: number): ItemsRangeResult { + const d = this._dimensions; + const scrollHeight = d.itemHeight * itemsLength; + + const scrollTop = Math.max(0, scrollPos); + const indexByScrollTop = scrollTop / scrollHeight * itemsLength; + let end = Math.min(itemsLength, Math.ceil(indexByScrollTop) + (d.itemsPerViewport + 1)); + + const maxStartEnd = end; + const maxStart = Math.max(0, maxStartEnd - d.itemsPerViewport); + let start = Math.min(maxStart, Math.floor(indexByScrollTop)); + + let topPadding = d.itemHeight * Math.ceil(start) - (d.itemHeight * Math.min(start, buffer)); + topPadding = !isNaN(topPadding) ? topPadding : 0; + start = !isNaN(start) ? start : -1; + end = !isNaN(end) ? end : -1; + start -= buffer; + start = Math.max(0, start); + end += buffer; + end = Math.min(itemsLength, end); + + return { + topPadding, + scrollHeight, + start, + end + } + } + + setDimensions(itemHeight: number, panelHeight: number) { + const itemsPerViewport = Math.max(1, Math.floor(panelHeight / itemHeight)); + this._dimensions = { + itemHeight, + panelHeight, + itemsPerViewport + }; + } + + getScrollTo(itemTop: number, itemHeight: number, lastScroll: number) { + const itemBottom = itemTop + itemHeight; + const top = lastScroll; + const bottom = top + this.dimensions.panelHeight; + + if (itemBottom > bottom) { + return top + itemBottom - bottom; + } else if (itemTop <= top) { + return itemTop; + } + + return null; + } +} diff --git a/src/ng-select/ng-select.component.spec.ts b/src/ng-select/ng-select.component.spec.ts index a9d3ae4a3..e3e0fdf59 100644 --- a/src/ng-select/ng-select.component.spec.ts +++ b/src/ng-select/ng-select.component.spec.ts @@ -5,13 +5,12 @@ import { ConsoleService } from './console.service'; import { FormsModule } from '@angular/forms'; import { getNgSelectElement, selectOption, TestsErrorHandler, tickAndDetectChanges, triggerKeyDownEvent } from '../testing/helpers'; import { KeyCode, NgOption } from './ng-select.types'; -import { MockConsole, MockNgWindow, MockNgZone } from '../testing/mocks'; +import { MockConsole, MockNgZone } from '../testing/mocks'; import { NgSelectComponent } from './ng-select.component'; import { NgSelectModule } from './ng-select.module'; import { Subject } from 'rxjs'; -import { WindowService } from './window.service'; -describe('NgSelectComponent', function () { +describe('NgSelectComponent', () => { describe('Data source', () => { it('should set items from primitive numbers array', fakeAsync(() => { @@ -556,7 +555,7 @@ describe('NgSelectComponent', function () { })]); })); - it('should bind bind to nested value property', fakeAsync(() => { + it('should bind to nested value property', fakeAsync(() => { const fixture = createTestingModule( NgSelectTestCmp, ` ({ id: i, name: String.fromCharCode(97 + i) })); tickAndDetectChanges(fixture); options = fixture.debugElement.nativeElement.querySelectorAll('.ng-option'); - expect(options.length).toBe(6); + expect(options.length).toBe(8); expect(options[0].innerText).toBe('a'); })); + it('should scroll to selected item on first open when virtual scroll is enabled', fakeAsync(() => { + const fixture = createTestingModule( + NgSelectTestCmp, + ` + `); + + const select = fixture.componentInstance.select; + const cmp = fixture.componentInstance; + cmp.cities = Array.from(Array(30).keys()).map((_, i) => ({ id: i, name: String.fromCharCode(97 + i) })); + cmp['city'] = cmp.cities[10]; + tickAndDetectChanges(fixture); + + select.open(); + tickAndDetectChanges(fixture); + fixture.detectChanges(); + + const buffer = 4; + const itemHeight = 18; + const options = fixture.debugElement.nativeElement.querySelectorAll('.ng-option'); + const marked = fixture.debugElement.nativeElement.querySelector('.ng-option-marked'); + + expect(options.length).toBe(22); + expect(marked.innerText).toBe('k'); + expect(marked.offsetTop).toBe(buffer * itemHeight); + })); + it('should scroll to item and do not change scroll position when scrolled to visible item', fakeAsync(() => { const fixture = createTestingModule( NgSelectTestCmp, @@ -1001,14 +1033,14 @@ describe('NgSelectComponent', function () { cmp.cities = Array.from(Array(30).keys()).map((_, i) => ({ id: i, name: String.fromCharCode(97 + i) })); tickAndDetectChanges(fixture); - cmp.select.dropdownPanel.scrollInto(cmp.select.itemsList.items[1]); + cmp.select.dropdownPanel.scrollTo(cmp.select.itemsList.items[1]); tickAndDetectChanges(fixture); const panelItems = el.querySelector('.ng-dropdown-panel-items'); expect(panelItems.scrollTop).toBe(0); })); - it('should scroll to item and change scroll position when scrolled to not visible visible item', fakeAsync(() => { + it('should scroll to item and change scroll position when scrolled to not visible item', fakeAsync(() => { const fixture = createTestingModule( NgSelectTestCmp, ` ({ id: i, name: String.fromCharCode(97 + i) })); + cmp.select.open(); tickAndDetectChanges(fixture); - cmp.select.dropdownPanel.scrollInto(cmp.select.itemsList.items[15]); + cmp.select.dropdownPanel.scrollTo(cmp.select.itemsList.items[15]); tickAndDetectChanges(fixture); const panelItems = el.querySelector('.ng-dropdown-panel-items'); - expect(panelItems.scrollTop).toBe(54); + expect(panelItems.scrollTop).toBe(48); })); - it('should close on option select by default', async(() => { + it('should close on option select by default', fakeAsync(() => { const fixture = createTestingModule( NgSelectTestCmp, ` { + it('should not close when isOpen is true', fakeAsync(() => { const fixture = createTestingModule( NgSelectTestCmp, ` { + it('should remove appended dropdown when it is destroyed', async(() => { const fixture = createTestingModule( NgSelectTestCmp, ` @@ -1129,8 +1159,6 @@ describe('NgSelectComponent', function () { fixture.detectChanges(); fixture.whenStable().then(() => { - const selectClasses = (fixture.nativeElement).querySelector('.ng-select').classList; - expect(selectClasses.contains('ng-select-bottom')).toBeFalsy(); const dropdown = document.querySelector('.ng-dropdown-panel'); expect(dropdown).toBeNull(); }); @@ -1211,13 +1239,13 @@ describe('NgSelectComponent', function () { }); describe('arrows', () => { - it('should select next value on arrow down', () => { + it('should select next value on arrow down', fakeAsync(() => { selectOption(fixture, KeyCode.ArrowDown, 1); const result = [jasmine.objectContaining({ value: fixture.componentInstance.cities[1] })]; expect(select.selectedItems).toEqual(result); - }); + })); it('should stop marked loop if all items disabled', fakeAsync(() => { fixture.componentInstance.cities[0].disabled = true; @@ -1262,13 +1290,13 @@ describe('NgSelectComponent', function () { expect(select.selectedItems).toEqual(result); })); - it('should select last value on arrow up', () => { + it('should select last value on arrow up', fakeAsync(() => { selectOption(fixture, KeyCode.ArrowUp, 1); const result = [jasmine.objectContaining({ value: fixture.componentInstance.cities[2] })]; expect(select.selectedItems).toEqual(result); - }); + })); }); describe('esc', () => { @@ -1856,13 +1884,13 @@ describe('NgSelectComponent', function () { })); })); - it('should not toggle item on enter when dropdown is closed', () => { + it('should not toggle item on enter when dropdown is closed', fakeAsync(() => { selectOption(fixture, KeyCode.ArrowDown, 0); triggerKeyDownEvent(getNgSelectElement(fixture), KeyCode.Esc); expect((fixture.componentInstance.select.selectedItems).length).toBe(1); triggerKeyDownEvent(getNgSelectElement(fixture), KeyCode.Enter); expect((fixture.componentInstance.select.selectedItems).length).toBe(1); - }); + })); describe('max selected items', () => { let arrowIcon: DebugElement = null; @@ -3370,7 +3398,6 @@ function createTestingModule(cmp: Type, template: string): ComponentFixtur providers: [ { provide: ErrorHandler, useClass: TestsErrorHandler }, { provide: NgZone, useFactory: () => new MockNgZone() }, - { provide: WindowService, useFactory: () => new MockNgWindow() }, { provide: ConsoleService, useFactory: () => new MockConsole() } ] }) diff --git a/src/ng-select/ng-select.component.ts b/src/ng-select/ng-select.component.ts index 9b7299cc1..c09b93a4a 100644 --- a/src/ng-select/ng-select.component.ts +++ b/src/ng-select/ng-select.component.ts @@ -50,6 +50,7 @@ import { NgDropdownPanelComponent } from './ng-dropdown-panel.component'; import { NgOptionComponent } from './ng-option.component'; import { SelectionModelFactory } from './selection-model'; import { NgSelectConfig } from './config.service'; +import { NgDropdownPanelService } from './ng-dropdown-panel.service'; export const SELECTION_MODEL_FACTORY = new InjectionToken('ng-select-selection-model'); export type DropdownPosition = 'bottom' | 'top' | 'auto'; @@ -67,7 +68,7 @@ export type GroupValueFn = (key: string | object, children: any[]) => string | o provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NgSelectComponent), multi: true - }], + }, NgDropdownPanelService], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { @@ -344,6 +345,8 @@ export class NgSelectComponent implements OnDestroy, OnChanges, AfterViewInit, C this.typeahead.next(null); } this.clearEvent.emit(); + + this._onSelectionChanged(); } clearModel() { @@ -404,6 +407,7 @@ export class NgSelectComponent implements OnDestroy, OnChanges, AfterViewInit, C } this.isOpen = false; this._clearSearch(); + this.itemsList.unmarkItem(); this._onTouched(); this.closeEvent.emit(); this._cd.markForCheck(); @@ -419,6 +423,8 @@ export class NgSelectComponent implements OnDestroy, OnChanges, AfterViewInit, C } else { this.select(item); } + + this._onSelectionChanged(); } select(item: NgOption) { @@ -555,12 +561,6 @@ export class NgSelectComponent implements OnDestroy, OnChanges, AfterViewInit, C } } - updateDropdownPosition() { - if (this.dropdownPanel) { - this.dropdownPanel.updateDropdownPosition(); - } - } - private _setItems(items: any[]) { const firstItem = items[0]; this.bindLabel = this.bindLabel || this._defaultLabel; @@ -733,14 +733,22 @@ export class NgSelectComponent implements OnDestroy, OnChanges, AfterViewInit, C if (!this.isOpen || !this.dropdownPanel) { return; } - this.dropdownPanel.scrollInto(this.itemsList.markedItem); + this.dropdownPanel.scrollTo(this.itemsList.markedItem); } private _scrollToTag() { if (!this.isOpen || !this.dropdownPanel) { return; } - this.dropdownPanel.scrollIntoTag(); + this.dropdownPanel.scrollToTag(); + } + + private _onSelectionChanged() { + if (this.isOpen && this.multiple && this.appendTo) { + // Make sure items are rendered. + this._cd.detectChanges(); + this.dropdownPanel.adjustPosition(); + } } private _handleTab($event: KeyboardEvent) { diff --git a/src/ng-select/virtual-scroll.service.spec.ts b/src/ng-select/virtual-scroll.service.spec.ts deleted file mode 100644 index 5f0770577..000000000 --- a/src/ng-select/virtual-scroll.service.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { TestBed, inject } from '@angular/core/testing'; -import { VirtualScrollService } from './virtual-scroll.service'; - -describe('VirtualScrollService', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [VirtualScrollService] - }); - }); - - it('should calculate items', inject([VirtualScrollService], (service: VirtualScrollService) => { - const dropdown = document.createElement('div'); - dropdown.style.width = '120px'; - dropdown.style.height = '100px'; - document.body.appendChild(dropdown); - - const content = document.createElement('div'); - content.innerHTML = `
`; - document.body.appendChild(content); - - const itemsLength = 100; - const buffer = 4; - const d = service.calculateDimensions(itemsLength, 0, dropdown, content); - const res = service.calculateItems(d, dropdown, buffer); - - expect(res).toEqual({ - start: 0, - end: 9, - topPadding: 0, - scrollHeight: 2500 - }) - })) - - it('should calculate dimensions', inject([VirtualScrollService], (service: VirtualScrollService) => { - const dropdown = document.createElement('div'); - dropdown.style.width = '120px'; - dropdown.style.height = '100px'; - document.body.appendChild(dropdown); - - const content = document.createElement('div'); - content.innerHTML = `
`; - document.body.appendChild(content); - - const itemsLength = 100; - const res = service.calculateDimensions(itemsLength, 0, dropdown, content); - - expect(res).toEqual({ - itemsLength: itemsLength, - viewWidth: 120, - viewHeight: 100, - childWidth: 120, - childHeight: 25, - itemsPerCol: 4 - }) - })); -}); diff --git a/src/ng-select/virtual-scroll.service.ts b/src/ng-select/virtual-scroll.service.ts deleted file mode 100644 index ffbd9d77e..000000000 --- a/src/ng-select/virtual-scroll.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Injectable } from '@angular/core'; - -export interface ItemsDimensions { - itemsLength: number; - viewWidth: number; - viewHeight: number; - childWidth: number; - childHeight: number; - itemsPerCol: number; -} - -export interface ItemsRangeResult { - scrollHeight: number; - topPadding: number; - start: number; - end: number; -} - -@Injectable({ providedIn: 'root' }) -export class VirtualScrollService { - - calculateItems(d: ItemsDimensions, dropdownEl: HTMLElement, bufferAmount: number): ItemsRangeResult { - const scrollHeight = d.childHeight * d.itemsLength; - if (dropdownEl.scrollTop > scrollHeight) { - dropdownEl.scrollTop = scrollHeight; - } - - const scrollTop = Math.max(0, dropdownEl.scrollTop); - const indexByScrollTop = scrollTop / scrollHeight * d.itemsLength; - let end = Math.min(d.itemsLength, Math.ceil(indexByScrollTop) + (d.itemsPerCol + 1)); - - const maxStartEnd = end; - const maxStart = Math.max(0, maxStartEnd - d.itemsPerCol - 1); - let start = Math.min(maxStart, Math.floor(indexByScrollTop)); - - let topPadding = d.childHeight * Math.ceil(start) - (d.childHeight * Math.min(start, bufferAmount)); - topPadding = !isNaN(topPadding) ? topPadding : 0; - start = !isNaN(start) ? start : -1; - end = !isNaN(end) ? end : -1; - start -= bufferAmount; - start = Math.max(0, start); - end += bufferAmount; - end = Math.min(d.itemsLength, end); - - return { - topPadding: topPadding, - scrollHeight: scrollHeight, - start: start, - end: end - } - } - - calculateDimensions(itemsLength: number, index: number, panelEl: HTMLElement, contentEl: HTMLElement): ItemsDimensions { - const panelRect = panelEl.getBoundingClientRect(); - const itemRect = contentEl.children[index] ? contentEl.children[index].getBoundingClientRect() : { - width: panelRect.width, - height: panelRect.height, - top: 0, - }; - const itemsPerCol = Math.max(1, Math.floor(panelRect.height / itemRect.height)); - - return { - itemsLength: itemsLength, - viewWidth: panelRect.width, - viewHeight: panelRect.height, - childWidth: itemRect.width, - childHeight: itemRect.height, - itemsPerCol: itemsPerCol, - }; - } -} diff --git a/src/ng-select/window.service.ts b/src/ng-select/window.service.ts deleted file mode 100644 index 6bbc3ecb0..000000000 --- a/src/ng-select/window.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ providedIn: 'root' }) -export class WindowService { - requestAnimationFrame(fn) { - return window.requestAnimationFrame(fn); - } - - setTimeout(handler: (...args: any[]) => void, timeout: number): number { - return window.setTimeout(handler, timeout); - } -} diff --git a/src/testing/helpers.ts b/src/testing/helpers.ts index a8097c22c..439a36b11 100644 --- a/src/testing/helpers.ts +++ b/src/testing/helpers.ts @@ -1,13 +1,9 @@ -import { ComponentFixture, tick } from '@angular/core/testing'; -import { KeyCode } from '../ng-select/ng-select.types'; import { DebugElement } from '@angular/core'; +import { ComponentFixture, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { KeyCode } from '../ng-select/ng-select.types'; -export class TestsErrorHandler { - handleError(error: any) { - throw error; - } -} +export class TestsErrorHandler {} export function tickAndDetectChanges(fixture: ComponentFixture) { fixture.detectChanges(); @@ -16,6 +12,7 @@ export function tickAndDetectChanges(fixture: ComponentFixture) { export function selectOption(fixture, key: KeyCode, index: number) { triggerKeyDownEvent(getNgSelectElement(fixture), KeyCode.Space); // open + tickAndDetectChanges(fixture); // need to tick and detect changes, since dropdown fully inits after promise is resolved for (let i = 0; i < index; i++) { triggerKeyDownEvent(getNgSelectElement(fixture), key); } diff --git a/src/testing/mocks.ts b/src/testing/mocks.ts index 64dcf2b1a..c712a0d01 100644 --- a/src/testing/mocks.ts +++ b/src/testing/mocks.ts @@ -1,5 +1,4 @@ import { Injectable, NgZone } from '@angular/core'; -import { WindowService } from '../ng-select/window.service'; @Injectable() export class MockNgZone extends NgZone { @@ -16,18 +15,6 @@ export class MockNgZone extends NgZone { } } -@Injectable() -export class MockNgWindow extends WindowService { - requestAnimationFrame(fn: Function): any { - return fn(); - } - - setTimeout(handler: (...args: any[]) => void, _: number): number { - handler(); - return 0; - } -} - @Injectable() export class MockConsole { warn() { diff --git a/src/themes/default.theme.scss b/src/themes/default.theme.scss index 415d9c9b9..ea1d40947 100644 --- a/src/themes/default.theme.scss +++ b/src/themes/default.theme.scss @@ -255,7 +255,6 @@ $ng-select-value-font-size: 0.9em !default; padding: 5px 7px; } .ng-dropdown-panel-items { - margin-bottom: 1px; .ng-optgroup { user-select: none; padding: 8px 10px;