Skip to content

Commit

Permalink
fix: close dropdown on outside click (#465)
Browse files Browse the repository at this point in the history
fixes #453 
fixes #313 

Previously ng-select dropdown close was handled by clicking on invisible overlay, but it causes some issues. This approach uses global document mousedown event listener to determine when to close dropdown.
  • Loading branch information
anjmao committed Apr 17, 2018
1 parent 852643f commit 8d8f2a5
Show file tree
Hide file tree
Showing 9 changed files with 111 additions and 83 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,6 @@
"typescript": "^2.5.1",
"webpack": "3.8.1",
"webpack-dev-server": "2.9.1",
"zone.js": "^0.8.16"
"zone.js": "^0.8.26"
}
}
29 changes: 24 additions & 5 deletions src/ng-select/ng-dropdown-panel.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A

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

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

private _selectElementRef: ElementRef;
private _selectElement: HTMLElement;
private _previousStart: number;
private _previousEnd: number;
private _startupLoop = true;
Expand All @@ -75,6 +76,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A
private _currentPosition: 'bottom' | 'top';
private _disposeScrollListener = () => { };
private _disposeDocumentResizeListener = () => { };
private _disposeDocumentClickListener = () => { };

constructor(
@Inject(forwardRef(() => NgSelectComponent)) _ngSelect: NgSelectComponent,
Expand All @@ -84,12 +86,13 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A
private _virtualScrollService: VirtualScrollService,
private _window: WindowService
) {
this._selectElementRef = _ngSelect.elementRef;
this._selectElement = _ngSelect.elementRef.nativeElement;
this._itemsList = _ngSelect.itemsList;
}

ngOnInit() {
this._handleScroll();
this._handleDocumentClick();
}

ngOnChanges(changes: SimpleChanges) {
Expand All @@ -101,6 +104,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A
ngOnDestroy() {
this._disposeDocumentResizeListener();
this._disposeScrollListener();
this._disposeDocumentClickListener();
if (this.appendTo) {
this._renderer.removeChild(this._elementRef.nativeElement.parentNode, this._elementRef.nativeElement);
}
Expand Down Expand Up @@ -156,6 +160,21 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A
});
}

private _handleDocumentClick() {
this._disposeDocumentClickListener = this._renderer.listen('document', 'mousedown', ($event: any) => {
if (this._selectElement.contains($event.target)) {
return;
}

const dropdown: HTMLElement = this._elementRef.nativeElement;
if (dropdown.contains($event.target)) {
return;
}

this.outsideClick.emit();
});
}

private _handleItemsChange(items: { previousValue: NgOption[], currentValue: NgOption[] }) {
this._scrollToEndFired = false;
this._previousStart = undefined;
Expand Down Expand Up @@ -262,7 +281,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A

const dropdownEl: HTMLElement = this._elementRef.nativeElement;
this._currentPosition = this._calculateCurrentPosition(dropdownEl);
const selectEl: HTMLElement = this._selectElementRef.nativeElement;
const selectEl: HTMLElement = this._selectElement;
if (this._currentPosition === 'top') {
this._renderer.addClass(dropdownEl, TOP_CSS_CLASS)
this._renderer.removeClass(dropdownEl, BOTTOM_CSS_CLASS)
Expand All @@ -286,7 +305,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A
if (this.position !== 'auto') {
return this.position;
}
const selectRect: ClientRect = this._selectElementRef.nativeElement.getBoundingClientRect();
const selectRect: ClientRect = this._selectElement.getBoundingClientRect();
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
const offsetTop = selectRect.top + window.pageYOffset;
const height = selectRect.height;
Expand All @@ -308,7 +327,7 @@ export class NgDropdownPanelComponent implements OnInit, OnChanges, OnDestroy, A

private _updateAppendedDropdownPosition() {
const parent = document.querySelector(this.appendTo) || document.body;
const selectRect: ClientRect = this._selectElementRef.nativeElement.getBoundingClientRect();
const selectRect: ClientRect = this._selectElement.getBoundingClientRect();
const dropdownPanel: HTMLElement = this._elementRef.nativeElement;
const boundingRect = parent.getBoundingClientRect();
const offsetTop = selectRect.top - boundingRect.top;
Expand Down
11 changes: 4 additions & 7 deletions src/ng-select/ng-select.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div (mousedown)="searchable ? open() : toggle()" [class.ng-has-value]="hasValue" class="ng-select-container">
<div (mousedown)="handleMousedown($event)" [class.ng-has-value]="hasValue" class="ng-select-container">
<div class="ng-value-container">
<div class="ng-placeholder">{{placeholder}}</div>

Expand Down Expand Up @@ -50,19 +50,15 @@

<div class="ng-spinner-loader" *ngIf="isLoading"></div>

<span *ngIf="showClear()" (click)="handleClearClick($event)" class="ng-clear-wrapper" title="{{clearAllText}}">
<span *ngIf="showClear()" class="ng-clear-wrapper" title="{{clearAllText}}">
<span class="ng-clear" aria-hidden="true">×</span>
</span>

<span (mousedown)="handleArrowClick($event)" class="ng-arrow-wrapper">
<span class="ng-arrow-wrapper">
<span class="ng-arrow"></span>
</span>
</div>

<div class="ng-overlay-container" *ngIf="isOpen">
<div class="ng-overlay" (click)="close()" ></div>
</div>

<ng-dropdown-panel *ngIf="isOpen"
class="ng-dropdown-panel"
[virtualScroll]="virtualScroll"
Expand All @@ -74,6 +70,7 @@
[items]="itemsList.filteredItems"
(update)="viewPortItems = $event"
(scrollToEnd)="scrollToEnd.emit($event)"
(outsideClick)="close()"
[ngClass]="{'multiple': multiple}"
[id]="dropdownId">

Expand Down
19 changes: 0 additions & 19 deletions src/ng-select/ng-select.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -198,23 +198,4 @@
position: relative;
}
}
.ng-overlay-container {
pointer-events: none;
top: 0;
left: 0;
height: 100%;
width: 100%;
position: fixed;
z-index: 1000;
.ng-overlay {
top: 0;
bottom: 0;
left: 0;
right: 0;
opacity: 0;
position: absolute;
pointer-events: auto;
z-index: 1000;
}
}
}
80 changes: 40 additions & 40 deletions src/ng-select/ng-select.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,8 @@ describe('NgSelectComponent', function () {
beforeEach(() => {
fixture = createTestingModule(
NgSelectTestCmp,
` <ng-select id="select" [items]="cities"
`<div id="outside">Outside</div><br />
<ng-select id="select" [items]="cities"
bindLabel="name"
multiple="true"
[closeOnSelect]="false"
Expand All @@ -1137,33 +1138,26 @@ describe('NgSelectComponent', function () {
</ng-select>`);
});

it('close dropdown if opened and clicked outside dropdown container', () => {
it('close dropdown if opened and clicked outside dropdown container', fakeAsync(() => {
triggerKeyDownEvent(getNgSelectElement(fixture), KeyCode.Space);
expect(fixture.componentInstance.select.isOpen).toBeTruthy();
fixture.whenStable().then(() => {
(document.querySelector('.ng-overlay') as HTMLElement).click();
expect(fixture.componentInstance.select.isOpen).toBeFalsy();
});
});
document.getElementById('outside').click();
let event = new MouseEvent('mousedown', {bubbles: true});
document.getElementById('outside').dispatchEvent(event);
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.select.isOpen).toBeFalsy();
}));

it('prevent dropdown close if clicked on select', () => {
it('prevent dropdown close if clicked on select', fakeAsync(() => {
triggerKeyDownEvent(getNgSelectElement(fixture), KeyCode.Space);
expect(fixture.componentInstance.select.isOpen).toBeTruthy();
fixture.whenStable().then(() => {
document.getElementById('select').click();
expect(fixture.componentInstance.select.isOpen).toBeTruthy();
});
});

it('should not close dropdown when closeOnSelect is false and in body', () => {
triggerKeyDownEvent(getNgSelectElement(fixture), KeyCode.Space);
document.getElementById('select').click();
let event = new MouseEvent('mousedown', {bubbles: true});
document.getElementById('select').dispatchEvent(event);
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.select.isOpen).toBeTruthy();
fixture.whenStable().then(() => {
(document.querySelector('.ng-option.ng-option-marked') as HTMLElement).click();
fixture.detectChanges();
expect(fixture.componentInstance.select.isOpen).toBeTruthy();
});
});
}));

});

describe('Dropdown position', () => {
Expand Down Expand Up @@ -1573,7 +1567,7 @@ describe('NgSelectComponent', function () {
it('should reset list while clearing all selected items', fakeAsync(() => {
fixture.componentInstance.selectedCities = [...fixture.componentInstance.cities];
tickAndDetectChanges(fixture);
select.handleClearClick(<any>{ stopPropagation: () => { } });
select.handleClearClick();
expect(select.selectedItems.length).toBe(0);
expect(select.itemsList.filteredItems.length).toBe(3);
}));
Expand Down Expand Up @@ -1733,7 +1727,7 @@ describe('NgSelectComponent', function () {
const placeholder: any = selectEl.querySelector('.ng-placeholder');
expect(ngControl.classList.contains('ng-has-value')).toBeTruthy();

select.handleClearClick(<any>{ stopPropagation: () => { } });
select.handleClearClick();
tickAndDetectChanges(fixture);
tickAndDetectChanges(fixture);

Expand All @@ -1746,6 +1740,7 @@ describe('NgSelectComponent', function () {
const selectEl: HTMLElement = fixture.componentInstance.select.elementRef.nativeElement;
const ngControl = selectEl.querySelector('.ng-select-container')
selectOption(fixture, KeyCode.ArrowDown, 2);
tickAndDetectChanges(fixture);
expect(ngControl.classList.contains('ng-has-value')).toBeTruthy();
}));
});
Expand Down Expand Up @@ -1806,12 +1801,12 @@ describe('NgSelectComponent', function () {

const selectInput = fixture.debugElement.query(By.css('.ng-select-container'));
// open
selectInput.triggerEventHandler('mousedown', { stopPropagation: () => { } });
selectInput.triggerEventHandler('mousedown', { target: {} });
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.select.isOpen).toBe(true);

// close
selectInput.triggerEventHandler('mousedown', { stopPropagation: () => { } });
selectInput.triggerEventHandler('mousedown', { target: {} });
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.select.isOpen).toBe(false);
}));
Expand Down Expand Up @@ -2227,7 +2222,7 @@ describe('NgSelectComponent', function () {

fixture.componentInstance.selectedCities = [fixture.componentInstance.cities[0]];
tickAndDetectChanges(fixture);
fixture.componentInstance.select.handleClearClick(<any>{ stopPropagation: () => { } });
fixture.componentInstance.select.handleClearClick();
tickAndDetectChanges(fixture);

expect(fixture.componentInstance.onClear).toHaveBeenCalled();
Expand All @@ -2236,7 +2231,7 @@ describe('NgSelectComponent', function () {

describe('Clear icon click', () => {
let fixture: ComponentFixture<NgSelectTestCmp>;
let clickIcon: DebugElement = null;
let triggerClearClick = null;

beforeEach(fakeAsync(() => {
fixture = createTestingModule(
Expand All @@ -2252,11 +2247,14 @@ describe('NgSelectComponent', function () {
fixture.componentInstance.selectedCity = fixture.componentInstance.cities[0];
tickAndDetectChanges(fixture);
tickAndDetectChanges(fixture);
clickIcon = fixture.debugElement.query(By.css('.ng-clear-wrapper'));
triggerClearClick = () => {
const control = fixture.debugElement.query(By.css('.ng-select-container'))
control.triggerEventHandler('mousedown', { target: { className: 'ng-clear'} });
};
}));

it('should clear model', fakeAsync(() => {
clickIcon.triggerEventHandler('click', { stopPropagation: () => { } });
triggerClearClick();
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.selectedCity).toBe(null);
expect(fixture.componentInstance.onChange).toHaveBeenCalledTimes(1);
Expand All @@ -2266,14 +2264,14 @@ describe('NgSelectComponent', function () {
fixture.componentInstance.selectedCity = null;
fixture.componentInstance.select.filterValue = 'Hey! Whats up!?';
tickAndDetectChanges(fixture);
clickIcon.triggerEventHandler('click', { stopPropagation: () => { } });
triggerClearClick();
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.onChange).toHaveBeenCalledTimes(0);
expect(fixture.componentInstance.select.filterValue).toBe(null);
}));

it('should not open dropdown', fakeAsync(() => {
clickIcon.triggerEventHandler('click', { stopPropagation: () => { } });
triggerClearClick();
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.select.isOpen).toBe(false);
}));
Expand All @@ -2289,7 +2287,7 @@ describe('NgSelectComponent', function () {

describe('Arrow icon click', () => {
let fixture: ComponentFixture<NgSelectTestCmp>;
let arrowIcon: DebugElement = null;
let triggerArrowIconClick = null;

beforeEach(fakeAsync(() => {
fixture = createTestingModule(
Expand All @@ -2301,23 +2299,25 @@ describe('NgSelectComponent', function () {

fixture.componentInstance.selectedCity = fixture.componentInstance.cities[0];
tickAndDetectChanges(fixture);
arrowIcon = fixture.debugElement.query(By.css('.ng-arrow-wrapper'));
triggerArrowIconClick = () => {
const control = fixture.debugElement.query(By.css('.ng-select-container'))
control.triggerEventHandler('mousedown', { target: { className: 'ng-arrow'} });
};
}));

it('should toggle dropdown', fakeAsync(() => {
const clickArrow = () => arrowIcon.triggerEventHandler('mousedown', { stopPropagation: () => { } });
// open
clickArrow();
triggerArrowIconClick();
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.select.isOpen).toBe(true);

// close
clickArrow();
triggerArrowIconClick();
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.select.isOpen).toBe(false);

// open
clickArrow();
triggerArrowIconClick();
tickAndDetectChanges(fixture);
expect(fixture.componentInstance.select.isOpen).toBe(true);
}));
Expand All @@ -2338,7 +2338,7 @@ describe('NgSelectComponent', function () {
fixture.whenStable().then(() => {
const dropdown = <HTMLElement>document.querySelector('.ng-dropdown-panel');
expect(dropdown.parentElement).toBe(document.body);
expect(dropdown.style.top).toBe('18px');
expect(dropdown.style.top).not.toBe('0px');
expect(dropdown.style.left).toBe('0px');
})
}));
Expand All @@ -2358,7 +2358,7 @@ describe('NgSelectComponent', function () {

fixture.whenStable().then(() => {
const dropdown = <HTMLElement>document.querySelector('.container .ng-dropdown-panel');
expect(dropdown.style.top).toBe('18px');
expect(dropdown.style.top).not.toBe('0px');
expect(dropdown.style.left).toBe('0px');
});
}));
Expand Down

0 comments on commit 8d8f2a5

Please sign in to comment.