Skip to content

Commit 06e19aa

Browse files
committed
fix(select): prevent the panel from going outside the viewport horizontally
Prevents the select panel from going outside the viewport along the x axis. Fixes angular#3504. Fixes angular#3831.
1 parent e7a4a03 commit 06e19aa

File tree

4 files changed

+164
-35
lines changed

4 files changed

+164
-35
lines changed

src/lib/select/select.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
<ng-template cdk-connected-overlay [origin]="origin" [open]="panelOpen" hasBackdrop (backdropClick)="close()"
1717
backdropClass="cdk-overlay-transparent-backdrop" [positions]="_positions" [minWidth]="_triggerWidth"
18-
[offsetY]="_offsetY" [offsetX]="_offsetX" (attach)="_setScrollTop()">
18+
[offsetY]="_offsetY" (attach)="_onAttached()">
1919
<div class="mat-select-panel" [@transformPanel]="'showing'" (@transformPanel.done)="_onPanelDone()"
2020
(keydown)="_keyManager.onKeydown($event)" [style.transformOrigin]="_transformOrigin"
2121
[class.mat-select-panel-done-animating]="_panelDoneAnimating">

src/lib/select/select.scss

+1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ $mat-select-trigger-font-size: 16px !default;
125125
padding-top: 0;
126126
padding-bottom: 0;
127127
max-height: $mat-select-panel-max-height;
128+
min-width: 100%; // prevents some animation twitching and test inconsistencies in IE11
128129

129130
@include cdk-high-contrast {
130131
outline: solid 1px;

src/lib/select/select.spec.ts

+114-18
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import {
1212
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
1313
import {MdSelectModule} from './index';
1414
import {OverlayContainer} from '../core/overlay/overlay-container';
15-
import {MdSelect, MdSelectFloatPlaceholderType} from './select';
15+
import {
16+
MdSelect,
17+
MdSelectFloatPlaceholderType,
18+
SELECT_PANEL_EXTRA_WIDTH,
19+
SELECT_PANEL_VIEWPORT_PADDING,
20+
} from './select';
1621
import {MdSelectDynamicMultipleError, MdSelectNonArrayValueError} from './select-errors';
1722
import {MdOption} from '../core/option/option';
1823
import {Dir} from '../core/rtl/dir';
@@ -24,9 +29,22 @@ import {dispatchFakeEvent} from '../core/testing/dispatch-events';
2429
import {wrappedErrorMessage} from '../core/testing/wrapped-error-message';
2530

2631

27-
describe('MdSelect', () => {
32+
class FakeViewportRuler {
33+
getViewportRect() {
34+
return {
35+
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
36+
};
37+
}
38+
39+
getViewportScrollPosition() {
40+
return {top: 0, left: 0};
41+
}
42+
}
43+
44+
fdescribe('MdSelect', () => {
2845
let overlayContainerElement: HTMLElement;
2946
let dir: {value: 'ltr'|'rtl'};
47+
let fakeViewportRuler = new FakeViewportRuler();
3048

3149
beforeEach(async(() => {
3250
TestBed.configureTestingModule({
@@ -65,7 +83,7 @@ describe('MdSelect', () => {
6583
{provide: Dir, useFactory: () => {
6684
return dir = { value: 'ltr' };
6785
}},
68-
{provide: ViewportRuler, useClass: FakeViewportRuler}
86+
{provide: ViewportRuler, useValue: fakeViewportRuler}
6987
]
7088
});
7189

@@ -882,6 +900,97 @@ describe('MdSelect', () => {
882900

883901
});
884902

903+
describe('limited space to open horizontally', () => {
904+
beforeEach(() => {
905+
select.style.position = 'absolute';
906+
select.style.top = '200px';
907+
});
908+
909+
it('should stay within the viewport when overflowing on the left in ltr', async(() => {
910+
select.style.left = '-75px';
911+
trigger.click();
912+
fixture.detectChanges();
913+
914+
fixture.whenStable().then(() => {
915+
const panelLeft = document.querySelector('.mat-select-panel')
916+
.getBoundingClientRect().left;
917+
expect(Math.round(panelLeft)).toBe(SELECT_PANEL_VIEWPORT_PADDING,
918+
`Expected select panel to be inside the viewport in ltr.`);
919+
});
920+
}));
921+
922+
it('should stay within the viewport when overflowing on the right in rtl', async(() => {
923+
dir.value = 'rtl';
924+
select.style.left = '-75px';
925+
trigger.click();
926+
fixture.detectChanges();
927+
928+
fixture.whenStable().then(() => {
929+
const panelLeft = document.querySelector('.mat-select-panel')
930+
.getBoundingClientRect().left;
931+
const targetLeft = SELECT_PANEL_VIEWPORT_PADDING + SELECT_PANEL_EXTRA_WIDTH;
932+
933+
expect(Math.round(panelLeft)).toBe(targetLeft,
934+
`Expected select panel to be inside the viewport in rtl.`);
935+
});
936+
}));
937+
938+
it('should stay within the viewport when overflowing on the right in ltr', async(() => {
939+
select.style.right = '-75px';
940+
trigger.click();
941+
fixture.detectChanges();
942+
943+
fixture.whenStable().then(() => {
944+
const viewportRect = fakeViewportRuler.getViewportRect().right;
945+
const panelRight = document.querySelector('.mat-select-panel')
946+
.getBoundingClientRect().right;
947+
const targetRight = SELECT_PANEL_VIEWPORT_PADDING + SELECT_PANEL_EXTRA_WIDTH;
948+
949+
expect(viewportRect - Math.round(panelRight)).toBe(targetRight,
950+
`Expected select panel to be inside the viewport in ltr.`);
951+
});
952+
}));
953+
954+
it('should stay within the viewport when overflowing on the right in rtl', async(() => {
955+
dir.value = 'rtl';
956+
select.style.right = '-75px';
957+
trigger.click();
958+
fixture.detectChanges();
959+
960+
fixture.whenStable().then(() => {
961+
const viewportRect = fakeViewportRuler.getViewportRect().right;
962+
const panelRight = document.querySelector('.mat-select-panel')
963+
.getBoundingClientRect().right;
964+
965+
expect(viewportRect - Math.round(panelRight)).toBe(SELECT_PANEL_VIEWPORT_PADDING,
966+
`Expected select panel to be inside the viewport in rtl.`);
967+
});
968+
}));
969+
970+
it('should keep the position within the viewport on repeat openings', async(() => {
971+
select.style.left = '-75px';
972+
trigger.click();
973+
fixture.detectChanges();
974+
975+
let panelLeft = document.querySelector('.mat-select-panel').getBoundingClientRect().left;
976+
977+
expect(Math.round(panelLeft)).toBe(SELECT_PANEL_VIEWPORT_PADDING,
978+
`Expected select panel to be inside the viewport.`);
979+
980+
fixture.componentInstance.select.close();
981+
fixture.detectChanges();
982+
983+
fixture.whenStable().then(() => {
984+
trigger.click();
985+
fixture.detectChanges();
986+
panelLeft = document.querySelector('.mat-select-panel').getBoundingClientRect().left;
987+
988+
expect(Math.round(panelLeft)).toBe(SELECT_PANEL_VIEWPORT_PADDING,
989+
`Expected select panel continue being inside the viewport.`);
990+
});
991+
}));
992+
});
993+
885994
describe('when scrolled', () => {
886995

887996
// Need to set the scrollTop two different ways to support
@@ -1030,8 +1139,8 @@ describe('MdSelect', () => {
10301139
trigger = multiFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
10311140
select = multiFixture.debugElement.query(By.css('md-select')).nativeElement;
10321141

1033-
select.style.marginLeft = '20px';
1034-
select.style.marginRight = '20px';
1142+
select.style.marginLeft = '50px';
1143+
select.style.marginRight = '50px';
10351144
});
10361145

10371146
it('should adjust for the checkbox in ltr', () => {
@@ -1956,16 +2065,3 @@ class SelectWithPlainTabindex { }
19562065
`
19572066
})
19582067
class SelectEarlyAccessSibling { }
1959-
1960-
1961-
class FakeViewportRuler {
1962-
getViewportRect() {
1963-
return {
1964-
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
1965-
};
1966-
}
1967-
1968-
getViewportScrollPosition() {
1969-
return {top: 0, left: 0};
1970-
}
1971-
}

src/lib/select/select.ts

+48-16
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ export const SELECT_PANEL_PADDING_Y = 16;
8585
*/
8686
export const SELECT_PANEL_VIEWPORT_PADDING = 8;
8787

88+
/** Extra width that gets added the to select panel during the open animation. */
89+
export const SELECT_PANEL_EXTRA_WIDTH = 32;
90+
8891
/** Change event object that is emitted when the select value has changed. */
8992
export class MdSelectChange {
9093
constructor(public source: MdSelect, public value: any) { }
@@ -187,13 +190,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
187190
/** Whether the panel's animation is done. */
188191
_panelDoneAnimating: boolean = false;
189192

190-
/**
191-
* The x-offset of the overlay panel in relation to the trigger's top start corner.
192-
* This must be adjusted to align the selected option text over the trigger text when
193-
* the panel opens. Will change based on LTR or RTL text direction.
194-
*/
195-
_offsetX = 0;
196-
197193
/**
198194
* The y-offset of the overlay panel in relation to the trigger's top start corner.
199195
* This must be adjusted to align the selected option text over the trigger text.
@@ -468,6 +464,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
468464
} else {
469465
this.onClose.emit();
470466
this._panelDoneAnimating = false;
467+
this.overlayDir.offsetX = 0;
471468
}
472469
}
473470

@@ -489,12 +486,20 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
489486
}
490487
}
491488

489+
/**
490+
* Callback that is invoked when the overlay panel has been attached.
491+
*/
492+
_onAttached(): void {
493+
this._calculateOverlayXOffset();
494+
this._setScrollTop();
495+
}
496+
492497
/**
493498
* Sets the scroll position of the scroll container. This must be called after
494499
* the overlay pane is attached or the scroll container element will not yet be
495500
* present in the DOM.
496501
*/
497-
_setScrollTop(): void {
502+
private _setScrollTop(): void {
498503
const scrollContainer =
499504
this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-panel');
500505
scrollContainer.scrollTop = this._scrollTop;
@@ -693,12 +698,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
693698

694699
/** Calculates the scroll position and x- and y-offsets of the overlay panel. */
695700
private _calculateOverlayPosition(): void {
696-
this._offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X;
697-
698-
if (!this._isRtl()) {
699-
this._offsetX *= -1;
700-
}
701-
702701
const panelHeight =
703702
Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
704703
const scrollContainerHeight = this.options.length * SELECT_OPTION_HEIGHT;
@@ -712,7 +711,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
712711
// center of the overlay panel rather than the top.
713712
const scrollBuffer = panelHeight / 2;
714713
this._scrollTop = this._calculateOverlayScroll(selectedIndex, scrollBuffer, maxScroll);
715-
this._offsetY = this._calculateOverlayOffset(selectedIndex, scrollBuffer, maxScroll);
714+
this._offsetY = this._calculateOverlayYOffset(selectedIndex, scrollBuffer, maxScroll);
716715
} else {
717716
// If no option is selected, the panel centers on the first option. In this case,
718717
// we must only adjust for the height difference between the option element
@@ -774,12 +773,45 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
774773
return this.ariaLabelledby ? null : this.ariaLabel || this.placeholder;
775774
}
776775

776+
/**
777+
* Sets the x-offset of the overlay panel in relation to the trigger's top start corner.
778+
* This must be adjusted to align the selected option text over the trigger text when
779+
* the panel opens. Will change based on LTR or RTL text direction. Note that the offset
780+
* can't be calculated until the panel has been attached, because we need to know the
781+
* content width in order to constrain the panel within the viewport.
782+
*/
783+
private _calculateOverlayXOffset(): void {
784+
let overlayRect = this.overlayDir.overlayRef.overlayElement.getBoundingClientRect();
785+
let viewportRect = this._viewportRuler.getViewportRect();
786+
let offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X;
787+
let isRtl = this._isRtl();
788+
789+
if (!isRtl) {
790+
offsetX *= -1;
791+
}
792+
793+
let leftOverflow = 0 - (overlayRect.left + offsetX - (isRtl ? SELECT_PANEL_EXTRA_WIDTH : 0));
794+
let rightOverflow = overlayRect.right + offsetX - viewportRect.width
795+
+ (isRtl ? 0 : SELECT_PANEL_EXTRA_WIDTH);
796+
797+
if (leftOverflow > 0) {
798+
offsetX += leftOverflow + SELECT_PANEL_VIEWPORT_PADDING;
799+
} else if (rightOverflow > 0) {
800+
offsetX -= rightOverflow + SELECT_PANEL_VIEWPORT_PADDING;
801+
}
802+
803+
// Set the offset directly in order to avoid having to go through change detection and
804+
// potentially triggering "changed after it was checked" errors.
805+
this.overlayDir.offsetX = offsetX;
806+
this.overlayDir.overlayRef.updatePosition();
807+
}
808+
777809
/**
778810
* Calculates the y-offset of the select's overlay panel in relation to the
779811
* top start corner of the trigger. It has to be adjusted in order for the
780812
* selected option to be aligned over the trigger when the panel opens.
781813
*/
782-
private _calculateOverlayOffset(selectedIndex: number, scrollBuffer: number,
814+
private _calculateOverlayYOffset(selectedIndex: number, scrollBuffer: number,
783815
maxScroll: number): number {
784816
let optionOffsetFromPanelTop: number;
785817

0 commit comments

Comments
 (0)