Components: CDK overlay auto calculate width when using ConnectedPositionStrategy

Created on 13 Mar 2018  路  7Comments  路  Source: angular/components

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.
good

What is the current behavior?

bad

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();
P3 cdoverlay feature

Most helpful comment

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();
    };
  }
}

All 7 comments

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 馃槂

  1. pass origin element to ref
  2. 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.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

LoganDupont picture LoganDupont  路  3Comments

jelbourn picture jelbourn  路  3Comments

MurhafSousli picture MurhafSousli  路  3Comments

shlomiassaf picture shlomiassaf  路  3Comments

3mp3ri0r picture 3mp3ri0r  路  3Comments