UNPKG

@rishavtandon93/ng-select-aria

Version:

Angular ng-select - All in One UI Select, Multiselect and Autocomplete

442 lines (377 loc) 15.4 kB
import { DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Inject, Input, NgZone, OnChanges, OnDestroy, OnInit, Optional, Output, Renderer2, SimpleChanges, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; import { animationFrameScheduler, asapScheduler, fromEvent, merge, Subject } from 'rxjs'; import { auditTime, takeUntil } from 'rxjs/operators'; import { NgDropdownPanelService, PanelDimensions } from './ng-dropdown-panel.service'; import { DropdownPosition } from './ng-select.types'; import { NgOption } from './ng-select.types'; import { isDefined } from './value-utils'; const CSS_POSITIONS: Readonly<string[]> = ['top', 'right', 'bottom', 'left']; const SCROLL_SCHEDULER = typeof requestAnimationFrame !== 'undefined' ? animationFrameScheduler : asapScheduler; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, selector: 'ng-dropdown-panel', template: ` <div *ngIf="headerTemplate" class="ng-dropdown-header"> <ng-container [ngTemplateOutlet]="headerTemplate" [ngTemplateOutletContext]="{ searchTerm: filterValue }"></ng-container> </div> <div #scroll class="ng-dropdown-panel-items scroll-host"> <div #padding [class.total-padding]="virtualScroll"></div> <div #content [class.scrollable-content]="virtualScroll && items.length"> <ng-content></ng-content> </div> </div> <div *ngIf="footerTemplate" class="ng-dropdown-footer"> <ng-container [ngTemplateOutlet]="footerTemplate" [ngTemplateOutletContext]="{ searchTerm: filterValue }"></ng-container> </div> ` }) export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy { @Input() items: NgOption[] = []; @Input() markedItem: NgOption; @Input() position: DropdownPosition = 'auto'; @Input() appendTo: string; @Input() bufferAmount; @Input() virtualScroll = false; @Input() headerTemplate: TemplateRef<any>; @Input() footerTemplate: TemplateRef<any>; @Input() filterValue: string = null; @Output() update = new EventEmitter<any[]>(); @Output() scroll = new EventEmitter<{ start: number; end: number }>(); @Output() scrollToEnd = new EventEmitter<void>(); @Output() outsideClick = new EventEmitter<void>(); @ViewChild('content', { read: ElementRef, static: true }) contentElementRef: ElementRef; @ViewChild('scroll', { read: ElementRef, static: true }) scrollElementRef: ElementRef; @ViewChild('padding', { read: ElementRef, static: true }) paddingElementRef: ElementRef; private readonly _destroy$ = new Subject<void>(); private readonly _dropdown: HTMLElement; private _virtualPadding: HTMLElement; private _scrollablePanel: HTMLElement; private _contentPanel: HTMLElement; private _select: HTMLElement; private _parent: HTMLElement; private _scrollToEndFired = false; private _updateScrollHeight = false; private _lastScrollPosition = 0; constructor( private _renderer: Renderer2, private _zone: NgZone, 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(); } } private get _startOffset() { if (this.markedItem) { const { itemHeight, panelHeight } = this._panelService.dimensions; const offset = this.markedItem.index * itemHeight; return panelHeight > offset ? 0 : offset; } return 0; } ngOnInit() { this._select = this._dropdown.parentElement; this._virtualPadding = this.paddingElementRef.nativeElement; this._scrollablePanel = this.scrollElementRef.nativeElement; this._contentPanel = this.contentElementRef.nativeElement; this._handleScroll(); this._handleOutsideClick(); this._appendDropdown(); this._setupMousedownListener(); } ngOnChanges(changes: SimpleChanges) { if (changes.items) { const change = changes.items; this._onItemsChange(change.currentValue, change.firstChange); } } ngOnDestroy() { this._destroy$.next(); this._destroy$.complete(); this._destroy$.unsubscribe(); if (this.appendTo) { this._renderer.removeChild(this._dropdown.parentNode, this._dropdown); } } scrollTo(option: NgOption, startFromOption = false) { if (!option) { return; } const index = this.items.indexOf(option); if (index < 0 || index >= this.itemsLength) { return; } let scrollTo; if (this.virtualScroll) { const itemHeight = this._panelService.dimensions.itemHeight; scrollTo = this._panelService.getScrollTo(index * itemHeight, itemHeight, this._lastScrollPosition); } else { 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; } } scrollToTag() { const panel = this._scrollablePanel; panel.scrollTop = panel.scrollHeight - panel.clientHeight; } adjustPosition() { this._updateYPosition(); } private _handleDropdownPosition() { this._currentPosition = this._calculateCurrentPosition(this._dropdown); if (CSS_POSITIONS.includes(this._currentPosition)) { this._updateDropdownClass(this._currentPosition); } else { this._updateDropdownClass('bottom'); } if (this.appendTo) { this._updateYPosition(); } this._dropdown.style.opacity = '1'; } private _updateDropdownClass(currentPosition: string) { CSS_POSITIONS.forEach((position) => { const REMOVE_CSS_CLASS = `ng-select-${position}`; this._renderer.removeClass(this._dropdown, REMOVE_CSS_CLASS); this._renderer.removeClass(this._select, REMOVE_CSS_CLASS); }); const ADD_CSS_CLASS = `ng-select-${currentPosition}`; this._renderer.addClass(this._dropdown, ADD_CSS_CLASS); this._renderer.addClass(this._select, ADD_CSS_CLASS); } private _handleScroll() { this._zone.runOutsideAngular(() => { fromEvent(this.scrollElementRef.nativeElement, 'scroll') .pipe(takeUntil(this._destroy$), auditTime(0, SCROLL_SCHEDULER)) .subscribe((e: { path, composedPath, target }) => { const path = e.path || (e.composedPath && e.composedPath()); const scrollTop = !path || path.length === 0 ? e.target.scrollTop : path[0].scrollTop this._onContentScrolled(scrollTop); }); }); } private _handleOutsideClick() { if (!this._document) { return; } 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; } const path = $event.path || ($event.composedPath && $event.composedPath()); if ($event.target && $event.target.shadowRoot && path && path[0] && this._select.contains(path[0])) { return; } this._zone.run(() => this.outsideClick.emit()); } private _onItemsChange(items: NgOption[], firstChange: boolean) { this.items = items || []; this._scrollToEndFired = false; this.itemsLength = items.length; if (this.virtualScroll) { this._updateItemsRange(firstChange); } else { this._setVirtualHeight(); this._updateItems(firstChange); } } private _updateItems(firstChange: boolean) { this.update.emit(this.items); if (firstChange === false) { return; } this._zone.runOutsideAngular(() => { Promise.resolve().then(() => { const panelHeight = this._scrollablePanel.clientHeight; this._panelService.setDimensions(0, panelHeight); this._handleDropdownPosition(); this.scrollTo(this.markedItem, firstChange); }); }); } private _updateItemsRange(firstChange: boolean) { this._zone.runOutsideAngular(() => { this._measureDimensions().then(() => { if (firstChange) { this._renderItemsRange(this._startOffset); this._handleDropdownPosition(); } else { this._renderItemsRange(); } }); }); } private _onContentScrolled(scrollTop: number) { if (this.virtualScroll) { this._renderItemsRange(scrollTop); } this._lastScrollPosition = scrollTop; this._fireScrollToEnd(scrollTop); } private _updateVirtualHeight(height: number) { if (this._updateScrollHeight) { this._virtualPadding.style.height = `${height}px`; this._updateScrollHeight = false; } } private _setVirtualHeight() { if (!this._virtualPadding) { return; } this._virtualPadding.style.height = `0px`; } private _onItemsLengthChanged() { this._updateScrollHeight = true; } private _renderItemsRange(scrollTop = null) { if (scrollTop && this._lastScrollPosition === scrollTop) { return; } 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 _measureDimensions(): Promise<PanelDimensions> { if (this._panelService.dimensions.itemHeight > 0 || this.itemsLength === 0) { 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; } const padding = this.virtualScroll ? this._virtualPadding : this._contentPanel; if (scrollTop + this._dropdown.clientHeight >= padding.clientHeight - 1) { this._zone.run(() => this.scrollToEnd.emit()); this._scrollToEndFired = true; } } private _calculateCurrentPosition(dropdownEl: HTMLElement) { if (this.position !== 'auto') { return this.position; } const selectRect: ClientRect = this._select.getBoundingClientRect(); const scrollTop = document.documentElement.scrollTop || document.body.scrollTop; const offsetTop = selectRect.top + window.pageYOffset; const height = selectRect.height; const dropdownHeight = dropdownEl.getBoundingClientRect().height; if (offsetTop + height + dropdownHeight > scrollTop + document.documentElement.clientHeight) { return 'top'; } else { return 'bottom'; } } private _appendDropdown() { if (!this.appendTo) { return; } this._parent = document.querySelector(this.appendTo); if (!this._parent) { throw new Error(`appendTo selector ${this.appendTo} did not found any parent element`); } this._updateXPosition(); this._parent.appendChild(this._dropdown); } private _updateXPosition() { const select = this._select.getBoundingClientRect(); const parent = this._parent.getBoundingClientRect(); const offsetLeft = select.left - parent.left; this._dropdown.style.left = offsetLeft + 'px'; this._dropdown.style.width = select.width + 'px'; this._dropdown.style.minWidth = select.width + 'px'; } private _updateYPosition() { const select = this._select.getBoundingClientRect(); const parent = this._parent.getBoundingClientRect(); 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'; } } private _setupMousedownListener(): void { this._zone.runOutsideAngular(() => { fromEvent(this._dropdown, 'mousedown') .pipe(takeUntil(this._destroy$)) .subscribe((event: MouseEvent) => { const target = event.target as HTMLElement; if (target.tagName === 'INPUT') { return; } event.preventDefault(); }); }); } }