From 091373444edfbdd11c16a6426d73404c88977e02 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Wed, 27 Nov 2019 11:53:34 +0000 Subject: [PATCH] Add modal dialogue component --- src/govuk/all.js | 6 + src/govuk/components/_all.scss | 1 + src/govuk/components/modal-dialogue/README.md | 0 .../modal-dialogue/_modal-dialogue.scss | 164 +++++++++++++++ src/govuk/components/modal-dialogue/macro.njk | 3 + .../modal-dialogue/modal-dialogue.js | 186 ++++++++++++++++++ .../modal-dialogue/modal-dialogue.yaml | 120 +++++++++++ .../components/modal-dialogue/template.njk | 69 +++++++ .../modal-dialogue/template.test.js | 26 +++ 9 files changed, 575 insertions(+) create mode 100644 src/govuk/components/modal-dialogue/README.md create mode 100644 src/govuk/components/modal-dialogue/_modal-dialogue.scss create mode 100644 src/govuk/components/modal-dialogue/macro.njk create mode 100644 src/govuk/components/modal-dialogue/modal-dialogue.js create mode 100644 src/govuk/components/modal-dialogue/modal-dialogue.yaml create mode 100644 src/govuk/components/modal-dialogue/template.njk create mode 100644 src/govuk/components/modal-dialogue/template.test.js diff --git a/src/govuk/all.js b/src/govuk/all.js index 2e20e847558..3f03b11df99 100644 --- a/src/govuk/all.js +++ b/src/govuk/all.js @@ -6,6 +6,7 @@ import CharacterCount from './components/character-count/character-count' import Checkboxes from './components/checkboxes/checkboxes' import ErrorSummary from './components/error-summary/error-summary' import Header from './components/header/header' +import ModalDialogue from './components/modal-dialogue/modal-dialogue' import Radios from './components/radios/radios' import Tabs from './components/tabs/tabs' @@ -50,6 +51,11 @@ function initAll (options) { var $toggleButton = scope.querySelector('[data-module="govuk-header"]') new Header($toggleButton).init() + var $modals = scope.querySelectorAll('[data-module="govuk-modal-dialogue"]') + nodeListForEach($modals, function ($modal) { + new ModalDialogue($modal).init() + }) + var $radios = scope.querySelectorAll('[data-module="govuk-radios"]') nodeListForEach($radios, function ($radio) { new Radios($radio).init() diff --git a/src/govuk/components/_all.scss b/src/govuk/components/_all.scss index 82612495130..aeb989ba086 100644 --- a/src/govuk/components/_all.scss +++ b/src/govuk/components/_all.scss @@ -17,6 +17,7 @@ @import "input/input"; @import "inset-text/inset-text"; @import "label/label"; +@import "modal-dialogue/modal-dialogue"; @import "panel/panel"; @import "phase-banner/phase-banner"; @import "tabs/tabs"; diff --git a/src/govuk/components/modal-dialogue/README.md b/src/govuk/components/modal-dialogue/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/govuk/components/modal-dialogue/_modal-dialogue.scss b/src/govuk/components/modal-dialogue/_modal-dialogue.scss new file mode 100644 index 00000000000..9b9fa36b995 --- /dev/null +++ b/src/govuk/components/modal-dialogue/_modal-dialogue.scss @@ -0,0 +1,164 @@ +// Disables linting for this file only +// sass-lint:disable no-duplicate-properties + +@import "../../settings/all"; +@import "../../tools/all"; +@import "../../helpers/all"; + +@include govuk-exports("govuk/component/modal-dialogue") { + $govuk-dialogue-width: 640px; + + .govuk-modal-dialogue, + .govuk-modal-dialogue__backdrop { + position: fixed; + z-index: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + // Hide dialogue when closed + .govuk-modal-dialogue { + display: none; + } + + // Show dialogue when opened + .govuk-modal-dialogue--open { + display: block; + } + + // Wrapper to handle overflow scrolling + .govuk-modal-dialogue__wrapper { + box-sizing: border-box; + display: flex; + height: 100%; + @include govuk-responsive-padding(7, "top"); + @include govuk-responsive-padding(7, "bottom"); + overflow-y: auto; + align-items: flex-start; + align-items: safe center; + } + + // HTML5 dialogue component + .govuk-modal-dialogue__box { + box-sizing: border-box; + display: block; + position: relative; + z-index: 1; + width: 90%; + margin: auto; + padding: 0; + overflow-y: auto; + border: $govuk-focus-width solid govuk-colour("black"); + background: govuk-colour("white"); + + // Add focus outline to dialogue + &:focus { + outline: $govuk-focus-width solid $govuk-focus-colour; + } + + // Hide browser backdrop + &::backdrop { + display: none; + } + } + + // Header with close button + .govuk-modal-dialogue__header { + @include govuk-clearfix; + @include govuk-responsive-margin(5, "bottom"); + padding-bottom: $govuk-focus-width; + color: govuk-colour("white"); + background-color: govuk-colour("black"); + text-align: right; + } + + // Inner content + .govuk-modal-dialogue__content { + @include govuk-font($size: 16); + @include govuk-responsive-padding(6); + padding-top: 0; + } + + .govuk-modal-dialogue__description { + @include govuk-responsive-margin(4, "bottom"); + } + + // Remove bottom margins + .govuk-modal-dialogue__description:last-child, + .govuk-modal-dialogue__description > :last-child, + .govuk-modal-dialogue__content > :last-child { + margin-bottom: 0; + } + + // Custom backdrop + .govuk-modal-dialogue__backdrop { + opacity: .8; + background: govuk-colour("black"); + pointer-events: none; + touch-action: none; + } + + // Crown icon + .govuk-modal-dialogue__crown { + display: block; + margin: 6px 0 0 6px; + @include govuk-responsive-margin(5, "left"); + float: left; + } + + // Heading + .govuk-modal-dialogue__heading:last-child { + margin-bottom: 0; + } + + // Close button + .govuk-modal-dialogue__close { + $font-size: 36px; + $line-height: 1; + + display: block; + width: auto; + min-width: 44px; + margin: 0; + padding: 2px 5px; + float: right; + color: govuk-colour("white"); + background-color: govuk-colour("black"); + box-shadow: none !important; + font-size: $font-size; + @if $govuk-typography-use-rem { + font-size: govuk-px-to-rem($font-size); + } + @include govuk-typography-weight-bold; + line-height: $line-height; + + &:hover { + color: govuk-colour("black"); + background-color: govuk-colour("yellow"); + } + + &:active { + top: 0; + } + } + + // New dialogue width, inline button + link + @include govuk-media-query($from: tablet) { + .govuk-modal-dialogue__content { + padding-top: 0; + } + + .govuk-modal-dialogue__box { + width: percentage($govuk-dialogue-width / map-get($govuk-breakpoints, desktop)); + } + } + + // Fixed width + @include govuk-media-query($from: desktop) { + .govuk-modal-dialogue__box { + width: $govuk-dialogue-width; + } + } +} diff --git a/src/govuk/components/modal-dialogue/macro.njk b/src/govuk/components/modal-dialogue/macro.njk new file mode 100644 index 00000000000..af2a5f80ea3 --- /dev/null +++ b/src/govuk/components/modal-dialogue/macro.njk @@ -0,0 +1,3 @@ +{% macro govukModalDialogue(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/src/govuk/components/modal-dialogue/modal-dialogue.js b/src/govuk/components/modal-dialogue/modal-dialogue.js new file mode 100644 index 00000000000..b169f7c748a --- /dev/null +++ b/src/govuk/components/modal-dialogue/modal-dialogue.js @@ -0,0 +1,186 @@ +import { nodeListForEach } from '../../common' +import '../../vendor/polyfills/Element/prototype/classList' +import '../../vendor/polyfills/Function/prototype/bind' +import '../../vendor/polyfills/Event' // addEventListener and event.target normaliziation + +function ModalDialogue ($module) { + this.$module = $module + this.$dialogBox = $module.querySelector('dialog') + this.$container = document.documentElement + + // Check for browser support + this.hasNativeDialog = 'showModal' in this.$dialogBox + + // Allowed focussable elements + this.focussable = [ + 'button', + '[href]', + 'input', + 'select', + 'textarea' + ] +} + +// Initialize component +ModalDialogue.prototype.init = function (options) { + this.options = options || {} + + this.open = this.handleOpen.bind(this) + this.close = this.handleClose.bind(this) + this.focus = this.handleFocus.bind(this) + this.focusTrap = this.handleFocusTrap.bind(this) + this.boundKeyDown = this.handleKeyDown.bind(this) + + // Modal elements + this.$buttonClose = this.$dialogBox.querySelector('.govuk-modal-dialogue__close') + this.$focussable = this.$dialogBox.querySelectorAll(this.focussable.toString()) + this.$focusableLast = this.$focussable[this.$focussable.length - 1] + this.$focusElement = this.options.focusElement || this.$dialogBox + + if (this.$dialogBox.hasAttribute('open')) { + this.open() + } + + this.initEvents() + + return this +} + +// Initialize component events +ModalDialogue.prototype.initEvents = function (options) { + if (this.options.triggerElement) { + this.options.triggerElement.addEventListener('click', this.open) + } + + // Close dialogue on close button click + this.$buttonClose.addEventListener('click', this.close) +} + +// Open modal +ModalDialogue.prototype.handleOpen = function (event) { + if (event) { + event.preventDefault() + } + + // Save last-focussed element + this.$lastActiveElement = document.activeElement + + // Disable scrolling, show wrapper + this.$container.classList.add('govuk-!-scroll-disabled') + this.$module.classList.add('govuk-modal-dialogue--open') + + // Close on escape key, trap focus + document.addEventListener('keydown', this.boundKeyDown, true) + + // Optional 'onOpen' callback + if (typeof this.options.onOpen === 'function') { + this.options.onOpen.call(this) + } + + // Skip open if already open + if (this.$dialogBox.hasAttribute('open')) { + return + } + + // Show modal + this.hasNativeDialog + ? this.$dialogBox.show() + : this.$dialogBox.setAttribute('open', '') + + // Handle focus + this.focus() +} + +// Close modal +ModalDialogue.prototype.handleClose = function (event) { + if (event) { + event.preventDefault() + } + + // Skip close if already closed + if (!this.$dialogBox.hasAttribute('open')) { + return + } + + // Hide modal + this.hasNativeDialog + ? this.$dialogBox.close() + : this.$dialogBox.removeAttribute('open') + + // Hide wrapper, enable scrolling + this.$module.classList.remove('govuk-modal-dialogue--open') + this.$container.classList.remove('govuk-!-scroll-disabled') + + // Restore focus to last active element + this.$lastActiveElement.focus() + + // Optional 'onClose' callback + if (typeof this.options.onClose === 'function') { + this.options.onClose.call(this) + } + + // Remove escape key and trap focus listener + document.removeEventListener('keydown', this.boundKeyDown, true) +} + +// Lock scroll, focus modal +ModalDialogue.prototype.handleFocus = function () { + this.$dialogBox.scrollIntoView() + this.$focusElement.focus({ preventScroll: true }) +} + +// Ensure focus stays within modal +ModalDialogue.prototype.handleFocusTrap = function (event) { + var $focusElement + + // Check for tabbing outside dialog + var hasFocusEscaped = document.activeElement !== this.$dialogBox + + // Loop inner focussable elements + if (hasFocusEscaped) { + nodeListForEach(this.$focussable, function (element) { + // Actually, focus is on an inner focussable element + if (hasFocusEscaped && document.activeElement === element) { + hasFocusEscaped = false + } + }) + + // Wrap focus back to first element + $focusElement = hasFocusEscaped + ? this.$dialogBox + : undefined + } + + // Wrap focus back to first/last element + if (!$focusElement) { + if ((document.activeElement === this.$focusableLast && !event.shiftKey) || !this.$focussable.length) { + $focusElement = this.$dialogBox + } else if (document.activeElement === this.$dialogBox && event.shiftKey) { + $focusElement = this.$focusableLast + } + } + + // Wrap focus + if ($focusElement) { + event.preventDefault() + $focusElement.focus({ preventScroll: true }) + } +} + +// Listen for key presses +ModalDialogue.prototype.handleKeyDown = function (event) { + var KEY_TAB = 9 + var KEY_ESCAPE = 27 + + switch (event.keyCode) { + case KEY_TAB: + this.focusTrap(event) + break + + case KEY_ESCAPE: + this.close() + break + } +} + +export default ModalDialogue diff --git a/src/govuk/components/modal-dialogue/modal-dialogue.yaml b/src/govuk/components/modal-dialogue/modal-dialogue.yaml new file mode 100644 index 00000000000..b56df2dacf3 --- /dev/null +++ b/src/govuk/components/modal-dialogue/modal-dialogue.yaml @@ -0,0 +1,120 @@ +params: +- name: id + type: string + required: true + description: The id of the modal dialogue. +- name: open + type: boolean + required: true + description: If true, modal dialogue element will be visible. +- name: headingLevel + type: integer + required: false + description: Heading level, from 1 to 6. Default is `2`. +- name: classes + type: string + required: false + description: Classes to add to the modal dialogue. +- name: attributes + type: object + required: false + description: HTML attributes (for example data attributes) to add to the modal dialogue. +- name: assetsPath + type: string + required: false + description: The public path for the assets folder. If not provided it defaults to /assets/images +- name: role + type: string + required: false + description: Optional ARIA role attribute. +- name: labelledBy + type: string + required: false + description: One or more element IDs to add to the modal dialogue `aria-labelledby` attribute, used to provide a heading which labels the dialogue for screenreader users. +- name: describedBy + type: string + required: false + description: One or more element IDs to add to the modal dialogue `aria-describedby` attribute, used to provide additional text to describe the dialogue for screenreader users. +- name: heading.text + type: string + required: true + description: The title of the modal dialogue. If `heading.html` is supplied, this is ignored. +- name: heading.html + type: string + required: true + description: The HTML content for the title of the modal dialogue. +- name: description.text + type: string + required: true + description: The modal dialog’s prompt text. If `description.html` is supplied, this is ignored. +- name: description.html + type: string + required: true + description: The HTML content for the modal dialog’s prompt text. + +previewLayout: modal + +examples: + - name: default + description: Defaults to hidden and must be opened with the modal dialogue’s open() method. + data: + id: modal + heading: + text: This is a modal dialogue + description: + html:

Prompt text for the modal dialogue component goes here

+ + - name: open + data: + id: modal + open: true + heading: + text: This is a modal dialogue + description: + html:

Prompt text for the modal dialogue component goes here

+ + - name: open with scrolling content + data: + id: modal + open: true + heading: + text: This is a modal dialogue + description: + html: | +

+ This is some very long content. +

+ call: + html: | +

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac consectetur purus, et varius nisi. Praesent convallis neque id mollis pharetra. Vivamus rutrum, dolor at aliquam facilisis, massa augue commodo felis, sit amet ultricies nunc nulla nec urna. Cras cursus sodales lorem porttitor varius. Curabitur eget auctor mi. In blandit neque vel gravida commodo. Etiam consectetur scelerisque orci, vitae consequat felis. Sed pulvinar, enim vel efficitur laoreet, enim ligula mollis felis, vitae elementum urna ex sit amet nisi. Proin ut cursus mi. Fusce non feugiat ex. Donec a justo sed nunc tincidunt pharetra. Aenean lacinia non sapien eu facilisis. Praesent laoreet vestibulum mattis. Aliquam laoreet hendrerit leo. Quisque aliquam non magna ornare faucibus. Sed venenatis porttitor urna, vitae euismod nunc posuere nec.

+

Suspendisse fringilla pellentesque massa. Nulla facilisi. Quisque sit amet eleifend metus, vel vestibulum nulla. Donec nec nisl at ipsum consequat blandit eget quis quam. Proin urna quam, imperdiet at aliquam ut, varius sit amet enim. Cras tempor finibus leo, vel rhoncus justo lacinia non. Curabitur nec tempus odio, at euismod turpis. Sed aliquam mi sed mauris commodo, non mollis velit molestie. Donec semper malesuada dapibus. In id gravida mi, eu mollis tortor. Morbi ut arcu porta, euismod nisi sed, mattis mi. Duis sit amet varius odio.

+

Duis porttitor dolor sed orci convallis, et congue nibh vulputate. Cras eu nulla a metus pretium porttitor quis sed diam. Integer mollis vitae urna quis varius. Curabitur eu lorem sit amet nulla pulvinar maximus. Vestibulum id dui luctus, mattis metus sit amet, consectetur neque. Vestibulum ac libero at turpis consequat semper eu nec nisl. In ultricies vel massa sed scelerisque.

+

Donec egestas velit vitae lacus eleifend, mollis aliquam libero laoreet. Curabitur ac neque a urna rhoncus fringilla sed vitae mi. Maecenas luctus vitae lectus ut accumsan. Nulla ullamcorper malesuada tortor a sollicitudin. Aliquam quam ipsum, rhoncus sit amet elit eu, pharetra cursus nibh. Nunc blandit diam sit amet leo lacinia, sed condimentum velit malesuada. Quisque vitae ex et odio condimentum pretium. Aenean lectus sapien, gravida feugiat est vitae, dignissim luctus erat. Suspendisse eget dui posuere, congue augue a, convallis nibh.

+

Fusce maximus leo leo, ut efficitur felis tincidunt sed. Morbi a quam at nisi facilisis tincidunt. Vestibulum porta risus at elit pharetra volutpat. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In posuere arcu eu orci volutpat scelerisque. Vivamus vel sapien a diam tempus viverra. Fusce dapibus arcu ipsum, in ultricies purus tempus id.

+

Vivamus faucibus, mauris vitae molestie suscipit, mauris enim pellentesque mauris, in facilisis nibh mauris vitae risus. Aenean mattis nunc nec ligula dapibus, nec vehicula erat cursus. Curabitur iaculis, ante vel venenatis commodo, metus metus ornare dolor, euismod vehicula nisi dui eget ante. Nunc pretium est vitae dui vehicula, sit amet finibus libero pretium. Nulla et tellus rhoncus, varius massa et, molestie odio. Nulla varius elit et feugiat lobortis. Maecenas id vestibulum sapien, vitae pellentesque neque. Proin pellentesque mi at bibendum venenatis.

+

Aenean sit amet laoreet libero, facilisis varius enim. Nullam ullamcorper leo ut sollicitudin sagittis. Mauris lobortis lacus mi, semper sollicitudin nulla condimentum a. Ut tempor, diam non maximus congue, purus velit tincidunt eros, at posuere purus tortor eu erat. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed pulvinar velit quis tincidunt imperdiet. Praesent congue condimentum eros, ut ultricies sem pulvinar vel. Vestibulum cursus at lorem eu lobortis. Praesent eleifend sit amet eros cursus sodales. Phasellus sapien erat, efficitur id accumsan vitae, ornare ut magna. Suspendisse venenatis rutrum magna, quis vulputate sem convallis id. Curabitur eu mauris id libero maximus vestibulum. Sed est turpis, tincidunt eu tincidunt vel, elementum a eros.

+

Morbi vel purus erat. Phasellus ac velit id magna pretium venenatis in ac ante. Nunc ut nisi nec leo tincidunt ullamcorper. Morbi convallis dignissim sem, ac tempus lorem faucibus non. Donec interdum erat quam, at vehicula nulla porttitor ac. Cras sagittis mattis maximus. Curabitur venenatis interdum leo, eget ornare felis. Vestibulum nec ipsum vestibulum, tincidunt neque sed, facilisis justo. Nunc elementum sagittis justo quis congue. Donec neque magna, consequat in viverra a, aliquam ut ipsum. Fusce eu interdum sem, in pulvinar erat.

+

Vestibulum ultricies arcu ligula, ut bibendum mi pulvinar at. Vestibulum luctus lectus vel ornare euismod. Duis porta elit eget pharetra commodo. Cras in enim at tortor porta tincidunt vitae quis arcu. Fusce eget molestie turpis, quis sodales eros. Integer felis purus, molestie a magna vel, viverra congue odio. Aenean tincidunt finibus erat, in tincidunt ligula porttitor sit amet. Nullam vel tristique nibh. Maecenas pharetra, tellus eget varius interdum, sem libero dapibus purus, at varius lectus urna quis felis. Morbi sed purus egestas, dictum purus vel, placerat nulla.

+

Vivamus eget nisl quis elit placerat consectetur sit amet in libero. Nam vitae tortor at felis egestas posuere in eget leo. Maecenas eget porta felis. In convallis at ipsum et euismod. Mauris nec odio urna. Maecenas tellus lacus, gravida a faucibus at, posuere eget lacus. Nullam tortor nunc, vulputate et tempus ut, hendrerit maximus turpis. Cras maximus dapibus tincidunt. Integer ultricies consequat ullamcorper. Sed vitae dolor lacus. Curabitur at eleifend velit. Morbi sit amet lorem tempus, tincidunt elit eu, egestas odio. Suspendisse potenti. Vivamus a pharetra ipsum. Morbi volutpat dolor at tempus porttitor.

+ + - name: open with timeout message + data: + id: modal + open: true + role: alertdialog + heading: + text: Your session is due to expire in 5 minutes + description: + html: | +

+ You have not done anything on the service for a while. +

+

+ Your session will end in 5 minutes if you do not do anything on the + page. You’ll need to start your application again if that happens. +

+ call: + html: | + diff --git a/src/govuk/components/modal-dialogue/template.njk b/src/govuk/components/modal-dialogue/template.njk new file mode 100644 index 00000000000..fafd129eef4 --- /dev/null +++ b/src/govuk/components/modal-dialogue/template.njk @@ -0,0 +1,69 @@ +{% from "../button/macro.njk" import govukButton %} +{% set headingLevel = params.headingLevel if params.headingLevel else 2 %} +{% set labelledBy = params.labelledBy if params.labelledBy else params.id + "-title" %} +{% set describedBy = params.describedBy if params.describedBy else params.id + "-description" %} +
+
+ +
+ {#- We use an inline SVG for the crown so that we can cascade the + currentColor into the crown whilst continuing to support older browsers + which do not support external SVGs without a Javascript polyfill. This + adds approximately 1kb to every page load. + We use currentColour so that we can easily invert it when printing and + when the focus state is applied. This also benefits users who override + colours in their browser as they will still see the crown. + The SVG needs `focusable="false"` so that Internet Explorer does not + treat it as an interactive element - without this it will be + 'focusable' when using the keyboard to navigate. #} + + + {#- Fallback PNG image for older browsers. + The element is a valid SVG element. In SVG, you would specify + the URL of the image file with the xlink:href – as we don't reference an + image it has no effect. It's important to include the empty xlink:href + attribute as this prevents versions of IE which support SVG from + downloading the fallback image when they don't need to. + In other browsers is synonymous for the tag and will be + interpreted as such, displaying the fallback image. #} + + + + {{ govukButton({ + text: "×", + type: "button", + classes: "govuk-modal-dialogue__close", + attributes: { + "aria-label": "Close modal dialogue" + } + }) }} +
+
+ + {{ params.heading.html | safe if params.heading.html else params.heading.text }} + + {% if params.description.html or params.description.text %} +
+ {{ params.description.html | safe if params.description.html else params.description.text }} +
+ {% endif %} + {% if caller %} + {{ caller() }} + {% endif %} +
+
+
+
+
diff --git a/src/govuk/components/modal-dialogue/template.test.js b/src/govuk/components/modal-dialogue/template.test.js new file mode 100644 index 00000000000..a0489c9cb2a --- /dev/null +++ b/src/govuk/components/modal-dialogue/template.test.js @@ -0,0 +1,26 @@ +/** + * @jest-environment jsdom + */ +/* eslint-env jest */ + +const axe = require('../../../../lib/axe-helper') + +const { render, getExamples } = require('../../../../lib/jest-helpers') + +const examples = getExamples('modal-dialogue') + +describe('Modal dialogue', () => { + it('passes accessibility tests', async () => { + const $ = render('modal-dialogue', examples.default) + + const results = await axe($.html()) + expect(results).toHaveNoViolations() + }) + + it('has a role of `dialog`', () => { + const $ = render('modal-dialogue', {}) + + const $component = $('dialog') + expect($component.attr('role')).toEqual('dialog') + }) +})