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 = ` +
+
+
+
+ introduction +
+
+ initialization +
+
+ options +
+
+
+
+
+ +
+
+
+
+
+
+ `; + 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 {