bug(Sidenav+Tabs+Drag n drop): Drag scroll not working if Sidenav-Content height set to 100vh

Reproduction

https://stackblitz.com/edit/angular-ebzjz5?file=src%%2Fapp%%2Fexpansion-overview-example.html

Steps to reproduce:

  1. Put a mat-accordion with drag n drop inside a mat-tab that is inside mat-sidenav-content.
  2. Set the height of mat-sidenav-content to 100vh, so all items of the sidenav are always visible. The mat-accordion overflows the 100vh with scroll due to a large amount of items in it.
  3. Try to drag an item from the list to a position that is not visible without scrolling. It won’t scroll.

Expected Behavior

The view autoscrolls while draging the item to the bottom of the screen.

Actual Behavior

It doesn’t autoscoll.

It does if i either delete the tab-group or if i delete the max-height of 100vh of the sidenav-content.
Please take a look at the stackblitz.

Environment

  • Angular: 9.0.5
  • CDK/Material: 9.1
  • Browser(s): all
  • Operating System (e.g. Windows, macOS, Ubuntu): all

A possible workaround could be to delete the height of 100vh and set items in the sidenav to a fixed position so they are always visible. Not testet though.

1 possible answer(s) on “bug(Sidenav+Tabs+Drag n drop): Drag scroll not working if Sidenav-Content height set to 100vh

  1. To some degree, this doesn’t have anything to do with the sidenav or tab components. The main issue with your demo is you’re trying to scroll an element that is not the CdkDropList element, nor the window.

    One solution is to manually register the sidenav content as a scrollable parent of the droplist – something like this:

    @ViewChild(MatSidenavContent , {read: ElementRef}) sideNavContentElement: ElementRef;
    @ViewChild(CdkDropList) dropList: CdkDropList;
    ngAfterViewInit() {
      // register the sidenav content as a scrollable parent element
      if (this.sideNavContentElement) {
        this.dropList._dropListRef.withScrollableParents([this.sideNavContentElement.nativeElement])
      }
    }

    https://stackblitz.com/edit/angular-ebzjz5-ftwbai?file=src%%2Fapp%%2Fexpansion-overview-example.ts

    If you have a statically-defined number of droplists/sidenav contents then this is fine, but it’s not a very scalable solution.

    This pull request added the ability to define scrollable containers other than the droplist and window by adding a cdkScrollable directive to elements. Theoretically, you would be able to do this (adding cdkScrollable):

    <mat-sidenav-content style="max-height: 100vh" cdkScrollable>
    ...
    </mat-sidenav-content>

    and the drop list would “automatically” pick up this element as a scrollable parent. Unfortunately, this doesn’t work with your use case, because the drop list is projected (multiple levels) inside the sidenav content and registering the cdkScrollables happens inside the ngAfterContentInit() lifecycle hook. My knowledge on this is a little fuzzy, but the gist of the problem is that during ngAfterContentInit(), you cannot traverse up DOM elements across projection boundaries. Meaning this method of scroll-dispatcher.ts:

    /** Returns all registered Scrollables that contain the provided element. */
      getAncestorScrollContainers(elementRef: ElementRef): CdkScrollable[] {
        const scrollingContainers: CdkScrollable[] = [];
    
        this.scrollContainers.forEach((_subscription: Subscription, scrollable: CdkScrollable) => {
          if (this._scrollableContainsElement(scrollable, elementRef)) {
            scrollingContainers.push(scrollable);
          }
        });
    
        return scrollingContainers;
      }

    will return an empty list of scrollContainers, despite your HTML actually having a cdkScrollable as an ancestor of the drop list.

    You can find ways to get around this…for example, if not using Ivy, you can override ngAfterContentInit() and wrap the withScrollableParents() call inside a setTimeout to defer execution until the view is fully initialized:

    CdkDropList.prototype.ngAfterViewInit = function(){
        // @breaking-change 11.0.0 Remove null check for _scrollDispatcher once it's required.
        if (this._scrollDispatcher) {
          setTimeout(() => {const scrollableParents = this._scrollDispatcher
             .getAncestorScrollContainers(this.element)
             .map(scrollable => scrollable.getElementRef().nativeElement);
           this._dropListRef.withScrollableParents(scrollableParents);
           })
        }
    }

    But really, I think the best solution is to use ngAfterViewInit() instead of ngAfterContentInit().

    CdkDropList.prototype.ngAfterViewInit = function(){
        // @breaking-change 11.0.0 Remove null check for _scrollDispatcher once it's required.
        if (this._scrollDispatcher) {
          const scrollableParents = this._scrollDispatcher
            .getAncestorScrollContainers(this.element)
            .map(scrollable => scrollable.getElementRef().nativeElement);
          this._dropListRef.withScrollableParents(scrollableParents);
        }
    }

    Using ngAfterViewInit() allows the cdkScrollable to be picked up across projection boundaries.

    Example here:
    https://stackblitz.com/edit/angular-ebzjz5-3csse8?file=src/app/expansion-overview-example.ts
    (Notice how commenting out the prototype override does not allow the auto-scrolling, despite cdkScrollable being added to the <mat-sidenav-content>)

    @crisbeto thoughts on switching the CdkDropList scrollableParents logic from ngAfterContentInit() to ngAfterViewInit()?