components
components copied to clipboard
CDK overlay auto calculate width when using ConnectedPositionStrategy
Bug, feature request, or proposal:
Feature
What is the expected behavior?
That i can somehow configure the popup overlay to change it width to be the same as the parent, without having to add a bunch of custom styling to my popup component.

What is the current behavior?

What is the use-case or motivation for changing an existing behavior?
I would have to do less custom styling to get a nice dropdown
Is there anything else we should know?
In the screenshots we have changed the width using css, however this is suboptimal to having a responsive design, where the input can change their width all the time. To get around the problem we have to hard-code the width of both the input and the popups, so they look to fit together.
My best suggestion would be able to do something like this:
this.overlay.position()
.connectedTo(target, {
originY: 'bottom',
originX: 'start'
}, {
overlayY: 'top',
overlayX: 'start'
})
.immitateWidth();
Hello, I have created a loading spinner overlay and encountered the same issues. I wanted the spinner to have exactly the same width and height of another component to display a transparent backdrop. The main issue was that overlay position and size were not updated when the target element was resize (for example, when the sidenav was toggled). It is because overlay position is updated only when window is resized (see ViewportRuler).
I found out a solution but not sure it's working correctly among all browsers and for server side rendering. Suggestions are welcomed!
First, I have created a service to create watcher on element size:
import { Injectable, NgZone } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { auditTime } from 'rxjs/operators';
export interface ElementRulerRef {
/** Emits element size when it changes */
change: Observable<{ width: number; height: number }>;
/** Stop watching element size */
dispose(): void;
}
@Injectable()
export class ElementRuler {
constructor(private zone: NgZone) {}
/**
* Creates an instance of ElementRulerRef to watch element size.
*/
create(node: any, throttleTime = 100): ElementRulerRef {
let width;
let height;
let animationFrameId;
const _change = new BehaviorSubject({ width: 0, height: 0 });
const watchOnFrame = () => {
const currentWidth = node.clientWidth;
const currentHeight = node.clientHeight;
if (currentWidth !== width || currentHeight !== height) {
width = currentWidth;
height = currentHeight;
_change.next({ width, height });
}
animationFrameId = requestAnimationFrame(watchOnFrame);
};
this.zone.runOutsideAngular(watchOnFrame);
const dispose = () => {
cancelAnimationFrame(animationFrameId);
_change.complete();
};
const obs = _change.asObservable();
const change = throttleTime > 0 ? obs.pipe(auditTime(throttleTime)) : obs;
return { dispose, change };
}
}
Then, I use the change observable in my overlay service:
@Injectable()
export class SpinnerService {
constructor(private overlay: Overlay, private ruler: ElementRuler) {}
create(el: ElementRef): () => void {
const positionStrategy = this.overlay
.position()
.connectedTo(
el,
{ originX: 'start', originY: 'top' },
{ overlayX: 'start', overlayY: 'top' }
);
const overlayRef = this.overlay.create({ positionStrategy });
const spinnerPortal = new ComponentPortal(SpinnerComponent);
overlayRef.attach(spinnerPortal);
const rulerRef = this.ruler.create(el.nativeElement, 0);
rulerRef.change.subscribe(({ width, height }) => {
overlayRef.updateSize({ width, height });
overlayRef.updatePosition();
});
return () => {
overlayRef.dispose();
positionStrategy.dispose();
rulerRef.dispose();
};
}
}
You can listen to the window resize event and set the width of the overlayref as describe in the below article.
const refRect = this.reference.getBoundingClientRect();
this.overlayRef.updateSize({ width: refRect.width });
http://prideparrot.com/blog/archive/2019/3/how_to_create_custom_dropdown_cdk
I guess it can be achieved more easily, without subscribing to anything 😃
- pass origin element to ref
- assign origin element inside overlay template to the container width
a short snippet of my code below:
OverlayService:
open<T>({ origin, data }: CustomParams<T>): CustomOverlayRef<T> {
const overlayRef = this.overlay.create(this.getOverlayConfig(origin));
const overlayRef= new CustomOverlayRef<T>(overlayRef, data, origin);
const injector = this.createInjector(autocompleteRef, this.injector);
overlayRef.attach(new ComponentPortal(ModalComponent, null, injector));
return autocompleteRef;
}
CustomOverlayRef:
export class CustomOverlayRef<T = any> {
constructor(public overlay: OverlayRef, public data: T, public origin: HTMLElement) {
...
}
...
}
OverlayComponent template:
<section [style.width.px]="overlayRef.origin.getBoundingClientRect().width">
... template here
</section>
I think its the easiest way of solving this issue. Due to not using the subscribe it's probably more efficient? and for sure it does not require to take care of the unsubscribing the subscription.
While that might seem like less code, you might want to be careful about called getBoundingClientRect() on every change detection cycle, since it has a bad habit of causing slow-downs if used too much. In addition this will only update if your overlay goes through a change detection when the origin changes, which is not gauranteed if you are using OnPush change detection.
I believe we are currently woring around this by using a mutation observer on the origin element, so we can watch it for changes, without having to poll all the time.
I took a page out of angular material's book to overcome this.
import { ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ViewportRuler } from '@angular/cdk/scrolling';
import { takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
@Component({
selector: 'w-select',
templateUrl: './select.component.html',
styleUrls: ['./select.component.scss']
})
export class SelectComponent implements OnInit, OnDestroy {
@ViewChild('trigger') trigger: ElementRef;
protected readonly _destroy = new Subject<void>();
_triggerRect: ClientRect;
_isOpen = false;
constructor(protected _viewportRuler: ViewportRuler, protected _changeDetectorRef: ChangeDetectorRef) {}
ngOnInit() {
// Check rect on resize
this._viewportRuler
.change()
.pipe(takeUntil(this._destroy))
.subscribe(() => {
if (this._isOpen) {
this._triggerRect = this.trigger.nativeElement.getBoundingClientRect();
this._changeDetectorRef.markForCheck();
}
});
}
ngOnDestroy() {
this._destroy.next();
this._destroy.complete();
}
toggle() {
// Check rect on toggle, this is for dropdown width
this._triggerRect = this.trigger.nativeElement.getBoundingClientRect();
this._isOpen = !this._isOpen;
}
}
<div (click)="toggle()" cdkOverlayOrigin #trigger #origin="cdkOverlayOrigin">
<!-- ... -->
</div>
<ng-template
cdkConnectedOverlay
cdkConnectedOverlayHasBackdrop
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayMinWidth]="_triggerRect?.width!"
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOpen]="_isOpen"
(backdropClick)="toggle()"
>
<!-- ... -->
</ng-template>
https://github.com/angular/components/tree/master/src/material/select
Seems to work well for my purposes. Idk, helped me - maybe it'll help someone else.
@baxelson12 thank you for your solution! In my case I had to adjust it a little bit to achieve resizing when window's size is changed:
private watchTriggerWidth() {
this.viewportRuler
.change()
.pipe(takeUntil(this.onDestroy$), debounceTime(300)) // added debounceTime() to avoid too many re-renders
.subscribe(() => {
if (this.dropdownActive) {
this.calculateTriggerWidth()
this.changeDetectorRef.detectChanges() // markForCheck didnt work for me for some reason
}
})
}
private calculateTriggerWidth() {
this.dropdownTriggerWidth = this.selectElement.nativeElement.getBoundingClientRect().width
}
Also I have used [cdkConnectedOverlayWidth] instead of [cdkConnectedOverlayMinWidth]
.pipe(takeUntil(this.onDestroy$), debounceTime(300))
takeUntil(this.onDestroy$) <- while not a big deal here, best practice wise it is a good idea to have this as last in the pipe to ensure all previous operators are handled.
Just a heads up that we kicked off a community voting process for your feature request. There are 20 days until the voting process ends.
Find more details about Angular's feature request process in our documentation.
Thank you for submitting your feature request! Looks like during the polling process it didn't collect a sufficient number of votes to move to the next stage.
We want to keep Angular rich and ergonomic and at the same time be mindful about its scope and learning journey. If you think your request could live outside Angular's scope, we'd encourage you to collaborate with the community on publishing it as an open source package.
You can find more details about the feature request process in our documentation.
You can use the min-width property in overlay config. and make connected overlay width same as the parent(origin overlay)
const overlayConfig = new OverlayConfig({
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
scrollStrategy: this._overlay.scrollStrategies.reposition({ autoClose: true }),
positionStrategy: this._overlay.position().flexibleConnectedTo(this._elementRef).withPositions(positions),
minWidth: this._elementRef.nativeElement.getBoundingClientRect().width
});