Scroll down to see the menu to highlight the current section and to see example code.
`;
- }
-
- beforeAll(setupDOM);
-
- it('should return an array when passed a DOM element', async () => {
- const scroll = document.querySelector('.js-scroll-spy');
- const els = getSelection(scroll);
- expect(els instanceof Array).toBe(true);
- expect(els.length).toEqual(1);
- });
-
- it('should return an array when passed a NodeList element', async () => {
- const scroll = document.querySelectorAll('.js-scroll-spy');
- const els = getSelection(scroll);
- expect(els instanceof Array).toBe(true);
- expect(els.length).toEqual(1);
- });
-
- it('should return an array when passed an array of DOM elements', async () => {
- const scroll = document.querySelector('.js-scroll-spy');
- const els = getSelection([scroll]);
- expect(els instanceof Array).toBe(true);
- expect(els.length).toEqual(1);
- });
-
- it('should return an array when passed a string', async () => {
- const els = getSelection('.js-scroll-spy');
- expect(els instanceof Array).toBe(true);
- expect(els.length).toEqual(1);
- });
-
+import scrollSpy from '../../src';
+import { getSelection } from '../../src/lib/utils';
+
+let basic, withCallback;
+const init = () => {
+ window.IntersectionObserver = jest.fn(function(cb) {
+ this.observe = () => {};
+ this.entries = [{ isIntersecting: true }];
+ });
+
+ // Set up our document body
+ document.body.innerHTML = `
+
Scroll Spy
+
Example
+
Scroll down to see the menu to highlight the current section and to see example code.
+
+
+
+
`;
+
+ basic = scrollSpy('.js-scroll-spy');
+ withCallback = scrollSpy('.js-scroll-spy-two', {
+ callback(){
+ // this.node.classList.toggle('callback-test');
+ }
+ });
+};
+
+describe(`Scroll Spy > Initialisation`, () => {
+
+ beforeAll(init);
+
+ it('should return undefined if no nodes match the init selector', async () => {
+ expect(scrollSpy('.not-found')).toEqual(undefined);
+ });
+
+ it('should return an object with the expected properties', () => {
+ expect(basic).not.toBeNull();
+ expect(basic.getState().spies).not.toBeNull();
+ expect(basic.getState().settings).not.toBeNull();
+ expect(basic.getState()).not.toBeNull();
+ });
+
+ it('should initialisation with different settings if different options are passed', () => {
+ expect(basic.getState().settings.callback).not.toEqual(withCallback.getState().settings.callback);
+ });
+
+});
+
+describe('Scroll spy > Initialisation > Get Selection', () => {
+
+ const setupDOM = () => {
+ // Set up our document body
+ document.body.innerHTML = `
+
+
Scroll Spy
+
Example
+
Scroll down to see the menu to highlight the current section and to see example code.
+
`;
+ }
+
+ beforeAll(setupDOM);
+
+ it('should return an array when passed a DOM element', async () => {
+ const scroll = document.querySelector('.js-scroll-spy');
+ const els = getSelection(scroll);
+ expect(els instanceof Array).toBe(true);
+ expect(els.length).toEqual(1);
+ });
+
+ it('should return an array when passed a NodeList element', async () => {
+ const scroll = document.querySelectorAll('.js-scroll-spy');
+ const els = getSelection(scroll);
+ expect(els instanceof Array).toBe(true);
+ expect(els.length).toEqual(1);
+ });
+
+ it('should return an array when passed an array of DOM elements', async () => {
+ const scroll = document.querySelector('.js-scroll-spy');
+ const els = getSelection([scroll]);
+ expect(els instanceof Array).toBe(true);
+ expect(els.length).toEqual(1);
+ });
+
+ it('should return an array when passed a string', async () => {
+ const els = getSelection('.js-scroll-spy');
+ expect(els instanceof Array).toBe(true);
+ expect(els.length).toEqual(1);
+ });
+
});
\ No newline at end of file
diff --git a/packages/scroll-spy/__tests__/integration.js b/packages/scroll-spy/__tests__/jest/integration.js
similarity index 70%
rename from packages/scroll-spy/__tests__/integration.js
rename to packages/scroll-spy/__tests__/jest/integration.js
index 1a9ae50f..f24ba9a6 100644
--- a/packages/scroll-spy/__tests__/integration.js
+++ b/packages/scroll-spy/__tests__/jest/integration.js
@@ -1,133 +1,124 @@
-import { callback } from '../src/lib/factory';
-import defaults from '../src/lib/defaults';
-import { createStore } from '../src/lib/store';
-
-describe('Scroll spy > factory > callback', () => {
-
- it('should do nothing if the entry is not intersecting and there are no active items in state', () => {
- const state = {
- settings: defaults,
- active: []
- };
- const updateMock = jest.fn();
- const storeMock = {
- getState() { return state; },
- update: updateMock
- };
- const spy = { node: {}, target: {} };
- const entries = [{ isIntersecting: false }];
- const observer = { disconnect: () => {} };
- callback(storeMock, spy)(entries, observer);
- expect(updateMock).not.toBeCalled();
- expect(storeMock.getState()).toEqual(state);
- });
-
- it('should update new state to the store', () => {
- const spy = { node: 'node-1', target: 'target-1' };
- const spy2 = { node: 'node-2', target: 'target-2' };
- const updateMock = jest.fn();
- const storeMock = {
- getState() { return this.state; },
- state: {
- settings: defaults,
- active: [spy]
- },
- update: updateMock
- };
- const entries = [{ isIntersecting: true }];
- const observer = { disconnect: () => {} };
- callback(storeMock, spy2)(entries, observer);
- expect(updateMock).toBeCalled();
- });
-
- it('should add a spy to the active array', () => {
- document.body.innerHTML = '
';
- const node = document.querySelector('.node');
- const spy = { node, target: 'target-1' };
- const Store = createStore();
- Store.update({ spies: [spy], settings: defaults, active: [] });
- const entries = [{ isIntersecting: true }];
- const observer = { disconnect: () => {} };
- callback(Store, spy)(entries, observer);
- expect(Store.getState().active).toEqual([spy]);
- expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
- });
-
- it('should add a spy to the active array and remove active className from currently active if settings.single', () => {
- document.body.innerHTML = `
`;
- const node = document.querySelector('.node');
- const node2 = document.querySelector('.node-2');
- const spy = { node, target: 'target-1' };
- const spy2 = { node: node2, target: 'target-2' };
- const Store = createStore();
- Store.update({ spies: [spy], settings: defaults, active: [spy] });
- const entries = [{ isIntersecting: true }];
- const observer = { disconnect: () => {} };
- callback(Store, spy2)(entries, observer);
- expect(Store.getState().active).toEqual([spy, spy2]);
- expect(node.classList.contains(defaults.activeClassName)).toEqual(false);
- expect(node2.classList.contains(defaults.activeClassName)).toEqual(true);
- });
-
- it('should add a spy to the active array and add className to spy node, preserving currently active if !settings.single', () => {
- document.body.innerHTML = `
`;
- const node = document.querySelector('.node');
- const node2 = document.querySelector('.node-2');
- const spy = { node, target: 'target-1' };
- const spy2 = { node: node2, target: 'target-2' };
- const Store = createStore();
- Store.update({ spies: [spy], settings: Object.assign({}, defaults, { single: false }), active: [spy] });
- const entries = [{ isIntersecting: true }];
- const observer = { disconnect: () => {} };
- callback(Store, spy2)(entries, observer);
- expect(Store.getState().active).toEqual([spy, spy2]);
- expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
- expect(node2.classList.contains(defaults.activeClassName)).toEqual(true);
- });
-
- it('should remove a spy from the active array', () => {
- document.body.innerHTML = `
`;
- const node = document.querySelector('.node');
- const spy = { node, target: 'target-1' };
- const Store = createStore();
- Store.update({ spies: [spy], settings: defaults, active: [spy] });
- const entries = [{ isIntersecting: false }];
- const observer = { disconnect: () => {} };
- callback(Store, spy)(entries, observer);
- expect(Store.getState().active).toEqual([]);
- expect(node.classList.contains(defaults.activeClassName)).toEqual(false);
- });
-
- it('should remove a spy from the active array, remove active className from currently active if settings.single, and reassign it to any remaining active spies', () => {
- document.body.innerHTML = `
`;
- const node = document.querySelector('.node');
- const node2 = document.querySelector('.node-2');
- const spy = { node, target: 'target-1' };
- const spy2 = { node: node2, target: 'target-2' };
- const Store = createStore();
- Store.update({ spies: [spy, spy2], settings: defaults, active: [spy, spy2] });
- const entries = [{ isIntersecting: false }];
- const observer = { disconnect: () => {} };
- callback(Store, spy2)(entries, observer);
- expect(Store.getState().active).toEqual([spy]);
- expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
- expect(node2.classList.contains(defaults.activeClassName)).toEqual(false);
- });
-
- it('should remove a spy from the active array, remove active className from currently active if settings.single', () => {
- document.body.innerHTML = `
`;
- const node = document.querySelector('.node');
- const node2 = document.querySelector('.node-2');
- const spy = { node, target: 'target-1' };
- const spy2 = { node: node2, target: 'target-2' };
- const Store = createStore();
- Store.update({ spies: [spy, spy2], settings: Object.assign({}, defaults, { single: false }), defaults, active: [spy, spy2] });
- const entries = [{ isIntersecting: false }];
- const observer = { disconnect: () => {} };
- callback(Store, spy2)(entries, observer);
- expect(Store.getState().active).toEqual([spy]);
- expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
- expect(node2.classList.contains(defaults.activeClassName)).toEqual(false);
- });
-
+import { intersectionCallback } from '../../src/lib/factory';
+import defaults from '../../src/lib/defaults';
+import { createStore } from '../../src/lib/store';
+
+describe('Scroll spy > factory > callback', () => {
+
+ it('should update new state to the store', () => {
+ const spy = { node: 'node-1', target: 'target-1' };
+ const spy2 = { node: 'node-2', target: 'target-2' };
+ const updateMock = jest.fn();
+ const storeMock = {
+ getState() { return this.state; },
+ state: {
+ settings: defaults,
+ active: [spy],
+ hasScrolledToBottom: false
+ },
+ update: updateMock
+ };
+ const entries = [{ isIntersecting: true }];
+ intersectionCallback(storeMock, spy2)(entries);
+ expect(updateMock).toBeCalled();
+ });
+
+ it('should add a spy to the active array', () => {
+ document.body.innerHTML = '
';
+ const node = document.querySelector('.node');
+ const spy = { node, target: 'target-1' };
+ const Store = createStore();
+ Store.update({ spies: [spy], settings: defaults, active: [], hasScrolledToBottom: false });
+ const entries = [{ isIntersecting: true }];
+ intersectionCallback(Store, spy)(entries);
+ expect(Store.getState().active).toEqual([spy]);
+ expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
+ });
+
+ it('should add a spy to the active array and remove active className from currently active if settings.single', () => {
+ document.body.innerHTML = `
`;
+ const node = document.querySelector('.node');
+ const node2 = document.querySelector('.node-2');
+ const spy = { node, target: 'target-1' };
+ const spy2 = { node: node2, target: 'target-2' };
+ const Store = createStore();
+ Store.update({ spies: [spy], settings: defaults, active: [spy], hasScrolledToBottom: false });
+ const entries = [{ isIntersecting: true }];
+ intersectionCallback(Store, spy2)(entries);
+ expect(Store.getState().active).toEqual([spy, spy2]);
+ expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
+ expect(node2.classList.contains(defaults.activeClassName)).toEqual(false);
+ });
+
+ it('should add a spy to the active array and add className to spy node, preserving currently active if !settings.single', () => {
+ document.body.innerHTML = `
`;
+ const node = document.querySelector('.node');
+ const node2 = document.querySelector('.node-2');
+ const spy = { node, target: 'target-1' };
+ const spy2 = { node: node2, target: 'target-2' };
+ const Store = createStore();
+ Store.update({ spies: [spy], settings: Object.assign({}, defaults, { single: false }), active: [spy], hasScrolled: false });
+ const entries = [{ isIntersecting: true }];
+ intersectionCallback(Store, spy2)(entries);
+ expect(Store.getState().active).toEqual([spy, spy2]);
+ expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
+ expect(node2.classList.contains(defaults.activeClassName)).toEqual(true);
+ });
+
+ it('should remove a spy from the active array', () => {
+ document.body.innerHTML = `
`;
+ const node = document.querySelector('.node');
+ const spy = { node, target: 'target-1' };
+ const Store = createStore();
+ Store.update({ spies: [spy], settings: defaults, active: [spy], hasScrolledToBottom: false });
+ const entries = [{ isIntersecting: false }];
+ intersectionCallback(Store, spy)(entries);
+ expect(Store.getState().active).toEqual([]);
+ expect(node.classList.contains(defaults.activeClassName)).toEqual(false);
+ });
+
+ it('should remove a spy from the active array, remove active className from currently active if settings.single, and reassign it to the top-most node', () => {
+ document.body.innerHTML = `
`;
+ const node = document.querySelector('.node');
+ const node2 = document.querySelector('.node-2');
+ const spy = { node, target: 'target-1' };
+ const spy2 = { node: node2, target: 'target-2' };
+ const Store = createStore();
+ Store.update({ spies: [spy, spy2], settings: defaults, active: [spy], hasScrolledToBottom: false });
+ const entries = [{ isIntersecting: true }];
+ intersectionCallback(Store, spy2)(entries);
+ expect(Store.getState().active).toEqual([spy, spy2]);
+ expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
+ expect(node2.classList.contains(defaults.activeClassName)).toEqual(false);
+ });
+
+ it('should remove a spy from the active array, remove active className from currently active if settings.single, and the user has scrolled to the bottom', () => {
+ document.body.innerHTML = `
`;
+ const node = document.querySelector('.node');
+ const node2 = document.querySelector('.node-2');
+ const spy = { node, target: 'target-1' };
+ const spy2 = { node: node2, target: 'target-2' };
+ const Store = createStore();
+ Store.update({ spies: [spy, spy2], settings: defaults, active: [spy], hasScrolledToBottom: true });
+ const entries = [{ isIntersecting: true }];
+ intersectionCallback(Store, spy2)(entries);
+ expect(Store.getState().active).toEqual([spy, spy2]);
+ expect(node.classList.contains(defaults.activeClassName)).toEqual(false);
+ expect(node2.classList.contains(defaults.activeClassName)).toEqual(true);
+ });
+
+ it('should remove a spy from the active array, remove active className from currently active if settings.single', () => {
+ document.body.innerHTML = `
`;
+ const node = document.querySelector('.node');
+ const node2 = document.querySelector('.node-2');
+ const spy = { node, target: 'target-1' };
+ const spy2 = { node: node2, target: 'target-2' };
+ const Store = createStore();
+ Store.update({ spies: [spy, spy2], settings: Object.assign({}, defaults, { single: false }), defaults, active: [spy, spy2], hasScrolledToBottom: false });
+ const entries = [{ isIntersecting: false}];
+ intersectionCallback(Store, spy2)(entries);
+ expect(Store.getState().active).toEqual([spy]);
+ expect(node.classList.contains(defaults.activeClassName)).toEqual(true);
+ expect(node2.classList.contains(defaults.activeClassName)).toEqual(false);
+ });
+
});
\ No newline at end of file
diff --git a/packages/scroll-spy/__tests__/reducers.js b/packages/scroll-spy/__tests__/jest/reducers.js
similarity index 93%
rename from packages/scroll-spy/__tests__/reducers.js
rename to packages/scroll-spy/__tests__/jest/reducers.js
index 53be4428..8303ba40 100644
--- a/packages/scroll-spy/__tests__/reducers.js
+++ b/packages/scroll-spy/__tests__/jest/reducers.js
@@ -1,50 +1,50 @@
-import { addActive, removeActive } from '../src/lib/reducers';
-
-describe(`Scroll spy > reducers > addActive`, () => {
-
- it('should add a spy to the state active array', () => {
- const newSpy = { node: 'testNode', target: 'testTarget' };
- const state = {
- active: [{ node: {}, target: {} }]
- };
- expect(addActive(state, newSpy)).toEqual({
- active: [
- { node: {}, target: {} },
- newSpy
- ]
- });
- });
-
- it('should return the state array intact if the spy is already included in the state active array', () => {
- const newSpy = { node: 'testNode', target: 'testTarget' };
- const state = {
- active: [{ node: {}, target: {} }, newSpy],
- };
- expect(addActive(state, newSpy)).toEqual(state);
- });
-
-});
-
-describe(`Scroll spy > reducers > removeActive`, () => {
-
- it('should remove a spy from the active array', () => {
- const spy = { node: 'testNode', target: 'testTarget' };
- const state = {
- active: [{ node: {}, target: {} }, spy]
- };
- expect(removeActive(state, spy)).toEqual({
- active: [
- { node: {}, target: {} }
- ]
- });
- });
-
- it('should return the state array intact if the spy is missing from the state active array', () => {
- const newSpy = { node: 'testNode', target: 'testTarget' };
- const state = {
- active: [{ node: {}, target: {} }]
- };
- expect(removeActive(state, newSpy)).toEqual(state);
- });
-
+import { addActive, removeActive } from '../../src/lib/reducers';
+
+describe(`Scroll spy > reducers > addActive`, () => {
+
+ it('should add a spy to the state active array', () => {
+ const newSpy = { node: 'testNode', target: 'testTarget' };
+ const state = {
+ active: [{ node: {}, target: {} }]
+ };
+ expect(addActive(state, newSpy)).toEqual({
+ active: [
+ { node: {}, target: {} },
+ newSpy
+ ]
+ });
+ });
+
+ it('should return the state array intact if the spy is already included in the state active array', () => {
+ const newSpy = { node: 'testNode', target: 'testTarget' };
+ const state = {
+ active: [{ node: {}, target: {} }, newSpy],
+ };
+ expect(addActive(state, newSpy)).toEqual(state);
+ });
+
+});
+
+describe(`Scroll spy > reducers > removeActive`, () => {
+
+ it('should remove a spy from the active array', () => {
+ const spy = { node: 'testNode', target: 'testTarget' };
+ const state = {
+ active: [{ node: {}, target: {} }, spy]
+ };
+ expect(removeActive(state, spy)).toEqual({
+ active: [
+ { node: {}, target: {} }
+ ]
+ });
+ });
+
+ it('should return the state array intact if the spy is missing from the state active array', () => {
+ const newSpy = { node: 'testNode', target: 'testTarget' };
+ const state = {
+ active: [{ node: {}, target: {} }]
+ };
+ expect(removeActive(state, newSpy)).toEqual(state);
+ });
+
});
\ No newline at end of file
diff --git a/packages/scroll-spy/__tests__/store.js b/packages/scroll-spy/__tests__/jest/store.js
similarity index 93%
rename from packages/scroll-spy/__tests__/store.js
rename to packages/scroll-spy/__tests__/jest/store.js
index be86c757..7cd4b701 100644
--- a/packages/scroll-spy/__tests__/store.js
+++ b/packages/scroll-spy/__tests__/jest/store.js
@@ -1,37 +1,37 @@
-import { createStore } from '../src/lib/store';
-
-describe(`Scroll spy > Store`, () => {
-
- const Store = createStore();
-
- it('createStore should return an Object with an API', async () => {
- expect(Store).not.toBeNull();
- expect(Store.getState).not.toBeNull();
- expect(Store.update).not.toBeNull();
- });
-
- it('should have a getState function that returns a private state Object', async () => {
- expect(Store.state).toBeUndefined();
- expect(Store.getState()).toEqual({});
- });
-
- it('should have a update function that updates state', async () => {
- const nextState = { isOpen: true };
- Store.update(nextState);
- expect(Store.getState()).toEqual(nextState);
- });
-
- it('should have a update function that does not update state if nextState is not passed', async () => {
- const Store = createStore();
- Store.update();
- expect(Store.getState()).toEqual({});
- });
-
- it('should have a update function that invokes any side effect functions passed after the state change, with new state as only argument', async () => {
- const sideEffect = jest.fn();
- Store.update({}, [sideEffect]);
- expect(sideEffect).toBeCalled();
- });
-
-
-});
+import { createStore } from '../../src/lib/store';
+
+describe(`Scroll spy > Store`, () => {
+
+ const Store = createStore();
+
+ it('createStore should return an Object with an API', async () => {
+ expect(Store).not.toBeNull();
+ expect(Store.getState).not.toBeNull();
+ expect(Store.update).not.toBeNull();
+ });
+
+ it('should have a getState function that returns a private state Object', async () => {
+ expect(Store.state).toBeUndefined();
+ expect(Store.getState()).toEqual({});
+ });
+
+ it('should have a update function that updates state', async () => {
+ const nextState = { isOpen: true };
+ Store.update(nextState);
+ expect(Store.getState()).toEqual(nextState);
+ });
+
+ it('should have a update function that does not update state if nextState is not passed', async () => {
+ const Store = createStore();
+ Store.update();
+ expect(Store.getState()).toEqual({});
+ });
+
+ it('should have a update function that invokes any side effect functions passed after the state change, with new state as only argument', async () => {
+ const sideEffect = jest.fn();
+ Store.update({}, [sideEffect]);
+ expect(sideEffect).toBeCalled();
+ });
+
+
+});
diff --git a/packages/scroll-spy/__tests__/playwright/multiple.spec.js b/packages/scroll-spy/__tests__/playwright/multiple.spec.js
new file mode 100644
index 00000000..9459727f
--- /dev/null
+++ b/packages/scroll-spy/__tests__/playwright/multiple.spec.js
@@ -0,0 +1,26 @@
+const { test, expect } = require('@playwright/test');
+import AxeBuilder from '@axe-core/playwright';
+
+let tabKey;
+
+test.beforeEach(async ({ page }, testInfo) => {
+ await page.goto('/multiple.html');
+ tabKey = testInfo.project.use.defaultBrowserType === 'webkit'
+ ? "Alt+Tab"
+ : "Tab";
+});
+
+test.describe('Scroll spy > functionality', { tag: '@all'}, () => {
+ test('Multiple links should be active when the page is loaded', async ({ page }) => {
+ const matchingLinks = page.locator('.is--active');
+ expect(await matchingLinks.count()).toBeGreaterThan(1);
+ });
+});
+
+test.describe('Scroll spy > Axe', { tag: '@reduced'}, () => {
+ test('Should not have any automatically detectable accessibility issues', async ({ page }) => {
+ const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+});
+
diff --git a/packages/scroll-spy/__tests__/playwright/single.spec.js b/packages/scroll-spy/__tests__/playwright/single.spec.js
new file mode 100644
index 00000000..246330fd
--- /dev/null
+++ b/packages/scroll-spy/__tests__/playwright/single.spec.js
@@ -0,0 +1,55 @@
+const { test, expect } = require('@playwright/test');
+import AxeBuilder from '@axe-core/playwright';
+
+let tabKey;
+
+test.beforeEach(async ({ page }, testInfo) => {
+ await page.goto('/');
+ tabKey = testInfo.project.use.defaultBrowserType === 'webkit'
+ ? "Alt+Tab"
+ : "Tab";
+});
+
+test.describe('Scroll spy > functionality', { tag: '@all'}, () => {
+ test('Activate the first link when page is loaded', async ({ page }) => {
+ const matchingLink = page.locator('nav a[href="#section1"]');
+ await expect(matchingLink).toHaveClass(/is--active/);
+ });
+
+ test('Activate the link when section scrolled into view', async ({ page }) => {
+ const matchingLink = page.locator('nav a[href="#section5"]');
+ await page.evaluate(() => window.scrollBy(0, 500));
+ await expect(matchingLink).toHaveClass(/is--active/);
+ });
+
+ test('Only one link should be active when the page is loaded', async ({ page }) => {
+ const matchingLinks = page.locator('.is--active');
+ expect(await matchingLinks.count()).toBe(1);
+ });
+
+ test('Only one link should be active when the page is scrolled', async ({ page }) => {
+ await page.evaluate(() => window.scrollBy(0, 500));
+ const matchingLinks = page.locator('.is--active');
+ expect(await matchingLinks.count()).toBe(1);
+ });
+
+ test('Clicking the spy link should activate it', async ({ page }) => {
+ const matchingLink = page.locator('nav a[href="#section5"]');
+ await matchingLink.click();
+ await expect(matchingLink).toHaveClass(/is--active/);
+ });
+
+ test('Activate the last link when the page is at the bottom, even if not intersecting top of window', async ({ page }) => {
+ const matchingLink = page.locator('nav a[href="#section3"]');
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight || document.documentElement.scrollHeight));
+ await expect(matchingLink).toHaveClass(/is--active/);
+ });
+});
+
+test.describe('Scroll spy > Axe', { tag: '@reduced'}, () => {
+ test('Should not have any automatically detectable accessibility issues', async ({ page }) => {
+ const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+});
+
diff --git a/packages/scroll-spy/example/src/index.html b/packages/scroll-spy/example/src/index.html
index 272b744a..24f62725 100644
--- a/packages/scroll-spy/example/src/index.html
+++ b/packages/scroll-spy/example/src/index.html
@@ -1,6 +1,7 @@
-
+
+
StormID
-
+
Scroll Spy
Example
Scroll down to see the menu to highlight the current section and to see example code.
+
+
@@ -95,5 +111,11 @@ Example
Section 3
+
diff --git a/packages/scroll-spy/example/src/js/index.js b/packages/scroll-spy/example/src/js/index.js
index 184b3367..2698d66c 100644
--- a/packages/scroll-spy/example/src/js/index.js
+++ b/packages/scroll-spy/example/src/js/index.js
@@ -1,5 +1,6 @@
import scrollSpy from '../../../src';
window.addEventListener('DOMContentLoaded', () => {
- scrollSpy('.js-scroll-spy');
+ scrollSpy('.js-scroll-spy', {single: true});
+ scrollSpy('.js-scroll-spy-multiple', {single: false});
});
\ No newline at end of file
diff --git a/packages/scroll-spy/example/src/multiple.html b/packages/scroll-spy/example/src/multiple.html
new file mode 100644
index 00000000..51964547
--- /dev/null
+++ b/packages/scroll-spy/example/src/multiple.html
@@ -0,0 +1,121 @@
+
+
+
+
StormID
+
+
+
+
+
+
+ Scroll Spy
+ Example
+ Scroll down to see the menu to highlight the current section and to see example code.
+
+
+
+
+
+
+
+
+
diff --git a/packages/scroll-spy/package.json b/packages/scroll-spy/package.json
index d1575794..3bfeeaf1 100644
--- a/packages/scroll-spy/package.json
+++ b/packages/scroll-spy/package.json
@@ -27,9 +27,9 @@
],
"scripts": {
"build": "microbundle --name ScrollSpy",
- "test": "jest --coverage",
- "dev": "webpack-dev-server --config example/webpack.config.js",
- "prod": "webpack --config example/webpack.config.js --mode production",
+ "test": "jest --coverage & npx playwright test",
+ "dev": "webpack-dev-server --config tools/webpack.config.js",
+ "prod": "webpack --config tools/webpack.config.js --mode production",
"prepublish": "npm run -s build"
},
"dependencies": {
diff --git a/packages/scroll-spy/playwright.config.js b/packages/scroll-spy/playwright.config.js
new file mode 100644
index 00000000..0968cfa7
--- /dev/null
+++ b/packages/scroll-spy/playwright.config.js
@@ -0,0 +1,16 @@
+
+const { defineConfig } = require('@playwright/test');
+const baseConfig = require('../../tools/playwright/config.base.js');
+const server = require('./tools/playwright.webpack.config.js');
+
+module.exports = defineConfig({
+ ...baseConfig,
+ use: {
+ ...baseConfig.use,
+ baseURL: `http://localhost:${server.devServer.port}/`,
+ },
+ webServer: {
+ ...baseConfig.webServer,
+ url: `http://localhost:${server.devServer.port}/`,
+ },
+});
\ No newline at end of file
diff --git a/packages/scroll-spy/src/lib/defaults.js b/packages/scroll-spy/src/lib/defaults.js
index b4e93134..a9b34bc1 100644
--- a/packages/scroll-spy/src/lib/defaults.js
+++ b/packages/scroll-spy/src/lib/defaults.js
@@ -5,5 +5,5 @@ export default {
threshold: 0,
activeClassName: 'is--active',
callback: null,
- single: true
+ single: true,
};
\ No newline at end of file
diff --git a/packages/scroll-spy/src/lib/dom.js b/packages/scroll-spy/src/lib/dom.js
index 8f38d2f1..db85b757 100644
--- a/packages/scroll-spy/src/lib/dom.js
+++ b/packages/scroll-spy/src/lib/dom.js
@@ -1,31 +1,29 @@
export const findSpies = nodes => nodes.map(node => {
if (!node.hash || !document.querySelector(node.hash)) return void console.warn('Node is missing a href hash or the hash target id does not exist');
-
- return {
+ return {
node,
target: document.querySelector(node.hash),
- // parent: node.parentNode.tagName.toLowerCase() === 'li' ? node.parentNode : null,
};
});
-export const setActive = spy => state => {
- const { settings } = state;
- if (spy !== undefined) spy.node.classList.add(settings.activeClassName);
-};
-
-export const unsetActive = spy => state => {
- const { settings } = state;
- if (spy !== undefined) spy.node.classList.remove(settings.activeClassName);
-};
+export const setActive = () => state => {
+ const { settings, spies, active, hasScrolledToBottom } = state;
-export const unsetAllActive = state => {
- const { settings, spies } = state;
+ //Reset everything to work out new active state
spies.map(spy => {
- if (spy !== undefined) spy.node.classList.remove(settings.activeClassName)
+ if (spy !== undefined) spy.node.classList.remove(settings.activeClassName);
});
-};
-export const findActive = state => {
- const { settings, active } = state;
- if (active.length > 0) active[0].node.classList.add(settings.activeClassName);
-};
\ No newline at end of file
+ active.forEach((spy, index) => {
+ //If the user has scrolled to the bottom we want the last element to be active
+ //even if it hasn't passed the threshold.
+ if (hasScrolledToBottom && index !== active.length - 1) return;
+
+ //Otherwise, if the settings require just one active element it should always the
+ //the first in the active array
+ if (!hasScrolledToBottom && settings.single && index !== 0) return;
+
+ //Set the active class based on all those conditions
+ spy.node.classList.add(settings.activeClassName);
+ })
+};
diff --git a/packages/scroll-spy/src/lib/factory.js b/packages/scroll-spy/src/lib/factory.js
index 6bf8fbd8..992449ba 100644
--- a/packages/scroll-spy/src/lib/factory.js
+++ b/packages/scroll-spy/src/lib/factory.js
@@ -1,35 +1,46 @@
import { createStore } from './store';
-import { findSpies, setActive, unsetAllActive, unsetActive, findActive } from './dom';
-import { addActive, removeActive } from './reducers';
+import { findSpies, setActive } from './dom';
+import { addActive, removeActive, setScrolled } from './reducers';
-export const callback = (store, spy) => (entries, observer) => {
- const { settings, active } = store.getState();
- if (entries[0].isIntersecting) {
- if (settings.single) store.update(addActive(store.getState(), spy), [ unsetAllActive, setActive(spy) ]);
- else store.update(addActive(store.getState(), spy), [ setActive(spy) ]);
- } else {
- if (active.length === 0) return;
- if (settings.single) store.update(removeActive(store.getState(), spy), [ unsetActive(spy), findActive ]);
- else store.update(removeActive(store.getState(), spy), [ unsetActive(spy) ]);
- }
+export const intersectionCallback = (store, spy) => (entries, observer) => {
+ //check if the interstion has happened. If the element is visible, it's a candidate for being active.
+ (entries[0].isIntersecting) ? store.update(addActive(store.getState(), spy), [ setActive() ]) : store.update(removeActive(store.getState(), spy), [ setActive() ]);
};
-const initObservers = store => state => {
+export const scrollCallback = store => () => {
+ //Check if the scroll position has hit the bottom of the window. Set a flag in the store to indicate this.
+ const rest = document.documentElement.scrollHeight - document.documentElement.scrollTop;
+ (Math.abs(document.documentElement.clientHeight - rest) < 1) ? store.update(setScrolled(store.getState(), true), [setActive()]) : store.update(setScrolled(store.getState(), false), []);
+}
+
+export const initObservers = store => state => {
const { settings, spies } = store.getState();
+
spies.map(spy => {
if (spy === undefined) return;
- const observer = new IntersectionObserver(callback(store, spy), {
+ const observer = new IntersectionObserver(intersectionCallback(store, spy), {
root: settings.root,
rootMargin: settings.rootMargin,
threshold: settings.threshold
});
observer.observe(spy.target);
});
+
+ let throttleTick = false;
+ window.addEventListener('scroll', () => {
+ if (!throttleTick) {
+ window.requestAnimationFrame(() => {
+ scrollCallback(store)();
+ throttleTick = false;
+ });
+ throttleTick = true;
+ }
+ });
};
export default ({ settings, nodes }) => {
const store = createStore();
- store.update({ spies: findSpies(nodes), settings, active: [] }, [ initObservers(store) ]);
+ store.update({ spies: findSpies(nodes), settings, active: [], hasScrolledToBottom: false}, [ initObservers(store) ]);
return {
getState: store.getState
diff --git a/packages/scroll-spy/src/lib/reducers.js b/packages/scroll-spy/src/lib/reducers.js
index 4292ec97..b6ed624a 100644
--- a/packages/scroll-spy/src/lib/reducers.js
+++ b/packages/scroll-spy/src/lib/reducers.js
@@ -1,9 +1,20 @@
export const addActive = (state, spy) => {
if (state.active.includes(spy)) return state;
- return { ...state, active: [ ...state.active, spy ] };
+
+ //Add the new active item to the array and re-sort it based on the target's current
+ //vertical position in the document
+ const newActiveSpies = [ ...state.active, spy ].sort((a, b) => {
+ return a.target.offsetTop - b.target.offsetTop;
+ });
+
+ return { ...state, active: newActiveSpies };
};
export const removeActive = (state, spy) => {
if (!state.active.includes(spy)) return state;
return { ...state, active: state.active.filter(item => item !== spy) };
+};
+
+export const setScrolled = (state, scrolled) => {
+ return { ...state, hasScrolledToBottom: scrolled };
};
\ No newline at end of file
diff --git a/packages/scroll-spy/src/lib/utils.js b/packages/scroll-spy/src/lib/utils.js
index 55ac0900..3fc4c499 100644
--- a/packages/scroll-spy/src/lib/utils.js
+++ b/packages/scroll-spy/src/lib/utils.js
@@ -4,7 +4,6 @@
* @param selector, Can be a string, Array of DOM nodes, a NodeList or a single DOM element.
*/
export const getSelection = selector => {
-
if (typeof selector === 'string') return [].slice.call(document.querySelectorAll(selector));
if (selector instanceof Array) return selector;
if (Object.prototype.isPrototypeOf.call(NodeList.prototype, selector)) return [].slice.call(selector);
diff --git a/packages/scroll-spy/tools/playwright.webpack.config.js b/packages/scroll-spy/tools/playwright.webpack.config.js
new file mode 100644
index 00000000..0d703044
--- /dev/null
+++ b/packages/scroll-spy/tools/playwright.webpack.config.js
@@ -0,0 +1,8 @@
+const baseConfig = require('./webpack.config');
+
+module.exports = {
+ ...baseConfig,
+ devServer: {
+ port: 8085
+ }
+};
diff --git a/packages/scroll-spy/example/webpack.config.js b/packages/scroll-spy/tools/webpack.config.js
similarity index 87%
rename from packages/scroll-spy/example/webpack.config.js
rename to packages/scroll-spy/tools/webpack.config.js
index 0d24f362..011280e2 100644
--- a/packages/scroll-spy/example/webpack.config.js
+++ b/packages/scroll-spy/tools/webpack.config.js
@@ -22,6 +22,11 @@ module.exports = {
title: pkg.name,
template: './example/src/index.html',
filename: 'index.html'
+ }),
+ new HtmlWebpackPlugin({
+ title: pkg.name,
+ template: './example/src/multiple.html',
+ filename: 'multiple.html'
})
],
module: {
diff --git a/packages/skip/playwright.config.js b/packages/skip/playwright.config.js
index b9c22fb4..0968cfa7 100644
--- a/packages/skip/playwright.config.js
+++ b/packages/skip/playwright.config.js
@@ -7,10 +7,10 @@ module.exports = defineConfig({
...baseConfig,
use: {
...baseConfig.use,
- baseURL: `http://127.0.0.1:${server.devServer.port}/`,
+ baseURL: `http://localhost:${server.devServer.port}/`,
},
webServer: {
...baseConfig.webServer,
- url: `http://127.0.0.1:${server.devServer.port}/`,
+ url: `http://localhost:${server.devServer.port}/`,
},
});
\ No newline at end of file
diff --git a/packages/textarea/playwright.config.js b/packages/textarea/playwright.config.js
index b9c22fb4..0968cfa7 100644
--- a/packages/textarea/playwright.config.js
+++ b/packages/textarea/playwright.config.js
@@ -7,10 +7,10 @@ module.exports = defineConfig({
...baseConfig,
use: {
...baseConfig.use,
- baseURL: `http://127.0.0.1:${server.devServer.port}/`,
+ baseURL: `http://localhost:${server.devServer.port}/`,
},
webServer: {
...baseConfig.webServer,
- url: `http://127.0.0.1:${server.devServer.port}/`,
+ url: `http://localhost:${server.devServer.port}/`,
},
});
\ No newline at end of file
diff --git a/tools/playwright/config.base.js b/tools/playwright/config.base.js
index fc7ce412..1fbf77e3 100644
--- a/tools/playwright/config.base.js
+++ b/tools/playwright/config.base.js
@@ -12,7 +12,7 @@ module.exports = {
timeout: 10_000,
},
use: {
- baseURL: 'http://127.0.0.1:8081',
+ baseURL: 'http://localhost:8081',
trace: 'on-first-retry',
},
projects: [
@@ -48,7 +48,7 @@ module.exports = {
],
webServer: {
command: 'webpack-dev-server --config tools/playwright.webpack.config.js --hot --no-open',
- url: 'http://127.0.0.1:8081',
+ url: 'http://localhost:8081',
reuseExistingServer: !process.env.CI,
},
};