Skip to content

Commit e0f3ae7

Browse files
crisbetommalerba
authored andcommitted
feat(overlay): add support for swappable position strategies (#12306)
Adds the ability for the consumer to swap out one position strategy for another, even while an overlay is open. This allows us to handle cases like having a menu that is a dropdown on desktop, but becomes a full-screen overlay on a mobile device.
1 parent a6c91bc commit e0f3ae7

6 files changed

+218
-20
lines changed

src/cdk/overlay/overlay-ref.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {OverlayKeyboardDispatcher} from './keyboard/overlay-keyboard-dispatcher'
1515
import {OverlayConfig} from './overlay-config';
1616
import {coerceCssPixelValue, coerceArray} from '@angular/cdk/coercion';
1717
import {OverlayReference} from './overlay-reference';
18+
import {PositionStrategy} from './position/position-strategy';
1819

1920

2021
/** An object where all of its properties cannot be written. */
@@ -31,12 +32,14 @@ export class OverlayRef implements PortalOutlet, OverlayReference {
3132
private _backdropClick: Subject<MouseEvent> = new Subject();
3233
private _attachments = new Subject<void>();
3334
private _detachments = new Subject<void>();
35+
private _positionStrategy: PositionStrategy | undefined;
3436

3537
/**
3638
* Reference to the parent of the `_host` at the time it was detached. Used to restore
3739
* the `_host` to its original position in the DOM when it gets re-attached.
3840
*/
3941
private _previousHostParent: HTMLElement;
42+
4043
private _keydownEventsObservable: Observable<KeyboardEvent> = Observable.create(observer => {
4144
const subscription = this._keydownEvents.subscribe(observer);
4245
this._keydownEventSubscriptions++;
@@ -65,6 +68,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference {
6568
if (_config.scrollStrategy) {
6669
_config.scrollStrategy.attach(this);
6770
}
71+
72+
this._positionStrategy = _config.positionStrategy;
6873
}
6974

7075
/** The overlay's HTML element */
@@ -100,8 +105,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference {
100105
attach(portal: Portal<any>): any {
101106
let attachResult = this._portalOutlet.attach(portal);
102107

103-
if (this._config.positionStrategy) {
104-
this._config.positionStrategy.attach(this);
108+
if (this._positionStrategy) {
109+
this._positionStrategy.attach(this);
105110
}
106111

107112
// Update the pane element with the given configuration.
@@ -166,8 +171,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference {
166171
// pointer events therefore. Depends on the position strategy and the applied pane boundaries.
167172
this._togglePointerEvents(false);
168173

169-
if (this._config.positionStrategy && this._config.positionStrategy.detach) {
170-
this._config.positionStrategy.detach();
174+
if (this._positionStrategy && this._positionStrategy.detach) {
175+
this._positionStrategy.detach();
171176
}
172177

173178
if (this._config.scrollStrategy) {
@@ -197,8 +202,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference {
197202
dispose(): void {
198203
const isAttached = this.hasAttached();
199204

200-
if (this._config.positionStrategy) {
201-
this._config.positionStrategy.dispose();
205+
if (this._positionStrategy) {
206+
this._positionStrategy.dispose();
202207
}
203208

204209
if (this._config.scrollStrategy) {
@@ -258,8 +263,26 @@ export class OverlayRef implements PortalOutlet, OverlayReference {
258263

259264
/** Updates the position of the overlay based on the position strategy. */
260265
updatePosition() {
261-
if (this._config.positionStrategy) {
262-
this._config.positionStrategy.apply();
266+
if (this._positionStrategy) {
267+
this._positionStrategy.apply();
268+
}
269+
}
270+
271+
/** Switches to a new position strategy and updates the overlay position. */
272+
updatePositionStrategy(strategy: PositionStrategy) {
273+
if (strategy === this._positionStrategy) {
274+
return;
275+
}
276+
277+
if (this._positionStrategy) {
278+
this._positionStrategy.dispose();
279+
}
280+
281+
this._positionStrategy = strategy;
282+
283+
if (this.hasAttached()) {
284+
strategy.attach(this);
285+
this.updatePosition();
263286
}
264287
}
265288

src/cdk/overlay/overlay.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,70 @@ describe('Overlay', () => {
411411
expect(config.positionStrategy.apply).not.toHaveBeenCalled();
412412
}));
413413

414+
it('should be able to swap position strategies', fakeAsync(() => {
415+
const firstStrategy = new FakePositionStrategy();
416+
const secondStrategy = new FakePositionStrategy();
417+
418+
[firstStrategy, secondStrategy].forEach(strategy => {
419+
spyOn(strategy, 'attach');
420+
spyOn(strategy, 'apply');
421+
spyOn(strategy, 'dispose');
422+
});
423+
424+
config.positionStrategy = firstStrategy;
425+
426+
const overlayRef = overlay.create(config);
427+
overlayRef.attach(componentPortal);
428+
viewContainerFixture.detectChanges();
429+
zone.simulateZoneExit();
430+
tick();
431+
432+
expect(firstStrategy.attach).toHaveBeenCalledTimes(1);
433+
expect(firstStrategy.apply).toHaveBeenCalledTimes(1);
434+
435+
expect(secondStrategy.attach).not.toHaveBeenCalled();
436+
expect(secondStrategy.apply).not.toHaveBeenCalled();
437+
438+
overlayRef.updatePositionStrategy(secondStrategy);
439+
viewContainerFixture.detectChanges();
440+
tick();
441+
442+
expect(firstStrategy.attach).toHaveBeenCalledTimes(1);
443+
expect(firstStrategy.apply).toHaveBeenCalledTimes(1);
444+
expect(firstStrategy.dispose).toHaveBeenCalledTimes(1);
445+
446+
expect(secondStrategy.attach).toHaveBeenCalledTimes(1);
447+
expect(secondStrategy.apply).toHaveBeenCalledTimes(1);
448+
}));
449+
450+
it('should not do anything when trying to swap a strategy with itself', fakeAsync(() => {
451+
const strategy = new FakePositionStrategy();
452+
453+
spyOn(strategy, 'attach');
454+
spyOn(strategy, 'apply');
455+
spyOn(strategy, 'dispose');
456+
457+
config.positionStrategy = strategy;
458+
459+
const overlayRef = overlay.create(config);
460+
overlayRef.attach(componentPortal);
461+
viewContainerFixture.detectChanges();
462+
zone.simulateZoneExit();
463+
tick();
464+
465+
expect(strategy.attach).toHaveBeenCalledTimes(1);
466+
expect(strategy.apply).toHaveBeenCalledTimes(1);
467+
expect(strategy.dispose).not.toHaveBeenCalled();
468+
469+
overlayRef.updatePositionStrategy(strategy);
470+
viewContainerFixture.detectChanges();
471+
tick();
472+
473+
expect(strategy.attach).toHaveBeenCalledTimes(1);
474+
expect(strategy.apply).toHaveBeenCalledTimes(1);
475+
expect(strategy.dispose).not.toHaveBeenCalled();
476+
}));
477+
414478
});
415479

416480
describe('size', () => {

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,46 @@ describe('FlexibleConnectedPositionStrategy', () => {
150150
document.body.removeChild(originElement);
151151
});
152152

153+
it('should clean up after itself when disposed', () => {
154+
const origin = document.createElement('div');
155+
const positionStrategy = overlay.position()
156+
.flexibleConnectedTo(origin)
157+
.withPositions([{
158+
overlayX: 'start',
159+
overlayY: 'top',
160+
originX: 'start',
161+
originY: 'bottom'
162+
}]);
163+
164+
// Needs to be in the DOM for IE not to throw an "Unspecified error".
165+
document.body.appendChild(origin);
166+
attachOverlay({positionStrategy});
167+
168+
const boundingBox = overlayRef.hostElement;
169+
const pane = overlayRef.overlayElement;
170+
171+
positionStrategy.dispose();
172+
173+
expect(boundingBox.style.top).toBeFalsy();
174+
expect(boundingBox.style.bottom).toBeFalsy();
175+
expect(boundingBox.style.left).toBeFalsy();
176+
expect(boundingBox.style.right).toBeFalsy();
177+
expect(boundingBox.style.width).toBeFalsy();
178+
expect(boundingBox.style.height).toBeFalsy();
179+
expect(boundingBox.style.alignItems).toBeFalsy();
180+
expect(boundingBox.style.justifyContent).toBeFalsy();
181+
expect(boundingBox.classList).not.toContain('cdk-overlay-connected-position-bounding-box');
182+
183+
expect(pane.style.top).toBeFalsy();
184+
expect(pane.style.bottom).toBeFalsy();
185+
expect(pane.style.left).toBeFalsy();
186+
expect(pane.style.right).toBeFalsy();
187+
expect(pane.style.position).toBeFalsy();
188+
189+
overlayRef.dispose();
190+
document.body.removeChild(origin);
191+
});
192+
153193
describe('without flexible dimensions and pushing', () => {
154194
const ORIGIN_HEIGHT = DEFAULT_HEIGHT;
155195
const ORIGIN_WIDTH = DEFAULT_WIDTH;

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

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import {OverlayContainer} from '../overlay-container';
2626
// TODO: refactor clipping detection into a separate thing (part of scrolling module)
2727
// TODO: doesn't handle both flexible width and height when it has to scroll along both axis.
2828

29+
/** Class to be added to the overlay bounding box. */
30+
const boundingBoxClass = 'cdk-overlay-connected-position-bounding-box';
31+
2932
/**
3033
* A strategy for positioning overlays. Using this strategy, an overlay is given an
3134
* implicit position relative some origin element. The relative position is defined in terms of
@@ -38,7 +41,7 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
3841
private _overlayRef: OverlayReference;
3942

4043
/** Whether we're performing the very first positioning of the overlay. */
41-
private _isInitialRender = true;
44+
private _isInitialRender: boolean;
4245

4346
/** Last size used for the bounding box. Used to avoid resizing the overlay after open. */
4447
private _lastBoundingBoxSize = {width: 0, height: 0};
@@ -152,11 +155,14 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
152155

153156
this._validatePositions();
154157

155-
overlayRef.hostElement.classList.add('cdk-overlay-connected-position-bounding-box');
158+
overlayRef.hostElement.classList.add(boundingBoxClass);
156159

157160
this._overlayRef = overlayRef;
158161
this._boundingBox = overlayRef.hostElement;
159162
this._pane = overlayRef.overlayElement;
163+
this._isDisposed = false;
164+
this._isInitialRender = true;
165+
this._lastPosition = null;
160166
this._resizeSubscription.unsubscribe();
161167
this._resizeSubscription = this._viewportRuler.change().subscribe(() => {
162168
// When the window is resized, we want to trigger the next reposition as if it
@@ -303,12 +309,37 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
303309

304310
/** Cleanup after the element gets destroyed. */
305311
dispose() {
306-
if (!this._isDisposed) {
307-
this.detach();
308-
this._boundingBox = null;
309-
this._positionChanges.complete();
310-
this._isDisposed = true;
312+
if (this._isDisposed) {
313+
return;
311314
}
315+
316+
// We can't use `_resetBoundingBoxStyles` here, because it resets
317+
// some properties to zero, rather than removing them.
318+
if (this._boundingBox) {
319+
extendStyles(this._boundingBox.style, {
320+
top: '',
321+
left: '',
322+
right: '',
323+
bottom: '',
324+
height: '',
325+
width: '',
326+
alignItems: '',
327+
justifyContent: '',
328+
} as CSSStyleDeclaration);
329+
}
330+
331+
if (this._pane) {
332+
this._resetOverlayElementStyles();
333+
}
334+
335+
if (this._overlayRef) {
336+
this._overlayRef.hostElement.classList.remove(boundingBoxClass);
337+
}
338+
339+
this.detach();
340+
this._positionChanges.complete();
341+
this._overlayRef = this._boundingBox = null!;
342+
this._isDisposed = true;
312343
}
313344

314345
/**

src/cdk/overlay/position/global-position-strategy.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,27 @@ describe('GlobalPositonStrategy', () => {
303303
const parentStyle = (overlayRef.overlayElement.parentNode as HTMLElement).style;
304304
expect(parentStyle.justifyContent).toBe('flex-start');
305305
});
306+
307+
it('should clean up after itself when it has been disposed', () => {
308+
const positionStrategy = overlay.position().global().top('10px').left('40px');
309+
310+
attachOverlay({positionStrategy});
311+
312+
const elementStyle = overlayRef.overlayElement.style;
313+
const parentStyle = (overlayRef.overlayElement.parentNode as HTMLElement).style;
314+
315+
positionStrategy.dispose();
316+
317+
expect(elementStyle.marginTop).toBeFalsy();
318+
expect(elementStyle.marginLeft).toBeFalsy();
319+
expect(elementStyle.marginBottom).toBeFalsy();
320+
expect(elementStyle.marginBottom).toBeFalsy();
321+
expect(elementStyle.position).toBeFalsy();
322+
323+
expect(parentStyle.justifyContent).toBeFalsy();
324+
expect(parentStyle.alignItems).toBeFalsy();
325+
});
326+
306327
});
307328

308329

src/cdk/overlay/position/global-position-strategy.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import {PositionStrategy} from './position-strategy';
1010
import {OverlayReference} from '../overlay-reference';
1111

12+
/** Class to be added to the overlay pane wrapper. */
13+
const wrapperClass = 'cdk-global-overlay-wrapper';
1214

1315
/**
1416
* A strategy for positioning overlays. Using this strategy, an overlay is given an
@@ -28,6 +30,7 @@ export class GlobalPositionStrategy implements PositionStrategy {
2830
private _justifyContent: string = '';
2931
private _width: string = '';
3032
private _height: string = '';
33+
private _isDisposed: boolean;
3134

3235
attach(overlayRef: OverlayReference): void {
3336
const config = overlayRef.getConfig();
@@ -42,7 +45,8 @@ export class GlobalPositionStrategy implements PositionStrategy {
4245
overlayRef.updateSize({height: this._height});
4346
}
4447

45-
overlayRef.hostElement.classList.add('cdk-global-overlay-wrapper');
48+
overlayRef.hostElement.classList.add(wrapperClass);
49+
this._isDisposed = false;
4650
}
4751

4852
/**
@@ -153,7 +157,7 @@ export class GlobalPositionStrategy implements PositionStrategy {
153157
// Since the overlay ref applies the strategy asynchronously, it could
154158
// have been disposed before it ends up being applied. If that is the
155159
// case, we shouldn't do anything.
156-
if (!this._overlayRef.hasAttached()) {
160+
if (!this._overlayRef || !this._overlayRef.hasAttached()) {
157161
return;
158162
}
159163

@@ -170,7 +174,7 @@ export class GlobalPositionStrategy implements PositionStrategy {
170174
if (config.width === '100%') {
171175
parentStyles.justifyContent = 'flex-start';
172176
} else if (this._justifyContent === 'center') {
173-
parentStyles.justifyContent = 'center';
177+
parentStyles.justifyContent = 'center';
174178
} else if (this._overlayRef.getConfig().direction === 'rtl') {
175179
// In RTL the browser will invert `flex-start` and `flex-end` automatically, but we
176180
// don't want that because our positioning is explicitly `left` and `right`, hence
@@ -189,8 +193,23 @@ export class GlobalPositionStrategy implements PositionStrategy {
189193
}
190194

191195
/**
192-
* Noop implemented as a part of the PositionStrategy interface.
196+
* Cleans up the DOM changes from the position strategy.
193197
* @docs-private
194198
*/
195-
dispose(): void { }
199+
dispose(): void {
200+
if (this._isDisposed || !this._overlayRef) {
201+
return;
202+
}
203+
204+
const styles = this._overlayRef.overlayElement.style;
205+
const parent = this._overlayRef.hostElement;
206+
const parentStyles = parent.style;
207+
208+
parent.classList.remove(wrapperClass);
209+
parentStyles.justifyContent = parentStyles.alignItems = styles.marginTop =
210+
styles.marginBottom = styles.marginLeft = styles.marginRight = styles.position = '';
211+
212+
this._overlayRef = null!;
213+
this._isDisposed = true;
214+
}
196215
}

0 commit comments

Comments
 (0)