Skip to content

Commit e10bb18

Browse files
crisbetokara
authored andcommitted
fix(select): prevent the panel from going outside the viewport horizontally (#3864)
Fixes #3504. Fixes #3831.
1 parent 5983a2b commit e10bb18

File tree

3 files changed

+180
-69
lines changed

3 files changed

+180
-69
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()" (detach)="close()">
18+
[offsetY]="_offsetY" (attach)="_onAttached()" (detach)="close()">
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" [ngClass]="'mat-' + color">

src/lib/select/select.spec.ts

+133-52
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,23 @@ import {TAB} from '../core/keyboard/keycodes';
2727
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
2828

2929

30+
class FakeViewportRuler {
31+
getViewportRect() {
32+
return {
33+
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
34+
};
35+
}
36+
37+
getViewportScrollPosition() {
38+
return {top: 0, left: 0};
39+
}
40+
}
41+
3042
describe('MdSelect', () => {
3143
let overlayContainerElement: HTMLElement;
3244
let dir: {value: 'ltr'|'rtl'};
3345
let scrolledSubject = new Subject();
46+
let fakeViewportRuler = new FakeViewportRuler();
3447

3548
beforeEach(async(() => {
3649
TestBed.configureTestingModule({
@@ -69,10 +82,10 @@ describe('MdSelect', () => {
6982

7083
return {getContainerElement: () => overlayContainerElement};
7184
}},
85+
{provide: ViewportRuler, useValue: fakeViewportRuler},
7286
{provide: Dir, useFactory: () => {
7387
return dir = { value: 'ltr' };
7488
}},
75-
{provide: ViewportRuler, useClass: FakeViewportRuler},
7689
{provide: ScrollDispatcher, useFactory: () => {
7790
return {scrolled: (delay: number, callback: () => any) => {
7891
return scrolledSubject.asObservable().subscribe(callback);
@@ -942,6 +955,91 @@ describe('MdSelect', () => {
942955

943956
});
944957

958+
describe('limited space to open horizontally', () => {
959+
beforeEach(() => {
960+
select.style.position = 'absolute';
961+
select.style.top = '200px';
962+
});
963+
964+
it('should stay within the viewport when overflowing on the left in ltr', fakeAsync(() => {
965+
select.style.left = '-100px';
966+
trigger.click();
967+
tick(400);
968+
fixture.detectChanges();
969+
970+
const panelLeft = document.querySelector('.mat-select-panel')
971+
.getBoundingClientRect().left;
972+
expect(panelLeft).toBeGreaterThan(0,
973+
`Expected select panel to be inside the viewport in ltr.`);
974+
}));
975+
976+
it('should stay within the viewport when overflowing on the left in rtl', fakeAsync(() => {
977+
dir.value = 'rtl';
978+
select.style.left = '-100px';
979+
trigger.click();
980+
tick(400);
981+
fixture.detectChanges();
982+
983+
const panelLeft = document.querySelector('.mat-select-panel')
984+
.getBoundingClientRect().left;
985+
986+
expect(panelLeft).toBeGreaterThan(0,
987+
`Expected select panel to be inside the viewport in rtl.`);
988+
}));
989+
990+
it('should stay within the viewport when overflowing on the right in ltr', fakeAsync(() => {
991+
select.style.right = '-100px';
992+
trigger.click();
993+
tick(400);
994+
fixture.detectChanges();
995+
996+
const viewportRect = fakeViewportRuler.getViewportRect().right;
997+
const panelRight = document.querySelector('.mat-select-panel')
998+
.getBoundingClientRect().right;
999+
1000+
expect(viewportRect - panelRight).toBeGreaterThan(0,
1001+
`Expected select panel to be inside the viewport in ltr.`);
1002+
}));
1003+
1004+
it('should stay within the viewport when overflowing on the right in rtl', fakeAsync(() => {
1005+
dir.value = 'rtl';
1006+
select.style.right = '-100px';
1007+
trigger.click();
1008+
tick(400);
1009+
fixture.detectChanges();
1010+
1011+
const viewportRect = fakeViewportRuler.getViewportRect().right;
1012+
const panelRight = document.querySelector('.mat-select-panel')
1013+
.getBoundingClientRect().right;
1014+
1015+
expect(viewportRect - panelRight).toBeGreaterThan(0,
1016+
`Expected select panel to be inside the viewport in rtl.`);
1017+
}));
1018+
1019+
it('should keep the position within the viewport on repeat openings', async(() => {
1020+
select.style.left = '-100px';
1021+
trigger.click();
1022+
fixture.detectChanges();
1023+
1024+
let panelLeft = document.querySelector('.mat-select-panel').getBoundingClientRect().left;
1025+
1026+
expect(panelLeft).toBeGreaterThan(0, `Expected select panel to be inside the viewport.`);
1027+
1028+
fixture.componentInstance.select.close();
1029+
fixture.detectChanges();
1030+
1031+
fixture.whenStable().then(() => {
1032+
trigger.click();
1033+
fixture.detectChanges();
1034+
panelLeft = document.querySelector('.mat-select-panel').getBoundingClientRect().left;
1035+
1036+
expect(panelLeft).toBeGreaterThan(0,
1037+
`Expected select panel continue being inside the viewport.`);
1038+
});
1039+
}));
1040+
1041+
});
1042+
9451043
describe('when scrolled', () => {
9461044

9471045
// Need to set the scrollTop two different ways to support
@@ -1063,42 +1161,38 @@ describe('MdSelect', () => {
10631161
select.style.marginRight = '30px';
10641162
});
10651163

1066-
it('should align the trigger and the selected option on the x-axis in ltr', async(() => {
1164+
it('should align the trigger and the selected option on the x-axis in ltr', fakeAsync(() => {
10671165
trigger.click();
1166+
tick(400);
10681167
fixture.detectChanges();
10691168

1070-
fixture.whenStable().then(() => {
1071-
const triggerLeft = trigger.getBoundingClientRect().left;
1072-
const firstOptionLeft = document.querySelector('.cdk-overlay-pane md-option')
1073-
.getBoundingClientRect().left;
1169+
const triggerLeft = trigger.getBoundingClientRect().left;
1170+
const firstOptionLeft = document.querySelector('.cdk-overlay-pane md-option')
1171+
.getBoundingClientRect().left;
10741172

1075-
// Each option is 32px wider than the trigger, so it must be adjusted 16px
1076-
// to ensure the text overlaps correctly.
1077-
expect(firstOptionLeft.toFixed(2)).toEqual((triggerLeft - 16).toFixed(2),
1078-
`Expected trigger to align with the selected option on the x-axis in LTR.`);
1079-
});
1173+
// Each option is 32px wider than the trigger, so it must be adjusted 16px
1174+
// to ensure the text overlaps correctly.
1175+
expect(firstOptionLeft.toFixed(2)).toEqual((triggerLeft - 16).toFixed(2),
1176+
`Expected trigger to align with the selected option on the x-axis in LTR.`);
10801177
}));
10811178

1082-
it('should align the trigger and the selected option on the x-axis in rtl', async(() => {
1179+
it('should align the trigger and the selected option on the x-axis in rtl', fakeAsync(() => {
10831180
dir.value = 'rtl';
1084-
fixture.whenStable().then(() => {
1085-
fixture.detectChanges();
1181+
fixture.detectChanges();
10861182

1087-
trigger.click();
1088-
fixture.detectChanges();
1183+
trigger.click();
1184+
tick(400);
1185+
fixture.detectChanges();
10891186

1090-
fixture.whenStable().then(() => {
1091-
const triggerRight = trigger.getBoundingClientRect().right;
1092-
const firstOptionRight =
1093-
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
1094-
1095-
// Each option is 32px wider than the trigger, so it must be adjusted 16px
1096-
// to ensure the text overlaps correctly.
1097-
expect(firstOptionRight.toFixed(2))
1098-
.toEqual((triggerRight + 16).toFixed(2),
1099-
`Expected trigger to align with the selected option on the x-axis in RTL.`);
1100-
});
1101-
});
1187+
const triggerRight = trigger.getBoundingClientRect().right;
1188+
const firstOptionRight =
1189+
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
1190+
1191+
// Each option is 32px wider than the trigger, so it must be adjusted 16px
1192+
// to ensure the text overlaps correctly.
1193+
expect(firstOptionRight.toFixed(2))
1194+
.toEqual((triggerRight + 16).toFixed(2),
1195+
`Expected trigger to align with the selected option on the x-axis in RTL.`);
11021196
}));
11031197
});
11041198

@@ -1111,8 +1205,8 @@ describe('MdSelect', () => {
11111205
trigger = multiFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
11121206
select = multiFixture.debugElement.query(By.css('md-select')).nativeElement;
11131207

1114-
select.style.marginLeft = '20px';
1115-
select.style.marginRight = '20px';
1208+
select.style.marginLeft = '60px';
1209+
select.style.marginRight = '60px';
11161210
});
11171211

11181212
it('should adjust for the checkbox in ltr', async(() => {
@@ -1131,21 +1225,20 @@ describe('MdSelect', () => {
11311225
});
11321226
}));
11331227

1134-
it('should adjust for the checkbox in rtl', async(() => {
1228+
it('should adjust for the checkbox in rtl', fakeAsync(() => {
11351229
dir.value = 'rtl';
11361230
trigger.click();
1231+
tick(400);
11371232
multiFixture.detectChanges();
11381233

1139-
multiFixture.whenStable().then(() => {
1140-
const triggerRight = trigger.getBoundingClientRect().right;
1141-
const firstOptionRight =
1142-
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
1234+
const triggerRight = trigger.getBoundingClientRect().right;
1235+
const firstOptionRight =
1236+
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
11431237

1144-
// 48px accounts for the checkbox size, margin and the panel's padding.
1145-
expect(firstOptionRight.toFixed(2))
1146-
.toEqual((triggerRight + 48).toFixed(2),
1147-
`Expected trigger label to align along x-axis, accounting for the checkbox.`);
1148-
});
1238+
// 48px accounts for the checkbox size, margin and the panel's padding.
1239+
expect(firstOptionRight.toFixed(2))
1240+
.toEqual((triggerRight + 48).toFixed(2),
1241+
`Expected trigger label to align along x-axis, accounting for the checkbox.`);
11491242
}));
11501243
});
11511244

@@ -2126,15 +2219,3 @@ class BasicSelectWithTheming {
21262219
@ViewChild(MdSelect) select: MdSelect;
21272220
theme: string;
21282221
}
2129-
2130-
class FakeViewportRuler {
2131-
getViewportRect() {
2132-
return {
2133-
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
2134-
};
2135-
}
2136-
2137-
getViewportScrollPosition() {
2138-
return {top: 0, left: 0};
2139-
}
2140-
}

src/lib/select/select.ts

+46-16
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
194194
/** Whether the panel's animation is done. */
195195
_panelDoneAnimating: boolean = false;
196196

197-
/**
198-
* The x-offset of the overlay panel in relation to the trigger's top start corner.
199-
* This must be adjusted to align the selected option text over the trigger text when
200-
* the panel opens. Will change based on LTR or RTL text direction.
201-
*/
202-
_offsetX = 0;
203-
204197
/**
205198
* The y-offset of the overlay panel in relation to the trigger's top start corner.
206199
* This must be adjusted to align the selected option text over the trigger text.
@@ -505,6 +498,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
505498
} else {
506499
this.onClose.emit();
507500
this._panelDoneAnimating = false;
501+
this.overlayDir.offsetX = 0;
508502
}
509503
}
510504

@@ -526,12 +520,20 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
526520
}
527521
}
528522

523+
/**
524+
* Callback that is invoked when the overlay panel has been attached.
525+
*/
526+
_onAttached(): void {
527+
this._calculateOverlayOffsetX();
528+
this._setScrollTop();
529+
}
530+
529531
/**
530532
* Sets the scroll position of the scroll container. This must be called after
531533
* the overlay pane is attached or the scroll container element will not yet be
532534
* present in the DOM.
533535
*/
534-
_setScrollTop(): void {
536+
private _setScrollTop(): void {
535537
const scrollContainer =
536538
this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-panel');
537539
scrollContainer.scrollTop = this._scrollTop;
@@ -729,12 +731,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
729731

730732
/** Calculates the scroll position and x- and y-offsets of the overlay panel. */
731733
private _calculateOverlayPosition(): void {
732-
this._offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X;
733-
734-
if (!this._isRtl()) {
735-
this._offsetX *= -1;
736-
}
737-
738734
const panelHeight =
739735
Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
740736
const scrollContainerHeight = this.options.length * SELECT_OPTION_HEIGHT;
@@ -748,7 +744,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
748744
// center of the overlay panel rather than the top.
749745
const scrollBuffer = panelHeight / 2;
750746
this._scrollTop = this._calculateOverlayScroll(selectedIndex, scrollBuffer, maxScroll);
751-
this._offsetY = this._calculateOverlayOffset(selectedIndex, scrollBuffer, maxScroll);
747+
this._offsetY = this._calculateOverlayOffsetY(selectedIndex, scrollBuffer, maxScroll);
752748
} else {
753749
// If no option is selected, the panel centers on the first option. In this case,
754750
// we must only adjust for the height difference between the option element
@@ -810,12 +806,46 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
810806
return this.ariaLabelledby ? null : this.ariaLabel || this.placeholder;
811807
}
812808

809+
/**
810+
* Sets the x-offset of the overlay panel in relation to the trigger's top start corner.
811+
* This must be adjusted to align the selected option text over the trigger text when
812+
* the panel opens. Will change based on LTR or RTL text direction. Note that the offset
813+
* can't be calculated until the panel has been attached, because we need to know the
814+
* content width in order to constrain the panel within the viewport.
815+
*/
816+
private _calculateOverlayOffsetX(): void {
817+
const overlayRect = this.overlayDir.overlayRef.overlayElement.getBoundingClientRect();
818+
const viewportRect = this._viewportRuler.getViewportRect();
819+
const isRtl = this._isRtl();
820+
let offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X;
821+
822+
if (!isRtl) {
823+
offsetX *= -1;
824+
}
825+
826+
const leftOverflow = 0 - (overlayRect.left + offsetX
827+
- (isRtl ? SELECT_PANEL_PADDING_X * 2 : 0));
828+
const rightOverflow = overlayRect.right + offsetX - viewportRect.width
829+
+ (isRtl ? 0 : SELECT_PANEL_PADDING_X * 2);
830+
831+
if (leftOverflow > 0) {
832+
offsetX += leftOverflow + SELECT_PANEL_VIEWPORT_PADDING;
833+
} else if (rightOverflow > 0) {
834+
offsetX -= rightOverflow + SELECT_PANEL_VIEWPORT_PADDING;
835+
}
836+
837+
// Set the offset directly in order to avoid having to go through change detection and
838+
// potentially triggering "changed after it was checked" errors.
839+
this.overlayDir.offsetX = offsetX;
840+
this.overlayDir.overlayRef.updatePosition();
841+
}
842+
813843
/**
814844
* Calculates the y-offset of the select's overlay panel in relation to the
815845
* top start corner of the trigger. It has to be adjusted in order for the
816846
* selected option to be aligned over the trigger when the panel opens.
817847
*/
818-
private _calculateOverlayOffset(selectedIndex: number, scrollBuffer: number,
848+
private _calculateOverlayOffsetY(selectedIndex: number, scrollBuffer: number,
819849
maxScroll: number): number {
820850
let optionOffsetFromPanelTop: number;
821851

0 commit comments

Comments
 (0)