ng-bootstrap scrollspy doesn't work without height?

183 Views Asked by At

I am using ng-bootstrap [ngbScrollSpy] directive in my project, as mentioned in the documentation, but it didn't work - on scroll the active item doesn't change.

My code is the following:

<div>
    <div class="sticky-top">
        <ul class="nav menu-sidebar">
            <li >
                <a [ngbScrollSpyItem]="[spy, 'about']">About</a>
            </li>
            <li >
                <a [ngbScrollSpyItem]="spy" fragment="schedule">Schedule</a>
            </li>
            <li >
                <a [ngbScrollSpyItem]="spy" fragment="hotel">Information about the hotel</a>
            </li>
        </ul>
    </div>

    <div ngbScrollSpy #spy="ngbScrollSpy" >
        <section ngbScrollSpyFragment="about">
            <h3>About</h3>
            <p>{{some long long text and content}}</p>
        </section>
        <section ngbScrollSpyFragment="schedule">
            <h3>Schedule</h3>
            <p>{{some long long text and content}}</p>
        </section>
        <section ngbScrollSpyFragment="hotel">
            <h3>Information about the hotel</h3>
            <p>{{some long long text and content}}</p>
        </section>
    </div>
</div>

I saw in this stackoverflow question that my problem is that I didn't provide height to my div, and that's true.

but my scroll spy sections spread through the whole page, not a small div, (the nav itself is sticky-top). so I cannot give it height.

I understood that there is alternative way - to refresh the scrollspy on window scroll, but I don't find correct code that may help me.

can you solve my problem? provide me code for refreshing the scrollspy / give me tips about the height / help me to find another corresponding element.

thanks a lot!

attaching link to stackblitz demo

2

There are 2 best solutions below

0
ayala On BEST ANSWER

Since it seemed to be no solution for my demands, I decided to create my own scrollspy element via two directives:

  1. scrollTo directive will be responsible for scrolling to the section on pressing the li link
import { Directive, HostListener, Input } from '@angular/core';

@Directive({
  selector: '[scrollTo]'
})
export class ScrollToDirective {

  @Input() target = '';
  constructor() { }

  @HostListener('click')
  onClick() {
    const targetElement = document.querySelector(this.target);
    if(targetElement)
      targetElement.scrollIntoView({block: 'start',behavior: 'smooth', inline:'nearest'});
  }
}

  1. scrolledTo directive will be responsible for detecting when scrolling to current element
import { Directive, ElementRef, HostListener, Input } from '@angular/core';

@Directive({
  selector: '[scrolledTo]',
  exportAs: 'scrolledTo'
})
export class ScrolledToDirective {
  @Input() isLast:boolean = false;
  focus = false;

  constructor(public el: ElementRef) { }

  @HostListener('window:scroll', ['$event'])
  onWindowScroll() {
    const elementPosition = this.el.nativeElement.offsetTop;
    const elementHeight = this.el.nativeElement.clientHeight;

    //you can change the check according to your requirements
    const scrollPosition = window.pageYOffset - window.screen.height / 2 ;
    this.focus = scrollPosition >= elementPosition && scrollPosition <= elementPosition + elementHeight;
    if(this.isLast)
      this.focus = scrollPosition >= elementPosition;

  }
}

and the html will be the following

<div>
    <div class="sticky-top">
        <ul class="nav menu-sidebar">
            <li >
                <a scrollTo target="#about" [class.active]="scrolledToElement1.focus">About</a>
            </li>
            <li >
                <a scrollTo target="#schedule" [class.active]="scrolledToElement2.focus">Schedule</a>
            </li>
            <li >
                <a scrollTo target="#hotel" [class.active]="scrolledToElement3.focus">Information about the hotel</a>
            </li>
        </ul>
    </div>

    <div >
        <section scrolledTo #scrolledToElement1="scrolledTo" id="about">
            <h3>About</h3>
            <p>{{some long long text and content}}</p>
        </section>
        <section scrolledTo #scrolledToElement2="scrolledTo" id="schedule">
            <h3>Schedule</h3>
            <p>{{some long long text and content}}</p>
        </section>
        <section scrolledTo #scrolledToElement3="scrolledTo" id="hotel">
            <h3>Information about the hotel</h3>
            <p>{{some long long text and content}}</p>
        </section>
    </div>
</div>

credits:

2
Eliseo On

NOTE: I use the NgbScrollSpy from @ng-bootstrap/ng-bootstrap (I imagine it's very similar library)

What I do:

I wrapper all (include the navigation) in the

<div class="wrapper" ngbScrollSpy #spy="ngbScrollSpy" 
   rootMargin="2px"  [threshold]="1.0" >

  <div class="sticky-top pt-4 pb-4 bg-white">
   ..here the navigation...
  </div>
      ..here the sections...
  <section ngbScrollSpyFragment="about">
  </section>
      //the last I use margin-bottom:2rem;
  <section ngbScrollSpyFragment="hotel" style="margin-bottom:10rem"
  </section>
</div>

See the "threshold". This indicate what percent (1 is 100% ) should be visible to "change" the active fragment

The wrapper class

.wrapper{
   height:100%;
   position: absolute;
   top:0;
}

I force the class of the link using

//remove all the class for the link
a{
  color:black;
  text-decoration:none;
  padding:8px 16px;
}
a.custom-active{
  color:white;
  background-color:royalblue
}

And each link I use

<a  [class.custom-active]="spy.active=='about'">About</a>

Well, the problem is how "scroll to". The "links" should work as link.

The first is indicate to the "router" that should take account the "fragments"

If our component is an standalone component we need use a provider, the provideRouter

bootstrapApplication(App, {
  providers: [
    provideRouter([], withInMemoryScrolling({anchorScrolling:'enabled'})),
  ]
})

Then, we need take account that really our page has no scroll, the div "wrapper" is who has the scroll. So we subscribe to router.events and when the event has an "anchor" we scroll the "wrapper" div

  constructor(router: Router) {
    router.events.pipe(filter((e:any) => e.anchor)).subscribe((e: any) => {

      (document.getElementById('wrapper') as any).scrollTop+=
                 (document.getElementById(e.anchor)?.getBoundingClientRect().top||0)
                                                       -72; //<--this is the offset
    });
  }

In this stactblitz you have a working example

Update: Listening the changes of ngbScrollSpy.

If we want to see a footer, we can use activeChange event of the ngbScrollSpy

Imagine we define an output in our myscrollspy

@Output()scrollEmit=new EventEmitter<any>()

We can, in the event activeChange emit the "fragment"

<div ngbScrollSpy #spy="ngbScrollSpy" class="content"   
                  rootMargin="2px"  [threshold]="1.0"
                  (activeChange)="scrollEmit.next($event)"
>

Now, we can "listen" this event in our app-root.component to change a variable displayFooter:boolean=false

   <app-my-scrollspy (scrollEmit)="displayFooter=$event=='hotel'">
   </app-my-scrollspy>

   @if(displayFooter)
   {
      <div  style="position:absolute;bottom:0;z-index:1000">
        <h3>footer to be displayed when finish scrolling the document</h3>
      </div>
   }

Your forked stackblitz