Skip to content

Commit

Permalink
Add modal dialogue component
Browse files Browse the repository at this point in the history
  • Loading branch information
colinrotherham committed Nov 29, 2019
1 parent e346314 commit 125474b
Show file tree
Hide file tree
Showing 9 changed files with 575 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/govuk/all.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/govuk/components/_all.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Empty file.
164 changes: 164 additions & 0 deletions src/govuk/components/modal-dialogue/_modal-dialogue.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
3 changes: 3 additions & 0 deletions src/govuk/components/modal-dialogue/macro.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% macro govukModalDialogue(params) %}
{%- include "./template.njk" -%}
{% endmacro %}
186 changes: 186 additions & 0 deletions src/govuk/components/modal-dialogue/modal-dialogue.js
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 125474b

Please sign in to comment.