components icon indicating copy to clipboard operation
components copied to clipboard

CDK overlay auto calculate width when using ConnectedPositionStrategy

Open zlepper opened this issue 7 years ago • 11 comments

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

zlepper avatar Mar 13 '18 10:03 zlepper

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

etiennecrb avatar Apr 13 '18 09:04 etiennecrb

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

VJAI avatar Mar 02 '19 13:03 VJAI

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.

MaciejWWojcik avatar Feb 10 '20 13:02 MaciejWWojcik

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.

zlepper avatar Feb 10 '20 13:02 zlepper

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 avatar Nov 15 '20 06:11 baxelson12

@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]

greetclock avatar Nov 18 '20 17:11 greetclock

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

JBorgia avatar Mar 18 '21 14:03 JBorgia

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.

angular-robot[bot] avatar Feb 01 '22 18:02 angular-robot[bot]

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.

angular-robot[bot] avatar Feb 22 '22 15:02 angular-robot[bot]

image You can use the min-width property in overlay config. and make connected overlay width same as the parent(origin overlay)

yan-loong avatar Jul 07 '22 14:07 yan-loong

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

khasogi27 avatar Apr 18 '24 07:04 khasogi27