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/src/accessible-autocomplete.scss b/src/accessible-autocomplete.scss index 0fa2efa3..cb923d58 100644 --- a/src/accessible-autocomplete.scss +++ b/src/accessible-autocomplete.scss @@ -11,3 +11,20 @@ $govuk-include-default-font-face: false; .autocomplete__hint { @include govuk-font($size: 19); } + +.autocomplete__dropdown-arrow-down { + z-index: 0; + pointer-events: none; +} + +// the following is a bit more targeted, wouldn't work in ie11, and might be a bit brittle + +.govuk-form-group--error div:has(+ .govuk-select--error[data-module='hmrc-accessible-autocomplete']) > .autocomplete__wrapper .autocomplete__input { + border-color: #d4351c; +} + +// the following is more compatible, and probably unlikely to style unintended stuff + +//.govuk-form-group--error .autocomplete__wrapper .autocomplete__input { +// border-color: #d4351c; +//} diff --git a/src/components/accessible-autocomplete/accessibility-patches.browser.test.js b/src/components/accessible-autocomplete/accessibility-patches.browser.test.js new file mode 100644 index 00000000..a407c931 --- /dev/null +++ b/src/components/accessible-autocomplete/accessibility-patches.browser.test.js @@ -0,0 +1,271 @@ +import { + delay, + render, + withHmrcStylesAndScripts, +} from '../../../lib/browser-tests/puppeteer-helpers'; + +const adamsPolyfill = ` +// 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; +} +`; + +describe('Patched accessible autocomplete', () => { + describe('original select has aria-describedby links (for example for an error and/or hint)', () => { + it('should prepend them to its own aria-describedby, so that the hint and error will be announced', async () => { + await render(page, withHmrcStylesAndScripts(` +
+ +
+ This can be different to where you went before +
+

+ Error: Select a location +

+ +
+ `)); + + 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 be possible for them to be added twice if page is still using adams patch', async () => { + await render(page, withHmrcStylesAndScripts(` +
+
+ +
+ This can be different to where you went before +
+

+ Error: Select a location +

+ +
+
+ `)); + + 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'); + }); + }); + describe('original select has an error', () => { + it('should have the border colour of a gov.uk input with errors', async () => { + await render(page, withHmrcStylesAndScripts(` +
+ +
+ This can be different to where you went before +
+

+ Error: Select a location +

+ +
+ `)); + + 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')); + + // await jestPuppeteer.debug(); + + expect(tagName).not.toBe('select'); // or select element was not enhanced to be an autocomplete component + expect(borderColor).toBe('rgb(212, 53, 28)'); + }); + }); + it('should not retain previous valid selection if an option that does not exist is entered', async () => { + await render(page, withHmrcStylesAndScripts(` +
+ +
+ This can be different to where you went before +
+

+ Error: Select a location +

+ +
+ `)); + + await expect(page).toFill('#location', 'Lon'); + await page.$eval('#location + ul li:nth-child(1)', (firstAutocompleteSuggestion) => firstAutocompleteSuggestion.click()); + expect(await page.$eval('select', (select) => select.value)).toBe('london'); + await expect(page).toFill('#location', 'Bristol'); + await page.$eval('#location', (input) => input.blur()); // simulate clicking out of field + expect(await page.$eval('select', (select) => select.value)).toBe(''); + }); +}); diff --git a/src/components/accessible-autocomplete/accessible-autocomplete.js b/src/components/accessible-autocomplete/accessible-autocomplete.js index 676d8185..19af9d7f 100644 --- a/src/components/accessible-autocomplete/accessible-autocomplete.js +++ b/src/components/accessible-autocomplete/accessible-autocomplete.js @@ -6,20 +6,32 @@ function AccessibleAutoComplete($module, window, document) { AccessibleAutoComplete.prototype.init = function init() { if (this.$module) { + const selectElement = this.$module; + const selectOptions = Array.from(selectElement.options); const showAllValues = (this.$module.getAttribute('data-show-all-values') === 'true'); const autoselect = (this.$module.getAttribute('data-auto-select') === 'true'); const defaultValue = this.$module.getAttribute('data-default-value'); const minLength = this.$module.getAttribute('data-min-length'); const configurationOptions = { - selectElement: this.$module, + selectElement, showAllValues, autoselect, defaultValue, minLength, + onConfirm: (val) => { + // if you try commenting out the following line you will get a failing test + // this triggers on blur of the field (so regardless of auto select) + selectElement.value = ''; // deselect currently selected option, so we don't retain previous answer if new one doesn't match any option + const selectedOption = [].filter.call( + selectOptions, + (option) => (option.textContent || option.innerText) === val, + )[0]; + if (selectedOption) selectedOption.selected = true; + }, }; - const language = this.$module.getAttribute('data-language') || 'en'; + const language = selectElement.getAttribute('data-language') || 'en'; if (language === 'cy') { configurationOptions.tAssistiveHint = () => 'Pan fydd canlyniadau awtogwblhau ar gael, defnyddiwch y saethau i fyny ac i lawr i’w hadolygu a phwyswch y fysell ’enter’ i’w dewis.' @@ -34,7 +46,34 @@ AccessibleAutoComplete.prototype.init = function init() { }; } + const selectElementOriginalId = selectElement.id; + const selectElementAriaDescribedBy = selectElement.getAttribute('aria-describedby') || ''; + window.HMRCAccessibleAutocomplete.enhanceSelectElement(configurationOptions); + + const autocompleteElement = document.getElementById(selectElementOriginalId); + const autocompleteElementAriaDescribedBy = (autocompleteElement && autocompleteElement.getAttribute('aria-describedby')) || ''; + + const autocompleteElementMissingAriaDescribedAttrs = ( + autocompleteElement + && autocompleteElement.tagName !== 'select' + && !autocompleteElementAriaDescribedBy.includes(selectElementAriaDescribedBy) + ); + if (autocompleteElementMissingAriaDescribedAttrs) { + // if there is a hint and/or error then the autocomplete element + // needs to be aria-describedby these, which it isn't be default + // we need to check if it hasn't already been done to avoid + autocompleteElement.setAttribute( + 'aria-describedby', + `${selectElementAriaDescribedBy} ${autocompleteElementAriaDescribedBy}`, + ); + // and in case page is still using adam's patch, this should stop + // the select elements aria described by being added to the + // autocomplete element twice when that runs (though unsure if a + // screen reader would actually announce the elements twice if same + // element was listed twice in the aria-describedby attribute) + selectElement.setAttribute('aria-describedby', ''); + } } }; diff --git a/src/components/account-menu/account-menu.nojs.browser.test.js b/src/components/account-menu/account-menu.nojs.browser.test.js index 83058990..49a4f118 100644 --- a/src/components/account-menu/account-menu.nojs.browser.test.js +++ b/src/components/account-menu/account-menu.nojs.browser.test.js @@ -3,7 +3,7 @@ import { examplePreview } from '../../../lib/url-helpers'; describe('/components/account-menu', () => { const defaultAccountMenu = examplePreview('account-menu/default'); - async function displayStyle(selector) { + function displayStyle(selector) { return page.$eval(selector, (el) => window.getComputedStyle(el).display); } diff --git a/src/components/back-link-helper/example.njk b/src/components/back-link-helper/example.njk index da5017e7..89f7379f 100644 --- a/src/components/back-link-helper/example.njk +++ b/src/components/back-link-helper/example.njk @@ -8,6 +8,7 @@ + {{ govukBackLink({ attributes: { "data-module": "hmrc-back-link"