Skip to content

Commit d26735c

Browse files
crisbetojelbourn
authored andcommitted
feat(overlay): add support for automatically setting the transform-origin based on the current position (#10868)
Currently we have a handful of components where we set the `transform-origin` depending on the position of their overlay. This ends up being a fair bit of similar logic that is scattered across the different components. These changes consolidate that logic into an option on the `FlexibleConnectedPositionStrategy`.
1 parent 521b111 commit d26735c

15 files changed

+177
-314
lines changed

src/cdk/overlay/overlay.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ overlay relative to another element on the page. These features include the abil
4343
overlay become scrollable once its content reaches the viewport edge, being able to configure a
4444
margin between the overlay and the viewport edge, having an overlay be pushed into the viewport if
4545
it doesn't fit into any of its preferred positions, as well as configuring whether the overlay's
46-
size can grow while the overlay is open.
46+
size can grow while the overlay is open. The flexible position strategy also allows for the
47+
`transform-origin` of an element, inside the overlay, to be set based on the current position using
48+
the `withTransformOriginOn`. This is useful when animating an overlay in and having the animation
49+
originate from the point at which it connects with the origin.
4750

4851
A custom position strategy can be created by implementing the `PositionStrategy` interface.
4952
Each `PositionStrategy` defines an `apply` method that is called whenever the overlay's position

src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,114 @@ describe('FlexibleConnectedPositionStrategy', () => {
507507

508508
});
509509

510+
describe('with transform origin', () => {
511+
it('should set the proper transform-origin when aligning to start/bottom', () => {
512+
positionStrategy.withTransformOriginOn('.transform-origin').withPositions([{
513+
originX: 'start',
514+
originY: 'bottom',
515+
overlayX: 'start',
516+
overlayY: 'top'
517+
}]);
518+
519+
attachOverlay({positionStrategy});
520+
521+
const target = overlayRef.overlayElement.querySelector('.transform-origin')! as HTMLElement;
522+
523+
expect(target.style.transformOrigin).toContain('left top');
524+
});
525+
526+
it('should set the proper transform-origin when aligning to end/bottom', () => {
527+
positionStrategy.withTransformOriginOn('.transform-origin').withPositions([{
528+
originX: 'end',
529+
originY: 'bottom',
530+
overlayX: 'end',
531+
overlayY: 'top'
532+
}]);
533+
534+
attachOverlay({positionStrategy});
535+
536+
const target = overlayRef.overlayElement.querySelector('.transform-origin')! as HTMLElement;
537+
538+
expect(target.style.transformOrigin).toContain('right top');
539+
});
540+
541+
it('should set the proper transform-origin when centering vertically', () => {
542+
positionStrategy.withTransformOriginOn('.transform-origin').withPositions([{
543+
originX: 'start',
544+
originY: 'center',
545+
overlayX: 'start',
546+
overlayY: 'center'
547+
}]);
548+
549+
attachOverlay({positionStrategy});
550+
551+
const target = overlayRef.overlayElement.querySelector('.transform-origin')! as HTMLElement;
552+
553+
expect(target.style.transformOrigin).toContain('left center');
554+
});
555+
556+
it('should set the proper transform-origin when centering horizontally', () => {
557+
positionStrategy.withTransformOriginOn('.transform-origin').withPositions([{
558+
originX: 'center',
559+
originY: 'top',
560+
overlayX: 'center',
561+
overlayY: 'top'
562+
}]);
563+
564+
attachOverlay({positionStrategy});
565+
566+
const target = overlayRef.overlayElement.querySelector('.transform-origin')! as HTMLElement;
567+
568+
expect(target.style.transformOrigin).toContain('center top');
569+
});
570+
571+
it('should set the proper transform-origin when aligning to start/top', () => {
572+
positionStrategy.withTransformOriginOn('.transform-origin').withPositions([{
573+
originX: 'start',
574+
originY: 'top',
575+
overlayX: 'start',
576+
overlayY: 'bottom'
577+
}]);
578+
579+
attachOverlay({positionStrategy});
580+
581+
const target = overlayRef.overlayElement.querySelector('.transform-origin')! as HTMLElement;
582+
583+
expect(target.style.transformOrigin).toContain('left bottom');
584+
});
585+
586+
it('should set the proper transform-origin when aligning to start/bottom in rtl', () => {
587+
positionStrategy.withTransformOriginOn('.transform-origin').withPositions([{
588+
originX: 'start',
589+
originY: 'bottom',
590+
overlayX: 'start',
591+
overlayY: 'top'
592+
}]);
593+
594+
attachOverlay({positionStrategy, direction: 'rtl'});
595+
596+
const target = overlayRef.overlayElement.querySelector('.transform-origin')! as HTMLElement;
597+
598+
expect(target.style.transformOrigin).toContain('right top');
599+
});
600+
601+
it('should set the proper transform-origin when aligning to end/bottom in rtl', () => {
602+
positionStrategy.withTransformOriginOn('.transform-origin').withPositions([{
603+
originX: 'end',
604+
originY: 'bottom',
605+
overlayX: 'end',
606+
overlayY: 'top'
607+
}]);
608+
609+
attachOverlay({positionStrategy, direction: 'rtl'});
610+
611+
const target = overlayRef.overlayElement.querySelector('.transform-origin')! as HTMLElement;
612+
613+
expect(target.style.transformOrigin).toContain('left top');
614+
});
615+
616+
});
617+
510618
it('should account for the `offsetX` pushing the overlay out of the screen', () => {
511619
// Position the element so it would have enough space to fit.
512620
originElement.style.top = '200px';
@@ -1676,7 +1784,11 @@ function createOverflowContainerElement() {
16761784

16771785

16781786
@Component({
1679-
template: `<div style="width: ${DEFAULT_WIDTH}px; height: ${DEFAULT_HEIGHT}px;"></div>`
1787+
template: `
1788+
<div
1789+
class="transform-origin"
1790+
style="width: ${DEFAULT_WIDTH}px; height: ${DEFAULT_HEIGHT}px;"></div>
1791+
`
16801792
})
16811793
class TestOverlay { }
16821794

src/cdk/overlay/position/flexible-connected-position-strategy.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ import {Platform} from '@angular/cdk/platform';
2424

2525

2626
// TODO: refactor clipping detection into a separate thing (part of scrolling module)
27-
// TODO: attribute selector to specify the transform-origin inside the overlay content
28-
// TODO: flexible position + centering doesn't work on IE11 (works on Edge).
2927
// TODO: doesn't handle both flexible width and height when it has to scroll along both axis.
3028

3129
/**
@@ -108,6 +106,9 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
108106
/** Default offset for the overlay along the y axis. */
109107
private _offsetY = 0;
110108

109+
/** Selector to be used when finding the elements on which to set the transform origin. */
110+
private _transformOriginSelector: string;
111+
111112
/** Observable sequence of position changes. */
112113
positionChanges: Observable<ConnectedOverlayPositionChange> =
113114
this._positionChanges.asObservable();
@@ -392,6 +393,19 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
392393
return this;
393394
}
394395

396+
/**
397+
* Configures that the position strategy should set a `transform-origin` on some elements
398+
* inside the overlay, depending on the current position that is being applied. This is
399+
* useful for the cases where the origin of an animation can change depending on the
400+
* alignment of the overlay.
401+
* @param selector CSS selector that will be used to find the target
402+
* elements onto which to set the transform origin.
403+
*/
404+
withTransformOriginOn(selector: string): this {
405+
this._transformOriginSelector = selector;
406+
return this;
407+
}
408+
395409
/**
396410
* Gets the (x, y) coordinate of a connection point on the origin based on a relative position.
397411
*/
@@ -556,11 +570,11 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
556570

557571
/**
558572
* Applies a computed position to the overlay and emits a position change.
559-
*
560573
* @param position The position preference
561574
* @param originPoint The point on the origin element where the overlay is connected.
562575
*/
563576
private _applyPosition(position: ConnectedPosition, originPoint: Point) {
577+
this._setTransformOrigin(position);
564578
this._setOverlayElementStyles(originPoint, position);
565579
this._setBoundingBoxStyles(originPoint, position);
566580

@@ -574,6 +588,30 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
574588
this._isInitialRender = false;
575589
}
576590

591+
/** Sets the transform origin based on the configured selector and the passed-in position. */
592+
private _setTransformOrigin(position: ConnectedPosition) {
593+
if (!this._transformOriginSelector) {
594+
return;
595+
}
596+
597+
const elements: NodeListOf<HTMLElement> =
598+
this._boundingBox!.querySelectorAll(this._transformOriginSelector);
599+
let xOrigin: 'left' | 'right' | 'center';
600+
let yOrigin: 'top' | 'bottom' | 'center' = position.overlayY;
601+
602+
if (position.overlayX === 'center') {
603+
xOrigin = 'center';
604+
} else if (this._isRtl()) {
605+
xOrigin = position.overlayX === 'start' ? 'right' : 'left';
606+
} else {
607+
xOrigin = position.overlayX === 'start' ? 'left' : 'right';
608+
}
609+
610+
for (let i = 0; i < elements.length; i++) {
611+
elements[i].style.transformOrigin = `${xOrigin} ${yOrigin}`;
612+
}
613+
}
614+
577615
/**
578616
* Gets the position and size of the overlay's sizing container.
579617
*

src/lib/core/style/_menu-common.scss

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -57,43 +57,3 @@ $mat-menu-icon-margin: 16px !default;
5757
}
5858
}
5959
}
60-
61-
/**
62-
* This mixin adds the correct panel transform styles based
63-
* on the direction that the menu panel opens.
64-
*/
65-
@mixin mat-menu-positions() {
66-
&.mat-menu-after.mat-menu-below {
67-
transform-origin: left top;
68-
}
69-
70-
&.mat-menu-after.mat-menu-above {
71-
transform-origin: left bottom;
72-
}
73-
74-
&.mat-menu-before.mat-menu-below {
75-
transform-origin: right top;
76-
}
77-
78-
&.mat-menu-before.mat-menu-above {
79-
transform-origin: right bottom;
80-
}
81-
82-
[dir='rtl'] & {
83-
&.mat-menu-after.mat-menu-below {
84-
transform-origin: right top;
85-
}
86-
87-
&.mat-menu-after.mat-menu-above {
88-
transform-origin: right bottom;
89-
}
90-
91-
&.mat-menu-before.mat-menu-below {
92-
transform-origin: left top;
93-
}
94-
95-
&.mat-menu-before.mat-menu-above {
96-
transform-origin: left bottom;
97-
}
98-
}
99-
}

src/lib/datepicker/datepicker-content.scss

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,13 @@ $mat-datepicker-touch-max-height: 788px;
2929

3030
display: block;
3131
border-radius: 2px;
32-
transform-origin: top center;
3332

3433
.mat-calendar {
3534
width: $mat-datepicker-non-touch-calendar-width;
3635
height: $mat-datepicker-non-touch-calendar-height;
3736
}
3837
}
3938

40-
.mat-datepicker-content-above {
41-
transform-origin: bottom center;
42-
}
43-
4439
.mat-datepicker-content-touch {
4540
@include mat-elevation(0);
4641

src/lib/datepicker/datepicker.spec.ts

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,52 +1476,6 @@ describe('MatDatepicker', () => {
14761476
}));
14771477
});
14781478

1479-
describe('popup animations', () => {
1480-
let fixture: ComponentFixture<StandardDatepicker>;
1481-
1482-
beforeEach(fakeAsync(() => {
1483-
TestBed.configureTestingModule({
1484-
imports: [MatDatepickerModule, MatNativeDateModule, NoopAnimationsModule],
1485-
declarations: [StandardDatepicker],
1486-
}).compileComponents();
1487-
1488-
fixture = TestBed.createComponent(StandardDatepicker);
1489-
fixture.detectChanges();
1490-
}));
1491-
1492-
it('should not set the `mat-datepicker-content-above` class when opening downwards',
1493-
fakeAsync(() => {
1494-
fixture.componentInstance.datepicker.open();
1495-
fixture.detectChanges();
1496-
flush();
1497-
fixture.detectChanges();
1498-
1499-
const content =
1500-
document.querySelector('.cdk-overlay-pane mat-datepicker-content')! as HTMLElement;
1501-
1502-
expect(content.classList).not.toContain('mat-datepicker-content-above');
1503-
}));
1504-
1505-
it('should set the `mat-datepicker-content-above` class when opening upwards', fakeAsync(() => {
1506-
const input = fixture.debugElement.nativeElement.querySelector('input');
1507-
1508-
// Push the input to the bottom of the page to force the calendar to open upwards
1509-
input.style.position = 'fixed';
1510-
input.style.bottom = '0';
1511-
1512-
fixture.componentInstance.datepicker.open();
1513-
fixture.detectChanges();
1514-
flush();
1515-
fixture.detectChanges();
1516-
1517-
const content =
1518-
document.querySelector('.cdk-overlay-pane mat-datepicker-content')! as HTMLElement;
1519-
1520-
expect(content.classList).toContain('mat-datepicker-content-above');
1521-
}));
1522-
1523-
});
1524-
15251479
describe('datepicker with custom header', () => {
15261480
let fixture: ComponentFixture<DatepickerWithCustomHeader>;
15271481
let testComponent: DatepickerWithCustomHeader;

0 commit comments

Comments
 (0)