UNPKG

novo-elements

Version:

Bullhorn's NOVO Element Repository for Angular 2

326 lines (297 loc) 12.4 kB
// NG2 import { Component, ElementRef, EventEmitter, OnInit, AfterContentInit, OnDestroy, Input, Output, ViewChild, DoCheck, Renderer, HostListener, ContentChildren, QueryList } from '@angular/core'; // APP import { OutsideClick } from '../../utils/outside-click/OutsideClick'; import { KeyCodes } from '../../utils/key-codes/KeyCodes'; import { Helpers } from '../../utils/Helpers'; @Component({ selector: 'novo-dropdown-container', template: '<ng-content></ng-content>' }) export class NovoDropdownContainer implements DoCheck { private position: ClientRect; private isVisible: boolean; private relativeElement: Element; private scrollHandler: any; private side: string; private appendToBody: boolean; public parent: NovoDropdownElement; constructor(public element: ElementRef, private renderer: Renderer) { this.scrollHandler = this.handleScroll.bind(this); } ngDoCheck() { if (this.isVisible && this.position) { const element = this.element.nativeElement; const position = Helpers.calcPositionOffset(this.position, element, this.side); if (position) { this.renderer.setElementStyle(element, 'top', position.top); this.renderer.setElementStyle(element, 'left', position.left); } } } private handleScroll(): void { // On scroll, don't force the position to update (jump from top/middle/bottom/right) this.updatePosition(this.relativeElement, this.side); } public show(appendToBody: boolean): void { this.appendToBody = appendToBody; this.renderer.setElementStyle(this.element.nativeElement, 'display', 'block'); this.renderer.setElementStyle(this.element.nativeElement, 'visibility', 'visible'); this.isVisible = true; if (appendToBody) { window.addEventListener('scroll', this.scrollHandler); } } public hide(): void { this.isVisible = false; this.renderer.setElementStyle(this.element.nativeElement, 'visibility', 'hidden'); if (this.appendToBody) { window.removeEventListener('scroll', this.scrollHandler); } } public updatePosition(element: Element, side: string): void { this.relativeElement = element; this.side = side; this.position = element.getBoundingClientRect(); this.ngDoCheck(); } @HostListener('keydown', ['$event']) public onKeyDown(event: KeyboardEvent): void { // Close with ESC/Enter if (this.isVisible && (event.keyCode === KeyCodes.ESC || event.keyCode === KeyCodes.ENTER)) { this.parent.toggleActive(null, false); } } } @Component({ selector: 'novo-dropdown', template: ` <ng-content select="button" #trigger></ng-content> <novo-dropdown-container class="dropdown-container {{ containerClass }}"> <ng-content></ng-content> </novo-dropdown-container> ` }) export class NovoDropdownElement extends OutsideClick implements OnInit, OnDestroy { // Append the dropdown container to the body @Input() appendToBody: boolean = false; // Listen for scroll on a parent selector, so we can close the dropdown @Input() parentScrollSelector: string; // What action to perform when we recieve scroll from parent selector // TODO - handle "move" @Input() parentScrollAction: string = 'close'; // Custom class for the dropdown container @Input() containerClass: string; // Side the dropdown will open @Input() side: string = 'left'; // Output for when the dropdown is toggled @Output() toggled: EventEmitter<boolean>; @ViewChild(NovoDropdownContainer) public container: NovoDropdownContainer; @ViewChild('trigger') public button; clickHandler: any; closeHandler: any; parentScrollElement: Element; private _items: QueryList<NovoItemElement>; private _textItems: string[]; private activeIndex: number = -1; private filterTerm: string = ''; private filterTermTimeout: any; constructor(element: ElementRef) { super(element); // Click handler this.clickHandler = this.toggleActive.bind(this); this.closeHandler = this.toggleActive.bind(this); this.toggled = this.onActiveChange; // Listen for active change to hide/show menu this.onActiveChange.subscribe((active) => { if (active) { this.show(); } else { this.hide(); } }); } public set items(items: QueryList<NovoItemElement>) { this._items = items; // Get the innertext of all the items to allow for searching this._textItems = items.map((item: NovoItemElement) => { return item.element.nativeElement.innerText; }); } public ngOnInit(): void { // Add a click handler to the button to toggle the menu let button = this.element.nativeElement.querySelector('button'); button.addEventListener('click', this.clickHandler); if (this.parentScrollSelector) { this.parentScrollElement = Helpers.findAncestor(this.element.nativeElement, this.parentScrollSelector); } } public ngOnDestroy(): void { // Remove listener let button = this.element.nativeElement.querySelector('button'); if (button) { button.removeEventListener('click', this.clickHandler); } } private show(): void { this.container.parent = this; this.container.show(this.appendToBody); this.otherElement = this.container.element; if (this.appendToBody) { this.container.updatePosition(this.element.nativeElement.children[0], this.side); // If append to body then rip it out of here and put on body window.document.body.appendChild(this.container.element.nativeElement); window.addEventListener('resize', this.closeHandler); } // Listen for scroll on a parent to force close if (this.parentScrollElement) { if (this.parentScrollAction === 'close') { this.parentScrollElement.addEventListener('scroll', this.closeHandler); } } } private hide(): void { this.container.hide(); // If append to body then rip it out of here and put on body if (this.appendToBody) { let elm = this.container.element.nativeElement; elm.parentNode.removeChild(elm); window.removeEventListener('resize', this.closeHandler); } if (this.parentScrollElement) { if (this.parentScrollAction === 'close') { this.parentScrollElement.removeEventListener('scroll', this.closeHandler); } } // Clear active index if (this.activeIndex !== -1) { this._items.toArray()[this.activeIndex].active = false; } this.activeIndex = -1; } @HostListener('keydown', ['$event']) public onKeyDown(event: KeyboardEvent): void { Helpers.swallowEvent(event); if (this.active && event.keyCode === KeyCodes.ESC) { // active & esc hit -- close this.toggleActive(); } else if (event.keyCode === KeyCodes.ENTER) { // enter -- perform the "click" this._items.toArray()[this.activeIndex].onClick(); } else if (event.keyCode === KeyCodes.DOWN) { // down - navigate through the list ignoring disabled ones if (this.activeIndex !== -1) { this._items.toArray()[this.activeIndex].active = false; } this.activeIndex++; if (this.activeIndex === this._items.length) { this.activeIndex = 0; } while (this._items.toArray()[this.activeIndex].disabled) { this.activeIndex++; if (this.activeIndex === this._items.length) { this.activeIndex = 0; } } this._items.toArray()[this.activeIndex].active = true; this.scrollToActive(); } else if (event.keyCode === KeyCodes.UP) { // up -- navigate through the list ignoring disabled ones if (this.activeIndex !== -1) { this._items.toArray()[this.activeIndex].active = false; } this.activeIndex--; if (this.activeIndex < 0) { this.activeIndex = this._items.length - 1; } while (this._items.toArray()[this.activeIndex].disabled) { this.activeIndex--; if (this.activeIndex < 0) { this.activeIndex = this._items.length - 1; } } this._items.toArray()[this.activeIndex].active = true; this.scrollToActive(); } else if ((event.keyCode >= 65 && event.keyCode <= 90) || (event.keyCode >= 96 && event.keyCode <= 105) || (event.keyCode >= 48 && event.keyCode <= 57) || event.keyCode === KeyCodes.SPACE) { // A-Z, 0-9, space -- filter the list and scroll to active filter // filter has hard reset after 2s clearTimeout(this.filterTermTimeout); this.filterTermTimeout = setTimeout(() => { this.filterTerm = ''; }, 2000); let char = String.fromCharCode(event.keyCode); this.filterTerm = this.filterTerm.concat(char); let index = this._textItems.findIndex((value: string) => { return new RegExp(`^${this.filterTerm.toLowerCase()}`).test(value.trim().toLowerCase()); }); if (index !== -1) { if (this.activeIndex !== -1) { this._items.toArray()[this.activeIndex].active = false; } this.activeIndex = index; this._items.toArray()[this.activeIndex].active = true; this.scrollToActive(); } } else if ([KeyCodes.BACKSPACE, KeyCodes.DELETE].includes(event.keyCode)) { // backspace, delete -- remove partial filters clearTimeout(this.filterTermTimeout); this.filterTermTimeout = setTimeout(() => { this.filterTerm = ''; }, 2000); this.filterTerm = this.filterTerm.slice(0, -1); } } private scrollToActive(): void { let container = this.element.nativeElement.querySelector('novo-dropdown-container'); let item = this._items.toArray()[this.activeIndex]; if (container && item) { container.scrollTop = item.element.nativeElement.offsetTop; } else { // Append to body container = document.querySelector('body > novo-dropdown-container'); if (container && item) { container.scrollTop = item.element.nativeElement.offsetTop; } } } } @Component({ selector: 'item', template: '<ng-content></ng-content>', host: { '[class.disabled]': 'disabled', '[class.active]': 'active' } }) export class NovoItemElement { @Input() public disabled: boolean; @Input() public keepOpen: boolean = false; @Output() public action: EventEmitter<any> = new EventEmitter(); public active: boolean = false; constructor(private dropdown: NovoDropdownElement, public element: ElementRef) { } @HostListener('click', []) public onClick(): void { // Poor man's disable if (!this.disabled) { // Close if keepOpen is false if (!this.keepOpen) { this.dropdown.toggleActive(); } // Emit the action this.action.emit(); } } } @Component({ selector: 'list', template: '<ng-content></ng-content>' }) export class NovoListElement implements AfterContentInit { @ContentChildren(NovoItemElement) public items: QueryList<NovoItemElement>; constructor(private dropdown: NovoDropdownElement) { } public ngAfterContentInit(): void { this.dropdown.items = this.items; } } @Component({ selector: 'dropdown-item-header', template: '<ng-content></ng-content>', }) export class NovoItemHeaderElement { }