blob: 4cdae49f2354d76307a927dbb1a4dcd4c56ba5f8 [file] [log] [blame]
<link rel="import" href="../polymer/polymer.html">
<link rel="import" href="../iron-overlay-behavior/iron-focusables-helper.html">
<dom-module id="chops-dialog">
<template>
<style>
:host {
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
}
:host(:not([opened])), [hidden] {
display: none;
visibility: hidden;
}
:host([close-on-outside-click]),
:host([close-on-outside-click]) .dialog::backdrop {
/* TODO(zhangtiff): Deprecate custom backdrop in favor of native
* browser backdrop.
*/
cursor: pointer;
}
.dialog {
background: none;
border: 0;
overflow: auto;
max-width: 90%;
}
.dialog-content {
/* This extra div is here because otherwise the browser can't
* differentiate between a click event that hits the dialog element or
* its backdrop pseudoelement.
*/
box-sizing: border-box;
background: white;
padding: 1em 16px;
cursor: default;
box-shadow: 0px 3px 20px 0px hsla(0, 0%, 0%, 0.4);
/*
* Would have really preferred to just have the dialog itself be the
* :host element so that users could style it more easily, but this
* causes issues with the backdrop, because there's not a good way to
* make a child element appear behind its parent without using wrappers.
*/
@apply --chops-dialog-theme;
}
</style>
<dialog id="dialog" class="dialog" role="dialog">
<div class="dialog-content">
<slot></slot>
</div>
</dialog>
</template>
<script>
(function(window) {
'use strict';
const ESC_KEYCODE = 27;
const TAB_KEYCODE = 9;
/**
* `<chops-dialog>` displays a modal/dialog overlay.
*
* @customElement
* @polymer
* @demo /demo/chops-dialog_demo.html
*/
class ChopsDialog extends Polymer.mixinBehaviors([Polymer.IronFocusablesHelper], Polymer.Element) {
static get is() { return 'chops-dialog'; }
static get properties() {
return {
/**
* Whether the dialog should currently be displayed or not.
*/
opened: {
type: Boolean,
notify: true,
value: false,
reflectToAttribute: true,
observer: '_openedChanged',
},
/**
* A boolean that determines whether clicking outside of the dialog
* window should close it.
*/
closeOnOutsideClick: {
type: Boolean,
value: true,
reflectToAttribute: true,
},
/**
* A function fired when the element tries to change its own opened
* state. This is useful if you want the dialog state managed outside
* of the dialog instead of with internal state. (ie: with Redux)
*/
onOpenedChange: Function,
/**
* Allow people to exit the dialog using keyboard shortcuts. Defaults
* to the escape key.
*/
exitKeys: {
type: Array,
value: [ESC_KEYCODE],
},
_boundKeydownHandler: {
type: Function,
value: function() {
return this._keydownHandler.bind(this);
},
},
_previousFocusedElement: Object,
}
}
ready() {
super.ready();
this.addEventListener('click', (evt) => {
if (!this.opened || !this.closeOnOutsideClick) return;
const hasDialog = evt.composedPath().find(
(node) => {
return node.classList && node.classList.contains('dialog-content');
}
);
if (hasDialog) return;
this.close();
});
}
connectedCallback() {
super.connectedCallback();
window.addEventListener('keydown', this._boundKeydownHandler, true);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('keydown', this._boundKeydownHandler,
true);
}
_keydownHandler(event) {
if (!this.opened) return;
let keyCode = event.keyCode;
// Handle closing hot keys.
if (this.exitKeys.includes(keyCode)) {
this.close();
}
// For accessibility, prevent tabbing outside of the dialog.
// Key code 9 is tab.
if (keyCode === TAB_KEYCODE) {
const tabbables = this.getTabbableNodes(this);
const active = this._getActiveElement();
if (!tabbables || !tabbables.length) {
event.preventDefault();
}
if (event.shiftKey) {
// backwards tab.
if (active === tabbables[0]) {
event.preventDefault();
tabbables[tabbables.length - 1].focus();
}
} else {
// forward tab.
if (active === tabbables[tabbables.length - 1]) {
event.preventDefault();
tabbables[0].focus();
}
}
}
}
close() {
if (this.onOpenedChange) {
this.onOpenedChange(false);
} else {
this.opened = false;
}
}
open() {
if (this.onOpenedChange) {
this.onOpenedChange(true);
} else {
this.opened = true;
}
}
toggle() {
this.opened = !this.opened;
}
_getActiveElement() {
// document.activeElement alone isn't sufficient to find the active
// element within shadow dom.
let active = document.activeElement || document.body;
while (active.root && Polymer.dom(active.root).activeElement) {
active = Polymer.dom(active.root).activeElement;
}
return active;
}
_openedChanged(opened) {
if (opened) {
if (this.$.dialog.showModal) {
this.$.dialog.showModal();
}
// For accessibility, we want to ensure we remember the element that was
// focused before this dialog opened.
this._previousFocusedElement = this._getActiveElement();
// Focus the first element within the dialog when it's opened.
const tabbables = this.getTabbableNodes(this);
if (tabbables && tabbables.length) {
tabbables[0].focus();
} else if (this._previousFocusedElement) {
this._previousFocusedElement.blur();
}
} else {
if (this.$.dialog.close) {
this.$.dialog.close();
}
if (this._previousFocusedElement) {
const element = this._previousFocusedElement;
setTimeout(function() {
// HACK. This is to prevent a possible accessibility bug where
// using a keypress to trigger a button that exits a modal causes
// the modal to immediately re-open because the button that
// originally opened the modal refocuses, and the keypress
// propagates.
element.focus();
}, 1);
}
}
}
}
customElements.define(ChopsDialog.is, ChopsDialog);
})(window);
</script>
<dom-module>