@ng-bootstrap/ng-bootstrap
Version:
Angular powered Bootstrap
590 lines (582 loc) • 24.9 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, inject, DOCUMENT, PLATFORM_ID, ChangeDetectorRef, NgZone, DestroyRef, Input, Directive, ContentChildren, ElementRef, Output, NgModule } from '@angular/core';
import { Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { isString } from './_ngb-ngbootstrap-utilities.mjs';
import { isPlatformBrowser } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
function toFragmentElement(container, id) {
if (!container || id == null) {
return null;
}
return isString(id) ? container.querySelector(`#${CSS.escape(id)}`) : id;
}
function getOrderedFragments(container, fragments) {
const selector = [...fragments].map(({ id }) => `#${CSS.escape(id)}`).join(',');
return Array.from(container.querySelectorAll(selector));
}
const defaultProcessChanges = (state, changeActive, ctx) => {
const { rootElement, fragments, scrollSpy, options, entries } = state;
const orderedFragments = getOrderedFragments(rootElement, fragments);
if (!ctx.initialized) {
ctx.initialized = true;
ctx.gapFragment = null;
ctx.visibleFragments = new Set();
// special case when one of the fragments was pre-selected
const preSelectedFragment = toFragmentElement(rootElement, options?.initialFragment);
if (preSelectedFragment) {
scrollSpy.scrollTo(preSelectedFragment);
return;
}
}
for (const entry of entries) {
const { isIntersecting, target: fragment } = entry;
// 1. an entry became visible
if (isIntersecting) {
// if we were in-between two elements, we have to clear it up
if (ctx.gapFragment) {
ctx.visibleFragments.delete(ctx.gapFragment);
ctx.gapFragment = null;
}
ctx.visibleFragments.add(fragment);
}
// 2. an entry became invisible
else {
ctx.visibleFragments.delete(fragment);
// nothing is visible anymore, but something just was actually
if (ctx.visibleFragments.size === 0 && scrollSpy.active !== '') {
// 2.1 scrolling down - keeping the same element
if (entry.boundingClientRect.top < entry.rootBounds.top) {
ctx.gapFragment = fragment;
ctx.visibleFragments.add(ctx.gapFragment);
}
// 2.2 scrolling up - getting the previous element
else {
// scrolling up and no more fragments above
if (fragment === orderedFragments[0]) {
ctx.gapFragment = null;
ctx.visibleFragments.clear();
changeActive('');
return;
}
// getting previous fragment
else {
const fragmentIndex = orderedFragments.indexOf(fragment);
ctx.gapFragment = orderedFragments[fragmentIndex - 1] || null;
if (ctx.gapFragment) {
ctx.visibleFragments.add(ctx.gapFragment);
}
}
}
}
}
}
// getting the first visible element in the DOM order of the fragments
for (const fragment of orderedFragments) {
if (ctx.visibleFragments.has(fragment)) {
changeActive(fragment.id);
break;
}
}
};
/**
* A configuration service for the [`NgbScrollSpyService`](#/components/scrollspy/api#NgbScrollSpyService).
*
* You can inject this service, typically in your root component, and customize the values of its properties in
* order to provide default values for all scrollspies used in the application.
*
* @since 15.1.0
*/
class NgbScrollSpyConfig {
constructor() {
this.scrollBehavior = 'smooth';
this.processChanges = defaultProcessChanges;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyConfig, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyConfig, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyConfig, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
const MATCH_THRESHOLD = 3;
/**
* A scrollspy service that allows tracking of elements scrolling in and out of view.
*
* It can be instantiated manually, or automatically by the `ngbScrollSpy` directive.
*
* @since 15.1.0
*/
class NgbScrollSpyService {
constructor() {
this._observer = null;
this._containerElement = null;
this._fragments = new Set();
this._preRegisteredFragments = new Set();
this._active$ = new Subject();
this._distinctActive$ = this._active$.pipe(distinctUntilChanged());
this._active = '';
this._config = inject(NgbScrollSpyConfig);
this._document = inject(DOCUMENT);
this._platformId = inject(PLATFORM_ID);
this._scrollBehavior = this._config.scrollBehavior;
this._diChangeDetectorRef = inject(ChangeDetectorRef, { optional: true });
this._changeDetectorRef = this._diChangeDetectorRef;
this._zone = inject(NgZone);
this._distinctActive$.pipe(takeUntilDestroyed()).subscribe((active) => {
this._active = active;
this._changeDetectorRef?.markForCheck();
});
}
/**
* Getter for the currently active fragment id. Returns empty string if none.
*/
get active() {
return this._active;
}
/**
* An observable emitting the currently active fragment. Emits empty string if none.
*/
get active$() {
return this._distinctActive$;
}
/**
* Starts the scrollspy service and observes specified fragments.
*
* You can specify a list of options to pass, like the root element, initial fragment, scroll behavior, etc.
* See the [`NgbScrollSpyOptions`](#/components/scrollspy/api#NgbScrollSpyOptions) interface for more details.
*/
start(options) {
if (isPlatformBrowser(this._platformId)) {
this._cleanup();
const { root, rootMargin, scrollBehavior, threshold, fragments, changeDetectorRef, processChanges } = {
...options,
};
this._containerElement = root ?? this._document.documentElement;
this._changeDetectorRef = changeDetectorRef ?? this._diChangeDetectorRef;
this._scrollBehavior = scrollBehavior ?? this._config.scrollBehavior;
const processChangesFn = processChanges ?? this._config.processChanges;
const context = {};
this._observer = new IntersectionObserver((entries) => processChangesFn({
entries,
rootElement: this._containerElement,
fragments: this._fragments,
scrollSpy: this,
options: { ...options },
}, (active) => this._active$.next(active), context), {
root: root ?? this._document,
...(rootMargin && { rootMargin }),
...(threshold && { threshold }),
});
// merging fragments added before starting and the ones passed as options
for (const element of [...this._preRegisteredFragments, ...(fragments ?? [])]) {
this.observe(element);
}
this._preRegisteredFragments.clear();
}
}
/**
* Stops the service and unobserves all fragments.
*/
stop() {
this._cleanup();
this._active$.next('');
}
/**
* Scrolls to a fragment, it must be known to the service and contained in the root element.
* An id or an element reference can be passed.
*
* [`NgbScrollToOptions`](#/components/scrollspy/api#NgbScrollToOptions) can be passed.
*/
scrollTo(fragment, options) {
const { behavior } = { behavior: this._scrollBehavior, ...options };
if (this._containerElement) {
const fragmentElement = toFragmentElement(this._containerElement, fragment);
if (fragmentElement) {
const heightPx = fragmentElement.offsetTop - this._containerElement.offsetTop;
this._containerElement.scrollTo({ top: heightPx, behavior });
let lastOffset = this._containerElement.scrollTop;
let matchCounter = 0;
// we should update the active section only after scrolling is finished
// and there is no clean way to do it at the moment
const containerElement = this._containerElement;
this._zone.runOutsideAngular(() => {
const updateActiveWhenScrollingIsFinished = () => {
const sameOffsetAsLastTime = lastOffset === containerElement.scrollTop;
if (sameOffsetAsLastTime) {
matchCounter++;
}
else {
matchCounter = 0;
}
if (!sameOffsetAsLastTime || (sameOffsetAsLastTime && matchCounter < MATCH_THRESHOLD)) {
lastOffset = containerElement.scrollTop;
requestAnimationFrame(updateActiveWhenScrollingIsFinished);
}
else {
this._zone.run(() => this._active$.next(fragmentElement.id));
}
};
requestAnimationFrame(updateActiveWhenScrollingIsFinished);
});
}
}
}
/**
* Adds a fragment to observe. It must be contained in the root element.
* An id or an element reference can be passed.
*/
observe(fragment) {
if (!this._observer) {
this._preRegisteredFragments.add(fragment);
return;
}
const fragmentElement = toFragmentElement(this._containerElement, fragment);
if (fragmentElement && !this._fragments.has(fragmentElement)) {
this._fragments.add(fragmentElement);
this._observer.observe(fragmentElement);
}
}
/**
* Unobserves a fragment.
* An id or an element reference can be passed.
*/
unobserve(fragment) {
if (!this._observer) {
this._preRegisteredFragments.delete(fragment);
return;
}
const fragmentElement = toFragmentElement(this._containerElement, fragment);
if (fragmentElement) {
this._fragments.delete(fragmentElement);
// we're removing and re-adding all current fragments to recompute active one
this._observer.disconnect();
for (const fragment of this._fragments) {
this._observer.observe(fragment);
}
}
}
ngOnDestroy() {
this._cleanup();
}
_cleanup() {
this._fragments.clear();
this._observer?.disconnect();
this._changeDetectorRef = this._diChangeDetectorRef;
this._scrollBehavior = this._config.scrollBehavior;
this._observer = null;
this._containerElement = null;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [] });
/**
* A helper directive to that links menu items and fragments together.
*
* It will automatically add the `.active` class to the menu item when the associated fragment becomes active.
*
* @since 15.1.0
*/
class NgbScrollSpyItem {
constructor() {
this._changeDetector = inject(ChangeDetectorRef);
this._scrollSpyMenu = inject(NgbScrollSpyMenu, { optional: true });
this._scrollSpyAPI = this._scrollSpyMenu ?? inject(NgbScrollSpyService);
this._destroyRef = inject(DestroyRef);
this._isActive = false;
}
/**
* References the scroll spy directive, the id of the associated fragment and the parent menu item.
*
* Can be used like:
* - `ngbScrollSpyItem="fragmentId"`
* - `[ngbScrollSpyItem]="scrollSpy" fragment="fragmentId"
* - `[ngbScrollSpyItem]="[scrollSpy, 'fragmentId']"` parent="parentId"`
* - `[ngbScrollSpyItem]="[scrollSpy, 'fragmentId', 'parentId']"`
*
* As well as together with `[fragment]` and `[parent]` inputs.
*/
set data(data) {
if (Array.isArray(data)) {
this._scrollSpyAPI = data[0];
this.fragment = data[1];
this.parent ??= data[2];
}
else if (data instanceof NgbScrollSpy) {
this._scrollSpyAPI = data;
}
else if (isString(data)) {
this.fragment = data;
}
}
ngOnInit() {
// if it is not a part of a bigger menu, it should handle activation itself
if (!this._scrollSpyMenu) {
this._scrollSpyAPI.active$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((active) => {
if (active === this.fragment) {
this._activate();
}
else {
this._deactivate();
}
this._changeDetector.markForCheck();
});
}
}
/**
* @internal
*/
_activate() {
this._isActive = true;
if (this._scrollSpyMenu) {
this._scrollSpyMenu.getItem(this.parent ?? '')?._activate();
}
}
/**
* @internal
*/
_deactivate() {
this._isActive = false;
if (this._scrollSpyMenu) {
this._scrollSpyMenu.getItem(this.parent ?? '')?._deactivate();
}
}
/**
* Returns `true`, if the associated fragment is active.
*/
isActive() {
return this._isActive;
}
/**
* Scrolls to the associated fragment.
*/
scrollTo(options) {
this._scrollSpyAPI.scrollTo(this.fragment, options);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyItem, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.4", type: NgbScrollSpyItem, isStandalone: true, selector: "[ngbScrollSpyItem]", inputs: { data: ["ngbScrollSpyItem", "data"], fragment: "fragment", parent: "parent" }, host: { listeners: { "click": "scrollTo();" }, properties: { "class.active": "isActive()" } }, exportAs: ["ngbScrollSpyItem"], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyItem, decorators: [{
type: Directive,
args: [{
selector: '[ngbScrollSpyItem]',
exportAs: 'ngbScrollSpyItem',
host: {
'[class.active]': 'isActive()',
'(click)': 'scrollTo();',
},
}]
}], propDecorators: { data: [{
type: Input,
args: ['ngbScrollSpyItem']
}], fragment: [{
type: Input
}], parent: [{
type: Input
}] } });
/**
* An optional scroll spy menu directive to build hierarchical menus
* and simplify the [`NgbScrollSpyItem`](#/components/scrollspy/api#NgbScrollSpyItem) configuration.
*
* @since 15.1.0
*/
class NgbScrollSpyMenu {
constructor() {
this._scrollSpyRef = inject(NgbScrollSpyService);
this._destroyRef = inject(DestroyRef);
this._map = new Map();
this._lastActiveItem = null;
}
set scrollSpy(scrollSpy) {
this._scrollSpyRef = scrollSpy;
}
get active() {
return this._scrollSpyRef.active;
}
get active$() {
return this._scrollSpyRef.active$;
}
scrollTo(fragment, options) {
this._scrollSpyRef.scrollTo(fragment, options);
}
getItem(id) {
return this._map.get(id);
}
ngAfterViewInit() {
this._items.changes.pipe(takeUntilDestroyed(this._destroyRef)).subscribe(() => this._rebuildMap());
this._rebuildMap();
this._scrollSpyRef.active$.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((activeId) => {
this._lastActiveItem?._deactivate();
const item = this._map.get(activeId);
if (item) {
item._activate();
this._lastActiveItem = item;
}
});
}
_rebuildMap() {
this._map.clear();
for (let item of this._items) {
this._map.set(item.fragment, item);
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyMenu, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.4", type: NgbScrollSpyMenu, isStandalone: true, selector: "[ngbScrollSpyMenu]", inputs: { scrollSpy: ["ngbScrollSpyMenu", "scrollSpy"] }, queries: [{ propertyName: "_items", predicate: NgbScrollSpyItem, descendants: true }], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyMenu, decorators: [{
type: Directive,
args: [{
selector: '[ngbScrollSpyMenu]',
}]
}], propDecorators: { _items: [{
type: ContentChildren,
args: [NgbScrollSpyItem, { descendants: true }]
}], scrollSpy: [{
type: Input,
args: ['ngbScrollSpyMenu']
}] } });
/**
* A directive to put on a scrollable container.
*
* It will instantiate a [`NgbScrollSpyService`](#/components/scrollspy/api#NgbScrollSpyService).
*
* @since 15.1.0
*/
class NgbScrollSpy {
constructor() {
this._initialFragment = null;
this._service = inject(NgbScrollSpyService);
this._nativeElement = inject(ElementRef).nativeElement;
/**
* An event raised when the active section changes.
*
* Payload is the id of the new active section, empty string if none.
*/
this.activeChange = this._service.active$;
}
set active(fragment) {
this._initialFragment = fragment;
this.scrollTo(fragment);
}
/**
* Getter/setter for the currently active fragment id.
*/
get active() {
return this._service.active;
}
/**
* Returns an observable that emits currently active section id.
*/
get active$() {
return this._service.active$;
}
ngAfterViewInit() {
this._service.start({
processChanges: this.processChanges,
root: this._nativeElement,
rootMargin: this.rootMargin,
threshold: this.threshold,
...(this._initialFragment && { initialFragment: this._initialFragment }),
});
}
/**
* @internal
*/
_registerFragment(fragment) {
this._service.observe(fragment.id);
}
/**
* @internal
*/
_unregisterFragment(fragment) {
this._service.unobserve(fragment.id);
}
/**
* Scrolls to a fragment that is identified by the `ngbScrollSpyFragment` directive.
* An id or an element reference can be passed.
*/
scrollTo(fragment, options) {
this._service.scrollTo(fragment, {
...(this.scrollBehavior && { behavior: this.scrollBehavior }),
...options,
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpy, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.4", type: NgbScrollSpy, isStandalone: true, selector: "[ngbScrollSpy]", inputs: { processChanges: "processChanges", rootMargin: "rootMargin", scrollBehavior: "scrollBehavior", threshold: "threshold", active: "active" }, outputs: { activeChange: "activeChange" }, host: { attributes: { "tabindex": "0" }, styleAttribute: "overflow-y: auto" }, providers: [NgbScrollSpyService], exportAs: ["ngbScrollSpy"], ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpy, decorators: [{
type: Directive,
args: [{
selector: '[ngbScrollSpy]',
exportAs: 'ngbScrollSpy',
host: {
tabindex: '0',
style: 'overflow-y: auto',
},
providers: [NgbScrollSpyService],
}]
}], propDecorators: { processChanges: [{
type: Input
}], rootMargin: [{
type: Input
}], scrollBehavior: [{
type: Input
}], threshold: [{
type: Input
}], active: [{
type: Input
}], activeChange: [{
type: Output
}] } });
/**
* A directive to put on a fragment observed inside a scrollspy container.
*
* @since 15.1.0
*/
class NgbScrollSpyFragment {
constructor() {
this._destroyRef = inject(DestroyRef);
this._scrollSpy = inject(NgbScrollSpy);
}
ngAfterViewInit() {
this._scrollSpy._registerFragment(this);
this._destroyRef.onDestroy(() => this._scrollSpy._unregisterFragment(this));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyFragment, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.4", type: NgbScrollSpyFragment, isStandalone: true, selector: "[ngbScrollSpyFragment]", inputs: { id: ["ngbScrollSpyFragment", "id"] }, host: { properties: { "id": "id" } }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyFragment, decorators: [{
type: Directive,
args: [{
selector: '[ngbScrollSpyFragment]',
host: {
'[id]': 'id',
},
}]
}], propDecorators: { id: [{
type: Input,
args: ['ngbScrollSpyFragment']
}] } });
class NgbScrollSpyModule {
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyModule, imports: [NgbScrollSpy, NgbScrollSpyItem, NgbScrollSpyFragment, NgbScrollSpyMenu], exports: [NgbScrollSpy, NgbScrollSpyItem, NgbScrollSpyFragment, NgbScrollSpyMenu] }); }
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyModule }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.4", ngImport: i0, type: NgbScrollSpyModule, decorators: [{
type: NgModule,
args: [{
imports: [NgbScrollSpy, NgbScrollSpyItem, NgbScrollSpyFragment, NgbScrollSpyMenu],
exports: [NgbScrollSpy, NgbScrollSpyItem, NgbScrollSpyFragment, NgbScrollSpyMenu],
}]
}] });
/**
* Generated bundle index. Do not edit.
*/
export { NgbScrollSpy, NgbScrollSpyConfig, NgbScrollSpyFragment, NgbScrollSpyItem, NgbScrollSpyMenu, NgbScrollSpyModule, NgbScrollSpyService };
//# sourceMappingURL=ng-bootstrap-ng-bootstrap-scrollspy.mjs.map