diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d11bc8ec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +## Tests + +Each package is tested with the Node built-in test runner plus Playwright: + +``` +npm test # all packages, via lerna +npm run lint # oxlint across the repo +``` diff --git a/README.md b/README.md index 1c689ab5..0edc7716 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,15 @@ Each package is available for use on npm. See [the docs](https://github.com/stor | Package | Description | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | [Autocomplete](./packages/autocomplete) | Accessible autocomplete / combobox (single, multiple, async and ` was consumed at + //enhancement, so it can't be restored anyway. + destroy: () => teardownListeners(store.getState()) }; }; \ No newline at end of file diff --git a/packages/autocomplete/src/lib/handle.js b/packages/autocomplete/src/lib/handle.js index dd596d0b..e163d155 100644 --- a/packages/autocomplete/src/lib/handle.js +++ b/packages/autocomplete/src/lib/handle.js @@ -1,6 +1,6 @@ import { emptyList, renderList, renderStatus, renderActive, clearStatus, announceSelectionChange, showList, hideList, showLoading, setValue, clearInput, focusInput, syncOutput, syncHiddenValue, broadcast } from './dom.js'; import { areEqual, capResults, debounce } from './utils.js'; -import { KEYCODES } from './constants.js'; +import { KEYS } from './constants.js'; //resolve the option an event fired from via its aria-posinset. closest() finds //the option element even when a click lands on a child node of a custom @@ -72,23 +72,23 @@ const resolveAsyncResults = (store, value, results) => { }; export const keydown = store => event => { - switch (KEYCODES[event.keyCode]) { - case 'up': + switch (event.key) { + case KEYS.UP: handleUpArrow(store, event); break; - case 'down': + case KEYS.DOWN: handleDownArrow(store, event); break; - case 'enter': + case KEYS.ENTER: handleEnter(store, event); break; - case 'escape': + case KEYS.ESC: handleEscape(store); break; - case 'tab': + case KEYS.TAB: handleBlur(store); break; - case 'backspace': + case KEYS.BACKSPACE: handleBackspace(store, event); break; } diff --git a/packages/autocomplete/src/lib/utils.js b/packages/autocomplete/src/lib/utils.js index 346be89f..12e41b36 100644 --- a/packages/autocomplete/src/lib/utils.js +++ b/packages/autocomplete/src/lib/utils.js @@ -4,13 +4,36 @@ * @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); - if (selector instanceof HTMLElement) return [selector]; + if (typeof selector === 'string') return Array.from(document.querySelectorAll(selector)); + if (Array.isArray(selector)) return selector; + if (selector instanceof NodeList || selector instanceof HTMLCollection) return Array.from(selector); + if (selector && selector.nodeType === 1) return [selector]; // nodeType check is cross-realm safe, unlike instanceof HTMLElement return []; }; +/* + * Settings that are Booleans in defaults, and so need coercing when they arrive as data-attributes + * (a DOMStringMap value is always a String) - without this, data-async="false" is the truthy + * String 'false'. + */ +export const BOOLEAN_SETTINGS = ['multiple', 'async', 'allowFreeText', 'submitOnConfirm', 'confirmOnBlur', 'clearOnBlur', 'spellcheck']; + +/* + * Coerces the named settings into their intended types. Applied to the fully merged settings, so + * it holds whichever of options / data-attributes won the merge. + * + * @param settings, Object, merged defaults + options + data-attributes + * @param booleans, Array of String, keys to coerce to Boolean + * @param numbers, Array of String, keys to coerce to Number (an invalid value falls back to 0) + * @return Object, settings with the named keys coerced + */ +export const coerceSettings = (settings, { booleans = [], numbers = [] } = {}) => { + const coerced = { ...settings }; + booleans.forEach(key => { coerced[key] = coerced[key] === true || coerced[key] === 'true'; }); + numbers.forEach(key => { const n = Number(coerced[key]); coerced[key] = Number.isFinite(n) ? n : 0; }); + return coerced; +}; + /* * Debounce a function by a configurable delay (settings.debounceDelay, in ms). * The delay is coerced because a data-* attribute always arrives as a string, and @@ -88,7 +111,7 @@ const toOption = option => ({ value: option.value, label: option.textContent.tri export const fromSelect = select => { //skip the placeholder (empty value) and disabled options — a disabled option must //not become a selectable suggestion - const selectableOptions = [...select.options].filter(option => option.value !== '' && !option.disabled); + const selectableOptions = Array.from(select.options).filter(option => option.value !== '' && !option.disabled); return { options: selectableOptions.map(toOption), //an option counts as selected if it's live-selected (.selected — catches a @@ -146,13 +169,4 @@ export const isHtml = value => value instanceof Html; * by mistake (see renderOptions), it renders as text instead. */ export const html = (strings, ...values) => - new Html(strings.reduce((out, string, i) => `${out}${string}${i < values.length ? escapeHtml(values[i]) : ''}`, '')); - -export const isPrintableKeyCode = keyCode => ( - (keyCode > 47 && keyCode < 58) || // number keys - keyCode === 32 || keyCode === 8 || // spacebar or backspace - (keyCode > 64 && keyCode < 91) || // letter keys - (keyCode > 95 && keyCode < 112) || // numpad keys - (keyCode > 185 && keyCode < 193) || // ;=,-./` (in order) - (keyCode > 218 && keyCode < 223) // [\]' (in order) -); \ No newline at end of file + new Html(strings.reduce((out, string, i) => `${out}${string}${i < values.length ? escapeHtml(values[i]) : ''}`, '')); \ No newline at end of file diff --git a/packages/autocomplete/tools/playwright.rspack.config.js b/packages/autocomplete/tools/playwright.rspack.config.js deleted file mode 100644 index acf530ba..00000000 --- a/packages/autocomplete/tools/playwright.rspack.config.js +++ /dev/null @@ -1,11 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - //spread base devServer so mock /api/countries route (setupMiddlewares) - //is available under Playwright too, not just `npm run dev` - devServer: { - ...baseConfig.devServer, - port: 8095 - } -}; diff --git a/packages/autocomplete/tools/rspack.config.js b/packages/autocomplete/tools/rspack.config.js index 43057db2..262faf90 100644 --- a/packages/autocomplete/tools/rspack.config.js +++ b/packages/autocomplete/tools/rspack.config.js @@ -13,7 +13,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8095, + port: 'auto', //mock remote endpoint for async fetch example - same dev server //backs both `npm run dev` and the Playwright run, so this one route //serves the example page and e2e test diff --git a/packages/boilerplate/.npmignore b/packages/boilerplate/.npmignore deleted file mode 100644 index 428a24ee..00000000 --- a/packages/boilerplate/.npmignore +++ /dev/null @@ -1,7 +0,0 @@ -.DS_Store -*.log -src -__tests__ -example -coverage -playwright.config.js diff --git a/packages/boilerplate/README.md b/packages/boilerplate/README.md deleted file mode 100644 index b01a133b..00000000 --- a/packages/boilerplate/README.md +++ /dev/null @@ -1,81 +0,0 @@ - -# Component Name - -This is a **boilerplate for developing UI components** by **Storm Id**. - ---- - -## Usage -Create a container element in HTML -``` -
-``` - -Install the package -``` -npm i -S @stormid/boilerplate -``` - -Import the module -``` -import boilerplate from '@stormid/boilerplate'; - -Initialise the module via selector string -``` -const [ instance ] = boilerplate('.js-boilerplate'); -``` - -Initialise with a DOM element -``` -const element = document.querySelector('.js-boilerplate'); -const [ instance ] = boilerplate(element); -``` - -Initialise with a Node list -``` -const elements = document.querySelectorAll('.js-boilerplate'); -const [ instance ] = boilerplate(elements); -``` - -Initialise with an Array of elements -``` -const elements = [].slice.call(document.querySelectorAll('.js-boilerplate')); -const [ instance ] = boilerplate(elements); -``` - -## Options -``` -{ - callback: null -} -``` - -For example -``` -boilerplate('.js-selector', { - callback(){ - console.log(this); - } -}); -``` - -## API -boilerplate() returns an array of instances. Each instance exposes the interface -``` -{ - node, DOMNode augmented by initialisation - click, trigger the handleClick method -} -``` - -## Tests -``` -npm t -``` - -## Browser support - -## Dependencies - -## License -MIT \ No newline at end of file diff --git a/packages/boilerplate/__tests__/playwright/playwright.spec.js b/packages/boilerplate/__tests__/playwright/playwright.spec.js deleted file mode 100644 index fef4641f..00000000 --- a/packages/boilerplate/__tests__/playwright/playwright.spec.js +++ /dev/null @@ -1,35 +0,0 @@ -const { test, expect } = require('@playwright/test'); -import AxeBuilder from '@axe-core/playwright'; - -test.beforeEach(async ({ page }) => { - await page.goto('/'); -}); - -test.describe('Boilerplate > Functionality', { tag: '@all'}, () => { - test("DOM elements should exist", async ({ page }) => { - await expect(page.locator(".js-boilerplate")).toHaveCount(2); - }); - - test('should attach the click eventListener to DOMElement of each instance with click eventHandler to toggle className', async({ page }) => { - const element = page.locator('.js-boilerplate').first(); - await element.click(); - await expect(element).toHaveClass(/clicked/); - await element.click(); - await expect(element).not.toHaveClass(/clicked/); - }); -}); - -// Add further describe blocks for whichever categories apply to this component, -// following the 'Boilerplate > Category' naming convention, e.g.: -// 'Boilerplate > Keyboard' - keyboard-interaction and focus-management tests -// 'Boilerplate > Aria' - aria attributes staying in sync (aria-controls / aria-expanded etc.) -// Only add what the component actually does. See toggle / modal / tabs for -// interactive examples, or textarea / skip for non-standard categories. - -test.describe('Boilerplate > 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/boilerplate/__tests__/unit/index.test.js b/packages/boilerplate/__tests__/unit/index.test.js deleted file mode 100644 index 732092e1..00000000 --- a/packages/boilerplate/__tests__/unit/index.test.js +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, before } from 'node:test'; -import assert from 'node:assert/strict'; -import component from '../../src/index.js'; -import { getSelection } from '../../src/lib/utils.js'; - -let basic, withCallback; -const init = () => { - // Set up our document body - document.body.innerHTML = `
-
-
-
`; - - basic = component('.js-boilerplate'); - withCallback = component('.js-boilerplate-two', { - callback(){ - this.classList.toggle('callback-test'); - } - }); -}; - -describe(`Boilerplate > Initialisation`, () => { - - before(init); - - it('should return two instances for a selector matching two DOMElements', async () => { - assert.deepStrictEqual(basic.length, 2); - }); - - it('should return undefined if no DOMElements are matched', async () => { - assert.deepStrictEqual(component('.js-unfound'), undefined); - }); - - it('each instances should be an object with DOMElement, settings, and click properties', () => { - assert.notStrictEqual(basic[0], null); - assert.notStrictEqual(basic[0].node, null); - assert.notStrictEqual(basic[0].settings, null); - assert.notStrictEqual(basic[0].click, null); - }); - - it('should initialisation with different settings if different options are passed', () => { - assert.notStrictEqual(basic[0].settings.callback, withCallback[0].settings.callback); - }); - -}); - -describe('Boilerplate > Options', () => { - - it('should be passed in options', () => { - assert.notStrictEqual(withCallback[0].settings.callback, null); - assert.strictEqual(basic[0].settings.callback, null); - }); - - it('should be execute a callback passed in options', () => { - assert.ok(!withCallback[0].node.classList.contains('callback-test')); - withCallback[0].node.click(); - assert.ok(withCallback[0].node.classList.contains('callback-test')); - }); - -}); - -describe('Boilerplate > Initialisation > Get Selection', () => { - - const setupDOM = () => { - document.body.innerHTML = `
`; - } - - before(setupDOM); - - it('should return an array when passed a DOM element', async () => { - const boilerplate = document.querySelector('.js-boilerplate'); - const els = getSelection(boilerplate); - assert.strictEqual(els instanceof Array, true); - assert.deepStrictEqual(els.length, 1); - }); - - it('should return an array when passed a NodeList element', async () => { - const boilerplate = document.querySelectorAll('.js-boilerplate'); - const els = getSelection(boilerplate); - assert.strictEqual(els instanceof Array, true); - assert.deepStrictEqual(els.length, 1); - }); - - it('should return an array when passed an array of DOM elements', async () => { - const boilerplate = document.querySelector('.js-boilerplate'); - const els = getSelection([boilerplate]); - assert.strictEqual(els instanceof Array, true); - assert.deepStrictEqual(els.length, 1); - }); - - it('should return an array when passed a string', async () => { - const els = getSelection('.js-boilerplate'); - assert.strictEqual(els instanceof Array, true); - assert.deepStrictEqual(els.length, 1); - }); - -}); diff --git a/packages/boilerplate/example/src/favicon.ico b/packages/boilerplate/example/src/favicon.ico deleted file mode 100644 index 23d3ceaa..00000000 Binary files a/packages/boilerplate/example/src/favicon.ico and /dev/null differ diff --git a/packages/boilerplate/example/src/index.html b/packages/boilerplate/example/src/index.html deleted file mode 100644 index f1cf57ea..00000000 --- a/packages/boilerplate/example/src/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - StormID - - - - -
-
-
-

Boilerplate example

-
-
-
-
-
- - diff --git a/packages/boilerplate/example/src/js/index.js b/packages/boilerplate/example/src/js/index.js deleted file mode 100644 index f9bec6b0..00000000 --- a/packages/boilerplate/example/src/js/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import boilerplate from '../../../src'; - -window.addEventListener('DOMContentLoaded', () => { - boilerplate('.js-boilerplate'); -}); \ No newline at end of file diff --git a/packages/boilerplate/package.json b/packages/boilerplate/package.json deleted file mode 100644 index 6dddcea5..00000000 --- a/packages/boilerplate/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@stormid/boilerplate", - "version": "1.0.0", - "description": "Boilerplate for developing UI components", - "author": "stormid", - "license": "MIT", - "publishConfig": { - "access": "public" - }, - "repository": "https://github.com/stormid/components/tree/master/packages/boilerplate", - "private": true, - "main": "dist/index.js", - "source": "src/index.js", - "module": "dist/index.modern.js", - "unpkg": "dist/index.umd.js", - "browser": "dist/index.umd.js", - "keywords": [ - "stormid", - "boilerplate", - "component", - "browser" - ], - "scripts": { - "build": "microbundle --name boilerplate", - "dev": "rspack serve --config tools/rspack.config.js", - "prod": "rspack build --config tools/rspack.config.js --mode production", - "prepare": "npm run -s build", - "test": "node --test --experimental-test-coverage --test-coverage-include=\"src/**\" --import ../../tools/test-setup.mjs \"__tests__/unit/*.test.js\" && npx playwright test" - }, - "gitHead": "9042db4005411c4360ba3e8188e03333c10098f2" -} diff --git a/packages/boilerplate/playwright.config.js b/packages/boilerplate/playwright.config.js deleted file mode 100644 index 39f721dc..00000000 --- a/packages/boilerplate/playwright.config.js +++ /dev/null @@ -1,16 +0,0 @@ - -const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.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/boilerplate/src/index.js b/packages/boilerplate/src/index.js deleted file mode 100644 index e78aae13..00000000 --- a/packages/boilerplate/src/index.js +++ /dev/null @@ -1,22 +0,0 @@ -import defaults from './lib/defaults.js'; -import factory from './lib/factory.js'; -import { getSelection } from './lib/utils.js'; - -/* - * Returns an array of objects augmenting DOM elements that match a selector - * - * @param selector, Can be a string, Array of DOM nodes, a NodeList or a single DOM element. - * @params options, Object, to be merged with defaults to become the settings propery of each returned object - */ -export default (selector, options) => { - let nodes = getSelection(selector); - - //no DOM nodes found, return with warning - if (nodes.length === 0) return void console.warn(`boilerplate not initialised, no elements found for selector '${selector}'`); - - //return array of objects, one for each DOM node found - return nodes.map(node => Object.create(factory({ - settings: { ...defaults, ...options }, - node - }))); -}; \ No newline at end of file diff --git a/packages/boilerplate/src/lib/defaults.js b/packages/boilerplate/src/lib/defaults.js deleted file mode 100644 index 33e22866..00000000 --- a/packages/boilerplate/src/lib/defaults.js +++ /dev/null @@ -1,4 +0,0 @@ -/* node:coverage disable */ -export default { - callback: null -}; \ No newline at end of file diff --git a/packages/boilerplate/src/lib/factory.js b/packages/boilerplate/src/lib/factory.js deleted file mode 100644 index d532bd97..00000000 --- a/packages/boilerplate/src/lib/factory.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Creates an eventHandler that is bindable (non-arrow, named function expression), with partially-applied settings - * - * @param settings, Object, merged defaults + options passed in as instantiation config to module default - * @returns handler fn: - * - * @param e, Event, DOM event passed in eventListener - */ -const handleClick = ({ callback }) => function handler(e){ - this.classList.toggle('clicked'); - if (callback && callback.call) callback.call(this); -}; - -/* - * Adds listener to augmented DOM node, returns Object defining instance API - * - * @param settings, Object, merged defaults + options passed in as instantiation config to module default - * @param node, HTMLElement, DOM node to be augmented - */ -export default ({ settings, node }) => { - node.addEventListener('click', handleClick(settings).bind(node), false); - return { settings, node, click: handleClick(settings) }; -}; \ No newline at end of file diff --git a/packages/boilerplate/src/lib/utils.js b/packages/boilerplate/src/lib/utils.js deleted file mode 100644 index 55ac0900..00000000 --- a/packages/boilerplate/src/lib/utils.js +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Converts a passed selector which can be of varying types into an array of DOM Objects - * - * @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); - if (selector instanceof HTMLElement) return [selector]; - return []; -}; \ No newline at end of file diff --git a/packages/boilerplate/tools/playwright.rspack.config.js b/packages/boilerplate/tools/playwright.rspack.config.js deleted file mode 100644 index 78f7b04d..00000000 --- a/packages/boilerplate/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8081 - } -}; diff --git a/packages/boilerplate/tools/rspack.config.js b/packages/boilerplate/tools/rspack.config.js deleted file mode 100644 index ff864c24..00000000 --- a/packages/boilerplate/tools/rspack.config.js +++ /dev/null @@ -1,42 +0,0 @@ -const path = require('path'); -const rspack = require('@rspack/core'); -const pkg = require('../package.json'); - -module.exports = { - entry: './example/src/js/index.js', - output: { - filename: 'app.js', - path: path.resolve(__dirname, '../build'), - clean: true - }, - mode: 'development', - devtool: 'source-map', - devServer: { - port: 8081 - }, - plugins: [ - new rspack.HtmlRspackPlugin({ - title: pkg.name, - template: './example/src/index.html', - filename: 'index.html' - }) - ], - module: { - rules: [{ - test: /\.js$/, - exclude: /(node_modules|bower_components)/, - loader: 'builtin:swc-loader', - options: { - jsc: { - parser: { - syntax: 'ecmascript' - } - } - } - }, - { - test: /\.(ico)$/, - type: 'asset/resource' - }] - } -}; diff --git a/packages/cookie-banner/README.md b/packages/cookie-banner/README.md index c8f1d69a..a7dcc558 100644 --- a/packages/cookie-banner/README.md +++ b/packages/cookie-banner/README.md @@ -97,7 +97,7 @@ const cookieBanner = banner({ I am OK with this @@ -111,7 +111,7 @@ const cookieBanner = banner({ No thank you @@ -139,15 +139,20 @@ euConsentTypes: { } ``` +## Clearing cookies +The banner has no map of which cookies belong to which consent category, so it cannot selectively remove cookies for a single withdrawn category. Cookie clearing therefore happens only on **Reject all**, where withdrawing every category makes a full wipe correct. + +Setting an individual category to "No" via the preferences form records that preference (and stops that category's functions running on the next page load), but does **not** delete cookies that category may already have set in the current session. If you need those removed immediately, reload the page after saving, or listen for the `banner.consent` event and clear the specific cookies yourself. + ## Options Full options that can be passed during initialisation: ``` { name: '.CookiePreferences', //name of the cookie set to record user consent path: '/', //path of the preferences cookie - domain: window.location.hostname === 'localhost' ? '' : `.${removeSubdomain(window.location.hostname)}`, //domain of the preferences cookie, defaults to . + domain: '.', //auto-derived so consent is shared across subdomains ('' host-only on localhost/IP); override to scope it yourself secure: true, //preferences cookie secure - samesite: 'lax', //preferences cookie samesite + samesite: 'strict', //preferences cookie samesite expiry: 365, //preferences cookie expiry in days types: {}, //types of cookie-dependent functionality euConsentTypes: {}, //map Google EU consent categories to types of cookie defined in 'types' @@ -184,6 +189,8 @@ When using the cookie banner, you **must** provide suitable HTML templates for b The templates should take the form of a function which returns a valid HTML string. The functions will be passed a 'model' which will expose the state of the cookie banner to help with rendering. See the example config above as a startring point. +All three templates (`bannerTemplate`, `formTemplate`, `messageTemplate`) receive the same model shape: the settings spread at the top level plus `settings` and `consent`. So both `model.classNames.banner` and `model.settings.classNames.banner` resolve, and `model.consent` is available in every template. + If these functions are not provided, the cookie banner will fail to initialise. ## Utility functions diff --git a/packages/cookie-banner/__tests__/unit/clear-cookies.test.js b/packages/cookie-banner/__tests__/unit/clear-cookies.test.js new file mode 100644 index 00000000..7d0b0bb1 --- /dev/null +++ b/packages/cookie-banner/__tests__/unit/clear-cookies.test.js @@ -0,0 +1,78 @@ +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import cookieBanner from '../../src/index.js'; +import defaults from '../../src/lib/defaults.js'; +import sampleTemplates from '../../example/src/js/sample-templates.js'; + +const types = { + performance: { + title: 'Performance preferences', + description: 'Performance description', + labels: { yes: 'yes', no: 'no' }, + fns: [() => { }] + }, + ads: { + title: 'Ads preferences', + description: 'Ads description', + labels: { yes: 'yes', no: 'no' }, + fns: [() => { }] + } +}; + +const clearAllCookies = () => document.cookie.split('; ').forEach(cookie => { + const name = cookie.split('=')[0]; + if (name) document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`; +}); + +const readCookieValue = name => { + const match = document.cookie.split('; ').find(cookie => cookie.split('=')[0] === name); + return match ? match.slice(match.indexOf('=') + 1) : undefined; +}; + +describe('Cookie banner > cookie clearing', () => { + beforeEach(() => { + clearAllCookies(); + document.body.innerHTML = `
`; + }); + + it('reject-all clears other cookies (deleteCookies runs on reject)', async () => { + cookieBanner({ ...sampleTemplates, secure: false, types }); + document.cookie = 'unrelated=keep; path=/'; + assert.equal(readCookieValue('unrelated'), 'keep'); + + document.querySelector(`.${defaults.classNames.rejectBtn}`).click(); + + assert.equal(readCookieValue('unrelated'), undefined, 'reject should have wiped the unrelated cookie'); + // the consent cookie is re-written after the wipe + assert.equal(readCookieValue(defaults.name), btoa(JSON.stringify({ consent: { performance: 0, ads: 0 } }))); + }); + + it('reject-all re-runs necessary consent fns so essential cookies survive the wipe', async () => { + const necessaryFn = () => { document.cookie = 'essential=1; path=/'; }; + cookieBanner({ ...sampleTemplates, secure: false, types, necessary: [necessaryFn] }); + // the necessary fn runs at init + assert.equal(readCookieValue('essential'), '1'); + + document.querySelector(`.${defaults.classNames.rejectBtn}`).click(); + + // deleteCookies wiped it, but necessary re-ran on reject and recreated it (no reload needed) + assert.equal(readCookieValue('essential'), '1', 'reject must re-run necessary fns to restore essential cookies'); + }); + + it('accept-all leaves other cookies in place (deleteCookies does not run on accept)', async () => { + cookieBanner({ ...sampleTemplates, secure: false, types }); + document.cookie = 'unrelated=keep; path=/'; + assert.equal(readCookieValue('unrelated'), 'keep'); + + document.querySelector(`.${defaults.classNames.acceptBtn}`).click(); + + assert.equal(readCookieValue('unrelated'), 'keep', 'accept should not wipe the unrelated cookie'); + assert.equal(readCookieValue(defaults.name), btoa(JSON.stringify({ consent: { performance: 1, ads: 1 } }))); + }); + + it('renders the banner without throwing when the body has no element children', async () => { + document.body.innerHTML = ''; + assert.doesNotThrow(() => cookieBanner({ ...sampleTemplates, secure: false, types })); + assert.notStrictEqual(document.querySelector(`.${defaults.classNames.banner}`), null); + }); +}); diff --git a/packages/cookie-banner/__tests__/unit/show-banner.test.js b/packages/cookie-banner/__tests__/unit/show-banner.test.js index 6385c05a..32fcbb81 100644 --- a/packages/cookie-banner/__tests__/unit/show-banner.test.js +++ b/packages/cookie-banner/__tests__/unit/show-banner.test.js @@ -67,3 +67,26 @@ describe(`Cookie banner > showBanner > show banner`, () => { }); }); + +describe(`Cookie banner > destroy`, () => { + it('should expose a destroy method that removes the banner and its listeners', () => { + //clear any consent cookie an earlier test wrote, so init shows the banner from a clean slate + document.cookie = `${defaults.name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + init(); + assert.strictEqual(typeof instance.destroy, 'function'); + assert.strictEqual(instance.getState().bannerOpen, true); + + //capture the accept button before destroy detaches it with the banner + const acceptBtn = document.querySelector(`.${defaults.classNames.acceptBtn}`); + assert.notStrictEqual(acceptBtn, null); + + instance.destroy(); + + //banner taken back out of the DOM + assert.strictEqual(document.querySelector(`.${defaults.classNames.banner}`), null); + + //listeners removed: activating the (now detached) accept button runs no accept effects + acceptBtn.click(); + assert.strictEqual(instance.getState().bannerOpen, true); + }); +}); diff --git a/packages/cookie-banner/__tests__/unit/state.test.js b/packages/cookie-banner/__tests__/unit/state.test.js index 9536506b..025d09c2 100644 --- a/packages/cookie-banner/__tests__/unit/state.test.js +++ b/packages/cookie-banner/__tests__/unit/state.test.js @@ -1,9 +1,11 @@ -import { describe, it, before } from 'node:test'; +import { describe, it, before, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import cookieBanner from '../../src/index.js'; import { updateConsent, updateExecuted } from '../../src/lib/reducers.js'; import sampleTemplates from '../../example/src/js/sample-templates.js'; +const domainTypes = { perf: { title: 't', description: 'd', labels: { yes: 'y', no: 'n' }, fns: [() => { }] } }; + const init = () => { // Set up container for form document.body.innerHTML = `
`; @@ -29,11 +31,10 @@ describe(`Cookie banner > state > update/reducers`, () => { }; const Store = cookieBanner({ ...sampleTemplates, types }); - // Jest's toEqual ignored undefined-valued properties; node:assert's - // deepStrictEqual does not. Init runs the `executed` reducer, which sets - // types.test.executed to undefined (no consent yet), so reflect that here. + // Init runs the `executed` reducer, which sets types.test.executed to a boolean derived + // from consent — false here, since there is no consent yet. assert.deepStrictEqual(Store.getState().settings.types, { - test: { ...types.test, executed: undefined } + test: { ...types.test, executed: false } }); }); @@ -148,3 +149,22 @@ describe(`Cookie banner > state > update/reducers`, () => { }); }); }); + +describe(`Cookie banner > state > cookie domain`, () => { + beforeEach(() => { document.body.innerHTML = `
`; }); + + it('derives the domain once into state when not provided (host-only on localhost)', async () => { + const instance = cookieBanner({ ...sampleTemplates, secure: false, types: domainTypes }); + assert.strictEqual(instance.getState().settings.domain, ''); + }); + + it('respects an explicitly provided domain rather than deriving one', async () => { + const instance = cookieBanner({ ...sampleTemplates, secure: false, types: domainTypes, domain: '.example.com' }); + assert.strictEqual(instance.getState().settings.domain, '.example.com'); + }); + + it('treats an explicit empty-string domain as host-only, not as unset', async () => { + const instance = cookieBanner({ ...sampleTemplates, secure: false, types: domainTypes, domain: '' }); + assert.strictEqual(instance.getState().settings.domain, ''); + }); +}); diff --git a/packages/cookie-banner/__tests__/unit/utils.test.js b/packages/cookie-banner/__tests__/unit/utils.test.js index 75f53b5e..d3c4311e 100644 --- a/packages/cookie-banner/__tests__/unit/utils.test.js +++ b/packages/cookie-banner/__tests__/unit/utils.test.js @@ -1,39 +1,65 @@ import { describe, it, mock } from 'node:test'; import assert from 'node:assert/strict'; -import { groupValueReducer, removeSubdomain, extractFromCookie, broadcast, renderIframe, gtmSnippet } from '../../src/lib/utils.js'; +import { groupValueReducer, getRegistrableDomain, readCookie, extractFromCookie, broadcast, renderIframe, gtmSnippet } from '../../src/lib/utils.js'; import defaults from '../../src/lib/defaults.js'; import { EVENTS } from '../../src/lib/constants.js'; import { createStore } from '../../src/lib/store.js'; -describe('Cookie > Utils > removeSubdomain', () => { - it('should return the same vaule for a root domain', async () => { - assert.deepStrictEqual(removeSubdomain('stormid.com'), 'stormid.com'); +describe('Cookie > Utils > getRegistrableDomain', () => { + // Simulate the browser's cookie public-suffix rule: it refuses cookies on ICANN public + // suffixes but ACCEPTS them on PSL private-section hosts (azurewebsites.net, netlify.app). + const PUBLIC_SUFFIXES = new Set(['com', 'net', 'org', 'app', 'io', 'dev', 'uk', 'co.uk']); + const fakeCanSet = candidate => !PUBLIC_SUFFIXES.has(candidate); + + it('should return the same value for a root domain', async () => { + assert.deepStrictEqual(getRegistrableDomain('stormid.com', fakeCanSet), 'stormid.com'); }); it('should remove www from a url', async () => { - assert.deepStrictEqual(removeSubdomain('www.stormid.com'), 'stormid.com'); + assert.deepStrictEqual(getRegistrableDomain('www.stormid.com', fakeCanSet), 'stormid.com'); }); it('should remove sub sub domains from a domain', async () => { - assert.deepStrictEqual(removeSubdomain('test.demo.stormid.com'), 'stormid.com'); + assert.deepStrictEqual(getRegistrableDomain('test.demo.stormid.com', fakeCanSet), 'stormid.com'); }); it('should remove subsub sub domains from a domain', async () => { - assert.deepStrictEqual(removeSubdomain('cookie.test.demo.stormid.com'), 'stormid.com'); + assert.deepStrictEqual(getRegistrableDomain('cookie.test.demo.stormid.com', fakeCanSet), 'stormid.com'); + }); + + it('should handle URLs with multi dot tlds', async () => { + assert.deepStrictEqual(getRegistrableDomain('cookie.test.demo.stormid.co.uk', fakeCanSet), 'stormid.co.uk'); + }); + + it('should narrow back to the app host for azurewebsites.net (private suffix)', async () => { + assert.deepStrictEqual(getRegistrableDomain('cookie-test-wip.azurewebsites.net', fakeCanSet), 'cookie-test-wip.azurewebsites.net'); }); - it('should handle URLs with multi dot tdls', async () => { - assert.deepStrictEqual(removeSubdomain('cookie.test.demo.stormid.co.uk'), 'stormid.co.uk'); + it('should narrow back to the app host for netlify.app (private suffix)', async () => { + assert.deepStrictEqual(getRegistrableDomain('cookie-test-wip.netlify.app', fakeCanSet), 'cookie-test-wip.netlify.app'); }); - it('should handle azurewebsites.net as a multi dot tdl', async () => { - assert.deepStrictEqual(removeSubdomain('cookie-test-wip.azurewebsites.net'), 'cookie-test-wip.azurewebsites.net'); + it('should keep the app host for a subdomain of a private suffix', async () => { + assert.deepStrictEqual(getRegistrableDomain('sub.myapp.azurewebsites.net', fakeCanSet), 'myapp.azurewebsites.net'); }); - it('should handle netlify.app as a multi dot tdl', async () => { - assert.deepStrictEqual(removeSubdomain('cookie-test-wip.netlify.app'), 'cookie-test-wip.netlify.app'); + it('should return an empty string (host-only) for localhost', async () => { + assert.deepStrictEqual(getRegistrableDomain('localhost', fakeCanSet), ''); }); + it('should return an empty string (host-only) for an IP address', async () => { + assert.deepStrictEqual(getRegistrableDomain('192.168.0.1', fakeCanSet), ''); + }); +}); + +describe('Cookie > Utils > readCookie', () => { + it('should preserve base64 "=" padding in the cookie value', async () => { + const value = btoa(JSON.stringify({ consent: { performance: 1, ads: 0 } })); + assert.ok(value.endsWith('='), 'test fixture should carry base64 padding'); + document.cookie = `${defaults.name}=${value}`; + // round-trips to the original JSON despite the '=' padding chars in the value + assert.deepStrictEqual(readCookie(defaults), JSON.stringify({ consent: { performance: 1, ads: 0 } })); + }); }); describe('Cookie > Utils > groupValueReducer', () => { diff --git a/packages/cookie-banner/example/src/js/sample-templates.js b/packages/cookie-banner/example/src/js/sample-templates.js index 8fa77c6c..9b0ee732 100644 --- a/packages/cookie-banner/example/src/js/sample-templates.js +++ b/packages/cookie-banner/example/src/js/sample-templates.js @@ -31,7 +31,7 @@ export default { I am OK with this @@ -45,7 +45,7 @@ export default { No thank you diff --git a/packages/cookie-banner/playwright.config.js b/packages/cookie-banner/playwright.config.js index 39f721dc..6f41eb80 100644 --- a/packages/cookie-banner/playwright.config.js +++ b/packages/cookie-banner/playwright.config.js @@ -1,16 +1,3 @@ - const { defineConfig } = require('@playwright/test'); -const baseConfig = require('../../tools/playwright/config.base.js'); -const server = require('./tools/playwright.rspack.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 +module.exports = defineConfig(require('../../tools/playwright/config.base.js')); diff --git a/packages/cookie-banner/src/lib/consent.js b/packages/cookie-banner/src/lib/consent.js index 867eea05..40881b34 100644 --- a/packages/cookie-banner/src/lib/consent.js +++ b/packages/cookie-banner/src/lib/consent.js @@ -4,7 +4,7 @@ export const applyEffects = state => { Object.keys(state.consent).forEach(key => { if (!state.settings.types[key]) return; if (state.settings.types[key].executed === true) return; - if (state.consent[key] && Boolean(state.consent[key]) && state.settings.types[key].fns) { + if (state.consent[key] && state.settings.types[key].fns) { state.settings.types[key].fns.forEach(fn => fn(state)); } }); @@ -18,7 +18,7 @@ export const apply = store => state => { Object.keys(state.settings.types).reduce((acc, type) => { acc[type] = { ...state.settings.types[type], - executed: state.settings.types[type].executed || (state.consent[type] && Boolean(state.consent[type])) + executed: state.settings.types[type].executed || Boolean(state.consent[type]) }; return acc; }, {}) diff --git a/packages/cookie-banner/src/lib/constants.js b/packages/cookie-banner/src/lib/constants.js index e8c37644..0c3e1098 100755 --- a/packages/cookie-banner/src/lib/constants.js +++ b/packages/cookie-banner/src/lib/constants.js @@ -7,4 +7,8 @@ export const EVENTS = { SHOW: 'banner.show', HIDE: 'banner.hide', CONSENT: 'banner.consent' +}; + +export const KEYS = { + TAB: 'Tab' }; \ No newline at end of file diff --git a/packages/cookie-banner/src/lib/defaults.js b/packages/cookie-banner/src/lib/defaults.js index 6cd9e982..a0052c36 100755 --- a/packages/cookie-banner/src/lib/defaults.js +++ b/packages/cookie-banner/src/lib/defaults.js @@ -1,10 +1,8 @@ /* node:coverage disable */ -import { removeSubdomain } from './utils.js'; - export default { name: '.CookiePreferences', path: '/', - domain: window.location.hostname === 'localhost' ? '' : `.${removeSubdomain(window.location.hostname)}`, + domain: undefined, // auto-derived from the host once at init (see factory), unless overridden secure: true, samesite: 'strict', expiry: 365, @@ -32,8 +30,8 @@ export default { hideBannerOnFormPage: true, trapTab: false, savedMessage: 'Your settings have been saved.', - messageTemplate(model){ - return `
${model.settings.savedMessage}
` + messageTemplate(model){ + return `` }, bannerTemplate: null, formTemplate: null diff --git a/packages/cookie-banner/src/lib/factory.js b/packages/cookie-banner/src/lib/factory.js index c1733731..e5c0048b 100644 --- a/packages/cookie-banner/src/lib/factory.js +++ b/packages/cookie-banner/src/lib/factory.js @@ -1,4 +1,4 @@ -import { cookiesEnabled, extractFromCookie, renderIframe, gtmSnippet, setGoogleConsent } from './utils.js'; +import { cookiesEnabled, extractFromCookie, renderIframe, gtmSnippet, setGoogleConsent, getRegistrableDomain } from './utils.js'; import { showBanner, initBanner, initForm, initBannerListeners, keyListener } from './ui.js'; import { necessary, apply } from './consent.js'; import { createStore } from './store.js'; @@ -10,15 +10,28 @@ export default settings => { console.warn('Missing required cookie banner and/or preferences form markup. Cookie banner not initialised.'); return; } + + // Derive the cookie domain once, here rather than at import: the probe runs only when a banner + // is actually initialised, and the result is memoised in state.settings.domain for reuse. + if (settings.domain === undefined) { + const registrable = getRegistrableDomain(); + settings.domain = registrable ? `.${registrable}` : ''; + } + const store = createStore(); - + const [ hasCookie, consent ] = extractFromCookie(settings); - + + //every listener this instance adds (banner buttons, the document Tab trap, the form's + //submit/change) is bound to this signal, so destroy() can remove them all in one call + const controller = new AbortController(); + store.update( { settings, bannerOpen: false, keyListener: keyListener(store), + controller, consent, utils: { renderIframe, gtmSnippet } }, @@ -36,9 +49,16 @@ export default settings => { return { getState: store.getState, showBanner(cb) { + if (store.getState().bannerOpen) return; showBanner(store)(cb); initBannerListeners(store)(); }, - renderForm: initForm(store) + renderForm: initForm(store), + //remove every listener this instance added and take the banner back out of the DOM + destroy() { + const state = store.getState(); + state.controller.abort(); + if (state.banner && state.banner.parentNode) state.banner.parentNode.removeChild(state.banner); + } }; }; \ No newline at end of file diff --git a/packages/cookie-banner/src/lib/ui.js b/packages/cookie-banner/src/lib/ui.js index 2b55a634..7e929055 100644 --- a/packages/cookie-banner/src/lib/ui.js +++ b/packages/cookie-banner/src/lib/ui.js @@ -1,12 +1,25 @@ import { writeCookie, groupValueReducer, deleteCookies, getFocusableChildren, broadcast, setGoogleConsent } from './utils.js'; -import { ACCEPTED_TRIGGERS, EVENTS } from './constants.js'; -import { apply } from './consent.js'; +import { ACCEPTED_TRIGGERS, EVENTS, KEYS } from './constants.js'; +import { apply, necessary } from './consent.js'; import { updateConsent, updateBannerOpen, updateBanner } from './reducers.js'; +// Every template (banner, form, message) receives the same model: the full state, with the +// settings object also spread at the top level. So `model.classNames`/`model.policyURL` resolve +// (settings spread), `model.settings.*`/`model.consent` resolve, and any top-level state field the +// form/message templates previously received (they were passed raw state) is still present. +const templateModel = state => Object.assign({}, state, state.settings, { + settings: state.settings, + consent: state.consent +}); + export const initBanner = store => () => { const state = store.getState(); if (state.bannerOpen || (state.settings.hideBannerOnFormPage && document.querySelector(`.${state.settings.classNames.formContainer}`))) return; - document.body.firstElementChild.insertAdjacentHTML('beforebegin', state.settings.bannerTemplate(state.settings)); + const markup = state.settings.bannerTemplate(templateModel(state)); + // firstElementChild is null on a page whose body has no element children — fall back to + // inserting the banner as the body's first child rather than throwing. + if (document.body.firstElementChild) document.body.firstElementChild.insertAdjacentHTML('beforebegin', markup); + else document.body.insertAdjacentHTML('afterbegin', markup); store.update( updateBanner(state, { @@ -51,10 +64,13 @@ export const initBannerListeners = store => () => { }; }; - const acceptBtns = [].slice.call(document.querySelectorAll(composeSelector(state.settings.classNames.acceptBtn))); - const rejectBtns = [].slice.call(document.querySelectorAll(composeSelector(state.settings.classNames.rejectBtn))); + const acceptBtns = Array.from(document.querySelectorAll(composeSelector(state.settings.classNames.acceptBtn))); + const rejectBtns = Array.from(document.querySelectorAll(composeSelector(state.settings.classNames.rejectBtn))); + + //bind to the instance's abort signal so destroy() removes these along with every other listener + const signal = state.controller && state.controller.signal; - if (state.settings.trapTab) document.addEventListener('keydown', state.keyListener); + if (state.settings.trapTab) document.addEventListener('keydown', state.keyListener, { signal }); acceptBtns.forEach(acceptBtn => { acceptBtn.addEventListener('click', e => { @@ -63,7 +79,6 @@ export const initBannerListeners = store => () => { store.update( updateConsent(state, consentObject), [ - deleteCookies, writeCookie, apply(store), removeBanner(store), @@ -76,7 +91,7 @@ export const initBannerListeners = store => () => { setGoogleConsent(store), ] ); - }); + }, { signal }); }); rejectBtns.forEach(rejectBtn => { @@ -86,6 +101,11 @@ export const initBannerListeners = store => () => { store.update( updateConsent(state, consentObject), [ + // Reject-all is the only path that clears cookies: withdrawing ALL consent is the + // one case where a blunt wipe is correct. Re-run the strictly-necessary consent fns + // afterwards so essential cookies the wipe removed are recreated without a reload. + deleteCookies, + necessary, writeCookie, removeBanner(store), initForm(store), @@ -97,7 +117,7 @@ export const initBannerListeners = store => () => { setGoogleConsent(store), ] ); - }); + }, { signal }); }); }; @@ -115,7 +135,7 @@ const trapTab = state => event => { }; export const keyListener = store => event => { - if (store.getState().banner && event.keyCode === 9) trapTab(store.getState())(event); + if (store.getState().banner && event.key === KEYS.TAB) trapTab(store.getState())(event); }; const removeBanner = store => () => { @@ -142,18 +162,25 @@ export const initForm = store => () => { const formContainer = document.querySelector(`.${state.settings.classNames.formContainer}`); if (!formContainer) return; - formContainer.innerHTML = state.settings.formTemplate(suggestedConsent(state)); + formContainer.innerHTML = state.settings.formTemplate(templateModel(suggestedConsent(state))); const form = document.querySelector(`.${state.settings.classNames.form}`); const button = document.querySelector(`.${state.settings.classNames.submitBtn}`); - const groups = [].slice.call(document.querySelectorAll(`.${state.settings.classNames.field}`)).reduce((groups, field) => { - const groupName = field.getAttribute('name').replace('privacy-', ''); + const groups = Array.from(document.querySelectorAll(`.${state.settings.classNames.field}`)).reduce((groups, field) => { + const groupName = field.getAttribute('name').replace(/^privacy-/, ''); if (groups[groupName]) groups[groupName].push(field); else groups[groupName] = [field]; return groups; }, {}); - const formAnnouncement = document.querySelector(`.${state.settings.classNames.formAnnouncement}`) - || document.body.appendChild(Object.assign(document.createElement('div'), { className: state.settings.classNames.formAnnouncement, role: 'alert' })); + let formAnnouncement = document.querySelector(`.${state.settings.classNames.formAnnouncement}`); + if (!formAnnouncement) { + formAnnouncement = document.createElement('div'); + formAnnouncement.className = state.settings.classNames.formAnnouncement; + //setAttribute reflects to the role attribute in every browser; the el.role IDL property + //(ARIAMixin) is unsupported in older ones, leaving the live region unannounced + formAnnouncement.setAttribute('role', 'alert'); + document.body.appendChild(formAnnouncement); + } const extractConsentObjects = () => { @@ -174,13 +201,16 @@ export const initForm = store => () => { }; }; + //bind to the instance's abort signal so destroy() removes these along with every other listener + const signal = state.controller && state.controller.signal; + const enableButton = e => { if (Object.keys(extractConsentObjects().consentObject).length !== Object.keys(groups).length) return; button.removeAttribute('disabled'); form.removeEventListener('change', enableButton); }; - button.hasAttribute('disabled') && form.addEventListener('change', enableButton); - + button.hasAttribute('disabled') && form.addEventListener('change', enableButton, { signal }); + form.addEventListener('submit', event => { event.preventDefault(); const { consentObject, analyticsObject } = extractConsentObjects(); @@ -188,7 +218,6 @@ export const initForm = store => () => { store.update( updateConsent(state, consentObject), [ - deleteCookies, writeCookie, apply(store), removeBanner(store), @@ -202,7 +231,7 @@ export const initForm = store => () => { setGoogleConsent(store), ] ); - }); + }, { signal }); if (window.location.hash.substring(1) === form.id) { window.scrollTo(0, form.getBoundingClientRect().top + window.scrollY); @@ -210,7 +239,7 @@ export const initForm = store => () => { }; export const renderMessage = button => state => { - button.insertAdjacentHTML('afterend', state.settings.messageTemplate(state)); + button.insertAdjacentHTML('afterend', state.settings.messageTemplate(templateModel(state))); button.setAttribute('disabled', 'disabled'); /* node:coverage ignore next */ window.setTimeout(() => { diff --git a/packages/cookie-banner/src/lib/utils.js b/packages/cookie-banner/src/lib/utils.js index 0252aef5..701a18e9 100755 --- a/packages/cookie-banner/src/lib/utils.js +++ b/packages/cookie-banner/src/lib/utils.js @@ -15,7 +15,7 @@ export const cookiesEnabled = () => { export const writeCookie = state => { document.cookie = [ `${state.settings.name}=${btoa(JSON.stringify({ consent: state.consent }))};`, - `expires=${(new Date(new Date().getTime() + (state.settings.expiry*24*60*60*1000))).toGMTString()};`, + `expires=${(new Date(new Date().getTime() + (state.settings.expiry*24*60*60*1000))).toUTCString()};`, state.settings.path ? `path=${state.settings.path};` : '', state.settings.domain ? `domain=${state.settings.domain};` : '', state.settings.samesite ? `SameSite=${state.settings.samesite};` : '', @@ -24,13 +24,10 @@ export const writeCookie = state => { }; export const readCookie = settings => { - const cookies = document.cookie.split('; '); - for (let n = 0; n <= cookies.length; n++) { - if (!cookies[n]) return false; - const [ name, value ] = cookies[n].split('='); - if (name === settings.name) return window.atob(value); - } - return false; + const match = document.cookie.split('; ').find(cookie => cookie.split('=')[0] === settings.name); + if (!match) return false; + // slice from the first '=' rather than split('=')[1]: the base64 value can contain '=' padding. + return window.atob(match.slice(match.indexOf('=') + 1)); }; const updateCookie = (state, cookie) => document.cookie = [ @@ -43,14 +40,17 @@ const updateCookie = (state, cookie) => document.cookie = [ ].join(''); export const deleteCookies = state => { + //no cookies: ''.split('; ') yields [''], which would expire a nameless "=undefined" cookie + if (!document.cookie) return; document.cookie .split('; ') + .filter(part => part.split('=')[0]) //skip any malformed, nameless entry .map(part => ({ name: part.split('=')[0], value: part.split('=')[1], expiry: 'Thu, 01 Jan 1970 00:00:01 GMT' })) - .map(cookie => updateCookie(state, cookie)); + .forEach(cookie => updateCookie(state, cookie)); }; export const extractFromCookie = settings => { @@ -87,25 +87,67 @@ export const groupValueReducer = (acc, input) => { return acc; }; -const firstTLDs = 'ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|be|bf|bg|bh|bi|bj|bm|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|cl|cm|cn|co|cr|cu|cv|cw|cx|cz|de|dj|dk|dm|do|dz|ec|ee|eg|es|et|eu|fi|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|im|in|io|iq|ir|is|it|je|jo|jp|kg|ki|km|kn|kp|kr|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|na|nc|ne|nf|ng|nl|no|nr|nu|nz|om|pa|pe|pf|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|yt'.split('|'); -const secondTLDs = 'netlify|azurewebsites|com|edu|gov|net|mil|org|nom|sch|caa|res|off|gob|int|tur|ip6|uri|urn|asn|act|nsw|qld|tas|vic|pro|biz|adm|adv|agr|arq|art|ato|bio|bmd|cim|cng|cnt|ecn|eco|emp|eng|esp|etc|eti|far|fnd|fot|fst|g12|ggf|imb|ind|inf|jor|jus|leg|lel|mat|med|mus|not|ntr|odo|ppg|psc|psi|qsl|rec|slg|srv|teo|tmp|trd|vet|zlg|web|ltd|sld|pol|fin|k12|lib|pri|aip|fie|eun|sci|prd|cci|pvt|mod|idv|rel|sex|gen|nic|abr|bas|cal|cam|emr|fvg|laz|lig|lom|mar|mol|pmn|pug|sar|sic|taa|tos|umb|vao|vda|ven|mie|北海道|和歌山|神奈川|鹿児島|ass|rep|tra|per|ngo|soc|grp|plc|its|air|and|bus|can|ddr|jfk|mad|nrw|nyc|ski|spy|tcm|ulm|usa|war|fhs|vgs|dep|eid|fet|fla|flå|gol|hof|hol|sel|vik|cri|iwi|ing|abo|fam|gok|gon|gop|gos|aid|atm|gsm|sos|elk|waw|est|aca|bar|cpa|jur|law|sec|plo|www|bir|cbg|jar|khv|msk|nov|nsk|ptz|rnd|spb|stv|tom|tsk|udm|vrn|cmw|kms|nkz|snz|pub|fhv|red|ens|nat|rns|rnu|bbs|tel|bel|kep|nhs|dni|fed|isa|nsn|gub|e12|tec|орг|обр|упр|alt|nis|jpn|mex|ath|iki|nid|gda|inc'.split('|'); +// PSL private-section suffixes: shared hosting domains where each subdomain is a separate site. +// Browsers do NOT block cookies on these (the cookie public-suffix rule uses the PSL's ICANN +// section only), so the domain probe below would over-broaden to the shared suffix and leak the +// consent cookie across every tenant. Narrowing back to the app host prevents that. Intentionally +// short — add hosting suffixes as needed. +export const PRIVATE_SUFFIXES = [ + 'azurewebsites.net', 'netlify.app', 'netlify.com', 'github.io', 'gitlab.io', + 'herokuapp.com', 'pages.dev', 'vercel.app', 'web.app', 'firebaseapp.com', 'appspot.com' +]; + +// Probe whether the browser will set a cookie scoped to `.candidate`. The browser refuses cookies +// on ICANN public suffixes (e.g. co.uk), so the broadest candidate that "sticks" is the +// registrable domain — no maintained TLD list required. +const canSetCookieOnDomain = candidate => { + const probe = '__cb_tld_probe'; + document.cookie = `${probe}=1; domain=.${candidate}; path=/; SameSite=Lax`; + const ok = document.cookie.indexOf(`${probe}=`) !== -1; + document.cookie = `${probe}=; domain=.${candidate}; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`; + return ok; +}; -export const removeSubdomain = s => { - s = s.replace(/^www\./, ''); - let parts = s.split('.'); - - while (parts.length > 3) { - parts.shift(); - } +/** + * Registrable domain for the preferences cookie, so consent is shared across subdomains. + * + * @param hostname [String] host to derive from (defaults to the current location) + * @param canSet [Function] predicate testing whether the browser accepts a cookie on `.candidate` + * (injectable so the resolution logic is unit-testable without the PSL) + * + * @returns [String] registrable domain, or '' for localhost/IP/failure (a safe host-only default) + */ +export const getRegistrableDomain = (hostname = window.location.hostname, canSet = canSetCookieOnDomain) => { + try { + if (hostname === 'localhost' || /^\d+(\.\d+){3}$/.test(hostname)) return ''; + const labels = hostname.replace(/^www\./, '').split('.'); + if (labels.length < 2) return ''; + + let registrable = ''; + // Broadest (2 labels) → narrowest; first candidate the browser accepts wins. + for (let k = 2; k <= labels.length; k++) { + const candidate = labels.slice(-k).join('.'); + if (canSet(candidate)) { + registrable = candidate; + break; + } + } + if (!registrable) return ''; - if (parts.length === 3 && ((secondTLDs.indexOf(parts[1]) === -1) && firstTLDs.indexOf(parts[2]) === -1)) { - parts.shift(); - } + // If the browser accepted a known shared-hosting suffix, narrow to the app host so consent + // is not shared across tenants. + if (PRIVATE_SUFFIXES.indexOf(registrable) !== -1) { + const extra = labels.length - registrable.split('.').length - 1; + return extra < 0 ? '' : labels.slice(extra).join('.'); + } - return parts.join('.'); + return registrable; + } catch { + return ''; + } }; -export const getFocusableChildren = node => [].slice.call(node.querySelectorAll(FOCUSABLE_ELEMENTS.join(','))).filter(el => el.offsetWidth > 0 || el.offsetHeight > 0); +export const getFocusableChildren = node => Array.from(node.querySelectorAll(FOCUSABLE_ELEMENTS.join(','))).filter(el => el.offsetWidth > 0 || el.offsetHeight > 0); export const broadcast = (type, store) => () => { const event = new CustomEvent(type, { @@ -118,7 +160,7 @@ export const broadcast = (type, store) => () => { }; export const renderIframe = () => { - [].slice.call(document.querySelectorAll('[data-iframe-src]')).forEach(node => { + Array.from(document.querySelectorAll('[data-iframe-src]')).forEach(node => { const iframe = document.createElement('iframe'); iframe.src = node.getAttribute('data-iframe-src'); if (node.hasAttribute('data-iframe-height')) iframe.style.height = node.getAttribute('data-iframe-height'); diff --git a/packages/cookie-banner/tools/playwright.rspack.config.js b/packages/cookie-banner/tools/playwright.rspack.config.js deleted file mode 100644 index 3a593117..00000000 --- a/packages/cookie-banner/tools/playwright.rspack.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const baseConfig = require('./rspack.config'); - -module.exports = { - ...baseConfig, - devServer: { - port: 8091 - } -}; diff --git a/packages/cookie-banner/tools/rspack.config.js b/packages/cookie-banner/tools/rspack.config.js index 8b879763..cafed110 100644 --- a/packages/cookie-banner/tools/rspack.config.js +++ b/packages/cookie-banner/tools/rspack.config.js @@ -12,7 +12,7 @@ module.exports = { mode: 'development', devtool: 'source-map', devServer: { - port: 8081 + port: 'auto' }, plugins: [ new rspack.HtmlRspackPlugin({ diff --git a/packages/modal-gallery/.npmignore b/packages/modal-gallery/.npmignore index 401eecba..b31ecff8 100644 --- a/packages/modal-gallery/.npmignore +++ b/packages/modal-gallery/.npmignore @@ -3,4 +3,6 @@ __tests__ example coverage +test-results +tools playwright.config.js diff --git a/packages/modal-gallery/README.md b/packages/modal-gallery/README.md index ec6cddc1..9d1f0311 100644 --- a/packages/modal-gallery/README.md +++ b/packages/modal-gallery/README.md @@ -29,7 +29,7 @@ Initialise the module ``` import modalGallery from '@stormid/modal-gallery'; -const [ gallery ] = modalGallery('.js-modal-gallery'); +const gallery = modalGallery('.js-modal-gallery'); ``` Example MVP CSS @@ -113,7 +113,7 @@ To create from a JavaScript Object ``` import modalGallery from '@stormid/modal-gallery'; -const [ gallery ] = modalGallery([ +const gallery = modalGallery([ { src: 'https://placehold.co/500x500', srcset:'https://placehold.co/800x800 800w, https://placehold.co/500x500 320w', @@ -141,19 +141,29 @@ document.querySelector('.js-modal-gallery__trigger').addEventListener('click', ( totals: true, //show totals scrollable: false, //modal is scrollable single: false, //images should display in separate modals rather than grouped into a navigable gallery + lockScroll: true, //prevent the page behind the modal from scrolling while it is open + inertBackground: true, //mark the rest of the page inert while open, so it is unreachable by keyboard and assistive tech + headingLevel: 'h2', //heading level (h1–h6) used for image titles in the default template templates: { // see src/lib/defaults/templates.js overlay, overlayInner, buttons, - item, - details + item, // item(items) => (details, index) => string + details // details(item, headingLevel) => string } } ``` ## API -Initialisation returns an array of instances. Each instance exposes the interface +By default (a grouped gallery) initialisation returns a single instance. With `{ single: true }` each +image becomes its own modal, so initialisation returns an array of instances instead: +``` +const gallery = modalGallery('.js-modal-gallery'); // one instance +const galleries = modalGallery('.js-modal-gallery', { single: true }); // array of instances +``` + +Each instance exposes the interface ``` { getState, a Function that returns the current state Object diff --git a/packages/modal-gallery/__tests__/playwright/playwright.spec.js b/packages/modal-gallery/__tests__/playwright/playwright.spec.js index 96b302d2..b8982026 100644 --- a/packages/modal-gallery/__tests__/playwright/playwright.spec.js +++ b/packages/modal-gallery/__tests__/playwright/playwright.spec.js @@ -74,6 +74,52 @@ test.describe('Modal gallery > Aria', { tag: '@all' }, () => { }); }); +test.describe('Modal gallery > Images', { tag: '@all' }, () => { + test('should render the current image with the srcset from its data-srcset', async ({ page }) => { + await openGallery(page); + const img = page.locator('.js-modal-gallery__item.is--active .modal-gallery__img'); + await expect(img).toHaveAttribute('srcset', /placehold\.co/); + }); + + test('should re-render the image after closing and reopening', async ({ page }) => { + await openGallery(page); + await expect(page.locator('.js-modal-gallery__item.is--active .modal-gallery__img')).toHaveAttribute('src', /placehold\.co/); + await page.locator('.js-modal-gallery__close').click(); + await expect(page.locator('.js-modal-gallery__outer')).toHaveCount(0); + await openGallery(page); + await expect(page.locator('.js-modal-gallery__item.is--active .modal-gallery__img')).toHaveAttribute('src', /placehold\.co/); + }); +}); + +test.describe('Modal gallery > Modal behaviour', { tag: '@all' }, () => { + test('should move focus to the close button when opened', async ({ page }) => { + await openGallery(page); + await expect(page.locator('.js-modal-gallery__close')).toBeFocused(); + }); + + test('should make the page behind the modal inert while open and restore it on close', async ({ page }) => { + await openGallery(page); + await expect(page.locator('main')).toHaveAttribute('inert', ''); + await page.locator('.js-modal-gallery__close').click(); + await expect(page.locator('.js-modal-gallery__outer')).toHaveCount(0); + await expect(page.locator('main')).not.toHaveAttribute('inert', ''); + }); + + test('should lock body scroll while open and restore it on close', async ({ page }) => { + await openGallery(page); + await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe('hidden'); + await page.locator('.js-modal-gallery__close').click(); + await expect(page.locator('.js-modal-gallery__outer')).toHaveCount(0); + await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe(''); + }); + + test('should return focus to the trigger after closing', async ({ page }) => { + await openGallery(page); + await page.keyboard.press('Escape'); + await expect(page.locator('.js-modal-gallery').first()).toBeFocused(); + }); +}); + test.describe('Modal gallery > Axe', { tag: '@reduced' }, () => { test('Should not have any automatically detectable accessibility issues on load', async ({ page }) => { const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); diff --git a/packages/modal-gallery/__tests__/unit/accessibility.test.js b/packages/modal-gallery/__tests__/unit/accessibility.test.js index 41bb4acf..4eecabfd 100644 --- a/packages/modal-gallery/__tests__/unit/accessibility.test.js +++ b/packages/modal-gallery/__tests__/unit/accessibility.test.js @@ -1,7 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import modalGallery from '../../src/index.js'; -import { KEY_CODES } from '../../src/lib/constants.js'; +import { KEYS } from '../../src/lib/constants.js'; describe(`Modal Gallery > accessibility > keyboard > escape`, () => { @@ -25,7 +25,7 @@ describe(`Modal Gallery > accessibility > keyboard > escape`, () => { const Gallery = modalGallery('.js-modal-gallery'); Gallery.getState().items[0].trigger.click(); assert.deepStrictEqual(Gallery.getState().isOpen, true); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.ESC, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.ESC, bubbles: true })); assert.deepStrictEqual(Gallery.getState().isOpen, false); }); @@ -53,7 +53,7 @@ describe(`Modal Gallery > accessibility > keyboard > left`, () => { const Gallery = modalGallery('.js-modal-gallery'); Gallery.getState().items[1].trigger.click(); assert.deepStrictEqual(Gallery.getState().current, 1); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.LEFT, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.LEFT, bubbles: true })); assert.deepStrictEqual(Gallery.getState().current, 0); }); @@ -62,7 +62,7 @@ describe(`Modal Gallery > accessibility > keyboard > left`, () => { describe(`Modal Gallery > accessibility > keyboard > right`, () => { - it('should navigate to the previous item', () => { + it('should navigate to the next item', () => { document.body.innerHTML = ` @@ -82,7 +82,7 @@ describe(`Modal Gallery > accessibility > keyboard > right`, () => { const Gallery = modalGallery('.js-modal-gallery'); Gallery.getState().items[0].trigger.click(); assert.deepStrictEqual(Gallery.getState().current, 0); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.RIGHT, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.RIGHT, bubbles: true })); assert.deepStrictEqual(Gallery.getState().current, 1); }); @@ -91,7 +91,7 @@ describe(`Modal Gallery > accessibility > keyboard > right`, () => { describe(`Modal Gallery > accessibility > keyboard > tab`, () => { - it('should navigate to the previous item', () => { + it('should trap focus within the modal', () => { document.body.innerHTML = ` @@ -114,13 +114,13 @@ describe(`Modal Gallery > accessibility > keyboard > tab`, () => { // expect(document.activeElement).toEqual(Gallery.getState().dom.focusableChildren[0]); //urgh, JSDOM doesn't dig document.activeElement //checking the test coverage it doesa appear that the correct items are being focused and tab is trapped - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.TAB, bubbles: true })); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.TAB, bubbles: true })); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.TAB, bubbles: true })); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.TAB, bubbles: true })); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.TAB, bubbles: true })); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: KEY_CODES.TAB, bubbles: true })); - document.dispatchEvent(new window.KeyboardEvent('keydown', { shiftKey: true, keyCode: KEY_CODES.TAB, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.TAB, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.TAB, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.TAB, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.TAB, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.TAB, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: KEYS.TAB, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { shiftKey: true, key: KEYS.TAB, bubbles: true })); // expect(document.activeElement).toEqual(Gallery.getState().dom.focusableChildren[0]); }); @@ -130,7 +130,7 @@ describe(`Modal Gallery > accessibility > keyboard > tab`, () => { describe(`Modal Gallery > accessibility > keyboard`, () => { - it('should navigate to the previous item', () => { + it('should ignore unhandled keys', () => { document.body.innerHTML = ` @@ -150,7 +150,7 @@ describe(`Modal Gallery > accessibility > keyboard`, () => { const Gallery = modalGallery('.js-modal-gallery'); Gallery.getState().items[0].trigger.click(); assert.deepStrictEqual(Gallery.getState().current, 0); - document.dispatchEvent(new window.KeyboardEvent('keydown', { keyCode: 83, bubbles: true })); + document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 's', bubbles: true })); assert.deepStrictEqual(Gallery.getState().current, 0); }); diff --git a/packages/modal-gallery/__tests__/unit/destroy.test.js b/packages/modal-gallery/__tests__/unit/destroy.test.js new file mode 100644 index 00000000..2d4368b2 --- /dev/null +++ b/packages/modal-gallery/__tests__/unit/destroy.test.js @@ -0,0 +1,37 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import modalGallery from '../../src/index.js'; + +const markup = ` + +`; + +describe('Modal Gallery > API > destroy', () => { + + it('should expose a destroy method', () => { + document.body.innerHTML = markup; + const gallery = modalGallery('.js-modal-gallery'); + assert.strictEqual(typeof gallery.destroy, 'function'); + }); + + it('should close an open gallery, remove the overlay, and detach trigger listeners', () => { + document.body.innerHTML = markup; + const gallery = modalGallery('.js-modal-gallery'); + const trigger = gallery.getState().items[0].trigger; + + //open via the trigger + trigger.click(); + assert.strictEqual(gallery.getState().isOpen, true); + + gallery.destroy(); + + //closed and overlay removed from the DOM + assert.strictEqual(gallery.getState().isOpen, false); + assert.strictEqual(document.querySelector('.js-modal-gallery__inner'), null); + + //trigger listener detached: clicking again does not reopen + trigger.click(); + assert.strictEqual(gallery.getState().isOpen, false); + }); + +}); diff --git a/packages/modal-gallery/__tests__/unit/dom.test.js b/packages/modal-gallery/__tests__/unit/dom.test.js index 9be1a482..436fd54f 100644 --- a/packages/modal-gallery/__tests__/unit/dom.test.js +++ b/packages/modal-gallery/__tests__/unit/dom.test.js @@ -229,3 +229,139 @@ describe(`Modal Gallery > accessibility > buttons`, () => { }); }); + +describe(`Modal Gallery > DOM > image rendering`, () => { + + it('should render srcset and sizes from the item model onto the image', () => { + document.body.innerHTML = ``; + const Store = createStore(); + Store.update({ + ...Store.getState(), + isOpen: true, + current: 0, + settings: defaults, + imageCache: [{}], // one cached entry === one item, so load() paints from cache + items: [{ + src: 'https://placehold.co/500x500', + title: 'Image 1', + srcset: 'https://placehold.co/800x800 800w, https://placehold.co/500x500 320w', + sizes: '(max-width: 320px) 280px, 800px' + }], + keyListener: mock.fn() + }); + + initUI(Store)(Store.getState()); + const img = document.querySelector('.modal-gallery__img'); + assert.notStrictEqual(img, null); + assert.strictEqual(img.getAttribute('srcset'), 'https://placehold.co/800x800 800w, https://placehold.co/500x500 320w'); + assert.strictEqual(img.getAttribute('sizes'), '(max-width: 320px) 280px, 800px'); + }); + + it('should escape attribute values so a crafted title cannot inject markup', () => { + document.body.innerHTML = ``; + const Store = createStore(); + Store.update({ + ...Store.getState(), + isOpen: true, + current: 0, + settings: defaults, + imageCache: [{}], + items: [{ src: 'https://placehold.co/500x500', title: '" onerror="alert(1)', srcset: null, sizes: null }], + keyListener: mock.fn() + }); + + initUI(Store)(Store.getState()); + const img = document.querySelector('.modal-gallery__img'); + assert.notStrictEqual(img, null); + assert.strictEqual(img.hasAttribute('onerror'), false); + assert.strictEqual(img.getAttribute('alt'), '" onerror="alert(1)'); + }); + + it('should paint cached slides that have no img yet (reopen / preload scenario)', () => { + document.body.innerHTML = ``; + const Store = createStore(); + const imageCache = []; + imageCache[0] = {}; + imageCache[2] = {}; // indices 0 and 2 cached, 1 not — so load() falls through to loadImages() + Store.update({ + ...Store.getState(), + isOpen: true, + current: 0, + settings: defaults, + imageCache, + items: [ + { src: 'https://placehold.co/1', title: 'A' }, + { src: 'https://placehold.co/2', title: 'B' }, + { src: 'https://placehold.co/3', title: 'C' } + ], + keyListener: mock.fn() + }); + + initUI(Store)(Store.getState()); + assert.notStrictEqual(Store.getState().dom.items[0].querySelector('.modal-gallery__img'), null); + assert.notStrictEqual(Store.getState().dom.items[2].querySelector('.modal-gallery__img'), null); + }); + +}); + +describe(`Modal Gallery > DOM > modal background`, () => { + + const setup = () => { + document.body.innerHTML = `
background content
`; + document.body.style.overflow = ''; + const Store = createStore(); + Store.update({ + ...Store.getState(), + isOpen: true, + current: 0, + settings: defaults, + imageCache: [], + items: [{ src: 'https://placehold.co/500x500', title: 'A' }], + keyListener: mock.fn() + }); + return Store; + }; + + it('should lock body scroll on open and restore it on close', () => { + const Store = setup(); + initUI(Store)(Store.getState()); + assert.strictEqual(document.body.style.overflow, 'hidden'); + close(Store); + assert.strictEqual(document.body.style.overflow, ''); + }); + + it('should mark background siblings inert on open and clear them on close', () => { + const Store = setup(); + initUI(Store)(Store.getState()); + assert.strictEqual(document.getElementById('bg').hasAttribute('inert'), true); + close(Store); + assert.strictEqual(document.getElementById('bg').hasAttribute('inert'), false); + }); + +}); + +describe(`Modal Gallery > DOM > status region`, () => { + + it('should announce the current position and title, and update on navigation', () => { + document.body.innerHTML = ``; + const Store = createStore(); + Store.update({ + ...Store.getState(), + isOpen: true, + current: 0, + settings: defaults, + imageCache: [], + items: [ + { src: 'https://placehold.co/1', title: 'A' }, + { src: 'https://placehold.co/2', title: 'B' } + ], + keyListener: mock.fn() + }); + + initUI(Store)(Store.getState()); + assert.strictEqual(Store.getState().dom.status.textContent, 'Image 1 of 2, A'); + next(Store); + assert.strictEqual(Store.getState().dom.status.textContent, 'Image 2 of 2, B'); + }); + +}); diff --git a/packages/modal-gallery/__tests__/unit/templates.test.js b/packages/modal-gallery/__tests__/unit/templates.test.js new file mode 100644 index 00000000..d746b024 --- /dev/null +++ b/packages/modal-gallery/__tests__/unit/templates.test.js @@ -0,0 +1,35 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { details, item } from '../../src/lib/defaults/templates.js'; + +describe('Modal Gallery > Templates > details', () => { + + it('should default the image title to an h2 heading', () => { + assert.match(details({ title: 'Title', description: '' }), /