diff --git a/spec/helpers/helper.js b/spec/helpers/helper.js
index 2959b39fc7..8832fd4937 100644
--- a/spec/helpers/helper.js
+++ b/spec/helpers/helper.js
@@ -121,11 +121,23 @@ const KEYMAP = {
'105': '9'
};
-function XloadHtml(html) {
+function XloadHtml(html, options) {
+ options = options ? options : {};
+ const defaultOptions = { insertionType: 'append' };
+ options = {
+ ...defaultOptions,
+ ...options
+ };
+
const div = document.createElement('div');
div.classList.add('please-delete-me');
div.innerHTML = html;
- document.body.appendChild(div);
+
+ if (options.insertionType === 'append') {
+ document.body.appendChild(div);
+ } else if (options.insertionType === 'prepend') {
+ document.body.prepend(div);
+ }
}
function XunloadFixtures() {
diff --git a/spec/tests/scrollspy/scrollspySpec.js b/spec/tests/scrollspy/scrollspySpec.js
new file mode 100644
index 0000000000..b83617c3f3
--- /dev/null
+++ b/spec/tests/scrollspy/scrollspySpec.js
@@ -0,0 +1,351 @@
+describe('Scrollspy component', () => {
+ const DELAY_IN_MS = 40;
+ const fixture1 = `
+
+ `;
+ const fixture2 = `
+
+
+
+
+ introduction
+
+
+
+ initialization
+
+
+
+ options
+
+
+
+
+
+
+ `;
+ const defaultOptions = { animationDuration: 1 };
+ let scrollspyInstances = [];
+
+ function isItemActive(value, activeClassName) {
+ activeClassName = activeClassName ? activeClassName : 'active';
+ const element = document.querySelector(`a[href="#${value}"]`);
+ return Array.from(element.classList).includes(activeClassName);
+ }
+
+ function expectOnlyThisElementIsActive(value, activeClassName) {
+ ['introduction', 'initialization', 'options']
+ .filter((el) => el !== value)
+ .forEach((el) =>
+ expect(isItemActive(el, activeClassName))
+ .withContext(`expecting ${el} not to be active`)
+ .toBeFalse()
+ );
+
+ expect(isItemActive(value, activeClassName))
+ .withContext(`expecting ${value} to be active`)
+ .toBeTrue();
+ }
+
+ function expectNoActiveElements(activeClassName) {
+ ['introduction', 'initialization', 'options'].forEach((el) =>
+ expect(isItemActive(el, activeClassName))
+ .withContext(`expecting ${el} not to be active`)
+ .toBeFalse()
+ );
+ }
+
+ function resetScrollspy(options) {
+ options = options ? options : {};
+ scrollspyInstances.forEach((value) => value.destroy());
+ const elements = document.querySelectorAll('.scrollspy');
+ scrollspyInstances = M.ScrollSpy.init(elements, options);
+ }
+
+ function clickLink(value) {
+ document.querySelector(`a[href="#${value}"]`).click();
+ }
+
+ function getDistanceFromTop(element) {
+ const rect = element.getBoundingClientRect();
+ const scrollTop = window.scrollY || window.pageYOffset;
+ const distanceFromTop = rect.top + scrollTop;
+
+ return distanceFromTop;
+ }
+
+ function scrollTo(targetPosition) {
+ window.scrollTo(0, targetPosition);
+ }
+
+ describe('Scrollspy with keepTopElementActive flag test cases', () => {
+ beforeEach(() => {
+ XloadHtml(fixture2, { insertionType: 'prepend' });
+ window.scrollTo(0, 0);
+ const elements = document.querySelectorAll('.scrollspy');
+ scrollspyInstances = M.ScrollSpy.init(elements, defaultOptions);
+ });
+
+ afterEach(() => {
+ scrollspyInstances.forEach((value) => value.destroy());
+ XunloadFixtures();
+ });
+
+ it('Test click on table of contents element for scrollspy with instant animationDuration', (done) => {
+ resetScrollspy({ animationDuration: 0, keepTopElementActive: true });
+ clickLink('options');
+ setTimeout(() => {
+ expectOnlyThisElementIsActive('introduction');
+ done();
+ }, DELAY_IN_MS);
+ });
+
+ it('Test first element is active on true keepTopElementActive even if the elements are much lower down on the page', () => {
+ resetScrollspy({ keepTopElementActive: true });
+ expectOnlyThisElementIsActive('introduction');
+ });
+
+ it('Test default keepTopElementActive value if false', () => {
+ expectNoActiveElements();
+ });
+
+ it('Test no active elements on false keepTopElementActive if the elements are much lower down on the page', () => {
+ resetScrollspy({ keepTopElementActive: false });
+ expectNoActiveElements();
+ });
+
+ it('Test scroll to the bottom and to the top of the page should keep last and then first element active', (done) => {
+ resetScrollspy({ ...defaultOptions, keepTopElementActive: true });
+
+ scrollTo(document.body.scrollHeight);
+ setTimeout(() => {
+ expectOnlyThisElementIsActive('options');
+ scrollTo(0);
+ setTimeout(() => {
+ expectOnlyThisElementIsActive('introduction');
+ done();
+ }, DELAY_IN_MS);
+ }, DELAY_IN_MS);
+ });
+
+ it('Test scroll to the noScrollSpy sections should keep nearest top element active on true keepTopElementActive', (done) => {
+ resetScrollspy({ ...defaultOptions, keepTopElementActive: true });
+
+ const [, noScrollSpy2, noScrollSpy3, noScrollSpy4] =
+ document.querySelectorAll('.noScrollSpy');
+
+ scrollTo(getDistanceFromTop(noScrollSpy2));
+ setTimeout(() => {
+ expectOnlyThisElementIsActive('introduction');
+ scrollTo(getDistanceFromTop(noScrollSpy3));
+ setTimeout(() => {
+ expectOnlyThisElementIsActive('initialization');
+ scrollTo(getDistanceFromTop(noScrollSpy4));
+ setTimeout(() => {
+ expectOnlyThisElementIsActive('options');
+ done();
+ }, DELAY_IN_MS);
+ }, DELAY_IN_MS);
+ }, DELAY_IN_MS);
+ });
+
+ it('Test on false keepTopElementActive scroll to the noScrollSpy should not make active elements', (done) => {
+ resetScrollspy({ ...defaultOptions, keepTopElementActive: false });
+
+ const [, noScrollSpy2, noScrollSpy3, noScrollSpy4] =
+ document.querySelectorAll('.noScrollSpy');
+
+ scrollTo(getDistanceFromTop(noScrollSpy2));
+ setTimeout(() => {
+ expectNoActiveElements();
+
+ scrollTo(getDistanceFromTop(noScrollSpy3));
+ setTimeout(() => {
+ expectNoActiveElements();
+
+ scrollTo(getDistanceFromTop(noScrollSpy4));
+ setTimeout(() => {
+ expectNoActiveElements();
+ done();
+ }, DELAY_IN_MS);
+ }, DELAY_IN_MS);
+ }, DELAY_IN_MS);
+ });
+ });
+
+ describe('Scrollspy basic test cases', () => {
+ beforeEach(() => {
+ XloadHtml(fixture1, { insertionType: 'prepend' });
+ window.scrollTo(0, 0);
+ const elements = document.querySelectorAll('.scrollspy');
+ scrollspyInstances = M.ScrollSpy.init(elements, defaultOptions);
+ });
+
+ afterEach(() => {
+ scrollspyInstances.forEach((value) => value.destroy());
+ XunloadFixtures();
+ });
+
+ it('Test scrollspy native smooth behavior', (done) => {
+ resetScrollspy({ ...defaultOptions, animationDuration: null });
+ const viewportHeightPx = window.innerHeight;
+
+ clickLink('options');
+ setTimeout(() => {
+ const scrollTop = window.scrollY;
+ expect(scrollTop).toBe(viewportHeightPx * 2);
+ done();
+ }, 800);
+ });
+
+ it('Test scrollspy smooth behavior positive case', (done) => {
+ const viewportHeightPx = window.innerHeight;
+
+ clickLink('options');
+ setTimeout(() => {
+ const scrollTop = window.scrollY;
+ expect(scrollTop).toBe(viewportHeightPx * 2);
+ done();
+ }, DELAY_IN_MS);
+ });
+
+ it('Test scrollspy smooth behavior negative case', (done) => {
+ resetScrollspy({ ...defaultOptions, animationDuration: 100 });
+ const viewportHeightPx = window.innerHeight;
+ clickLink('options');
+ setTimeout(() => {
+ const scrollTop = window.scrollY;
+ expect(scrollTop)
+ .withContext("Scroll animation shouldn't reach the element in the given time")
+ .toBeLessThan(viewportHeightPx * 2);
+ setTimeout(() => {
+ done();
+ }, 120);
+ }, 5);
+ });
+
+ it('Test click on an item in the table of contents should make item active', (done) => {
+ clickLink('introduction');
+ setTimeout(() => {
+ const scrollTop = window.scrollY;
+ expect(scrollTop).toBe(window.innerHeight * 0);
+ expectOnlyThisElementIsActive('introduction');
+
+ clickLink('initialization');
+ setTimeout(() => {
+ const scrollTop = window.scrollY;
+ expect(scrollTop).toBe(window.innerHeight * 1);
+ expectOnlyThisElementIsActive('initialization');
+
+ clickLink('options');
+ setTimeout(() => {
+ const scrollTop = window.scrollY;
+ expect(scrollTop).toBe(window.innerHeight * 2);
+ expectOnlyThisElementIsActive('options');
+
+ done();
+ }, DELAY_IN_MS);
+ }, DELAY_IN_MS);
+ }, DELAY_IN_MS);
+ });
+
+ it('Test click on an item in the table of contents should make item active with explicit class', (done) => {
+ resetScrollspy({ ...defaultOptions, activeClass: 'otherActiveExample' });
+
+ clickLink('options');
+ setTimeout(() => {
+ expectNoActiveElements('active');
+ expectOnlyThisElementIsActive('options', 'otherActiveExample');
+ done();
+ }, DELAY_IN_MS);
+ });
+
+ it('Test explicit getActiveElement implementation', (done) => {
+ const customGetActiveElement = (id) => {
+ const selector = 'div#testContainerId';
+ const testDivElement = document.querySelector(selector);
+ testDivElement.textContent = id;
+ return selector;
+ };
+ resetScrollspy({
+ ...defaultOptions,
+ getActiveElement: customGetActiveElement,
+ animationDuration: 100
+ });
+
+ clickLink('options');
+ setTimeout(() => {
+ expectNoActiveElements();
+
+ const element = document.querySelector('div#testContainerId');
+ expect(element.textContent).toBe('options');
+ expect(Array.from(element.classList)).toEqual(['active']);
+ done();
+ }, 120);
+ });
+ });
+});
diff --git a/src/scrollspy.ts b/src/scrollspy.ts
index 143420310b..99109f97e5 100644
--- a/src/scrollspy.ts
+++ b/src/scrollspy.ts
@@ -22,24 +22,43 @@ export interface ScrollSpyOptions extends BaseOptions {
* @default id => 'a[href="#' + id + '"]'
*/
getActiveElement: (id: string) => string;
+ /**
+ * Used to keep last top element active even if
+ * scrollbar goes outside of scrollspy elements.
+ *
+ * If there is no last top element,
+ * then the active one will be the first element.
+ *
+ * @default false
+ */
+ keepTopElementActive: boolean;
+ /**
+ * Used to set scroll animation duration in milliseconds.
+ * @default null (browser's native animation implementation/duration)
+ */
+ animationDuration: number | null;
};
let _defaults: ScrollSpyOptions = {
throttle: 100,
scrollOffset: 200, // offset - 200 allows elements near bottom of page to scroll
activeClass: 'active',
- getActiveElement: (id: string): string => { return 'a[href="#'+id+'"]'; }
+ getActiveElement: (id: string): string => { return 'a[href="#' + id + '"]'; },
+ keepTopElementActive: false,
+ animationDuration: null,
};
export class ScrollSpy extends Component {
static _elements: ScrollSpy[];
static _count: number;
static _increment: number;
- tickId: number;
- id: any;
static _elementsInView: ScrollSpy[];
static _visibleElements: any[];
static _ticks: number;
+ static _keptTopActiveElement: HTMLElement | null = null;
+
+ private tickId: number;
+ private id: any;
constructor(el: HTMLElement, options: Partial) {
super(el, options, ScrollSpy);
@@ -124,7 +143,12 @@ export class ScrollSpy extends Component {
const x = document.querySelector('a[href="#'+scrollspy.el.id+'"]');
if (trigger === x) {
e.preventDefault();
- scrollspy.el.scrollIntoView({behavior: 'smooth'});
+
+ if ((scrollspy.el as any).M_ScrollSpy.options.animationDuration) {
+ ScrollSpy._smoothScrollIntoView(scrollspy.el, (scrollspy.el as any).M_ScrollSpy.options.animationDuration);
+ } else {
+ scrollspy.el.scrollIntoView({ behavior: 'smooth' });
+ }
break;
}
}
@@ -165,6 +189,24 @@ export class ScrollSpy extends Component {
}
// remember elements in view for next tick
ScrollSpy._elementsInView = intersections;
+ if (ScrollSpy._elements.length) {
+ const options = (ScrollSpy._elements[0].el as any).M_ScrollSpy.options;
+ if (options.keepTopElementActive && ScrollSpy._visibleElements.length === 0) {
+ this._resetKeptTopActiveElementIfNeeded();
+ const topElements = ScrollSpy._elements.filter(value => ScrollSpy._getDistanceToViewport(value.el) <= 0)
+ .sort((a, b) => {
+ const distanceA = ScrollSpy._getDistanceToViewport(a.el);
+ const distanceB = ScrollSpy._getDistanceToViewport(b.el);
+ if (distanceA < distanceB) return -1;
+ if (distanceA > distanceB) return 1;
+ return 0;
+ });
+ const nearestTopElement = topElements.length ? topElements[topElements.length - 1] : ScrollSpy._elements[0];
+ const actElem = document.querySelector(options.getActiveElement(nearestTopElement.el.id));
+ actElem?.classList.add(options.activeClass);
+ ScrollSpy._keptTopActiveElement = actElem as HTMLElement;
+ }
+ }
}
static _offset(el) {
@@ -220,6 +262,7 @@ export class ScrollSpy extends Component {
else {
ScrollSpy._visibleElements.push(this.el);
}
+ this._resetKeptTopActiveElementIfNeeded();
const selector = this.options.getActiveElement(ScrollSpy._visibleElements[0].id);
document.querySelector(selector)?.classList.add(this.options.activeClass);
}
@@ -237,8 +280,43 @@ export class ScrollSpy extends Component {
// Check if empty
const selector = this.options.getActiveElement(ScrollSpy._visibleElements[0].id);
document.querySelector(selector)?.classList.add(this.options.activeClass);
+ this._resetKeptTopActiveElementIfNeeded();
+ }
+ }
+ }
+
+ private _resetKeptTopActiveElementIfNeeded() {
+ if (ScrollSpy._keptTopActiveElement) {
+ ScrollSpy._keptTopActiveElement.classList.remove(this.options.activeClass);
+ ScrollSpy._keptTopActiveElement = null;
+ }
+ }
+
+ private static _getDistanceToViewport(element) {
+ const rect = element.getBoundingClientRect();
+ const distance = rect.top;
+ return distance;
+ }
+
+ private static _smoothScrollIntoView(element, duration = 300) {
+ const targetPosition = element.getBoundingClientRect().top + (window.scrollY || window.pageYOffset);
+ const startPosition = (window.scrollY || window.pageYOffset);
+ const distance = targetPosition - startPosition;
+ const startTime = performance.now();
+
+ function scrollStep(currentTime) {
+ const elapsed = currentTime - startTime;
+ const progress = Math.min(elapsed / duration, 1);
+ const scrollY = startPosition + distance * progress;
+
+ if (progress < 1) {
+ window.scrollTo(0, scrollY);
+ requestAnimationFrame(scrollStep);
+ } else {
+ window.scrollTo(0, targetPosition);
}
}
+ requestAnimationFrame(scrollStep);
}
static {