diff --git a/packages/boilerplate/playwright.config.js b/packages/boilerplate/playwright.config.js index b9c22fb4..0968cfa7 100644 --- a/packages/boilerplate/playwright.config.js +++ b/packages/boilerplate/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/outliner/playwright.config.js b/packages/outliner/playwright.config.js index b9c22fb4..0968cfa7 100644 --- a/packages/outliner/playwright.config.js +++ b/packages/outliner/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/scroll-spy/__tests__/index.js b/packages/scroll-spy/__tests__/jest/index.js similarity index 94% rename from packages/scroll-spy/__tests__/index.js rename to packages/scroll-spy/__tests__/jest/index.js index dc8110d7..e003f756 100644 --- a/packages/scroll-spy/__tests__/index.js +++ b/packages/scroll-spy/__tests__/jest/index.js @@ -1,114 +1,114 @@ -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.

-
- Section 1 -
-
- Section 2 -
-
- Section 3 -
-
- Section 4 -
`; - - 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.

-
- Section 1 -
`; - } - - 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.

+
+ Section 1 +
+
+ Section 2 +
+
+ Section 3 +
+
+ Section 4 +
`; + + 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.

+
+ Section 1 +
`; + } + + 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.

Section 1
+
+ Section 4 +
+
+ Section 5 +
Section 2
@@ -95,5 +111,11 @@

Example

Section 3
+
+
+
+

© StormID

+
+
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.

+
+ Section 1 +
+
+ Section 4 +
+
+ Section 5 +
+
+ Section 2 +
+
+ Section 3 +
+
+ + + 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, }, };