Skip to content

Commit cbf5bcb

Browse files
committed
refactor(cdk/overlay): add opt-in popover behavior
Adds the option for the connected overlay to opt into being rendered into a native popover, instead of the overlay container.
1 parent 032baeb commit cbf5bcb

File tree

7 files changed

+215
-15
lines changed

7 files changed

+215
-15
lines changed

goldens/cdk/overlay/index.api.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,14 @@ export function createRepositionScrollStrategy(injector: Injector, config?: Repo
222222
export class FlexibleConnectedPositionStrategy implements PositionStrategy {
223223
constructor(connectedTo: FlexibleConnectedPositionStrategyOrigin, _viewportRuler: ViewportRuler, _document: Document, _platform: Platform, _overlayContainer: OverlayContainer);
224224
apply(): void;
225+
asPopover(isPopover: boolean): this;
225226
attach(overlayRef: OverlayRef): void;
227+
attachBackdrop(backdrop: HTMLElement, host: HTMLElement): boolean;
228+
attachHost(host: HTMLElement): boolean;
229+
createStructure(): {
230+
pane: HTMLDivElement;
231+
host: HTMLDivElement;
232+
} | null;
226233
// (undocumented)
227234
detach(): void;
228235
dispose(): void;
@@ -459,6 +466,12 @@ export interface OverlaySizeConfig {
459466
export interface PositionStrategy {
460467
apply(): void;
461468
attach(overlayRef: OverlayRef): void;
469+
attachBackdrop?(backdrop: HTMLElement, host: HTMLElement): boolean;
470+
attachHost?(host: HTMLElement): boolean;
471+
createStructure?(): {
472+
pane: HTMLElement;
473+
host: HTMLElement;
474+
} | null;
462475
detach?(): void;
463476
dispose(): void;
464477
}

src/cdk/overlay/_index.scss

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,29 @@ $backdrop-animation-timing-function: cubic-bezier(0.25, 0.8, 0.25, 1) !default;
185185
// block scrolling on a page that doesn't have a scrollbar in the first place.
186186
overflow-y: scroll;
187187
}
188+
189+
.cdk-overlay-popover {
190+
background: none;
191+
border: none;
192+
padding: 0;
193+
color: inherit;
194+
outline: 0;
195+
overflow: visible;
196+
position: fixed;
197+
pointer-events: none;
198+
199+
// These are important so the overlay can be measured before it's fully inserted.
200+
width: 100%;
201+
height: 100%;
202+
203+
// Chrome sets a user agent style of `inset: 0` which combined
204+
// with `align-self` can break the positioning (see #29809).
205+
inset: auto;
206+
207+
.cdk-overlay-backdrop {
208+
position: fixed;
209+
}
210+
}
188211
}
189212

190213
/// Emits structural styles required for cdk/overlay to function.

src/cdk/overlay/overlay-ref.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,23 @@ export class OverlayRef implements PortalOutlet {
117117
attach(portal: Portal<any>): any {
118118
// Insert the host into the DOM before attaching the portal, otherwise
119119
// the animations module will skip animations on repeat attachments.
120-
if (!this._host.parentElement && this._previousHostParent) {
121-
this._previousHostParent.appendChild(this._host);
120+
if (!this._host.parentElement) {
121+
const customIsAttached = this._positionStrategy?.attachHost?.(this._host);
122+
123+
if (!customIsAttached && this._previousHostParent) {
124+
this._previousHostParent.appendChild(this._host);
125+
}
122126
}
123127

124128
const attachResult = this._portalOutlet.attach(portal);
129+
this._positionStrategy?.attach(this);
125130

126-
if (this._positionStrategy) {
127-
this._positionStrategy.attach(this);
131+
// If the position strategy overrides the attachment behavior,
132+
// it's assumed that it'll handle the stacking order as well.
133+
if (!this._positionStrategy?.attachHost) {
134+
this._updateStackingOrder();
128135
}
129136

130-
this._updateStackingOrder();
131137
this._updateElementSize();
132138
this._updateElementDirection();
133139

@@ -426,9 +432,16 @@ export class OverlayRef implements PortalOutlet {
426432
this._toggleClasses(this._backdropRef.element, this._config.backdropClass, true);
427433
}
428434

429-
// Insert the backdrop before the pane in the DOM order,
430-
// in order to handle stacked overlays properly.
431-
this._host.parentElement!.insertBefore(this._backdropRef.element, this._host);
435+
const strategyAttached = this._positionStrategy?.attachBackdrop?.(
436+
this._backdropRef.element,
437+
this._host,
438+
);
439+
440+
if (!strategyAttached) {
441+
// Insert the backdrop before the pane in the DOM order,
442+
// in order to handle stacked overlays properly.
443+
this._host.parentElement!.insertBefore(this._backdropRef.element, this._host);
444+
}
432445

433446
// Add class to fade-in the backdrop after one frame.
434447
if (!this._animationsDisabled && typeof requestAnimationFrame !== 'undefined') {

src/cdk/overlay/overlay.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,17 +47,26 @@ export function createOverlayRef(injector: Injector, config?: OverlayConfig): Ov
4747
const idGenerator = injector.get(_IdGenerator);
4848
const appRef = injector.get(ApplicationRef);
4949
const directionality = injector.get(Directionality);
50+
const overlayConfig = new OverlayConfig(config);
51+
const customStructure = overlayConfig.positionStrategy?.createStructure?.();
52+
53+
let pane: HTMLElement;
54+
let host: HTMLElement;
5055

51-
const host = doc.createElement('div');
52-
const pane = doc.createElement('div');
56+
if (customStructure) {
57+
pane = customStructure.pane;
58+
host = customStructure.host;
59+
} else {
60+
host = doc.createElement('div');
61+
pane = doc.createElement('div');
62+
host.appendChild(pane);
63+
overlayContainer.getContainerElement().appendChild(host);
64+
}
5365

5466
pane.id = idGenerator.getId('cdk-overlay-');
5567
pane.classList.add('cdk-overlay-pane');
56-
host.appendChild(pane);
57-
overlayContainer.getContainerElement().appendChild(host);
5868

5969
const portalOutlet = new DomPortalOutlet(pane, appRef, injector);
60-
const overlayConfig = new OverlayConfig(config);
6170
const renderer =
6271
injector.get(Renderer2, null, {optional: true}) ||
6372
injector.get(RendererFactory2).createRenderer(null, null);

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

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('FlexibleConnectedPositionStrategy', () => {
3333
let overlayRef: OverlayRef;
3434
let viewport: ViewportRuler;
3535
let injector: Injector;
36+
let portal: ComponentPortal<TestOverlay>;
3637

3738
beforeEach(() => {
3839
injector = TestBed.inject(Injector);
@@ -50,7 +51,8 @@ describe('FlexibleConnectedPositionStrategy', () => {
5051

5152
function attachOverlay(config: OverlayConfig) {
5253
overlayRef = createOverlayRef(injector, config);
53-
overlayRef.attach(new ComponentPortal(TestOverlay));
54+
portal = new ComponentPortal(TestOverlay);
55+
overlayRef.attach(portal);
5456
TestBed.inject(ApplicationRef).tick();
5557
}
5658

@@ -125,7 +127,7 @@ describe('FlexibleConnectedPositionStrategy', () => {
125127
origin.remove();
126128
});
127129

128-
it('should for the virtual keyboard offset when positioning the overlay', () => {
130+
it('should account for the virtual keyboard offset when positioning the overlay', () => {
129131
const originElement = createPositionedBlockElement();
130132
document.body.appendChild(originElement);
131133

@@ -2951,6 +2953,70 @@ describe('FlexibleConnectedPositionStrategy', () => {
29512953
expect(overlayClassList).toContain('custom-panel-class');
29522954
});
29532955
});
2956+
2957+
describe('DOM location', () => {
2958+
let positionStrategy: FlexibleConnectedPositionStrategy;
2959+
let containerElement: HTMLElement;
2960+
let originElement: HTMLElement;
2961+
2962+
beforeEach(() => {
2963+
containerElement = overlayContainer.getContainerElement();
2964+
originElement = createPositionedBlockElement();
2965+
document.body.appendChild(originElement);
2966+
2967+
positionStrategy = createFlexibleConnectedPositionStrategy(
2968+
injector,
2969+
originElement,
2970+
).withPositions([
2971+
{
2972+
overlayX: 'start',
2973+
overlayY: 'top',
2974+
originX: 'start',
2975+
originY: 'bottom',
2976+
},
2977+
]);
2978+
});
2979+
2980+
afterEach(() => {
2981+
originElement.remove();
2982+
});
2983+
2984+
it('should place the overlay inside the overlay container by default', () => {
2985+
attachOverlay({positionStrategy});
2986+
expect(containerElement.contains(overlayRef.hostElement)).toBe(true);
2987+
expect(overlayRef.hostElement.getAttribute('popover')).toBeFalsy();
2988+
});
2989+
2990+
it('should be able to opt into placing the overlay inside an adjacent popover element', () => {
2991+
if (!('showPopover' in document.body)) {
2992+
return;
2993+
}
2994+
2995+
positionStrategy.asPopover(true);
2996+
attachOverlay({positionStrategy});
2997+
2998+
expect(containerElement.contains(overlayRef.hostElement)).toBe(false);
2999+
expect(originElement.nextElementSibling).toBe(overlayRef.hostElement);
3000+
expect(overlayRef.hostElement.getAttribute('popover')).toBe('manual');
3001+
});
3002+
3003+
it('should re-attach the popover next to the origin element', () => {
3004+
if (!('showPopover' in document.body)) {
3005+
return;
3006+
}
3007+
3008+
positionStrategy.asPopover(true);
3009+
attachOverlay({positionStrategy});
3010+
expect(originElement.nextElementSibling).toBe(overlayRef.hostElement);
3011+
3012+
overlayRef.detach();
3013+
TestBed.inject(ApplicationRef).tick();
3014+
expect(overlayRef.hostElement.parentNode).toBeFalsy();
3015+
3016+
overlayRef.attach(portal);
3017+
expect(originElement.nextElementSibling).toBe(overlayRef.hostElement);
3018+
});
3019+
});
29543020
});
29553021

29563022
/** Creates an absolutely positioned, display: block element with a default size. */

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
9595
/** Whether the overlay position is locked. */
9696
private _positionLocked = false;
9797

98+
/** Whether the overlay is using popovers for positioning. */
99+
private _popoverEnabled = false;
100+
98101
/** Cached origin dimensions */
99102
private _originRect: Dimensions;
100103

@@ -511,6 +514,67 @@ export class FlexibleConnectedPositionStrategy implements PositionStrategy {
511514
return this;
512515
}
513516

517+
/**
518+
* Configures that the overlay should be rendered inside a native popover. This has the benefit
519+
* if co-locating the overlay with the trigger and being better for accessibility.
520+
* @param isPopover Whether the overlay should be a popover.
521+
*/
522+
asPopover(isPopover: boolean): this {
523+
this._popoverEnabled = isPopover && 'showPopover' in this._document.body;
524+
return this;
525+
}
526+
527+
/** @docs-private */
528+
createStructure() {
529+
if (!this._popoverEnabled) {
530+
return null;
531+
}
532+
533+
const pane = this._document.createElement('div');
534+
const host = this._document.createElement('div');
535+
host.setAttribute('popover', 'manual');
536+
host.classList.add('cdk-overlay-popover');
537+
host.appendChild(pane);
538+
this.attachHost(host);
539+
return {pane, host};
540+
}
541+
542+
/** @docs-private */
543+
attachHost(host: HTMLElement) {
544+
if (!this._popoverEnabled) {
545+
return false;
546+
}
547+
548+
if (!host.parentNode) {
549+
let originEl: Element | null;
550+
551+
if (this._origin instanceof ElementRef) {
552+
originEl = this._origin.nativeElement;
553+
} else if (typeof Element !== 'undefined' && this._origin instanceof Element) {
554+
originEl = this._origin;
555+
} else {
556+
originEl = null;
557+
}
558+
559+
if (originEl) {
560+
originEl.after(host);
561+
} else {
562+
document.body.appendChild(host);
563+
}
564+
}
565+
566+
host.showPopover();
567+
return true;
568+
}
569+
570+
/** @docs-private */
571+
attachBackdrop(backdrop: HTMLElement, host: HTMLElement) {
572+
if (this._popoverEnabled) {
573+
host.prepend(backdrop);
574+
}
575+
return this._popoverEnabled;
576+
}
577+
514578
/**
515579
* Gets the (x, y) coordinate of a connection point on the origin based on a relative position.
516580
*/

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,16 @@ export interface PositionStrategy {
2121

2222
/** Cleans up any DOM modifications made by the position strategy, if necessary. */
2323
dispose(): void;
24+
25+
/**
26+
* Creates the structure of the overlay. If not provided,
27+
* structure will be created inside the overlay container.
28+
*/
29+
createStructure?(): {pane: HTMLElement; host: HTMLElement} | null;
30+
31+
/** Attaches the host element to the DOM. */
32+
attachHost?(host: HTMLElement): boolean;
33+
34+
/** Attaches the backdrop element to the host. */
35+
attachBackdrop?(backdrop: HTMLElement, host: HTMLElement): boolean;
2436
}

0 commit comments

Comments
 (0)