Skip to content

Commit 6bd9f4a

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 #3504. Fixes #3831.
1 parent 8d0cd04 commit 6bd9f4a

File tree

3 files changed

+180
-69
lines changed

3 files changed

+180
-69
lines changed

src/lib/select/select.html

Lines changed: 1 addition & 1 deletion
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.spec.ts

Lines changed: 133 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,22 @@ import {dispatchFakeEvent} from '../core/testing/dispatch-events';
2424
import {wrappedErrorMessage} from '../core/testing/wrapped-error-message';
2525

2626

27+
class FakeViewportRuler {
28+
getViewportRect() {
29+
return {
30+
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
31+
};
32+
}
33+
34+
getViewportScrollPosition() {
35+
return {top: 0, left: 0};
36+
}
37+
}
38+
2739
describe('MdSelect', () => {
2840
let overlayContainerElement: HTMLElement;
2941
let dir: {value: 'ltr'|'rtl'};
42+
let fakeViewportRuler = new FakeViewportRuler();
3043

3144
beforeEach(async(() => {
3245
TestBed.configureTestingModule({
@@ -67,7 +80,7 @@ describe('MdSelect', () => {
6780
{provide: Dir, useFactory: () => {
6881
return dir = { value: 'ltr' };
6982
}},
70-
{provide: ViewportRuler, useClass: FakeViewportRuler}
83+
{provide: ViewportRuler, useValue: fakeViewportRuler}
7184
]
7285
});
7386

@@ -918,6 +931,91 @@ describe('MdSelect', () => {
918931

919932
});
920933

934+
describe('limited space to open horizontally', () => {
935+
beforeEach(() => {
936+
select.style.position = 'absolute';
937+
select.style.top = '200px';
938+
});
939+
940+
it('should stay within the viewport when overflowing on the left in ltr', fakeAsync(() => {
941+
select.style.left = '-100px';
942+
trigger.click();
943+
tick(400);
944+
fixture.detectChanges();
945+
946+
const panelLeft = document.querySelector('.mat-select-panel')
947+
.getBoundingClientRect().left;
948+
expect(panelLeft).toBeGreaterThan(0,
949+
`Expected select panel to be inside the viewport in ltr.`);
950+
}));
951+
952+
it('should stay within the viewport when overflowing on the left in rtl', fakeAsync(() => {
953+
dir.value = 'rtl';
954+
select.style.left = '-100px';
955+
trigger.click();
956+
tick(400);
957+
fixture.detectChanges();
958+
959+
const panelLeft = document.querySelector('.mat-select-panel')
960+
.getBoundingClientRect().left;
961+
962+
expect(panelLeft).toBeGreaterThan(0,
963+
`Expected select panel to be inside the viewport in rtl.`);
964+
}));
965+
966+
it('should stay within the viewport when overflowing on the right in ltr', fakeAsync(() => {
967+
select.style.right = '-100px';
968+
trigger.click();
969+
tick(400);
970+
fixture.detectChanges();
971+
972+
const viewportRect = fakeViewportRuler.getViewportRect().right;
973+
const panelRight = document.querySelector('.mat-select-panel')
974+
.getBoundingClientRect().right;
975+
976+
expect(viewportRect - panelRight).toBeGreaterThan(0,
977+
`Expected select panel to be inside the viewport in ltr.`);
978+
}));
979+
980+
it('should stay within the viewport when overflowing on the right in rtl', fakeAsync(() => {
981+
dir.value = 'rtl';
982+
select.style.right = '-100px';
983+
trigger.click();
984+
tick(400);
985+
fixture.detectChanges();
986+
987+
const viewportRect = fakeViewportRuler.getViewportRect().right;
988+
const panelRight = document.querySelector('.mat-select-panel')
989+
.getBoundingClientRect().right;
990+
991+
expect(viewportRect - panelRight).toBeGreaterThan(0,
992+
`Expected select panel to be inside the viewport in rtl.`);
993+
}));
994+
995+
it('should keep the position within the viewport on repeat openings', async(() => {
996+
select.style.left = '-100px';
997+
trigger.click();
998+
fixture.detectChanges();
999+
1000+
let panelLeft = document.querySelector('.mat-select-panel').getBoundingClientRect().left;
1001+
1002+
expect(panelLeft).toBeGreaterThan(0, `Expected select panel to be inside the viewport.`);
1003+
1004+
fixture.componentInstance.select.close();
1005+
fixture.detectChanges();
1006+
1007+
fixture.whenStable().then(() => {
1008+
trigger.click();
1009+
fixture.detectChanges();
1010+
panelLeft = document.querySelector('.mat-select-panel').getBoundingClientRect().left;
1011+
1012+
expect(panelLeft).toBeGreaterThan(0,
1013+
`Expected select panel continue being inside the viewport.`);
1014+
});
1015+
}));
1016+
1017+
});
1018+
9211019
describe('when scrolled', () => {
9221020

9231021
// Need to set the scrollTop two different ways to support
@@ -1024,42 +1122,38 @@ describe('MdSelect', () => {
10241122
select.style.marginRight = '30px';
10251123
});
10261124

1027-
it('should align the trigger and the selected option on the x-axis in ltr', async(() => {
1125+
it('should align the trigger and the selected option on the x-axis in ltr', fakeAsync(() => {
10281126
trigger.click();
1127+
tick(400);
10291128
fixture.detectChanges();
10301129

1031-
fixture.whenStable().then(() => {
1032-
const triggerLeft = trigger.getBoundingClientRect().left;
1033-
const firstOptionLeft = document.querySelector('.cdk-overlay-pane md-option')
1034-
.getBoundingClientRect().left;
1130+
const triggerLeft = trigger.getBoundingClientRect().left;
1131+
const firstOptionLeft = document.querySelector('.cdk-overlay-pane md-option')
1132+
.getBoundingClientRect().left;
10351133

1036-
// Each option is 32px wider than the trigger, so it must be adjusted 16px
1037-
// to ensure the text overlaps correctly.
1038-
expect(firstOptionLeft.toFixed(2)).toEqual((triggerLeft - 16).toFixed(2),
1039-
`Expected trigger to align with the selected option on the x-axis in LTR.`);
1040-
});
1134+
// Each option is 32px wider than the trigger, so it must be adjusted 16px
1135+
// to ensure the text overlaps correctly.
1136+
expect(firstOptionLeft.toFixed(2)).toEqual((triggerLeft - 16).toFixed(2),
1137+
`Expected trigger to align with the selected option on the x-axis in LTR.`);
10411138
}));
10421139

1043-
it('should align the trigger and the selected option on the x-axis in rtl', async(() => {
1140+
it('should align the trigger and the selected option on the x-axis in rtl', fakeAsync(() => {
10441141
dir.value = 'rtl';
1045-
fixture.whenStable().then(() => {
1046-
fixture.detectChanges();
1142+
fixture.detectChanges();
10471143

1048-
trigger.click();
1049-
fixture.detectChanges();
1144+
trigger.click();
1145+
tick(400);
1146+
fixture.detectChanges();
10501147

1051-
fixture.whenStable().then(() => {
1052-
const triggerRight = trigger.getBoundingClientRect().right;
1053-
const firstOptionRight =
1054-
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
1055-
1056-
// Each option is 32px wider than the trigger, so it must be adjusted 16px
1057-
// to ensure the text overlaps correctly.
1058-
expect(firstOptionRight.toFixed(2))
1059-
.toEqual((triggerRight + 16).toFixed(2),
1060-
`Expected trigger to align with the selected option on the x-axis in RTL.`);
1061-
});
1062-
});
1148+
const triggerRight = trigger.getBoundingClientRect().right;
1149+
const firstOptionRight =
1150+
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
1151+
1152+
// Each option is 32px wider than the trigger, so it must be adjusted 16px
1153+
// to ensure the text overlaps correctly.
1154+
expect(firstOptionRight.toFixed(2))
1155+
.toEqual((triggerRight + 16).toFixed(2),
1156+
`Expected trigger to align with the selected option on the x-axis in RTL.`);
10631157
}));
10641158
});
10651159

@@ -1072,8 +1166,8 @@ describe('MdSelect', () => {
10721166
trigger = multiFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
10731167
select = multiFixture.debugElement.query(By.css('md-select')).nativeElement;
10741168

1075-
select.style.marginLeft = '20px';
1076-
select.style.marginRight = '20px';
1169+
select.style.marginLeft = '60px';
1170+
select.style.marginRight = '60px';
10771171
});
10781172

10791173
it('should adjust for the checkbox in ltr', async(() => {
@@ -1092,21 +1186,20 @@ describe('MdSelect', () => {
10921186
});
10931187
}));
10941188

1095-
it('should adjust for the checkbox in rtl', async(() => {
1189+
it('should adjust for the checkbox in rtl', fakeAsync(() => {
10961190
dir.value = 'rtl';
10971191
trigger.click();
1192+
tick(400);
10981193
multiFixture.detectChanges();
10991194

1100-
multiFixture.whenStable().then(() => {
1101-
const triggerRight = trigger.getBoundingClientRect().right;
1102-
const firstOptionRight =
1103-
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
1195+
const triggerRight = trigger.getBoundingClientRect().right;
1196+
const firstOptionRight =
1197+
document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right;
11041198

1105-
// 48px accounts for the checkbox size, margin and the panel's padding.
1106-
expect(firstOptionRight.toFixed(2))
1107-
.toEqual((triggerRight + 48).toFixed(2),
1108-
`Expected trigger label to align along x-axis, accounting for the checkbox.`);
1109-
});
1199+
// 48px accounts for the checkbox size, margin and the panel's padding.
1200+
expect(firstOptionRight.toFixed(2))
1201+
.toEqual((triggerRight + 48).toFixed(2),
1202+
`Expected trigger label to align along x-axis, accounting for the checkbox.`);
11101203
}));
11111204
});
11121205

@@ -2024,15 +2117,3 @@ class BasicSelectInitiallyHidden {
20242117
`
20252118
})
20262119
class BasicSelectNoPlaceholder { }
2027-
2028-
class FakeViewportRuler {
2029-
getViewportRect() {
2030-
return {
2031-
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
2032-
};
2033-
}
2034-
2035-
getViewportScrollPosition() {
2036-
return {top: 0, left: 0};
2037-
}
2038-
}

src/lib/select/select.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
187187
/** Whether the panel's animation is done. */
188188
_panelDoneAnimating: boolean = false;
189189

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-
197190
/**
198191
* The y-offset of the overlay panel in relation to the trigger's top start corner.
199192
* This must be adjusted to align the selected option text over the trigger text.
@@ -474,6 +467,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
474467
} else {
475468
this.onClose.emit();
476469
this._panelDoneAnimating = false;
470+
this.overlayDir.offsetX = 0;
477471
}
478472
}
479473

@@ -495,12 +489,20 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
495489
}
496490
}
497491

492+
/**
493+
* Callback that is invoked when the overlay panel has been attached.
494+
*/
495+
_onAttached(): void {
496+
this._calculateOverlayOffsetX();
497+
this._setScrollTop();
498+
}
499+
498500
/**
499501
* Sets the scroll position of the scroll container. This must be called after
500502
* the overlay pane is attached or the scroll container element will not yet be
501503
* present in the DOM.
502504
*/
503-
_setScrollTop(): void {
505+
private _setScrollTop(): void {
504506
const scrollContainer =
505507
this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-panel');
506508
scrollContainer.scrollTop = this._scrollTop;
@@ -698,12 +700,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
698700

699701
/** Calculates the scroll position and x- and y-offsets of the overlay panel. */
700702
private _calculateOverlayPosition(): void {
701-
this._offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X;
702-
703-
if (!this._isRtl()) {
704-
this._offsetX *= -1;
705-
}
706-
707703
const panelHeight =
708704
Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
709705
const scrollContainerHeight = this.options.length * SELECT_OPTION_HEIGHT;
@@ -717,7 +713,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
717713
// center of the overlay panel rather than the top.
718714
const scrollBuffer = panelHeight / 2;
719715
this._scrollTop = this._calculateOverlayScroll(selectedIndex, scrollBuffer, maxScroll);
720-
this._offsetY = this._calculateOverlayOffset(selectedIndex, scrollBuffer, maxScroll);
716+
this._offsetY = this._calculateOverlayOffsetY(selectedIndex, scrollBuffer, maxScroll);
721717
} else {
722718
// If no option is selected, the panel centers on the first option. In this case,
723719
// we must only adjust for the height difference between the option element
@@ -779,12 +775,46 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
779775
return this.ariaLabelledby ? null : this.ariaLabel || this.placeholder;
780776
}
781777

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

0 commit comments

Comments
 (0)