Skip to content

Commit

Permalink
fix(select): prevent the panel from going outside the viewport horizo…
Browse files Browse the repository at this point in the history
…ntally

Prevents the select panel from going outside the viewport along the x axis.

Fixes angular#3504.
Fixes angular#3831.
  • Loading branch information
crisbeto committed Apr 18, 2017
1 parent 11b97aa commit f409899
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 32 deletions.
2 changes: 1 addition & 1 deletion src/lib/select/select.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

<ng-template cdk-connected-overlay [origin]="origin" [open]="panelOpen" hasBackdrop (backdropClick)="close()"
backdropClass="cdk-overlay-transparent-backdrop" [positions]="_positions" [minWidth]="_triggerWidth"
[offsetY]="_offsetY" [offsetX]="_offsetX" (attach)="_setScrollTop()">
[offsetY]="_offsetY" (attach)="_onAttached()">
<div class="mat-select-panel" [@transformPanel]="'showing'" (@transformPanel.done)="_onPanelDone()"
(keydown)="_keyManager.onKeydown($event)" [style.transformOrigin]="_transformOrigin"
[class.mat-select-panel-done-animating]="_panelDoneAnimating">
Expand Down
119 changes: 104 additions & 15 deletions src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,22 @@ import {dispatchFakeEvent} from '../core/testing/dispatch-events';
import {wrappedErrorMessage} from '../core/testing/wrapped-error-message';


class FakeViewportRuler {
getViewportRect() {
return {
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
};
}

getViewportScrollPosition() {
return {top: 0, left: 0};
}
}

describe('MdSelect', () => {
let overlayContainerElement: HTMLElement;
let dir: {value: 'ltr'|'rtl'};
let fakeViewportRuler = new FakeViewportRuler();

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand Down Expand Up @@ -67,7 +80,7 @@ describe('MdSelect', () => {
{provide: Dir, useFactory: () => {
return dir = { value: 'ltr' };
}},
{provide: ViewportRuler, useClass: FakeViewportRuler}
{provide: ViewportRuler, useValue: fakeViewportRuler}
]
});

Expand Down Expand Up @@ -918,6 +931,94 @@ describe('MdSelect', () => {

});

describe('limited space to open horizontally', () => {
beforeEach(async(() => {
select.style.position = 'absolute';
select.style.top = '200px';
}));

it('should stay within the viewport when overflowing on the left in ltr', async(() => {
select.style.left = '-100px';
trigger.click();
fixture.detectChanges();

fixture.whenStable().then(() => {
const panelLeft = document.querySelector('.mat-select-panel')
.getBoundingClientRect().left;
expect(panelLeft).toBeGreaterThan(0,
`Expected select panel to be inside the viewport in ltr.`);
});
}));

it('should stay within the viewport when overflowing on the right in rtl', async(() => {
dir.value = 'rtl';
select.style.left = '-100px';
trigger.click();
fixture.detectChanges();

fixture.whenStable().then(() => {
const panelLeft = document.querySelector('.mat-select-panel')
.getBoundingClientRect().left;

expect(panelLeft).toBeGreaterThan(0,
`Expected select panel to be inside the viewport in rtl.`);
});
}));

it('should stay within the viewport when overflowing on the right in ltr', async(() => {
select.style.right = '-100px';
trigger.click();
fixture.detectChanges();

fixture.whenStable().then(() => {
const viewportRect = fakeViewportRuler.getViewportRect().right;
const panelRight = document.querySelector('.mat-select-panel')
.getBoundingClientRect().right;

expect(viewportRect - panelRight).toBeGreaterThan(0,
`Expected select panel to be inside the viewport in ltr.`);
});
}));

it('should stay within the viewport when overflowing on the right in rtl', async(() => {
dir.value = 'rtl';
select.style.right = '-100px';
trigger.click();
fixture.detectChanges();

fixture.whenStable().then(() => {
const viewportRect = fakeViewportRuler.getViewportRect().right;
const panelRight = document.querySelector('.mat-select-panel')
.getBoundingClientRect().right;

expect(viewportRect - panelRight).toBeGreaterThan(0,
`Expected select panel to be inside the viewport in rtl.`);
});
}));

it('should keep the position within the viewport on repeat openings', async(() => {
select.style.left = '-100px';
trigger.click();
fixture.detectChanges();

let panelLeft = document.querySelector('.mat-select-panel').getBoundingClientRect().left;

expect(panelLeft).toBeGreaterThan(0, `Expected select panel to be inside the viewport.`);

fixture.componentInstance.select.close();
fixture.detectChanges();

fixture.whenStable().then(() => {
trigger.click();
fixture.detectChanges();
panelLeft = document.querySelector('.mat-select-panel').getBoundingClientRect().left;

expect(panelLeft).toBeGreaterThan(0,
`Expected select panel continue being inside the viewport.`);
});
}));
});

describe('when scrolled', () => {

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

select.style.marginLeft = '20px';
select.style.marginRight = '20px';
select.style.marginLeft = '60px';
select.style.marginRight = '60px';
});

it('should adjust for the checkbox in ltr', async(() => {
Expand Down Expand Up @@ -2024,15 +2125,3 @@ class BasicSelectInitiallyHidden {
`
})
class BasicSelectNoPlaceholder { }

class FakeViewportRuler {
getViewportRect() {
return {
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
};
}

getViewportScrollPosition() {
return {top: 0, left: 0};
}
}
64 changes: 48 additions & 16 deletions src/lib/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export const SELECT_PANEL_PADDING_Y = 16;
*/
export const SELECT_PANEL_VIEWPORT_PADDING = 8;

/** Extra width that gets added the to select panel during the open animation. */
export const SELECT_PANEL_EXTRA_WIDTH = 32;

/** Change event object that is emitted when the select value has changed. */
export class MdSelectChange {
constructor(public source: MdSelect, public value: any) { }
Expand Down Expand Up @@ -187,13 +190,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
/** Whether the panel's animation is done. */
_panelDoneAnimating: boolean = false;

/**
* The x-offset of the overlay panel in relation to the trigger's top start corner.
* This must be adjusted to align the selected option text over the trigger text when
* the panel opens. Will change based on LTR or RTL text direction.
*/
_offsetX = 0;

/**
* The y-offset of the overlay panel in relation to the trigger's top start corner.
* This must be adjusted to align the selected option text over the trigger text.
Expand Down Expand Up @@ -474,6 +470,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
} else {
this.onClose.emit();
this._panelDoneAnimating = false;
this.overlayDir.offsetX = 0;
}
}

Expand All @@ -495,12 +492,20 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
}
}

/**
* Callback that is invoked when the overlay panel has been attached.
*/
_onAttached(): void {
this._calculateOverlayXOffset();
this._setScrollTop();
}

/**
* Sets the scroll position of the scroll container. This must be called after
* the overlay pane is attached or the scroll container element will not yet be
* present in the DOM.
*/
_setScrollTop(): void {
private _setScrollTop(): void {
const scrollContainer =
this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-panel');
scrollContainer.scrollTop = this._scrollTop;
Expand Down Expand Up @@ -698,12 +703,6 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal

/** Calculates the scroll position and x- and y-offsets of the overlay panel. */
private _calculateOverlayPosition(): void {
this._offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X;

if (!this._isRtl()) {
this._offsetX *= -1;
}

const panelHeight =
Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
const scrollContainerHeight = this.options.length * SELECT_OPTION_HEIGHT;
Expand All @@ -717,7 +716,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
// center of the overlay panel rather than the top.
const scrollBuffer = panelHeight / 2;
this._scrollTop = this._calculateOverlayScroll(selectedIndex, scrollBuffer, maxScroll);
this._offsetY = this._calculateOverlayOffset(selectedIndex, scrollBuffer, maxScroll);
this._offsetY = this._calculateOverlayYOffset(selectedIndex, scrollBuffer, maxScroll);
} else {
// If no option is selected, the panel centers on the first option. In this case,
// we must only adjust for the height difference between the option element
Expand Down Expand Up @@ -779,12 +778,45 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
return this.ariaLabelledby ? null : this.ariaLabel || this.placeholder;
}

/**
* Sets the x-offset of the overlay panel in relation to the trigger's top start corner.
* This must be adjusted to align the selected option text over the trigger text when
* the panel opens. Will change based on LTR or RTL text direction. Note that the offset
* can't be calculated until the panel has been attached, because we need to know the
* content width in order to constrain the panel within the viewport.
*/
private _calculateOverlayXOffset(): void {
let overlayRect = this.overlayDir.overlayRef.overlayElement.getBoundingClientRect();
let viewportRect = this._viewportRuler.getViewportRect();
let offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X;
let isRtl = this._isRtl();

if (!isRtl) {
offsetX *= -1;
}

let leftOverflow = 0 - (overlayRect.left + offsetX - (isRtl ? SELECT_PANEL_EXTRA_WIDTH : 0));
let rightOverflow = overlayRect.right + offsetX - viewportRect.width
+ (isRtl ? 0 : SELECT_PANEL_EXTRA_WIDTH);

if (leftOverflow > 0) {
offsetX += leftOverflow + SELECT_PANEL_VIEWPORT_PADDING;
} else if (rightOverflow > 0) {
offsetX -= rightOverflow + SELECT_PANEL_VIEWPORT_PADDING;
}

// Set the offset directly in order to avoid having to go through change detection and
// potentially triggering "changed after it was checked" errors.
this.overlayDir.offsetX = offsetX;
this.overlayDir.overlayRef.updatePosition();
}

/**
* Calculates the y-offset of the select's overlay panel in relation to the
* top start corner of the trigger. It has to be adjusted in order for the
* selected option to be aligned over the trigger when the panel opens.
*/
private _calculateOverlayOffset(selectedIndex: number, scrollBuffer: number,
private _calculateOverlayYOffset(selectedIndex: number, scrollBuffer: number,
maxScroll: number): number {
let optionOffsetFromPanelTop: number;

Expand Down

0 comments on commit f409899

Please sign in to comment.