Skip to content

Commit f5af2fa

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 8d0cd04 commit f5af2fa

File tree

3 files changed

+153
-32
lines changed

3 files changed

+153
-32
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.spec.ts

+104-15
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,94 @@ describe('MdSelect', () => {
918931

919932
});
920933

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

9231024
// Need to set the scrollTop two different ways to support
@@ -1072,8 +1173,8 @@ describe('MdSelect', () => {
10721173
trigger = multiFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
10731174
select = multiFixture.debugElement.query(By.css('md-select')).nativeElement;
10741175

1075-
select.style.marginLeft = '20px';
1076-
select.style.marginRight = '20px';
1176+
select.style.marginLeft = '60px';
1177+
select.style.marginRight = '60px';
10771178
});
10781179

10791180
it('should adjust for the checkbox in ltr', async(() => {
@@ -2024,15 +2125,3 @@ class BasicSelectInitiallyHidden {
20242125
`
20252126
})
20262127
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

+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.
@@ -474,6 +470,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
474470
} else {
475471
this.onClose.emit();
476472
this._panelDoneAnimating = false;
473+
this.overlayDir.offsetX = 0;
477474
}
478475
}
479476

@@ -495,12 +492,20 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
495492
}
496493
}
497494

495+
/**
496+
* Callback that is invoked when the overlay panel has been attached.
497+
*/
498+
_onAttached(): void {
499+
this._calculateOverlayXOffset();
500+
this._setScrollTop();
501+
}
502+
498503
/**
499504
* Sets the scroll position of the scroll container. This must be called after
500505
* the overlay pane is attached or the scroll container element will not yet be
501506
* present in the DOM.
502507
*/
503-
_setScrollTop(): void {
508+
private _setScrollTop(): void {
504509
const scrollContainer =
505510
this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-panel');
506511
scrollContainer.scrollTop = this._scrollTop;
@@ -698,12 +703,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
698703

699704
/** Calculates the scroll position and x- and y-offsets of the overlay panel. */
700705
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-
707706
const panelHeight =
708707
Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
709708
const scrollContainerHeight = this.options.length * SELECT_OPTION_HEIGHT;
@@ -717,7 +716,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
717716
// center of the overlay panel rather than the top.
718717
const scrollBuffer = panelHeight / 2;
719718
this._scrollTop = this._calculateOverlayScroll(selectedIndex, scrollBuffer, maxScroll);
720-
this._offsetY = this._calculateOverlayOffset(selectedIndex, scrollBuffer, maxScroll);
719+
this._offsetY = this._calculateOverlayYOffset(selectedIndex, scrollBuffer, maxScroll);
721720
} else {
722721
// If no option is selected, the panel centers on the first option. In this case,
723722
// we must only adjust for the height difference between the option element
@@ -779,12 +778,45 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
779778
return this.ariaLabelledby ? null : this.ariaLabel || this.placeholder;
780779
}
781780

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

0 commit comments

Comments
 (0)