diff --git a/lib/browser-tests/puppeteer-helpers.js b/lib/browser-tests/puppeteer-helpers.js index f8c83db3..6fa5e2a4 100644 --- a/lib/browser-tests/puppeteer-helpers.js +++ b/lib/browser-tests/puppeteer-helpers.js @@ -41,6 +41,7 @@ export function withHmrcStylesAndScripts(body) { + ${preloadGovukFonts} @@ -49,6 +50,7 @@ export function withHmrcStylesAndScripts(body) { ${body} + `; diff --git a/lib/jest-helpers.js b/lib/jest-helpers.js index f844807c..e1e298f1 100644 --- a/lib/jest-helpers.js +++ b/lib/jest-helpers.js @@ -22,9 +22,9 @@ nunjucks.configure( * @param {string} componentName * @param {string} params parameters that are used in the component macro * @param {any} children any child components or text, pass the children to the macro - * @returns {function} returns cheerio (jQuery) instance of the macro for easy DOM querying + * @returns {string} returns html */ -function render(componentName, params, children = false) { +function renderString(componentName, params, children = false) { if (typeof params === 'undefined') { throw new Error('Parameters passed to `render` should be an object but are undefined'); } @@ -42,9 +42,18 @@ function render(componentName, params, children = false) { macroString += `{{- ${macroName}(${macroParams}) -}}`; } - const output = nunjucks.renderString(macroString); + return nunjucks.renderString(macroString); +} - return cheerio.load(output); +/** + * Render a component's macro for testing + * @param {string} componentName + * @param {string} params parameters that are used in the component macro + * @param {any} children any child components or text, pass the children to the macro + * @returns {function} returns cheerio (jQuery) instance of the macro for easy DOM querying + */ +function render(componentName, params, children = false) { + return cheerio.load(renderString(componentName, params, children)); } /** @@ -87,4 +96,6 @@ function htmlWithClassName($, className) { return $.html($component); } -module.exports = { render, getExamples, htmlWithClassName }; +module.exports = { + renderString, render, getExamples, htmlWithClassName, +}; diff --git a/src/components/accessible-autocomplete/__tests__/2024-12-adams-polyfill.js.txt b/src/components/accessible-autocomplete/__tests__/2024-12-adams-polyfill.js.txt new file mode 100644 index 00000000..ad41ccc3 --- /dev/null +++ b/src/components/accessible-autocomplete/__tests__/2024-12-adams-polyfill.js.txt @@ -0,0 +1,119 @@ +// Copied from https://gist.github.com/adamliptrot-oc/f7cbb92f040082cc17cff27416ae348b +// Not intended for use by teams, fixes for the following are now built into hmrc-frontend +// This is included here for use during automated testing to check that our fixes don't +// conflict with the polyfill if teams are still using it when they upgrade to a new version +// of hmrc-frontend which has the fixes built in. +// --------------------------- + +// Note - updated to work with the HMRC Frontend implementation +// https://github.com/hmrc/play-frontend-hmrc#adding-accessible-autocomplete-css-and-javascript + +if (typeof HMRCAccessibleAutocomplete != 'undefined' && document.querySelector('[data-module="hmrc-accessible-autocomplete"]') != null) { + var originalSelect = document.querySelector('[data-module="hmrc-accessible-autocomplete"]'); + // load autocomplete - now handled by the HMRC component wrapper in Twirl + // accessibleAutocomplete.enhanceSelectElement({ + // selectElement: originalSelect, + // showAllValues: true + // }); + + // ===================================================== + // Polyfill autocomplete once loaded + // ===================================================== + var checkForLoad = setInterval(checkForAutocompleteLoad, 50); + var parentForm = upTo(originalSelect, 'form'); + + function polyfillAutocomplete(){ + var combo = parentForm.querySelector('[role="combobox"]'); + + // ===================================================== + // Update autocomplete once loaded with fallback's aria attributes + // Ensures hint and error are read out before usage instructions + // ===================================================== + if(originalSelect && originalSelect.getAttribute('aria-describedby') > ""){ + if(parentForm){ + if(combo){ + combo.setAttribute('aria-describedby', originalSelect.getAttribute('aria-describedby') + ' ' + combo.getAttribute('aria-describedby')); + } + } + } + // ===================================================== + // Update autocomplete once loaded with error styling if needed + // This won't work if the autocomplete css is loaded after the frontend library css because + // the autocomplete's border will override the error class's border (they are both the same specificity) + // but we can use the class assigned to build a more specific rule + // ===================================================== + setErrorClass(); + function setErrorClass(){ + if(originalSelect && originalSelect.classList.contains("govuk-select--error")){ + if(parentForm){ + if(combo){ + combo.classList.add("govuk-input--error"); + // Also set up an event listener to check for changes to input so we know when to repeat the copy + combo.addEventListener('focus', function(){setErrorClass()}); + combo.addEventListener('blur', function(){setErrorClass()}); + combo.addEventListener('change', function(){setErrorClass()}); + } + } + } + } + + // ===================================================== + // Ensure when user replaces valid answer with a non-valid answer, then valid answer is not retained + // ===================================================== + var holdSubmit = true; + parentForm.addEventListener('submit', function(e){ + if(holdSubmit){ + e.preventDefault() + if(originalSelect.querySelectorAll('[selected]').length > 0 || originalSelect.value > ""){ + + var resetSelect = false; + + if(originalSelect.value){ + if(combo.value != originalSelect.querySelector('option[value="' + originalSelect.value +'"]').text){ + resetSelect = true; + } + } + if(resetSelect){ + originalSelect.value = ""; + if(originalSelect.querySelectorAll('[selected]').length > 0){ + originalSelect.querySelectorAll('[selected]')[0].removeAttribute('selected'); + } + } + } + + holdSubmit = false; + //parentForm.submit(); + HTMLFormElement.prototype.submit.call(parentForm); // because submit buttons have id of "submit" which masks the form's natural form.submit() function + } + }) + + } + function checkForAutocompleteLoad(){ + if(parentForm.querySelector('[role="combobox"]')){ + clearInterval(checkForLoad) + polyfillAutocomplete(); + } + } + + +} + + +// Find first ancestor of el with tagName +// or undefined if not found +function upTo(el, tagName) { + tagName = tagName.toLowerCase(); + + while (el && el.parentNode) { + el = el.parentNode; + if (el.tagName && el.tagName.toLowerCase() == tagName) { + return el; + } + } + + // Many DOM methods return null if they don't + // find the element they are searching for + // It would be OK to omit the following and just + // return undefined + return null; + } diff --git a/src/components/accessible-autocomplete/accessible-autocomplete.browser.test.js b/src/components/accessible-autocomplete/accessible-autocomplete.browser.test.js new file mode 100644 index 00000000..30af1fc2 --- /dev/null +++ b/src/components/accessible-autocomplete/accessible-autocomplete.browser.test.js @@ -0,0 +1,345 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { + delay, + render, + withHmrcStylesAndScripts, +} from '../../../lib/browser-tests/puppeteer-helpers'; + +import { renderString as renderComponent } from '../../../lib/jest-helpers'; + +const adamsPolyfill = readFileSync(path.join(__dirname, '__tests__', '2024-12-adams-polyfill.js.txt'), 'utf8'); + +function withGovukSelect(params) { + return withHmrcStylesAndScripts(`
${ + // wrapped in form because adam's polyfill needs select to be in a form + // submit button precedes select because when it follows it, trying to + // click it when suggestions are showing causes it to move when they + // collapse and that then makes the click miss the button + renderComponent('govuk/components/select', params) + }
`); +} + +function acceptFirstSuggestionFor(autocompleteSelector) { + return page.$eval(`${autocompleteSelector} ~ ul[role="listbox"] li:nth-child(1)`, (firstSuggestion) => firstSuggestion.click()); +} + +async function interceptNextFormPost(page) { + let completePostedFormData; + const postedFormData = ( + new Promise((resolve) => { completePostedFormData = resolve; }) + ).finally(async () => { + await page.setRequestInterception(false); + }); + await page.setRequestInterception(true); + page.on('request', (request) => { + if (request.method() === 'POST') { + completePostedFormData(request.postData()); + return request.respond({ status: 200 }); + } + return request.continue(); + }); + return { + postedFormData, + }; +} + +describe('Patched accessible autocomplete', () => { + it.failing('should announce the hint and error message linked to the underlying select', async () => { + await render(page, withGovukSelect({ + id: 'location', + name: 'location', + attributes: { + 'data-module': 'hmrc-accessible-autocomplete', + }, + label: { + text: 'Choose location', + }, + errorMessage: { + text: 'You must choose a location', + }, + hint: { + text: 'This can be different to where you went before', + }, + items: [ + { + value: ' ', + text: 'Choose location', + }, + // omitted other options for brevity of test + ], + })); + + const element = await page.$('#location'); + const tagName = await element.evaluate((el) => el.tagName.toLowerCase()); + const ariaDescribedBy = await element.evaluate((el) => el.getAttribute('aria-describedby')); + + expect(tagName).not.toBe('select'); // or select element was not enhanced to be an autocomplete component + expect(ariaDescribedBy).toBe('location-hint location-error location__assistiveHint'); + }); + + it.failing('should inherit the error state of the underlying select', async () => { + await render(page, withGovukSelect({ + id: 'location', + name: 'location', + attributes: { + 'data-module': 'hmrc-accessible-autocomplete', + }, + label: { + text: 'Choose location', + }, + errorMessage: { + text: 'You must choose a location', + }, + hint: { + text: 'This can be different to where you went before', + }, + items: [ + { + value: ' ', + text: 'Choose location', + }, + // omitted other options for brevity of test + ], + })); + + const element = await page.$('#location'); + const tagName = await element.evaluate((el) => el.tagName.toLowerCase()); + const borderColor = await element.evaluate((el) => getComputedStyle(el).getPropertyValue('border-color')); + + expect(tagName).not.toBe('select'); // or select element was not enhanced to be an autocomplete component + expect(borderColor).toBe('rgb(212, 53, 28)'); + }); + + it.failing('should not retain the previous selection if an invalid option is entered', async () => { + await render(page, withGovukSelect({ + id: 'location', + name: 'location', + attributes: { + 'data-module': 'hmrc-accessible-autocomplete', + 'data-auto-select': 'false', + // this is the default, but included to be explicit with test state + // auto select would mean that you don't have to enter exactly matching + // text to select on blur + }, + label: { + text: 'Choose location', + }, + items: [ + { + value: ' ', + text: 'Choose location', + }, + { + value: 'london', + text: 'London', + }, + { + value: 'southwest', + text: 'South West', + }, + ], + })); + + await expect(page).toFill('#location', 'Lon'); + await acceptFirstSuggestionFor('#location'); + expect(await page.$eval('select', (select) => select.value)).toBe('london'); + await expect(page).toFill('#location', 'South'); + await page.$eval('#location', (input) => input.blur()); + expect(await page.$eval('select', (select) => select.value)).toBe(''); + }); + + it.failing('should select any option with exactly matching text on blur, even if it was not chosen from the suggestions', async () => { + await render(page, withGovukSelect({ + id: 'location', + name: 'location', + attributes: { + 'data-module': 'hmrc-accessible-autocomplete', + 'data-auto-select': 'false', + // this is the default, but included to be explicit with test state + // auto select would mean that you don't have to enter exactly matching + // text to select on blur + }, + label: { + text: 'Choose location', + }, + items: [ + { + value: ' ', + text: 'Choose location', + }, + { + value: 'london', + text: 'London', + }, + { + value: 'southwest', + text: 'South West', + }, + ], + })); + + await expect(page).toFill('#location', 'Lon'); + await acceptFirstSuggestionFor('#location'); + expect(await page.$eval('select', (select) => select.value)).toBe('london'); + await expect(page).toFill('#location', 'South West'); + await page.$eval('#location', (input) => input.blur()); + expect(await page.$eval('select', (select) => select.value)).toBe('southwest'); + }); + + it('should select the currently highlighted option on blur if interacting using keyboard', async () => { + await render(page, withGovukSelect({ + id: 'location', + name: 'location', + attributes: { + 'data-module': 'hmrc-accessible-autocomplete', + 'data-auto-select': 'false', + }, + label: { + text: 'Choose location', + }, + items: [ + { + value: ' ', + text: 'Choose location', + }, + { + value: 'london', + text: 'London', + }, + { + value: 'southeast', + text: 'South East', + }, + { + value: 'southwest', + text: 'South West', + }, + ], + })); + + await expect(page).toFill('#location', 'Lon'); + await acceptFirstSuggestionFor('#location'); + expect(await page.$eval('select', (select) => select.value)).toBe('london'); + await expect(page).toFill('#location', 'South'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Escape'); + expect(await page.$eval('select', (select) => select.value)).toBe('southwest'); + }); + + describe('when page is still using adams polyfill', () => { + it('should not have duplicated the links to the hint and error message of the underlying select', async () => { + await render(page, withGovukSelect({ + id: 'location', + name: 'location', + attributes: { + 'data-module': 'hmrc-accessible-autocomplete', + }, + label: { + text: 'Choose location', + }, + errorMessage: { + text: 'You must choose a location', + }, + hint: { + text: 'This can be different to where you went before', + }, + items: [ + { + value: ' ', + text: 'Choose location', + }, + // omitted other options for brevity of test + ], + })); + + await page.evaluate(adamsPolyfill); + await delay(100); // because it takes ~50ms for adam's polyfill to apply + + const element = await page.$('#location'); + const tagName = await element.evaluate((el) => el.tagName.toLowerCase()); + const ariaDescribedBy = await element.evaluate((el) => el.getAttribute('aria-describedby')); + + expect(tagName).not.toBe('select'); // or select element was not enhanced to be an autocomplete component + expect(ariaDescribedBy).toBe('location-hint location-error location__assistiveHint'); + }); + + it('should not prevent form data being submitted', async () => { + await render(page, withGovukSelect({ + id: 'location', + name: 'location', + attributes: { + 'data-module': 'hmrc-accessible-autocomplete', + }, + label: { + text: 'Choose location', + }, + errorMessage: { + text: 'You must choose a location', + }, + hint: { + text: 'This can be different to where you went before', + }, + items: [ + { + value: ' ', + text: 'Choose location', + }, + { + value: 'london', + text: 'London', + }, + ], + })); + + await page.evaluate(adamsPolyfill); + await delay(100); // because it takes ~50ms for adam's polyfill to apply + + await expect(page).toFill('#location', 'Lon'); + await acceptFirstSuggestionFor('#location'); + const { postedFormData } = await interceptNextFormPost(page); + await page.click('button[type="submit"]'); + await expect(postedFormData).resolves.toBe('location=london'); + }); + + it('should still not retain previous selection when an invalid option is entered', async () => { + await render(page, withGovukSelect({ + id: 'location', + name: 'location', + attributes: { + 'data-module': 'hmrc-accessible-autocomplete', + }, + label: { + text: 'Choose location', + }, + errorMessage: { + text: 'You must choose a location', + }, + hint: { + text: 'This can be different to where you went before', + }, + items: [ + { + value: ' ', + text: 'Choose location', + }, + { + value: 'london', + text: 'London', + }, + ], + })); + + await page.evaluate(adamsPolyfill); + await delay(100); // because it takes ~50ms for adam's polyfill to apply + + await expect(page).toFill('#location', 'Lon'); + await acceptFirstSuggestionFor('#location'); + const { postedFormData } = await interceptNextFormPost(page); + await expect(page).toFill('#location', 'South'); + await page.click('button[type="submit"]'); + await expect(postedFormData).resolves.toBe(undefined); + }); + }); +});