Home | History | Annotate | Download | only in iron-overlay-behavior
      1 <!--
      2 @license
      3 Copyright (c) 2015 The Polymer Project Authors. All rights reserved.
      4 This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
      5 The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
      6 The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
      7 Code distributed by Google as part of the polymer project is also
      8 subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
      9 -->
     10 
     11 <link rel="import" href="../polymer/polymer.html">
     12 <link rel="import" href="../iron-fit-behavior/iron-fit-behavior.html">
     13 <link rel="import" href="../iron-resizable-behavior/iron-resizable-behavior.html">
     14 <link rel="import" href="iron-overlay-manager.html">
     15 
     16 <script>
     17 (function() {
     18 'use strict';
     19 
     20 /**
     21 Use `Polymer.IronOverlayBehavior` to implement an element that can be hidden or shown, and displays
     22 on top of other content. It includes an optional backdrop, and can be used to implement a variety
     23 of UI controls including dialogs and drop downs. Multiple overlays may be displayed at once.
     24 
     25 See the [demo source code](https://github.com/PolymerElements/iron-overlay-behavior/blob/master/demo/simple-overlay.html)
     26 for an example.
     27 
     28 ### Closing and canceling
     29 
     30 An overlay may be hidden by closing or canceling. The difference between close and cancel is user
     31 intent. Closing generally implies that the user acknowledged the content on the overlay. By default,
     32 it will cancel whenever the user taps outside it or presses the escape key. This behavior is
     33 configurable with the `no-cancel-on-esc-key` and the `no-cancel-on-outside-click` properties.
     34 `close()` should be called explicitly by the implementer when the user interacts with a control
     35 in the overlay element. When the dialog is canceled, the overlay fires an 'iron-overlay-canceled'
     36 event. Call `preventDefault` on this event to prevent the overlay from closing.
     37 
     38 ### Positioning
     39 
     40 By default the element is sized and positioned to fit and centered inside the window. You can
     41 position and size it manually using CSS. See `Polymer.IronFitBehavior`.
     42 
     43 ### Backdrop
     44 
     45 Set the `with-backdrop` attribute to display a backdrop behind the overlay. The backdrop is
     46 appended to `<body>` and is of type `<iron-overlay-backdrop>`. See its doc page for styling
     47 options.
     48 
     49 In addition, `with-backdrop` will wrap the focus within the content in the light DOM.
     50 Override the [`_focusableNodes` getter](#Polymer.IronOverlayBehavior:property-_focusableNodes)
     51 to achieve a different behavior.
     52 
     53 ### Limitations
     54 
     55 The element is styled to appear on top of other content by setting its `z-index` property. You
     56 must ensure no element has a stacking context with a higher `z-index` than its parent stacking
     57 context. You should place this element as a child of `<body>` whenever possible.
     58 
     59 @demo demo/index.html
     60 @polymerBehavior Polymer.IronOverlayBehavior
     61 */
     62 
     63   Polymer.IronOverlayBehaviorImpl = {
     64 
     65     properties: {
     66 
     67       /**
     68        * True if the overlay is currently displayed.
     69        */
     70       opened: {
     71         observer: '_openedChanged',
     72         type: Boolean,
     73         value: false,
     74         notify: true
     75       },
     76 
     77       /**
     78        * True if the overlay was canceled when it was last closed.
     79        */
     80       canceled: {
     81         observer: '_canceledChanged',
     82         readOnly: true,
     83         type: Boolean,
     84         value: false
     85       },
     86 
     87       /**
     88        * Set to true to display a backdrop behind the overlay. It traps the focus
     89        * within the light DOM of the overlay.
     90        */
     91       withBackdrop: {
     92         observer: '_withBackdropChanged',
     93         type: Boolean
     94       },
     95 
     96       /**
     97        * Set to true to disable auto-focusing the overlay or child nodes with
     98        * the `autofocus` attribute` when the overlay is opened.
     99        */
    100       noAutoFocus: {
    101         type: Boolean,
    102         value: false
    103       },
    104 
    105       /**
    106        * Set to true to disable canceling the overlay with the ESC key.
    107        */
    108       noCancelOnEscKey: {
    109         type: Boolean,
    110         value: false
    111       },
    112 
    113       /**
    114        * Set to true to disable canceling the overlay by clicking outside it.
    115        */
    116       noCancelOnOutsideClick: {
    117         type: Boolean,
    118         value: false
    119       },
    120 
    121       /**
    122        * Contains the reason(s) this overlay was last closed (see `iron-overlay-closed`).
    123        * `IronOverlayBehavior` provides the `canceled` reason; implementers of the
    124        * behavior can provide other reasons in addition to `canceled`.
    125        */
    126       closingReason: {
    127         // was a getter before, but needs to be a property so other
    128         // behaviors can override this.
    129         type: Object
    130       },
    131 
    132       /**
    133        * Set to true to enable restoring of focus when overlay is closed.
    134        */
    135       restoreFocusOnClose: {
    136         type: Boolean,
    137         value: false
    138       },
    139 
    140       /**
    141        * Set to true to keep overlay always on top.
    142        */
    143       alwaysOnTop: {
    144         type: Boolean
    145       },
    146 
    147       /**
    148        * Shortcut to access to the overlay manager.
    149        * @private
    150        * @type {Polymer.IronOverlayManagerClass}
    151        */
    152       _manager: {
    153         type: Object,
    154         value: Polymer.IronOverlayManager
    155       },
    156 
    157       /**
    158        * The node being focused.
    159        * @type {?Node}
    160        */
    161       _focusedChild: {
    162         type: Object
    163       }
    164 
    165     },
    166 
    167     listeners: {
    168       'iron-resize': '_onIronResize'
    169     },
    170 
    171     /**
    172      * The backdrop element.
    173      * @type {Element}
    174      */
    175     get backdropElement() {
    176       return this._manager.backdropElement;
    177     },
    178 
    179     /**
    180      * Returns the node to give focus to.
    181      * @type {Node}
    182      */
    183     get _focusNode() {
    184       return this._focusedChild || Polymer.dom(this).querySelector('[autofocus]') || this;
    185     },
    186 
    187     /**
    188      * Array of nodes that can receive focus (overlay included), ordered by `tabindex`.
    189      * This is used to retrieve which is the first and last focusable nodes in order
    190      * to wrap the focus for overlays `with-backdrop`.
    191      *
    192      * If you know what is your content (specifically the first and last focusable children),
    193      * you can override this method to return only `[firstFocusable, lastFocusable];`
    194      * @type {Array<Node>}
    195      * @protected
    196      */
    197     get _focusableNodes() {
    198       // Elements that can be focused even if they have [disabled] attribute.
    199       var FOCUSABLE_WITH_DISABLED = [
    200         'a[href]',
    201         'area[href]',
    202         'iframe',
    203         '[tabindex]',
    204         '[contentEditable=true]'
    205       ];
    206 
    207       // Elements that cannot be focused if they have [disabled] attribute.
    208       var FOCUSABLE_WITHOUT_DISABLED = [
    209         'input',
    210         'select',
    211         'textarea',
    212         'button'
    213       ];
    214 
    215       // Discard elements with tabindex=-1 (makes them not focusable).
    216       var selector = FOCUSABLE_WITH_DISABLED.join(':not([tabindex="-1"]),') +
    217         ':not([tabindex="-1"]),' +
    218         FOCUSABLE_WITHOUT_DISABLED.join(':not([disabled]):not([tabindex="-1"]),') +
    219         ':not([disabled]):not([tabindex="-1"])';
    220 
    221       var focusables = Polymer.dom(this).querySelectorAll(selector);
    222       if (this.tabIndex >= 0) {
    223         // Insert at the beginning because we might have all elements with tabIndex = 0,
    224         // and the overlay should be the first of the list.
    225         focusables.splice(0, 0, this);
    226       }
    227       // Sort by tabindex.
    228       return focusables.sort(function (a, b) {
    229         if (a.tabIndex === b.tabIndex) {
    230           return 0;
    231         }
    232         if (a.tabIndex === 0 || a.tabIndex > b.tabIndex) {
    233           return 1;
    234         }
    235         return -1;
    236       });
    237     },
    238 
    239     ready: function() {
    240       // Used to skip calls to notifyResize and refit while the overlay is animating.
    241       this.__isAnimating = false;
    242       // with-backdrop needs tabindex to be set in order to trap the focus.
    243       // If it is not set, IronOverlayBehavior will set it, and remove it if with-backdrop = false.
    244       this.__shouldRemoveTabIndex = false;
    245       // Used for wrapping the focus on TAB / Shift+TAB.
    246       this.__firstFocusableNode = this.__lastFocusableNode = null;
    247       // Used for requestAnimationFrame when opened changes.
    248       this.__openChangedAsync = null;
    249       // Used for requestAnimationFrame when iron-resize is fired.
    250       this.__onIronResizeAsync = null;
    251       this._ensureSetup();
    252     },
    253 
    254     attached: function() {
    255       // Call _openedChanged here so that position can be computed correctly.
    256       if (this.opened) {
    257         this._openedChanged();
    258       }
    259       this._observer = Polymer.dom(this).observeNodes(this._onNodesChange);
    260     },
    261 
    262     detached: function() {
    263       Polymer.dom(this).unobserveNodes(this._observer);
    264       this._observer = null;
    265       this.opened = false;
    266     },
    267 
    268     /**
    269      * Toggle the opened state of the overlay.
    270      */
    271     toggle: function() {
    272       this._setCanceled(false);
    273       this.opened = !this.opened;
    274     },
    275 
    276     /**
    277      * Open the overlay.
    278      */
    279     open: function() {
    280       this._setCanceled(false);
    281       this.opened = true;
    282     },
    283 
    284     /**
    285      * Close the overlay.
    286      */
    287     close: function() {
    288       this._setCanceled(false);
    289       this.opened = false;
    290     },
    291 
    292     /**
    293      * Cancels the overlay.
    294      * @param {Event=} event The original event
    295      */
    296     cancel: function(event) {
    297       var cancelEvent = this.fire('iron-overlay-canceled', event, {cancelable: true});
    298       if (cancelEvent.defaultPrevented) {
    299         return;
    300       }
    301 
    302       this._setCanceled(true);
    303       this.opened = false;
    304     },
    305 
    306     _ensureSetup: function() {
    307       if (this._overlaySetup) {
    308         return;
    309       }
    310       this._overlaySetup = true;
    311       this.style.outline = 'none';
    312       this.style.display = 'none';
    313     },
    314 
    315     _openedChanged: function() {
    316       if (this.opened) {
    317         this.removeAttribute('aria-hidden');
    318       } else {
    319         this.setAttribute('aria-hidden', 'true');
    320       }
    321 
    322       // wait to call after ready only if we're initially open
    323       if (!this._overlaySetup) {
    324         return;
    325       }
    326 
    327       if (this.__openChangedAsync) {
    328         window.cancelAnimationFrame(this.__openChangedAsync);
    329       }
    330 
    331       // Synchronously remove the overlay.
    332       // The adding is done asynchronously to go out of the scope of the event
    333       // which might have generated the opening.
    334       if (!this.opened) {
    335         this._manager.removeOverlay(this);
    336       }
    337 
    338       // Defer any animation-related code on attached
    339       // (_openedChanged gets called again on attached).
    340       if (!this.isAttached) {
    341         return;
    342       }
    343 
    344       this.__isAnimating = true;
    345 
    346       // requestAnimationFrame for non-blocking rendering
    347       this.__openChangedAsync = window.requestAnimationFrame(function() {
    348         this.__openChangedAsync = null;
    349         if (this.opened) {
    350           this._manager.addOverlay(this);
    351           this._prepareRenderOpened();
    352           this._renderOpened();
    353         } else {
    354           this._renderClosed();
    355         }
    356       }.bind(this));
    357     },
    358 
    359     _canceledChanged: function() {
    360       this.closingReason = this.closingReason || {};
    361       this.closingReason.canceled = this.canceled;
    362     },
    363 
    364     _withBackdropChanged: function() {
    365       // If tabindex is already set, no need to override it.
    366       if (this.withBackdrop && !this.hasAttribute('tabindex')) {
    367         this.setAttribute('tabindex', '-1');
    368         this.__shouldRemoveTabIndex = true;
    369       } else if (this.__shouldRemoveTabIndex) {
    370         this.removeAttribute('tabindex');
    371         this.__shouldRemoveTabIndex = false;
    372       }
    373       if (this.opened && this.isAttached) {
    374         this._manager.trackBackdrop();
    375       }
    376     },
    377 
    378     /**
    379      * tasks which must occur before opening; e.g. making the element visible.
    380      * @protected
    381      */
    382     _prepareRenderOpened: function() {
    383 
    384       // Needed to calculate the size of the overlay so that transitions on its size
    385       // will have the correct starting points.
    386       this._preparePositioning();
    387       this.refit();
    388       this._finishPositioning();
    389 
    390       // Safari will apply the focus to the autofocus element when displayed for the first time,
    391       // so we blur it. Later, _applyFocus will set the focus if necessary.
    392       if (this.noAutoFocus && document.activeElement === this._focusNode) {
    393         this._focusNode.blur();
    394       }
    395     },
    396 
    397     /**
    398      * Tasks which cause the overlay to actually open; typically play an animation.
    399      * @protected
    400      */
    401     _renderOpened: function() {
    402       this._finishRenderOpened();
    403     },
    404 
    405     /**
    406      * Tasks which cause the overlay to actually close; typically play an animation.
    407      * @protected
    408      */
    409     _renderClosed: function() {
    410       this._finishRenderClosed();
    411     },
    412 
    413     /**
    414      * Tasks to be performed at the end of open action. Will fire `iron-overlay-opened`.
    415      * @protected
    416      */
    417     _finishRenderOpened: function() {
    418       // Focus the child node with [autofocus]
    419       this._applyFocus();
    420 
    421       this.notifyResize();
    422       this.__isAnimating = false;
    423 
    424       // Store it so we don't query too much.
    425       var focusableNodes = this._focusableNodes;
    426       this.__firstFocusableNode = focusableNodes[0];
    427       this.__lastFocusableNode = focusableNodes[focusableNodes.length - 1];
    428 
    429       this.fire('iron-overlay-opened');
    430     },
    431 
    432     /**
    433      * Tasks to be performed at the end of close action. Will fire `iron-overlay-closed`.
    434      * @protected
    435      */
    436     _finishRenderClosed: function() {
    437       // Hide the overlay and remove the backdrop.
    438       this.style.display = 'none';
    439       // Reset z-index only at the end of the animation.
    440       this.style.zIndex = '';
    441 
    442       this._applyFocus();
    443 
    444       this.notifyResize();
    445       this.__isAnimating = false;
    446       this.fire('iron-overlay-closed', this.closingReason);
    447     },
    448 
    449     _preparePositioning: function() {
    450       this.style.transition = this.style.webkitTransition = 'none';
    451       this.style.transform = this.style.webkitTransform = 'none';
    452       this.style.display = '';
    453     },
    454 
    455     _finishPositioning: function() {
    456       // First, make it invisible & reactivate animations.
    457       this.style.display = 'none';
    458       // Force reflow before re-enabling animations so that they don't start.
    459       // Set scrollTop to itself so that Closure Compiler doesn't remove this.
    460       this.scrollTop = this.scrollTop;
    461       this.style.transition = this.style.webkitTransition = '';
    462       this.style.transform = this.style.webkitTransform = '';
    463       // Now that animations are enabled, make it visible again
    464       this.style.display = '';
    465       // Force reflow, so that following animations are properly started.
    466       // Set scrollTop to itself so that Closure Compiler doesn't remove this.
    467       this.scrollTop = this.scrollTop;
    468     },
    469 
    470     /**
    471      * Applies focus according to the opened state.
    472      * @protected
    473      */
    474     _applyFocus: function() {
    475       if (this.opened) {
    476         if (!this.noAutoFocus) {
    477           this._focusNode.focus();
    478         }
    479       } else {
    480         this._focusNode.blur();
    481         this._focusedChild = null;
    482         this._manager.focusOverlay();
    483       }
    484     },
    485 
    486     /**
    487      * Cancels (closes) the overlay. Call when click happens outside the overlay.
    488      * @param {!Event} event
    489      * @protected
    490      */
    491     _onCaptureClick: function(event) {
    492       if (!this.noCancelOnOutsideClick) {
    493         this.cancel(event);
    494       }
    495     },
    496 
    497     /**
    498      * Keeps track of the focused child. If withBackdrop, traps focus within overlay.
    499      * @param {!Event} event
    500      * @protected
    501      */
    502     _onCaptureFocus: function (event) {
    503       if (!this.withBackdrop) {
    504         return;
    505       }
    506       var path = Polymer.dom(event).path;
    507       if (path.indexOf(this) === -1) {
    508         event.stopPropagation();
    509         this._applyFocus();
    510       } else {
    511         this._focusedChild = path[0];
    512       }
    513     },
    514 
    515     /**
    516      * Handles the ESC key event and cancels (closes) the overlay.
    517      * @param {!Event} event
    518      * @protected
    519      */
    520     _onCaptureEsc: function(event) {
    521       if (!this.noCancelOnEscKey) {
    522         this.cancel(event);
    523       }
    524     },
    525 
    526     /**
    527      * Handles TAB key events to track focus changes.
    528      * Will wrap focus for overlays withBackdrop.
    529      * @param {!Event} event
    530      * @protected
    531      */
    532     _onCaptureTab: function(event) {
    533       if (!this.withBackdrop) {
    534         return;
    535       }
    536       // TAB wraps from last to first focusable.
    537       // Shift + TAB wraps from first to last focusable.
    538       var shift = event.shiftKey;
    539       var nodeToCheck = shift ? this.__firstFocusableNode : this.__lastFocusableNode;
    540       var nodeToSet = shift ? this.__lastFocusableNode : this.__firstFocusableNode;
    541       var shouldWrap = false;
    542       if (nodeToCheck === nodeToSet) {
    543         // If nodeToCheck is the same as nodeToSet, it means we have an overlay
    544         // with 0 or 1 focusables; in either case we still need to trap the
    545         // focus within the overlay.
    546         shouldWrap = true;
    547       } else {
    548         // In dom=shadow, the manager will receive focus changes on the main
    549         // root but not the ones within other shadow roots, so we can't rely on
    550         // _focusedChild, but we should check the deepest active element.
    551         var focusedNode = this._manager.deepActiveElement;
    552         // If the active element is not the nodeToCheck but the overlay itself,
    553         // it means the focus is about to go outside the overlay, hence we
    554         // should prevent that (e.g. user opens the overlay and hit Shift+TAB).
    555         shouldWrap = (focusedNode === nodeToCheck || focusedNode === this);
    556       }
    557 
    558       if (shouldWrap) {
    559         // When the overlay contains the last focusable element of the document
    560         // and it's already focused, pressing TAB would move the focus outside
    561         // the document (e.g. to the browser search bar). Similarly, when the
    562         // overlay contains the first focusable element of the document and it's
    563         // already focused, pressing Shift+TAB would move the focus outside the
    564         // document (e.g. to the browser search bar).
    565         // In both cases, we would not receive a focus event, but only a blur.
    566         // In order to achieve focus wrapping, we prevent this TAB event and
    567         // force the focus. This will also prevent the focus to temporarily move
    568         // outside the overlay, which might cause scrolling.
    569         event.preventDefault();
    570         this._focusedChild = nodeToSet;
    571         this._applyFocus();
    572       }
    573     },
    574 
    575     /**
    576      * Refits if the overlay is opened and not animating.
    577      * @protected
    578      */
    579     _onIronResize: function() {
    580       if (this.__onIronResizeAsync) {
    581         window.cancelAnimationFrame(this.__onIronResizeAsync);
    582         this.__onIronResizeAsync = null;
    583       }
    584       if (this.opened && !this.__isAnimating) {
    585         this.__onIronResizeAsync = window.requestAnimationFrame(function() {
    586           this.__onIronResizeAsync = null;
    587           this.refit();
    588         }.bind(this));
    589       }
    590     },
    591 
    592     /**
    593      * Will call notifyResize if overlay is opened.
    594      * Can be overridden in order to avoid multiple observers on the same node.
    595      * @protected
    596      */
    597     _onNodesChange: function() {
    598       if (this.opened && !this.__isAnimating) {
    599         this.notifyResize();
    600       }
    601     }
    602   };
    603 
    604   /** @polymerBehavior */
    605   Polymer.IronOverlayBehavior = [Polymer.IronFitBehavior, Polymer.IronResizableBehavior, Polymer.IronOverlayBehaviorImpl];
    606 
    607   /**
    608    * Fired after the overlay opens.
    609    * @event iron-overlay-opened
    610    */
    611 
    612   /**
    613    * Fired when the overlay is canceled, but before it is closed.
    614    * @event iron-overlay-canceled
    615    * @param {Event} event The closing of the overlay can be prevented
    616    * by calling `event.preventDefault()`. The `event.detail` is the original event that
    617    * originated the canceling (e.g. ESC keyboard event or click event outside the overlay).
    618    */
    619 
    620   /**
    621    * Fired after the overlay closes.
    622    * @event iron-overlay-closed
    623    * @param {Event} event The `event.detail` is the `closingReason` property
    624    * (contains `canceled`, whether the overlay was canceled).
    625    */
    626 
    627 })();
    628 </script>
    629