Skip to content

Commit f81767b

Browse files
committed
feat(overlay): add support for swappable position strategies
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 1e1751f commit f81767b

6 files changed

+216
-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) {
@@ -213,8 +218,8 @@ export class OverlayRef implements PortalOutlet, OverlayReference {
213218
dispose(): void {
214219
const isAttached = this.hasAttached();
215220

216-
if (this._config.positionStrategy) {
217-
this._config.positionStrategy.dispose();
221+
if (this._positionStrategy) {
222+
this._positionStrategy.dispose();
218223
}
219224

220225
if (this._config.scrollStrategy) {
@@ -274,8 +279,26 @@ export class OverlayRef implements PortalOutlet, OverlayReference {
274279

275280
/** Updates the position of the overlay based on the position strategy. */
276281
updatePosition() {
277-
if (this._config.positionStrategy) {
278-
this._config.positionStrategy.apply();
282+
if (this._positionStrategy) {
283+
this._positionStrategy.apply();
284+
}
285+
}
286+
287+
/** Switches to a new position strategy and updates the overlay position. */
288+
updatePositionStrategy(strategy: PositionStrategy) {
289+
if (strategy === this._positionStrategy) {
290+
return;
291+
}
292+
293+
if (this._positionStrategy) {
294+
this._positionStrategy.dispose();
295+
}
296+
297+
this._positionStrategy = strategy;
298+
299+
if (this.hasAttached()) {
300+
strategy.attach(this);
301+
this.updatePosition();
279302
}
280303
}
281304

src/cdk/overlay/overlay.spec.ts

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

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

415477
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
@@ -151,6 +151,46 @@ describe('FlexibleConnectedPositionStrategy', () => {
151151
document.body.removeChild(originElement);
152152
});
153153

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

147150
this._validatePositions();
148151

149-
overlayRef.hostElement.classList.add('cdk-overlay-connected-position-bounding-box');
152+
overlayRef.hostElement.classList.add(boundingBoxClass);
150153

151154
this._overlayRef = overlayRef;
152155
this._boundingBox = overlayRef.hostElement;
153156
this._pane = overlayRef.overlayElement;
157+
this._isDisposed = false;
158+
this._isInitialRender = true;
159+
this._lastPosition = null;
154160
this._resizeSubscription.unsubscribe();
155161
this._resizeSubscription = this._viewportRuler.change().subscribe(() => this.apply());
156162
}
@@ -287,12 +293,37 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
287293

288294
/** Cleanup after the element gets destroyed. */
289295
dispose() {
290-
if (!this._isDisposed) {
291-
this.detach();
292-
this._boundingBox = null;
293-
this._positionChanges.complete();
294-
this._isDisposed = true;
296+
if (this._isDisposed) {
297+
return;
295298
}
299+
300+
// We can't use `_resetBoundingBoxStyles` here, because it resets
301+
// some properties to zero, rather than removing them.
302+
if (this._boundingBox) {
303+
extendStyles(this._boundingBox.style, {
304+
top: '',
305+
left: '',
306+
right: '',
307+
bottom: '',
308+
height: '',
309+
width: '',
310+
alignItems: '',
311+
justifyContent: '',
312+
} as CSSStyleDeclaration);
313+
}
314+
315+
if (this._pane) {
316+
this._resetOverlayElementStyles();
317+
}
318+
319+
if (this._overlayRef) {
320+
this._overlayRef.hostElement.classList.remove(boundingBoxClass);
321+
}
322+
323+
this.detach();
324+
this._positionChanges.complete();
325+
this._overlayRef = this._boundingBox = null!;
326+
this._isDisposed = true;
296327
}
297328

298329
/**

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) {
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)