diff --git a/packages/govuk-frontend/src/govuk/all.mjs b/packages/govuk-frontend/src/govuk/all.mjs index 78fe080e39..db11d8f459 100644 --- a/packages/govuk-frontend/src/govuk/all.mjs +++ b/packages/govuk-frontend/src/govuk/all.mjs @@ -3,6 +3,7 @@ export { Accordion } from './components/accordion/accordion.mjs' export { Button } from './components/button/button.mjs' export { CharacterCount } from './components/character-count/character-count.mjs' export { Checkboxes } from './components/checkboxes/checkboxes.mjs' +export { Details } from './components/details/details.mjs' export { ErrorSummary } from './components/error-summary/error-summary.mjs' export { ExitThisPage } from './components/exit-this-page/exit-this-page.mjs' export { Header } from './components/header/header.mjs' diff --git a/packages/govuk-frontend/src/govuk/components/details/details.mjs b/packages/govuk-frontend/src/govuk/components/details/details.mjs new file mode 100644 index 0000000000..4b79a30857 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/details/details.mjs @@ -0,0 +1,173 @@ +import { ElementError } from '../../errors/index.mjs' +import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs' + +const KEY_ENTER = 13 +const KEY_SPACE = 32 + +/** + * Details component + * + * @preserve + */ +export class Details extends GOVUKFrontendComponent { + /** + * Details component constructor + * + * @param {Element | null} $module - HTML element to use for details + */ + constructor($module) { + super() + + if (!($module instanceof HTMLElement)) { + throw new ElementError({ + componentName: 'Details', + element: $module, + identifier: 'Root element (`$module`)' + }) + } + + this.$module = $module + this.$summary = this.$module.getElementsByTagName('summary').item(0) + this.$content = this.$module.getElementsByTagName('div').item(0) + + // If
doesn't have a and a
representing the content + // it means the required HTML structure is not met so the script will stop + if (!this.$summary || !this.$content) { + return + } + + // If the content doesn't have an ID, assign it one now + // which we'll need for the summary's aria-controls assignment + if (!this.$content.id) { + this.$content.id = `details-content-${this.generateUniqueID()}` + } + + // Add ARIA role="group" to details + this.$module.setAttribute('role', 'group') + + // Add role=button to summary + this.$summary.setAttribute('role', 'button') + + // Add aria-controls + this.$summary.setAttribute('aria-controls', this.$content.id) + + // Set tabIndex so the summary is keyboard accessible for non-native elements + // + // 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. + this.$summary.tabIndex = 0 + + // Detect initial open state + if (this.$module.hasAttribute('open')) { + this.$summary.setAttribute('aria-expanded', 'true') + } else { + this.$summary.setAttribute('aria-expanded', 'false') + } + + // Bind an event to handle summary elements + this.polyfillHandleInputs(() => this.polyfillSetAttributes()) + } + + /** + * Define a statechange function that updates aria-expanded and style.display + * + * @private + * @returns {boolean} Returns true + */ + polyfillSetAttributes() { + console.log('Hello!') + if (this.$module.hasAttribute('open')) { + console.log('Closing') + // @ts-expect-error message to silence ts errors just for now + this.$summary.setAttribute('aria-expanded', 'false') + // this.$content.style.display = 'none' + } else { + console.log('Opening') + // @ts-expect-error message to silence ts errors just for now + this.$summary.setAttribute('aria-expanded', 'true') + // this.$content.style.display = '' + } + + return true + } + + /** + * Handle cross-modal click events + * + * @private + * @param {(event: UIEvent) => void} callback - function + */ + polyfillHandleInputs(callback) { + // @ts-expect-error message to silence ts errors just for now + this.$summary.addEventListener('keypress', (event) => { + const $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 instanceof HTMLElement && + $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 + // eslint-disable-next-line + 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 + // @ts-expect-error message to silence ts errors just for now + this.$summary.addEventListener('keyup', (event) => { + const $target = event.target + if (event.keyCode === KEY_SPACE) { + if ( + $target instanceof HTMLElement && + $target.nodeName.toLowerCase() === 'summary' + ) { + event.preventDefault() + } + } + }) + + // @ts-expect-error message to silence ts errors just for now + this.$summary.addEventListener('click', callback) + } + + /** + * Used to generate a unique string, allows multiple instances of the component + * without them conflicting with each other. + * https://stackoverflow.com/a/8809472 + * + * @private + * @returns {string} Unique ID + */ + generateUniqueID() { + let d = new Date().getTime() + if ( + typeof window.performance !== 'undefined' && + typeof window.performance.now === 'function' + ) { + d += window.performance.now() // use high-precision timer if available + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function (c) { + const r = (d + Math.random() * 16) % 16 | 0 + d = Math.floor(d / 16) + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) + } + ) + } + + /** + * Name for the component used when initialising using data-module attributes. + */ + static moduleName = 'govuk-details' +} diff --git a/packages/govuk-frontend/src/govuk/components/details/template.njk b/packages/govuk-frontend/src/govuk/components/details/template.njk index 6f80541e90..edb064a730 100644 --- a/packages/govuk-frontend/src/govuk/components/details/template.njk +++ b/packages/govuk-frontend/src/govuk/components/details/template.njk @@ -1,6 +1,6 @@ {% from "../../macros/attributes.njk" import govukAttributes -%} -
diff --git a/packages/govuk-frontend/src/govuk/init.mjs b/packages/govuk-frontend/src/govuk/init.mjs index 2aaaf86bc0..299a3d2412 100644 --- a/packages/govuk-frontend/src/govuk/init.mjs +++ b/packages/govuk-frontend/src/govuk/init.mjs @@ -3,6 +3,7 @@ import { Accordion } from './components/accordion/accordion.mjs' import { Button } from './components/button/button.mjs' import { CharacterCount } from './components/character-count/character-count.mjs' import { Checkboxes } from './components/checkboxes/checkboxes.mjs' +import { Details } from './components/details/details.mjs' import { ErrorSummary } from './components/error-summary/error-summary.mjs' import { ExitThisPage } from './components/exit-this-page/exit-this-page.mjs' import { Header } from './components/header/header.mjs' @@ -36,6 +37,7 @@ function initAll(config) { [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], + [Details], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header],