diff --git a/src/govuk/all.mjs b/src/govuk/all.mjs index 8b7523476f..ee9c1d352b 100644 --- a/src/govuk/all.mjs +++ b/src/govuk/all.mjs @@ -20,6 +20,7 @@ import Tabs from './components/tabs/tabs.mjs' * @param {Object} [config] * @param {HTMLElement} [config.scope=document] - scope to query for components * @param {Object} [config.accordion] - accordion config + * @param {Object} [config.button] - button config * @param {Object} [config.notificationBanner] - notification banner config */ function initAll (config) { @@ -36,7 +37,7 @@ function initAll (config) { var $buttons = $scope.querySelectorAll('[data-module="govuk-button"]') nodeListForEach($buttons, function ($button) { - new Button($button).init() + new Button($button, config.button).init() }) var $characterCounts = $scope.querySelectorAll('[data-module="govuk-character-count"]') diff --git a/src/govuk/components/button/button.mjs b/src/govuk/components/button/button.mjs index 8bc8da0d0f..623061625e 100644 --- a/src/govuk/components/button/button.mjs +++ b/src/govuk/components/button/button.mjs @@ -1,20 +1,46 @@ +import { mergeConfigs, normaliseDataset } from '../../common.mjs' import '../../vendor/polyfills/Event.mjs' // addEventListener and event.target normaliziation import '../../vendor/polyfills/Function/prototype/bind.mjs' var KEY_SPACE = 32 var DEBOUNCE_TIMEOUT_IN_SECONDS = 1 -function Button ($module) { +/** + * JavaScript enhancements for the Button component + * + * @class + * @param {HTMLElement} $module - The element this component controls + * @param {Object} config + * @param {Boolean} [config.preventDoubleClick=false] - Whether the button should prevent double clicks + */ +function Button ($module, config) { + if (!$module) { + return this + } + this.$module = $module this.debounceFormSubmitTimer = null + + var defaultConfig = { + preventDoubleClick: false + } + this.config = mergeConfigs( + defaultConfig, + config || {}, + normaliseDataset($module.dataset) + ) } /** * Initialise component */ Button.prototype.init = function () { + if (!this.$module) { + return + } + this.$module.addEventListener('keydown', this.handleKeyDown) - this.$module.addEventListener('click', this.debounce) + this.$module.addEventListener('click', this.debounce.bind(this)) } /** @@ -46,10 +72,8 @@ Button.prototype.handleKeyDown = function (event) { * @param {MouseEvent} event */ Button.prototype.debounce = function (event) { - var target = event.target - // Check the button that was clicked has preventDoubleClick enabled - if (target.getAttribute('data-prevent-double-click') !== 'true') { + if (!this.config.preventDoubleClick) { return } diff --git a/src/govuk/components/button/button.test.js b/src/govuk/components/button/button.test.js index e7c4c2ed91..c591ce6454 100644 --- a/src/govuk/components/button/button.test.js +++ b/src/govuk/components/button/button.test.js @@ -1,6 +1,9 @@ /** * @jest-environment puppeteer */ +const { renderAndInitialise, getExamples } = require('../../../../lib/jest-helpers') + +const examples = getExamples('button') const configPaths = require('../../../../config/paths.js') const PORT = configPaths.ports.test @@ -8,6 +11,23 @@ const PORT = configPaths.ports.test const baseUrl = 'http://localhost:' + PORT describe('/components/button', () => { + describe('mis-instantiation', () => { + it('does not prevent further JavaScript from running', async () => { + await page.goto(`${baseUrl}/tests/boilerplate`, { waitUntil: 'load' }) + + const result = await page.evaluate(() => { + // `undefined` simulates the element being missing, + // from an unchecked `document.querySelector` for example + new window.GOVUKFrontend.Button(undefined).init() + + // If our component initialisation breaks, this won't run + return true + }) + + expect(result).toBe(true) + }) + }) + describe('/components/button/link', () => { it('triggers the click event when the space key is pressed', async () => { await page.goto(baseUrl + '/components/button/link/preview', { waitUntil: 'load' }) @@ -27,112 +47,240 @@ describe('/components/button', () => { const url = await page.url() expect(url).toBe(baseUrl + href) }) - describe('preventing double clicks', () => { - it('prevents unintentional submissions when in a form', async () => { - await page.goto(baseUrl + '/components/button/prevent-double-click/preview', { waitUntil: 'load' }) + }) - // Our examples don't have form wrappers so we need to overwrite it. - await page.evaluate(() => { - const $button = document.querySelector('button') - const $form = document.createElement('form') - $button.parentNode.appendChild($form) - $button.parentNode.removeChild($button) - $form.appendChild($button) - - window.__SUBMIT_EVENTS = 0 - $form.addEventListener('submit', event => { - window.__SUBMIT_EVENTS++ - // Don't refresh the page, which will destroy the context to test against. - event.preventDefault() - }) + describe('preventing double clicks', () => { + // Click counting is done through using the button to submit + // a form and counting submissions. It requires some bits of recurring + // logic which are wrapped in the following helpers + + /** + * Wraps the button rendered on the page in a form + * + * Examples don't do this and we need it to have something to submit + */ + async function trackClicks () { + page.evaluate(() => { + const $button = document.querySelector('button') + const $form = document.createElement('form') + $button.parentNode.appendChild($form) + $form.appendChild($button) + + window.__SUBMIT_EVENTS = 0 + $form.addEventListener('submit', (event) => { + window.__SUBMIT_EVENTS++ + // Don't refresh the page, which will destroy the context to test against. + event.preventDefault() + }) + }) + } + + /** + * Gets the number of times the form was submitted + * + * @returns {Number} + */ + function getClicksCount () { + return page.evaluate(() => window.__SUBMIT_EVENTS) + } + + describe('not enabled', () => { + it('does not prevent multiple submissions', async () => { + await page.goto(baseUrl + '/components/button/preview', { + waitUntil: 'load' }) + await trackClicks() + await page.click('button') await page.click('button') - const submitCount = await page.evaluate(() => window.__SUBMIT_EVENTS) + const clicksCount = await getClicksCount() - expect(submitCount).toBe(1) + expect(clicksCount).toBe(2) }) + }) + + describe('using data-attributes', () => { + it('prevents unintentional submissions when in a form', async () => { + await page.goto( + baseUrl + '/components/button/prevent-double-click/preview', + { waitUntil: 'load' } + ) + + await trackClicks() + + await page.click('button') + await page.click('button') + + const clicksCount = await getClicksCount() + + expect(clicksCount).toBe(1) + }) + it('does not prevent intentional multiple clicks', async () => { - await page.goto(baseUrl + '/components/button/prevent-double-click/preview', { waitUntil: 'load' }) + await page.goto( + baseUrl + '/components/button/prevent-double-click/preview', + { waitUntil: 'load' } + ) - // Our examples don't have form wrappers so we need to overwrite it. + await trackClicks() + + await page.click('button') + await page.click('button', { delay: 1000 }) + + const clicksCount = await getClicksCount() + + expect(clicksCount).toBe(2) + }) + + it('does not prevent subsequent clicks on different buttons', async () => { + await page.goto( + baseUrl + '/components/button/prevent-double-click/preview', + { waitUntil: 'load' } + ) + + await trackClicks() + + // Clone button to have two buttons on the page await page.evaluate(() => { const $button = document.querySelector('button') - const $form = document.createElement('form') - $button.parentNode.appendChild($form) - $button.parentNode.removeChild($button) - $form.appendChild($button) - - window.__SUBMIT_EVENTS = 0 - $form.addEventListener('submit', event => { - window.__SUBMIT_EVENTS++ - // Don't refresh the page, which will destroy the context to test against. - event.preventDefault() - }) + const $secondButton = $button.cloneNode(true) + + document.querySelector('form').appendChild($secondButton) + }) + + await page.click('button:nth-child(1)') + await page.click('button:nth-child(2)') + + const clicksCount = await getClicksCount() + + expect(clicksCount).toBe(2) + }) + }) + + describe('using JavaScript configuration', () => { + // To ensure + beforeEach(async () => { + await renderAndInitialise('button', { + baseUrl, + nunjucksParams: examples.default, + javascriptConfig: { + preventDoubleClick: true + } }) + await trackClicks() + }) + + it('prevents unintentional submissions when in a form', async () => { + await page.click('button') + await page.click('button') + + const clicksCount = await getClicksCount() + + expect(clicksCount).toBe(1) + }) + + it('does not prevent intentional multiple clicks', async () => { await page.click('button') await page.click('button', { delay: 1000 }) - const submitCount = await page.evaluate(() => window.__SUBMIT_EVENTS) + const clicksCount = await getClicksCount() - expect(submitCount).toBe(2) + expect(clicksCount).toBe(2) }) - it('does not prevent multiple submissions when feature is not enabled', async () => { - await page.goto(baseUrl + '/components/button/preview', { waitUntil: 'load' }) - // Our examples don't have form wrappers so we need to overwrite it. + it('does not prevent subsequent clicks on different buttons', async () => { + // Clone button to have two buttons on the page await page.evaluate(() => { const $button = document.querySelector('button') - const $form = document.createElement('form') - $button.parentNode.appendChild($form) - $button.parentNode.removeChild($button) - $form.appendChild($button) - - window.__SUBMIT_EVENTS = 0 - $form.addEventListener('submit', event => { - window.__SUBMIT_EVENTS++ - // Don't refresh the page, which will destroy the context to test against. - event.preventDefault() - }) + const $secondButton = $button.cloneNode(true) + + document.querySelector('form').appendChild($secondButton) + }) + + await page.click('button:nth-child(1)') + await page.click('button:nth-child(2)') + + const clicksCount = await getClicksCount() + + expect(clicksCount).toBe(2) + }) + }) + + describe('using JavaScript configuration, but cancelled by data-attributes', () => { + it('does not prevent multiple submissions', async () => { + await renderAndInitialise('button', { + baseUrl, + nunjucksParams: examples["don't prevent double click"], + javascriptConfig: { + preventDoubleClick: true + } }) + await trackClicks() + await page.click('button') await page.click('button') - const submitCount = await page.evaluate(() => window.__SUBMIT_EVENTS) + const clicksCount = await getClicksCount() - expect(submitCount).toBe(2) + expect(clicksCount).toBe(2) }) - it('does not prevent subsequent clicks on different buttons', async () => { - await page.goto(baseUrl + '/components/button/prevent-double-click/preview', { waitUntil: 'load' }) + }) + + describe('using `initAll`', () => { + // To ensure + beforeEach(async () => { + await renderAndInitialise('button', { + baseUrl, + nunjucksParams: examples.default, + initialiser () { + window.GOVUKFrontend.initAll({ + button: { + preventDoubleClick: true + } + }) + } + }) + + await trackClicks() + }) + + it('prevents unintentional submissions when in a form', async () => { + await page.click('button') + await page.click('button') + + const clicksCount = await getClicksCount() - // Our examples don't have form wrappers so we need to overwrite it. + expect(clicksCount).toBe(1) + }) + + it('does not prevent intentional multiple clicks', async () => { + await page.click('button') + await page.click('button', { delay: 1000 }) + + const clicksCount = await getClicksCount() + + expect(clicksCount).toBe(2) + }) + + it('does not prevent subsequent clicks on different buttons', async () => { + // Clone button to have two buttons on the page await page.evaluate(() => { const $button = document.querySelector('button') - const $buttonPrime = $button.cloneNode() - const $form = document.createElement('form') - $button.parentNode.appendChild($form) - $button.parentNode.removeChild($button) - $form.appendChild($button) - $form.appendChild($buttonPrime) - - window.__SUBMIT_EVENTS = 0 - $form.addEventListener('submit', event => { - window.__SUBMIT_EVENTS++ - // Don't refresh the page, which will destroy the context to test against. - event.preventDefault() - }) + const $secondButton = $button.cloneNode(true) + + document.querySelector('form').appendChild($secondButton) }) await page.click('button:nth-child(1)') await page.click('button:nth-child(2)') - const submitCount = await page.evaluate(() => window.__SUBMIT_EVENTS) + const clicksCount = await getClicksCount() - expect(submitCount).toBe(2) + expect(clicksCount).toBe(2) }) }) }) diff --git a/src/govuk/components/button/button.yaml b/src/govuk/components/button/button.yaml index 7a5a51c556..8dd103b5df 100644 --- a/src/govuk/components/button/button.yaml +++ b/src/govuk/components/button/button.yaml @@ -233,4 +233,13 @@ examples: hidden: true data: text: Button! +- name: no data-prevent-double-click + hidden: true + data: + text: Submit +- name: don't prevent double click + hidden: true + data: + text: Submit + preventDoubleClick: false diff --git a/src/govuk/components/button/template.njk b/src/govuk/components/button/template.njk index b0717a29fe..aecc8aafd9 100644 --- a/src/govuk/components/button/template.njk +++ b/src/govuk/components/button/template.njk @@ -37,7 +37,7 @@ treat it as an interactive element - without this it will be {#- Define common attributes we can use for both button and input types #} -{%- set buttonAttributes %}{% if params.name %} name="{{ params.name }}"{% endif %}{% if params.disabled %} disabled="disabled" aria-disabled="true"{% endif %}{% if params.preventDoubleClick %} data-prevent-double-click="true"{% endif %}{% endset %} +{%- set buttonAttributes %}{% if params.name %} name="{{ params.name }}"{% endif %}{% if params.disabled %} disabled="disabled" aria-disabled="true"{% endif %}{% if params.preventDoubleClick !== undefined %} data-prevent-double-click="{{params.preventDoubleClick}}"{% endif %}{% endset %} {#- Actually create a button... or a link! #} diff --git a/src/govuk/components/button/template.test.js b/src/govuk/components/button/template.test.js index 246899d06d..54d6477c4a 100644 --- a/src/govuk/components/button/template.test.js +++ b/src/govuk/components/button/template.test.js @@ -77,11 +77,27 @@ describe('Button', () => { expect($component.html()).toContain('Start now') }) - it('renders with preventDoubleClick attribute', () => { - const $ = render('button', examples['prevent double click']) + describe('preventDoubleClick', () => { + it('does not render the attribute if not set', () => { + const $ = render('button', examples['no data-prevent-double-click']) - const $component = $('.govuk-button') - expect($component.attr('data-prevent-double-click')).toEqual('true') + const $component = $('.govuk-button') + expect($component.attr('data-prevent-double-click')).toBeUndefined() + }) + + it('renders with preventDoubleClick attribute set to true', () => { + const $ = render('button', examples['prevent double click']) + + const $component = $('.govuk-button') + expect($component.attr('data-prevent-double-click')).toEqual('true') + }) + + it('renders with preventDoubleClick attribute set to false', () => { + const $ = render('button', examples["don't prevent double click"]) + + const $component = $('.govuk-button') + expect($component.attr('data-prevent-double-click')).toEqual('false') + }) }) })