| <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]) { |
| cursor: pointer; |
| } |
| .dialog { |
| box-sizing: border-box; |
| background: white; |
| max-width: 90%; |
| max-height: 95%; |
| overflow: auto; |
| 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> |
| <div id="dialog" class="dialog" role="dialog"> |
| <slot></slot> |
| </div> |
| </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'); |
| } |
| ); |
| 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) { |
| // 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._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> |