Skip to content

Commit

Permalink
feat: auto position dropdown by default (#382)
Browse files Browse the repository at this point in the history
fixes #367 
fixes #256 
fixes #306
  • Loading branch information
anjmao committed Mar 29, 2018
1 parent fa06772 commit c75595d
Show file tree
Hide file tree
Showing 8 changed files with 154 additions and 127 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ map: {
| closeOnSelect | `boolean` | true | no | Whether to close the menu when a value is selected |
| [clearable] | `boolean` | `true` | no | Allow to clear selected value. Default `true`|
| clearAllText | `string` | `Clear all` | no | Set custom text for clear all icon title |
| dropdownPosition | `bottom`,`top`,`auto` | `bottom` | no | Set the dropdown position on open |
| dropdownPosition | `bottom`,`top`,`auto` | `auto` | no | Set the dropdown position on open |
| [groupBy] | `string` \| `Function` | null | no | Allow to group items by key or function expression |
| [selectableGroup] | `boolean` | false | no | Allow to select group when groupBy is used |
| [items] | `Array<NgOption>` | `[]` | yes | Items array |
Expand Down
26 changes: 4 additions & 22 deletions demo/app/examples/dropdown-positions.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,20 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
changeDetection: ChangeDetectionStrategy.Default,
template: `
<p>
By default the dropdown is displayed below the ng-select.
You can change the default position by setting dropdownPosition to top or bottom.
By default the dropdown position is set to auto and will be shown above if there is not space placing it at the bottom.
</p>
---html,true
<ng-select [dropdownPosition]="dropdownPosition"
[searchable]="false"
[items]="cities">
<ng-select [items]="cities">
</ng-select>
---
<br>
<label>
<input [(ngModel)]="dropdownPosition" type="radio" [value]="'top'">
top
</label>
<br>
<label>
<input [(ngModel)]="dropdownPosition" type="radio" [value]="'bottom'">
bottom
</label>
<hr>
<p>
Using "Auto" it still defaults to bottom, but if the dropdown would be out of view,
it will automatically change the dropdownPosition to top.
You can change force position to always to bottom or top by setting <b>dropdownPosition</b>
</p>
---html,true
<ng-select [dropdownPosition]="'auto'"
<ng-select [dropdownPosition]="'top'"
[searchable]="false"
[items]="cities">
</ng-select>
Expand Down
1 change: 1 addition & 0 deletions src/ng-select/ng-dropdown-panel.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
.ng-dropdown-panel {
box-sizing: border-box;
position: absolute;
opacity: 0;
width: 100%;
z-index: 1050;
-webkit-overflow-scrolling: touch;
Expand Down
156 changes: 98 additions & 58 deletions src/ng-select/ng-dropdown-panel.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
Inject,
ViewEncapsulation,
ChangeDetectionStrategy,
AfterContentInit,
OnInit,
OnChanges
} from '@angular/core';

import { NgOption } from './ng-select.types';
Expand All @@ -22,6 +25,9 @@ import { ItemsList } from './items-list';
import { WindowService } from './window.service';
import { VirtualScrollService } from './virtual-scroll.service';

const TOP_CSS_CLASS = 'top';
const BOTTOM_CSS_CLASS = 'bottom';

@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
Expand All @@ -40,16 +46,12 @@ import { VirtualScrollService } from './virtual-scroll.service';
<ng-container [ngTemplateOutlet]="footerTemplate"></ng-container>
</div>
`,
styleUrls: ['./ng-dropdown-panel.component.scss'],
host: {
'[class.top]': 'currentPosition === "top"',
'[class.bottom]': 'currentPosition === "bottom"',
}
styleUrls: ['./ng-dropdown-panel.component.scss']
})
export class NgDropdownPanelComponent implements OnDestroy {
export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, AfterContentInit {

@Input() items: NgOption[] = [];
@Input() position: DropdownPosition;
@Input() position: DropdownPosition = 'auto';
@Input() appendTo: string;
@Input() bufferAmount = 4;
@Input() virtualScroll = false;
Expand All @@ -58,21 +60,19 @@ export class NgDropdownPanelComponent implements OnDestroy {

@Output() update = new EventEmitter<any[]>();
@Output() scrollToEnd = new EventEmitter<{ start: number; end: number }>();
@Output() positionChange = new EventEmitter();

@ViewChild('content', { read: ElementRef }) contentElementRef: ElementRef;
@ViewChild('scroll', { read: ElementRef }) scrollElementRef: ElementRef;
@ViewChild('padding', { read: ElementRef }) paddingElementRef: ElementRef;

currentPosition: DropdownPosition = 'bottom';

private _selectElementRef: ElementRef;
private _previousStart: number;
private _previousEnd: number;
private _startupLoop = true;
private _isScrolledToMarked = false;
private _scrollToEndFired = false;
private _itemsList: ItemsList;
private _currentPosition: 'bottom' | 'top';
private _disposeScrollListener = () => { };
private _disposeDocumentResizeListener = () => { };

Expand All @@ -90,22 +90,9 @@ export class NgDropdownPanelComponent implements OnDestroy {

ngOnInit() {
this._handleScroll();
if (this.appendTo) {
this._handleAppendTo();
}
}

ngOnChanges(changes: SimpleChanges) {
if (changes.position && changes.position.currentValue) {
this.currentPosition = changes.position.currentValue;
if (this.currentPosition === 'auto') {
this._autoPositionDropdown();
}
if (this.appendTo) {
this._updateDropdownPosition();
}
}

if (changes.items) {
this._handleItemsChange(changes.items);
}
Expand All @@ -119,12 +106,22 @@ export class NgDropdownPanelComponent implements OnDestroy {
}
}

refresh() {
this._zone.runOutsideAngular(() => {
this._window.requestAnimationFrame(() => this._updateItems());
ngAfterContentInit() {
this._whenContentReady().then(() => {
this._handleDropdownPosition();
});
}

refresh(): Promise<void> {
return new Promise((resolve) => {
this._zone.runOutsideAngular(() => {
this._window.requestAnimationFrame(() => {
this._updateItems().then(resolve);
});
});
})
}

scrollInto(item: NgOption) {
if (!item) {
return;
Expand All @@ -133,7 +130,7 @@ export class NgDropdownPanelComponent implements OnDestroy {
if (index < 0 || index >= this.items.length) {
return;
}

const d = this._calculateDimensions(this.virtualScroll ? 0 : index);
const scrollEl: Element = this.scrollElementRef.nativeElement;
const buffer = Math.floor(d.viewHeight / d.childHeight) - 1;
Expand Down Expand Up @@ -168,21 +165,25 @@ export class NgDropdownPanelComponent implements OnDestroy {
this._startupLoop = true;
}
this.items = items.currentValue || [];
this.refresh();
this.refresh().then(() => {
if (this.appendTo && this._currentPosition === 'top') {
this._updateAppendedDropdownPosition();
}
});
}

private _updateItems(): void {
private _updateItems(): Promise<void> {
NgZone.assertNotInAngularZone();

if (!this.virtualScroll) {
this._zone.run(() => {
this.update.emit(this.items.slice());
this._scrollToMarked();
});
return;
return Promise.resolve();
}

const loop = () => {
const loop = (resolve) => {
const d = this._calculateDimensions();
const res = this._virtualScrollService.calculateItems(d, this.scrollElementRef.nativeElement, this.bufferAmount || 0);

Expand All @@ -198,17 +199,16 @@ export class NgDropdownPanelComponent implements OnDestroy {
this._previousEnd = res.end;

if (this._startupLoop === true) {
loop()
loop(resolve)
}

} else if (this._startupLoop === true) {
this._startupLoop = false;
this._scrollToMarked();
return
resolve();
}
};

loop();
return new Promise((resolve) => loop(resolve))
}

private _fireScrollToEnd() {
Expand Down Expand Up @@ -241,7 +241,7 @@ export class NgDropdownPanelComponent implements OnDestroy {
return;
}
this._disposeDocumentResizeListener = this._renderer.listen('window', 'resize', () => {
this._updateDropdownPosition();
this._updateAppendedDropdownPosition();
});
}

Expand All @@ -254,47 +254,87 @@ export class NgDropdownPanelComponent implements OnDestroy {
this.scrollInto(this._itemsList.markedItem)
}

private _handleAppendTo() {
private _handleDropdownPosition() {
if (this.appendTo) {
this._appendDropdown();
this._handleDocumentResize();
}

const dropdownEl: HTMLElement = this._elementRef.nativeElement;
this._currentPosition = this._calculateCurrentPosition(dropdownEl);
const selectEl: HTMLElement = this._selectElementRef.nativeElement;
if (this._currentPosition === 'top') {
this._renderer.addClass(dropdownEl, TOP_CSS_CLASS)
this._renderer.removeClass(dropdownEl, BOTTOM_CSS_CLASS)
this._renderer.addClass(selectEl, TOP_CSS_CLASS)
this._renderer.removeClass(selectEl, BOTTOM_CSS_CLASS)
} else {
this._renderer.addClass(dropdownEl, BOTTOM_CSS_CLASS)
this._renderer.removeClass(dropdownEl, TOP_CSS_CLASS)
this._renderer.addClass(selectEl, BOTTOM_CSS_CLASS)
this._renderer.removeClass(selectEl, TOP_CSS_CLASS)
}

if (this.appendTo) {
this._updateAppendedDropdownPosition();
}

dropdownEl.style.opacity = '1';
}

private _calculateCurrentPosition(dropdownEl: HTMLElement) {
if (this.position !== 'auto') {
return this.position;
}
const selectRect: ClientRect = this._selectElementRef.nativeElement.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() {
const parent = document.querySelector(this.appendTo);
if (!parent) {
throw new Error(`appendTo selector ${this.appendTo} did not found any parent element`)
}
this._updateDropdownPosition();
parent.appendChild(this._elementRef.nativeElement);
this._handleDocumentResize();
}

private _updateDropdownPosition() {
private _updateAppendedDropdownPosition() {
const parent = document.querySelector(this.appendTo) || document.body;
const selectRect: ClientRect = this._selectElementRef.nativeElement.getBoundingClientRect();
const dropdownPanel: HTMLElement = this._elementRef.nativeElement;
const boundingRect = parent.getBoundingClientRect();
const offsetTop = selectRect.top - boundingRect.top;
const offsetLeft = selectRect.left - boundingRect.left;
const topDelta = this.currentPosition === 'bottom' ? selectRect.height : -dropdownPanel.clientHeight;
const topDelta = this._currentPosition === 'bottom' ? selectRect.height : -dropdownPanel.clientHeight;
dropdownPanel.style.top = offsetTop + topDelta + 'px';
dropdownPanel.style.bottom = 'auto';
dropdownPanel.style.left = offsetLeft + 'px';
dropdownPanel.style.width = selectRect.width + 'px';
}

private _autoPositionDropdown() {
const ngOption = this._elementRef.nativeElement.querySelector('.ng-option');
if (this.items.length > 0 && !ngOption) {
setTimeout(() => { this._autoPositionDropdown(); }, 50);
return;
}

const selectRect: ClientRect = this._selectElementRef.nativeElement.getBoundingClientRect();
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
const offsetTop = selectRect.top + window.pageYOffset;
const height = selectRect.height;
const dropdownHeight = this._elementRef.nativeElement.getBoundingClientRect().height;
if (offsetTop + height + dropdownHeight > scrollTop + document.documentElement.clientHeight) {
this.currentPosition = 'top';
} else {
this.currentPosition = 'bottom';
private _whenContentReady(): Promise<void> {
if (this.items.length === 0) {
return Promise.resolve();
}
this.positionChange.emit(this.currentPosition);
const dropdownEl: HTMLElement = this._elementRef.nativeElement;
const ready = (resolve) => {
const ngOption = dropdownEl.querySelector('.ng-option');
if (ngOption) {
resolve();
return;
}
this._zone.runOutsideAngular(() => {
setTimeout(() => ready(resolve), 5);
});
};
return new Promise((resolve) => ready(resolve))
}
}
1 change: 0 additions & 1 deletion src/ng-select/ng-select.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
[footerTemplate]="footerTemplate"
[items]="itemsList.filteredItems"
(update)="viewPortItems = $event"
(positionChange)="currentDropdownPosition = $event"
(scrollToEnd)="scrollToEnd.emit($event)"
[ngClass]="{'multiple': multiple}">

Expand Down

0 comments on commit c75595d

Please sign in to comment.