diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a274f9c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## v2.0.0 + +- Removed `onCreate`, `onDestroy`, `onOpen` and `onClose` callbacks. +- Added `create`, `destroy`, `open` and `close` events. +- Added event subscription via `on` and `off` methods. + +## v1.0.0 + +- Initial release diff --git a/README.md b/README.md index 1bde382..9011235 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,26 @@ # Dialog -- Automatically handles adding and toggling the appropiate aria roles, states and properties. -- Can be used as an accesible baseline for any dialog or dialog like like UI e.g lightbox's or fullscreen menus. +Dialog provides a bare bones baseline for building accessible modals or modal like UI elements such as fullscreen menus. [View demo on CodePen](https://codepen.io/rynpsc/pen/YVVGdr) ## Install -### Yarn +### npm ``` -yarn add @rynpsc/dialog +npm install @rynpsc/dialog ``` -### NPM +## Usage -``` -npm install --save @rynpsc/dialog -``` +The `Dialog` constructor takes three parameters: -## Usage +* `dialog` - The element ID of the dialog element +* `main` - The element ID of the main page content +* `options` - Configuration Object ([see options](#options)) -Dialog requires that the dialog element lives outside of the main page content. +### HTML ```html @@ -37,47 +36,28 @@ Dialog requires that the dialog element lives outside of the main page content. ``` -Alternatively the dialog element can be placed within `#main` and the script will move the `#dialog` element outside of the `#main` element. - -```html - - -
- - -
- -
- -
- - -``` - -The `Dialog` constructor takes three parameters: - -* `dialog` - The element ID of the dialog element -* `main` - The element ID of the main page content -* `options` - Configuration Object ([see options](#options)) +### JavaScript ```js import Dialog from '@rynpsc/dialog'; -const dialog = Dialog(dialog, main, options); +const dialog = dialog(dialog, main, options); ``` -Dialog does not provide and styling of its own, instead this is left to the user to implement. +### CSS - ```css +```css .dialog { display: none; } -.dialog[aria-hidden="false"] { +.dialog.is-open { display: block; } ``` +[For an example on how to animate the dialog see the CodePen demo](https://codepen.io/rynpsc/pen/YVVGdr). + ## Options ```js @@ -94,22 +74,10 @@ const dialog = Dialog('dialog', 'main', { // Whether dialog is of type alertdialog alert: false, - // Callback on initialisation - onCreate: (dialog, main) => {}, - - // Callback on open - onOpen: (dialog, main) => {}, - - // Callback on close - onClose: (dialog, main) => {}, - - // Callback on destroy - onDestroy: (dialog, main) => {}, + autoInit: false, }); ``` -The callbacks , `onCreate`, `onOpen`, `onClose` and `onDestroy` each access to two parameters, `dialog` and `main` which reference the respective `HTMLElements`. - ## API ### open @@ -130,7 +98,7 @@ dialog.close(); ### toggle -Toggle the dialog between open and close. +Toggle the dialog between opened and closed. ```js dialog.toggle(force); @@ -140,13 +108,13 @@ If the optional `force` parameter evaluates to true, open the dialog, if false, ### destroy -Destroy the dialog. +Destroy the dialog. ```js dialog.destroy(); ``` -Note: If relying on the library to move the `dialog` element outsideof the `main` element the method does not currently restore the `dialog` element to it's previous DOM position. +Note: If relying on the library to move the `dialog` element outsideof the `main` element the method does not currently restore the `dialog` element to it's original DOM position. ### create @@ -156,6 +124,22 @@ Create the dialog after destroying it. dialog.create(); ``` +### on + +Subscribe to an event. + +```js +dialog.on(event); +``` + +### off + +Unsubscribe to an event. + +```js +dialog.off(event); +``` + ### isOpen Returns a Boolean indicating if the dialog is currently open. @@ -164,6 +148,60 @@ Returns a Boolean indicating if the dialog is currently open. dialog.isOpen; ``` +### elements + +An object containing the dialog and main elements. + +```js +const { dialog, main } = dialog.elements; +``` + +## Events + +### open + +Emitted when the dialog opens. + +```js +dialog.on('open', listener); +``` + +### close + +Emitted when the dialog closes. + +```js +dialog.on('close', listener); +``` + +### create + +```js +dialog.on('create', listener); +``` + +Emitted after the dialog is created. + +Note in order to listen for the initial create event the dialog must be manually created via the `create()` method. + +```js +const dialog = dialog(dialog, main, { + autoInit: false +}); + +dialog.on('create', () => console.log('Dialog created')); + +dialog.create(); +``` + +### destroy + +```js +dialog.on('destroy', listener); +``` + +Emitted after the dialog is destroyed. + ## License -MIT © 2017 [Ryan Pascoe](https://github.com/rynpsc) +MIT © [Ryan Pascoe](https://github.com/rynpsc) diff --git a/dist/dialog.js b/dist/dialog.js index d5cef8a..a9ab9cc 100644 --- a/dist/dialog.js +++ b/dist/dialog.js @@ -1,199 +1 @@ -var defaults = { - label: 'Dialog', - description: '', - focus: '', - alert: false, - onCreate: function onCreate(dialog, main) {}, - onOpen: function onOpen(dialog, main) {}, - onClose: function onClose(dialog, main) {}, - onDestroy: function onDestroy(dialog, main) {} -}; - -/** - * List of selectors for elements that are focusable via the keyborad. - */ -var selectors = ['a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', 'iframe', 'object', 'embed', '[contenteditable]', '[tabindex]:not([tabindex^="-"])']; - -/** - * Get a NodeList of all focusable child nodes - * @param {HTMLElement} node - * @return {NodeList} - */ -function getFocusableElements(node) { - return node.querySelectorAll(selectors); -} - -/** - * Focuses the first focusable element within a node - * @param {HTMLElement} node - */ -function focusFirstElement(node) { - var nodes = node.querySelectorAll(selectors); - nodes.length ? nodes[0].focus() : node.focus(); -} - -/** - * Traps tab key within a given node - * @param {HTMLElment} node - */ -function trapTabKey(node, event) { - var activeElement = document.activeElement; - var focusableElements = getFocusableElements(node); - var firstTabStop = focusableElements[0]; - var lastTabStop = focusableElements[focusableElements.length - 1]; - - if (event.shiftKey && activeElement === firstTabStop) { - lastTabStop.focus(); - event.preventDefault(); - } else if (!event.shiftKey && activeElement === lastTabStop) { - firstTabStop.focus(); - event.preventDefault(); - } -} - -function Dialog(modal, main, options) { - var mainElement = document.getElementById(main); - var modalElement = document.getElementById(modal); - var config = Object.assign({}, defaults, options); - - var isOpen = false; - var initialFocusedElement = null; - - if (!mainElement) { - throw new Error('No element with the id "' + main + '"'); - } - - if (!modalElement) { - throw new Error('No element with the id "' + modal + '"'); - } - - function create() { - var role = config.alert ? 'alertdialog' : 'dialog'; - - modalElement.setAttribute('role', role); - modalElement.setAttribute('tabindex', -1); - modalElement.setAttribute('aria-modal', true); - - toggleAriaHidden(isOpen); - - var matchesID = document.getElementById(config.label); - var attr = matchesID ? 'labeledby' : 'label'; - modalElement.setAttribute('aria-' + attr, config.label); - - if (document.getElementById(config.description)) { - modalElement.setAttribute('aria-describedby', config.description); - } - - if (mainElement.contains(modalElement)) { - document.body.appendChild(modalElement); - } - - if (typeof config.onCreate === 'function') { - config.onCreate(modalElement, mainElement); - } - } - - function onKeydown(event) { - if (event.key === 'Escape' && isOpen) { - close(); - } - - if (event.key === 'Tab' && isOpen) { - trapTabKey(modalElement, event); - } - } - - function trapFocus(event) { - if (!modalElement.contains(document.activeElement)) { - event.preventDefault(); - focusFirstElement(modalElement); - } - } - - function toggleAriaHidden(toggle) { - mainElement.setAttribute('aria-hidden', toggle); - modalElement.setAttribute('aria-hidden', !toggle); - } - - function setModalFocus() { - if (config.focus instanceof HTMLElement && modalElement.contains(config.focus)) { - config.focus.focus(); - } else { - focusFirstElement(modalElement); - } - } - - function open() { - if (isOpen) return; - - isOpen = true; - toggleAriaHidden(isOpen); - - initialFocusedElement = document.activeElement; - - setModalFocus(); - - if (!modalElement.contains(document.activeElement)) { - modalElement.addEventListener('transitionend', onTransitionEnd); - } - - document.addEventListener('keydown', onKeydown); - document.addEventListener('focus', trapFocus, true); - - if (typeof config.onOpen === 'function') { - config.onOpen(modalElement, mainElement); - } - } - - function close() { - if (!isOpen) return; - - isOpen = false; - toggleAriaHidden(isOpen); - - document.removeEventListener('keydown', onKeydown); - document.removeEventListener('focus', trapFocus, true); - - initialFocusedElement.focus(); - initialFocusedElement = null; - - if (typeof config.onClose === 'function') { - config.onClose(modalElement, mainElement); - } - } - - function toggle() { - var toggle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : !isOpen; - - toggle ? open() : close(); - } - - function onTransitionEnd(event) { - if (event.propertyName === 'visibility') { - setModalFocus(); - modalElement.removeEventListener('transitionend', onTransitionEnd); - } - } - - function destroy() { - var modalAttrs = ['aria-describedby', 'aria-hidden', 'aria-label', 'aria-labeledby', 'aria-modal', 'role', 'tabindex']; - - modalAttrs.forEach(function (attr) { - return modalElement.removeAttribute(attr); - }); - - mainElement.removeAttribute('aria-hidden'); - document.removeEventListener('keydown', onKeydown); - document.removeEventListener('focus', trapFocus, true); - - if (typeof config.onDestroy === 'function') { - config.onDestroy(modalElement, mainElement); - } - } - - create(); - - return { create: create, open: open, close: close, toggle: toggle, isOpen: isOpen, destroy: destroy }; -} - -export default Dialog; +var defaults={label:'Dialog',description:'',focus:'',alert:!1,openClass:'is-open',autoInit:!0},events=Object.create(null);function on(a,b){if(a&&b){var c=events[a]=events[a]||[];-1==c.indexOf(b)&&c.push(b)}}function off(a){var b=1 0 && arguments[0] !== undefined ? arguments[0] : !isOpen; - - toggle ? open() : close(); - } - - function onTransitionEnd(event) { - if (event.propertyName === 'visibility') { - setModalFocus(); - modalElement.removeEventListener('transitionend', onTransitionEnd); - } - } - - function destroy() { - var modalAttrs = ['aria-describedby', 'aria-hidden', 'aria-label', 'aria-labeledby', 'aria-modal', 'role', 'tabindex']; - - modalAttrs.forEach(function (attr) { - return modalElement.removeAttribute(attr); - }); - - mainElement.removeAttribute('aria-hidden'); - document.removeEventListener('keydown', onKeydown); - document.removeEventListener('focus', trapFocus, true); - - if (typeof config.onDestroy === 'function') { - config.onDestroy(modalElement, mainElement); - } - } - - create(); - - return { create: create, open: open, close: close, toggle: toggle, isOpen: isOpen, destroy: destroy }; -} - -return Dialog; - -}))); +(function(a,b){'object'==typeof exports&&'undefined'!=typeof module?module.exports=b():'function'==typeof define&&define.amd?define(b):a.dialog=b()})(this,function(){'use strict';function a(a,b){if(a&&b){var c=l[a]=l[a]||[];-1==c.indexOf(b)&&c.push(b)}}function b(a){var b=1 {}, - onOpen: (dialog, main) => {}, - onClose: (dialog, main) => {}, - onDestroy: (dialog, main) => {}, + + openClass: 'is-open', + + /** + * Auto initiate on instantiation + */ autoInit: true, }; diff --git a/src/dialog.js b/src/dialog.js deleted file mode 100644 index 697b98b..0000000 --- a/src/dialog.js +++ /dev/null @@ -1,151 +0,0 @@ -import defaults from './defaults'; -import * as Utils from './utils'; - -function Dialog(modal, main, options) { - const mainElement = document.getElementById(main); - const modalElement = document.getElementById(modal); - const config = Object.assign({}, defaults, options); - - let isOpen = false; - let initiated = false; - let initialFocusedElement = null; - - if (!mainElement) { - throw new Error(`No element with the id "${main}"`); - } - - if (!modalElement) { - throw new Error(`No element with the id "${modal}"`); - } - - function create() { - const role = config.alert ? 'alertdialog' : 'dialog'; - - modalElement.setAttribute('role', role); - modalElement.setAttribute('tabindex', -1); - modalElement.setAttribute('aria-modal', true); - - toggleAriaHidden(isOpen); - - let matchesID = document.getElementById(config.label); - let attr = matchesID ? 'labeledby' : 'label'; - modalElement.setAttribute(`aria-${attr}`, config.label); - - if (document.getElementById(config.description)) { - modalElement.setAttribute('aria-describedby', config.description); - } - - if (mainElement.contains(modalElement)) { - document.body.appendChild(modalElement); - } - - if (typeof config.onCreate === 'function') { - config.onCreate(modalElement, mainElement); - } - } - - function onKeydown(event) { - if (event.key === 'Escape' && isOpen) { - close(); - } - - if (event.key === 'Tab' && isOpen) { - Utils.trapTabKey(modalElement, event); - } - } - - function trapFocus(event) { - if (!modalElement.contains(document.activeElement)) { - event.preventDefault(); - Utils.focusFirstElement(modalElement); - } - } - - function toggleAriaHidden(toggle) { - mainElement.setAttribute('aria-hidden', toggle); - modalElement.setAttribute('aria-hidden', !toggle); - } - - function setModalFocus() { - if (config.focus instanceof HTMLElement && modalElement.contains(config.focus)) { - config.focus.focus(); - } else { - Utils.focusFirstElement(modalElement); - } - } - - initiated = true; - function open() { - if (isOpen || !initiated) return; - - isOpen = true; - toggleAriaHidden(isOpen); - - initialFocusedElement = document.activeElement; - - setModalFocus(); - - if (!modalElement.contains(document.activeElement)) { - modalElement.addEventListener('transitionend', onTransitionEnd); - } - - document.addEventListener('keydown', onKeydown); - document.addEventListener('focus', trapFocus, true); - - if (typeof config.onOpen === 'function') { - config.onOpen(modalElement, mainElement); - } - } - - function close() { - if (!isOpen || !initiated) return; - - isOpen = false; - toggleAriaHidden(isOpen); - - document.removeEventListener('keydown', onKeydown); - document.removeEventListener('focus', trapFocus, true); - - initialFocusedElement.focus(); - initialFocusedElement = null; - - if (typeof config.onClose === 'function') { - config.onClose(modalElement, mainElement); - } - } - - function toggle(toggle = !isOpen) { - toggle ? open() : close(); - } - - function onTransitionEnd(event) { - if (event.propertyName === 'visibility') { - setModalFocus(); - modalElement.removeEventListener('transitionend', onTransitionEnd); - } - } - - function destroy() { - let modalAttrs = [ 'aria-describedby', 'aria-hidden', 'aria-label', 'aria-labeledby', 'aria-modal', 'role', 'tabindex' ]; - - modalAttrs.forEach(attr => modalElement.removeAttribute(attr)); - - mainElement.removeAttribute('aria-hidden'); - document.removeEventListener('keydown', onKeydown); - document.removeEventListener('focus', trapFocus, true); - - if (typeof config.onDestroy === 'function') { - config.onDestroy(modalElement, mainElement); - } - initiated = false; - } - - if (config.autoInit) { - create(); - } - - return { create, open, close, toggle, isOpen, destroy } - -}; - -export default Dialog; diff --git a/src/emitter.js b/src/emitter.js new file mode 100644 index 0000000..cef5d9a --- /dev/null +++ b/src/emitter.js @@ -0,0 +1,37 @@ +const events = Object.create(null); + +/** + * Subscribe to an event + * @param {String} type + * @param {Function} handler + */ +export function on(type, handler) { + if (type && handler) { + const handlers = events[type] = events[type] || []; + if (handlers.indexOf(handler) == -1) { + handlers.push(handler); + } + } +} + +/** + * Unsubscribe from an event + * @param {String} event + * @param {Function} handler + */ +export function off(event, handler = false) { + if (handler) { + events[event].splice(events[event].indexOf(handler), 1); + } else { + delete events[event]; + } +} + +/** + * Emit an event + * @param {String} event + * @param {*} args + */ +export function emit(event, ...args) { + (events[event] || []).forEach(handler => handler.apply(handler, args)); +} diff --git a/src/focus-trap.js b/src/focus-trap.js new file mode 100644 index 0000000..4498f7d --- /dev/null +++ b/src/focus-trap.js @@ -0,0 +1,99 @@ +const selectors = [ + '[contenteditable]', + '[tabindex]:not([tabindex^="-"])', + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'embed', + 'iframe', + 'input:not([disabled])', + 'object', + 'select:not([disabled])', + 'textarea:not([disabled])' +]; + +function isVisible(element) { + const isHidden = !(element.offsetWidth || element.offsetHeight || element.getClientRects().length); + const isInvisible = window.getComputedStyle(element).visibility === 'hidden'; + + return !(isHidden || isInvisible); +} + +function getFocusableElements(node) { + return [...node.querySelectorAll(selectors)].filter(elem => isVisible(elem)); +} + +function focus(node) { + if (node && node.focus) node.focus(); +} + +function focusFirstElement(node) { + const nodes = getFocusableElements(node); + if (nodes.length) focus(nodes[0]); +} + +function trapTab(element, event) { + const activeElement = document.activeElement; + + const elements = getFocusableElements(element); + const firstTabStop = elements[0]; + const lastTabStop = elements[elements.length - 1]; + + if (event.shiftKey && activeElement === firstTabStop) { + focus(lastTabStop); + event.preventDefault(); + } + + if (!event.shiftKey && activeElement === lastTabStop) { + focus(firstTabStop); + event.preventDefault(); + } +} + +function focusTrap(element, initialElement) { + let trapActivated = false; + let initialActiveElement = undefined; + + function onFocus(event) { + const focusLost = !element.contains(document.activeElement); + event.preventDefault(); + event.stopImmediatePropagation(); + if (focusLost && trapActivated) focusFirstElement(element); + } + + function onKeydown(event) { + if (event.key === 'Tab') trapTab(element, event); + } + + function activate() { + if (trapActivated) return; + + trapActivated = true; + initialActiveElement = document.activeElement; + + if (initialElement && element.contains(initialElement)) { + focus(initialElement); + } else { + focusFirstElement(element); + } + + document.addEventListener('focus', onFocus, true); + document.addEventListener('keydown', onKeydown, true); + } + + function deactivate() { + if (!trapActivated) return; + + trapActivated = false; + focus(initialActiveElement); + initialActiveElement = undefined; + + document.removeEventListener('focus', onFocus, true); + document.removeEventListener('keydown', onKeydown, true); + } + + return { activate, deactivate } + +} + +export default focusTrap; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..34b0d69 --- /dev/null +++ b/src/index.js @@ -0,0 +1,111 @@ +import defaults from './defaults'; +import * as Emitter from './emitter'; +import focusTrap from './focus-trap'; + +function dialog(dialog, main, options) { + + const elements = { + main: document.getElementById(main), + dialog: document.getElementById(dialog), + }; + + if (!elements.dialog) { + throw new Error(`No element with the id "${dialog}"`); + } + + if (!elements.main) { + throw new Error(`No element with the id "${main}"`); + } + + const config = Object.assign({}, defaults, options); + const trap = focusTrap(elements.dialog, config.focus); + + let isOpen = false; + let initiated = false; + + function onKeydown(event) { + if (event.key === 'Escape') close(); + } + + /** + * Create + */ + function create() { + const role = config.alert ? 'alertdialog' : 'dialog'; + const labeledby = document.getElementById(config.label); + const attr = labeledby ? 'labeledby' : 'label'; + + elements.dialog.setAttribute('role', role); + elements.dialog.setAttribute('aria-modal', true); + elements.dialog.setAttribute(`aria-${attr}`, config.label); + + if (document.getElementById(config.description)) { + elements.dialog.setAttribute('aria-describedby', config.description); + } else if (config.description) { + throw new Error(`Invalid element: No element with the id "${config.description}"`); + } + + initiated = true; + + Emitter.emit('create', elements.dialog); + } + + /** + * Open + */ + function open() { + if (isOpen || !initiated) return; + + isOpen = true; + + elements.dialog.classList.add(config.openClass); + Emitter.emit('open', elements.dialog); + + document.addEventListener('keydown', onKeydown, true); + + trap.activate(); + } + + /** + * Close + */ + function close() { + if (!isOpen || !initiated) return; + + trap.deactivate(); + + isOpen = false; + + document.removeEventListener('keydown', onKeydown, true); + + elements.dialog.classList.remove(config.openClass); + Emitter.emit('close', elements.dialog); + } + + /** + * Toggle + */ + function toggle(toggle = !isOpen) { + toggle ? open() : close(); + } + + /** + * Destroy + */ + function destroy() { + const attributes = [ 'aria-describedby', 'aria-label', 'aria-labeledby', 'aria-modal', 'role' ]; + + close(); + initiated = false; + attributes.forEach(attr => elements.dialog.removeAttribute(attr)); + Emitter.emit('destroy', elements.dialog); + } + + if (config.autoInit) { + create(); + } + + return { elements, create, destroy, open, close, toggle, isOpen, on: Emitter.on, off: Emitter.off }; +} + +export default dialog; diff --git a/src/selectors.js b/src/selectors.js deleted file mode 100644 index 8b57323..0000000 --- a/src/selectors.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * List of selectors for elements that are focusable via the keyborad. - */ -const selectors = [ - '[contenteditable]', - '[tabindex]:not([tabindex^="-"])', - 'a[href]', - 'area[href]', - 'button:not([disabled])', - 'embed', - 'iframe', - 'input:not([disabled])', - 'object', - 'select:not([disabled])', - 'textarea:not([disabled])', -]; - -export default selectors; diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index dc4b350..0000000 --- a/src/utils.js +++ /dev/null @@ -1,38 +0,0 @@ -import selectors from './selectors'; - -/** - * Get a NodeList of all focusable child nodes - * @param {HTMLElement} node - * @return {NodeList} - */ -export function getFocusableElements(node) { - return node.querySelectorAll(selectors); -} - -/** - * Focuses the first focusable element within a node - * @param {HTMLElement} node - */ -export function focusFirstElement(node) { - const nodes = node.querySelectorAll(selectors); - nodes.length ? nodes[0].focus() : node.focus(); -} - -/** - * Traps tab key within a given node - * @param {HTMLElment} node - */ -export function trapTabKey(node, event) { - const activeElement = document.activeElement; - const focusableElements = getFocusableElements(node); - const firstTabStop = focusableElements[0]; - const lastTabStop = focusableElements[focusableElements.length - 1]; - - if (event.shiftKey && activeElement === firstTabStop) { - lastTabStop.focus(); - event.preventDefault(); - } else if (!event.shiftKey && activeElement === lastTabStop) { - firstTabStop.focus(); - event.preventDefault(); - } -}