• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1<!--
2@license
3Copyright (c) 2015 The Polymer Project Authors. All rights reserved.
4This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
5The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
6The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
7Code distributed by Google as part of the polymer project is also
8subject 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/**
21Use `Polymer.IronOverlayBehavior` to implement an element that can be hidden or shown, and displays
22on top of other content. It includes an optional backdrop, and can be used to implement a variety
23of UI controls including dialogs and drop downs. Multiple overlays may be displayed at once.
24
25See the [demo source code](https://github.com/PolymerElements/iron-overlay-behavior/blob/master/demo/simple-overlay.html)
26for an example.
27
28### Closing and canceling
29
30An overlay may be hidden by closing or canceling. The difference between close and cancel is user
31intent. Closing generally implies that the user acknowledged the content on the overlay. By default,
32it will cancel whenever the user taps outside it or presses the escape key. This behavior is
33configurable 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
35in the overlay element. When the dialog is canceled, the overlay fires an 'iron-overlay-canceled'
36event. Call `preventDefault` on this event to prevent the overlay from closing.
37
38### Positioning
39
40By default the element is sized and positioned to fit and centered inside the window. You can
41position and size it manually using CSS. See `Polymer.IronFitBehavior`.
42
43### Backdrop
44
45Set the `with-backdrop` attribute to display a backdrop behind the overlay. The backdrop is
46appended to `<body>` and is of type `<iron-overlay-backdrop>`. See its doc page for styling
47options.
48
49In addition, `with-backdrop` will wrap the focus within the content in the light DOM.
50Override the [`_focusableNodes` getter](#Polymer.IronOverlayBehavior:property-_focusableNodes)
51to achieve a different behavior.
52
53### Limitations
54
55The element is styled to appear on top of other content by setting its `z-index` property. You
56must ensure no element has a stacking context with a higher `z-index` than its parent stacking
57context. 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