diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f71f90ded..e4efa3f2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ govukInput({ ### Fixes +- [Pull request #1523: Improve accessibility of details component by polyfilling only where the native element is not available](https://github.com/alphagov/govuk-frontend/pull/1523). - [Pull request #1512: Update components to only output items when they are defined](https://github.com/alphagov/govuk-frontend/pull/1512). - [Pull request #1538: Simplify button types to avoid unnecessary type attribute](https://github.com/alphagov/govuk-frontend/pull/1538). diff --git a/app/views/examples/details-polyfill/index.njk b/app/views/examples/details-polyfill/index.njk new file mode 100644 index 0000000000..860b3aabe2 --- /dev/null +++ b/app/views/examples/details-polyfill/index.njk @@ -0,0 +1,56 @@ +{% from "back-link/macro.njk" import govukBackLink %} + +{% extends "layout.njk" %} + +{% block beforeContent %} + {{ govukBackLink({ + "href": "/" + }) }} +{% endblock %} + +{% block content %} +
+
+ +

+ Details polyfill examples +

+ +

+ In order to test our polyfill code for details we need to have examples that do not use the native elements. +

+ + +

+ Default +

+ +
+ + + Help with nationality + + +
+ We need to know your nationality so we can work out which elections you’re entitled to vote in. If you cannot provide your nationality, you’ll have to send copies of identity documents through the post. +
+
+ +

+ Expanded +

+ +
+ + + Help with nationality + + +
+ We need to know your nationality so we can work out which elections you’re entitled to vote in. If you cannot provide your nationality, you’ll have to send copies of identity documents through the post. +
+
+ +
+
+{% endblock %} diff --git a/src/govuk/components/details/details.js b/src/govuk/components/details/details.js index a4d5df4f33..9d8c131e5a 100644 --- a/src/govuk/components/details/details.js +++ b/src/govuk/components/details/details.js @@ -11,58 +11,28 @@ import { generateUniqueID } from '../../common.js' var KEY_ENTER = 13 var KEY_SPACE = 32 -// Create a flag to know if the browser supports navtive details -var NATIVE_DETAILS = typeof document.createElement('details').open === 'boolean' - function Details ($module) { this.$module = $module } -/** -* Handle cross-modal click events -* @param {object} node element -* @param {function} callback function -*/ -Details.prototype.handleInputs = function (node, callback) { - node.addEventListener('keypress', function (event) { - var target = event.target - // When the key gets pressed - check if it is enter or space - if (event.keyCode === KEY_ENTER || event.keyCode === KEY_SPACE) { - if (target.nodeName.toLowerCase() === 'summary') { - // Prevent space from scrolling the page - // and enter from submitting a form - event.preventDefault() - // Click to let the click event do all the necessary action - if (target.click) { - target.click() - } else { - // except Safari 5.1 and under don't support .click() here - callback(event) - } - } - } - }) +Details.prototype.init = function () { + if (!this.$module) { + return + } - // Prevent keyup to prevent clicking twice in Firefox when using space key - node.addEventListener('keyup', function (event) { - var target = event.target - if (event.keyCode === KEY_SPACE) { - if (target.nodeName.toLowerCase() === 'summary') { - event.preventDefault() - } - } - }) + // If there is native details support, we want to avoid running code to polyfill native behaviour. + var hasNativeDetails = typeof this.$module.open === 'boolean' - node.addEventListener('click', callback) + if (hasNativeDetails) { + return + } + + this.polyfillDetails() } -Details.prototype.init = function () { +Details.prototype.polyfillDetails = function () { var $module = this.$module - if (!$module) { - return - } - // Save shortcuts to the inner summary and content elements var $summary = this.$summary = $module.getElementsByTagName('summary').item(0) var $content = this.$content = $module.getElementsByTagName('div').item(0) @@ -92,9 +62,7 @@ Details.prototype.init = function () { // // We have to use the camelcase `tabIndex` property as there is a bug in IE6/IE7 when we set the correct attribute lowercase: // See http://web.archive.org/web/20170120194036/http://www.saliences.com/browserBugs/tabIndex.html for more information. - if (!NATIVE_DETAILS) { - $summary.tabIndex = 0 - } + $summary.tabIndex = 0 // Detect initial open state var openAttr = $module.getAttribute('open') !== null @@ -104,20 +72,18 @@ Details.prototype.init = function () { } else { $summary.setAttribute('aria-expanded', 'false') $content.setAttribute('aria-hidden', 'true') - if (!NATIVE_DETAILS) { - $content.style.display = 'none' - } + $content.style.display = 'none' } // Bind an event to handle summary elements - this.handleInputs($summary, this.setAttributes.bind(this)) + this.polyfillHandleInputs($summary, this.polyfillSetAttributes.bind(this)) } /** * Define a statechange function that updates aria-expanded and style.display * @param {object} summary element */ -Details.prototype.setAttributes = function () { +Details.prototype.polyfillSetAttributes = function () { var $module = this.$module var $summary = this.$summary var $content = this.$content @@ -128,27 +94,54 @@ Details.prototype.setAttributes = function () { $summary.setAttribute('aria-expanded', (expanded ? 'false' : 'true')) $content.setAttribute('aria-hidden', (hidden ? 'false' : 'true')) - if (!NATIVE_DETAILS) { - $content.style.display = (expanded ? 'none' : '') + $content.style.display = (expanded ? 'none' : '') - var hasOpenAttr = $module.getAttribute('open') !== null - if (!hasOpenAttr) { - $module.setAttribute('open', 'open') - } else { - $module.removeAttribute('open') - } + var hasOpenAttr = $module.getAttribute('open') !== null + if (!hasOpenAttr) { + $module.setAttribute('open', 'open') + } else { + $module.removeAttribute('open') } + return true } /** -* Remove the click event from the node element +* Handle cross-modal click events * @param {object} node element +* @param {function} callback function */ -Details.prototype.destroy = function (node) { - node.removeEventListener('keypress') - node.removeEventListener('keyup') - node.removeEventListener('click') +Details.prototype.polyfillHandleInputs = function (node, callback) { + node.addEventListener('keypress', function (event) { + var target = event.target + // When the key gets pressed - check if it is enter or space + if (event.keyCode === KEY_ENTER || event.keyCode === KEY_SPACE) { + if (target.nodeName.toLowerCase() === 'summary') { + // Prevent space from scrolling the page + // and enter from submitting a form + event.preventDefault() + // Click to let the click event do all the necessary action + if (target.click) { + target.click() + } else { + // except Safari 5.1 and under don't support .click() here + callback(event) + } + } + } + }) + + // Prevent keyup to prevent clicking twice in Firefox when using space key + node.addEventListener('keyup', function (event) { + var target = event.target + if (event.keyCode === KEY_SPACE) { + if (target.nodeName.toLowerCase() === 'summary') { + event.preventDefault() + } + } + }) + + node.addEventListener('click', callback) } export default Details diff --git a/src/govuk/components/details/details.test.js b/src/govuk/components/details/details.test.js index faeda7e19f..8726334049 100644 --- a/src/govuk/components/details/details.test.js +++ b/src/govuk/components/details/details.test.js @@ -5,130 +5,170 @@ const PORT = configPaths.ports.test const baseUrl = 'http://localhost:' + PORT -describe('/components/details', () => { - describe('/components/details/preview', () => { +describe('details', () => { + it('should not polyfill when details element is available', async () => { + await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + + const summaryAriaExpanded = await page.evaluate(() => { + return document.querySelector('summary').getAttribute('aria-expanded') + }) + expect(summaryAriaExpanded).toBe(null) + }) + describe('/examples/details-polyfill', () => { it('should add to summary the button role', async () => { - await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - const summaryRole = await page.evaluate(() => document.body.getElementsByTagName('summary')[0].getAttribute('role')) + const summaryRole = await page.evaluate(() => { + return document.getElementById('default').querySelector('summary').getAttribute('role') + }) expect(summaryRole).toBe('button') }) it('should set the element controlled by the summary using aria-controls', async () => { - await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - const summaryAriaControls = await page.evaluate(() => document.body.getElementsByTagName('summary')[0].getAttribute('aria-controls')) - const controlledContainerId = await page.evaluate(() => document.body.getElementsByTagName('details')[0].querySelectorAll('div')[0].getAttribute('id')) + const summaryAriaControls = await page.evaluate(() => { + return document.getElementById('default').querySelector('summary').getAttribute('aria-controls') + }) + const controlledContainerId = await page.evaluate(() => { + return document.getElementById('default').querySelector('.govuk-details__text').getAttribute('id') + }) expect(summaryAriaControls).toBe(controlledContainerId) }) it('should set the expanded state of the summary to false using aria-expanded', async () => { - await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - const summaryAriaExpanded = await page.evaluate(() => document.body.getElementsByTagName('summary')[0].getAttribute('aria-expanded')) + const summaryAriaExpanded = await page.evaluate(() => { + return document.getElementById('default').querySelector('summary').getAttribute('aria-expanded') + }) expect(summaryAriaExpanded).toBe('false') }) it('should present the content as hidden using aria-hidden', async () => { - await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - const hiddenContainerAriaHidden = await page.evaluate(() => document.body.getElementsByTagName('details')[0].querySelectorAll('div')[0].getAttribute('aria-hidden')) + const hiddenContainerAriaHidden = await page.evaluate(() => { + return document.getElementById('default').querySelector('.govuk-details__text').getAttribute('aria-hidden') + }) expect(hiddenContainerAriaHidden).toBe('true') }) it('should indicate the open state of the content', async () => { - await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - const detailsOpen = await page.evaluate(() => document.body.getElementsByTagName('details')[0].getAttribute('open')) + const detailsOpen = await page.evaluate(() => { + return document.getElementById('default').getAttribute('open') + }) expect(detailsOpen).toBeNull() }) describe('when details is triggered', () => { it('should indicate the expanded state of the summary using aria-expanded', async () => { - await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - await page.click('summary') + await page.click('#default summary') - const summaryAriaExpanded = await page.evaluate(() => document.body.getElementsByTagName('summary')[0].getAttribute('aria-expanded')) + const summaryAriaExpanded = await page.evaluate(() => { + return document.getElementById('default').querySelector('summary').getAttribute('aria-expanded') + }) expect(summaryAriaExpanded).toBe('true') }) it('should indicate the visible state of the content using aria-hidden', async () => { - await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - await page.click('summary') + await page.click('#default summary') - const hiddenContainerAriaHidden = await page.evaluate(() => document.body.getElementsByTagName('details')[0].querySelectorAll('div')[0].getAttribute('aria-hidden')) + const hiddenContainerAriaHidden = await page.evaluate(() => { + return document.getElementById('default').querySelector('.govuk-details__text').getAttribute('aria-hidden') + }) expect(hiddenContainerAriaHidden).toBe('false') }) it('should indicate the open state of the content', async () => { - await page.goto(baseUrl + '/components/details/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - await page.click('summary') + await page.click('#default summary') - const detailsOpen = await page.evaluate(() => document.body.getElementsByTagName('details')[0].getAttribute('open')) + const detailsOpen = await page.evaluate(() => { + return document.getElementById('default').getAttribute('open') + }) expect(detailsOpen).not.toBeNull() }) }) }) - describe('/components/details/expanded/preview', () => { + describe('expanded', () => { it('should indicate the expanded state of the summary using aria-expanded', async () => { - await page.goto(baseUrl + '/components/details/expanded/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - const summaryAriaExpanded = await page.evaluate(() => document.body.getElementsByTagName('summary')[0].getAttribute('aria-expanded')) + const summaryAriaExpanded = await page.evaluate(() => { + return document.getElementById('expanded').querySelector('summary').getAttribute('aria-expanded') + }) expect(summaryAriaExpanded).toBe('true') }) it('should indicate the visible state of the content using aria-hidden', async () => { - await page.goto(baseUrl + '/components/details/expanded/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - const hiddenContainerAriaHidden = await page.evaluate(() => document.body.getElementsByTagName('details')[0].querySelectorAll('div')[0].getAttribute('aria-hidden')) + const hiddenContainerAriaHidden = await page.evaluate(() => { + return document.getElementById('expanded').querySelector('.govuk-details__text').getAttribute('aria-hidden') + }) expect(hiddenContainerAriaHidden).toBe('false') }) it('should indicate the open state of the content', async () => { - await page.goto(baseUrl + '/components/details/expanded/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - const detailsOpen = await page.evaluate(() => document.body.getElementsByTagName('details')[0].getAttribute('open')) + const detailsOpen = await page.evaluate(() => { + return document.getElementById('expanded').getAttribute('open') + }) expect(detailsOpen).not.toBeNull() }) it('should not be affected when clicking the revealed content', async () => { - await page.goto(baseUrl + '/components/details/expanded/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - await page.click('.govuk-details__text') + await page.click('#expanded .govuk-details__text') - const summaryAriaExpanded = await page.evaluate(() => document.body.getElementsByTagName('summary')[0].getAttribute('aria-expanded')) + const summaryAriaExpanded = await page.evaluate(() => { + return document.getElementById('expanded').querySelector('summary').getAttribute('aria-expanded') + }) expect(summaryAriaExpanded).toBe('true') }) describe('when details is triggered', () => { it('should indicate the expanded state of the summary using aria-expanded', async () => { - await page.goto(baseUrl + '/components/details/expanded/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - await page.click('summary') + await page.click('#expanded summary') - const summaryAriaExpanded = await page.evaluate(() => document.body.getElementsByTagName('summary')[0].getAttribute('aria-expanded')) + const summaryAriaExpanded = await page.evaluate(() => { + return document.getElementById('expanded').querySelector('summary').getAttribute('aria-expanded') + }) expect(summaryAriaExpanded).toBe('false') }) it('should indicate the visible state of the content using aria-hidden', async () => { - await page.goto(baseUrl + '/components/details/expanded/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - await page.click('summary') + await page.click('#expanded summary') - const hiddenContainerAriaHidden = await page.evaluate(() => document.body.getElementsByTagName('details')[0].querySelectorAll('div')[0].getAttribute('aria-hidden')) + const hiddenContainerAriaHidden = await page.evaluate(() => { + return document.getElementById('expanded').querySelector('.govuk-details__text').getAttribute('aria-hidden') + }) expect(hiddenContainerAriaHidden).toBe('true') }) it('should indicate the open state of the content', async () => { - await page.goto(baseUrl + '/components/details/expanded/preview', { waitUntil: 'load' }) + await page.goto(baseUrl + '/examples/details-polyfill', { waitUntil: 'load' }) - await page.click('summary') + await page.click('#expanded summary') - const detailsOpen = await page.evaluate(() => document.body.getElementsByTagName('details')[0].getAttribute('open')) + const detailsOpen = await page.evaluate(() => { + return document.getElementById('expanded').getAttribute('open') + }) expect(detailsOpen).toBeNull() }) })