<!-- @license Copyright (c) 2017 Vaadin Ltd. This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ --> <link rel="import" href="../polymer/polymer-element.html"> <link rel="import" href="../polymer/lib/mixins/gesture-event-listeners.html"> <link rel="import" href="../vaadin-overlay/vaadin-overlay.html"> <link rel="import" href="vaadin-contextmenu-event.html"> <link rel="import" href="vaadin-device-detector.html"> <dom-module id="vaadin-context-menu-overlay-styles" theme-for="vaadin-context-menu-overlay"> <template> <style> :host { align-items: flex-start; justify-content: flex-start; } :host([phone]) { top: 0 !important; right: 0 !important; bottom: 0 !important; left: 0 !important; padding: 24px; align-items: stretch; justify-content: flex-end; } :host([phone]) [part="content"] { /* Ideally should be 100vh but iOS phone addr-bar covers view port */ max-height: 80vh; } </style> </template> </dom-module> <dom-module id="vaadin-context-menu-overlay"> <script> { /** * The overlay element. * * ### Styling * * See [`<vaadin-overlay>` documentation](https://github.com/vaadin/vaadin-overlay/blob/master/vaadin-overlay.html) * for `<vaadin-context-menu-overlay>` parts. * * @memberof Vaadin */ class VaadinContextMenuOverlayElement extends Vaadin.OverlayElement { static get is() { return 'vaadin-context-menu-overlay'; } } customElements.define(VaadinContextMenuOverlayElement.is, VaadinContextMenuOverlayElement); } </script> </dom-module> <dom-module id="vaadin-context-menu"> <template> <style> :host { display: block; } </style> <slot id="slot"></slot> <vaadin-device-detector phone="{{_phone}}"></vaadin-device-detector> <vaadin-context-menu-overlay id="overlay" on-opened-changed="_onOverlayOpened" with-backdrop="[[_phone]]" phone$="[[_phone]]"> </vaadin-context-menu-overlay> </template> <script> { /** * ```html * <vaadin-context-menu> * <template> * <paper-listbox> * <paper-item>First menu item</paper-item> * <paper-item>Second menu item</paper-item> * </paper-listbox> * </template> * </vaadin-context-menu> * ``` * * ### “vaadin-contextmenu” Gesture Event * * `vaadin-contextmenu` is a gesture event (a custom event fired by Polymer), * which is dispatched after either `contextmenu` and long touch events. * This enables support for both mouse and touch environments in a uniform way. * * `<vaadin-context-menu>` opens the menu overlay on the `vaadin-contextmenu` * event by default. * * ### Menu Listener * * By default, the `<vaadin-context-menu>` element listens for the menu opening * event on itself. In order to have a context menu on your content, wrap * your content with the `<vaadin-context-menu>` element, and add a template * element with a menu. Example: * * ```html * <vaadin-context-menu> * <template> * <paper-listbox> * <paper-item>First menu item</paper-item> * <paper-item>Second menu item</paper-item> * </paper-listbox> * </template> * * <p>This paragraph has the context menu provided in the above template.</p> * <p>Another paragraph with the context menu.</p> * </vaadin-context-menu> * ``` * * In case if you do not want to wrap the page content, you can listen for * events on an element outside the `<vaadin-context-menu>` by setting the * `listenOn` property: * * ```html * <vaadin-context-menu id="customListener"> * <template> * <paper-listbox> * ... * </paper-listbox> * </template> * </vaadin-context-menu> * * <div id="menuListener">The element that listens for the context menu.</div> * * <script> * const contextMenu = document.querySelector('vaadin-context-menu#customListener'); * contextMenu.listenOn = document.querySelector('#menuListener'); * </script> * ``` * * ### Filtering Menu Targets * * By default, the listener element and all its descendants open the context * menu. You can filter the menu targets to a smaller set of elements inside * the listener element by setting the `selector` property. * * In the following example, only the elements matching `.has-menu` will open the context menu: * * ```html * <vaadin-context-menu selector=".has-menu"> * <template> * <paper-listbox> * ... * </paper-listbox> * </template> * * <p class="has-menu">This paragraph opens the context menu</p> * <p>This paragraph does not open the context menu</p> * </vaadin-context-menu> * ``` * * ### Menu Context * * You can bind to the following properties in the menu template: * * - `target` is the menu opening event target, which is the element that * the user has called the context menu for * - `detail` is the menu opening event detail * * In the following example, the menu item text is composed with the contents * of the element that opened the menu: * * ```html * <vaadin-context-menu selector="li"> * <template> * <paper-listbox> * <paper-item>The menu target: [[target.textContent]]</paper-item> * </paper-listbox> * </template> * * <ul> * <li>Foo</li> * <li>Bar</li> * <li>Baz</li> * </ul> * </vaadin-context-menu> * ``` * * ### Styling * * [Generic styling/theming documentation](https://cdn.vaadin.com/vaadin-valo-theme/0.3.1/demo/customization.html) * * See [`<vaadin-overlay>` documentation](https://github.com/vaadin/vaadin-overlay/blob/master/vaadin-overlay.html) * for `<vaadin-context-menu-overlay>` parts. * * @memberof Vaadin * @mixes Polymer.GestureEventListeners * @demo demo/index.html */ class ContextMenuElement extends Polymer.GestureEventListeners(Polymer.Element) { static get is() { return 'vaadin-context-menu'; } static get properties() { return { /** * CSS selector that can be used to target any child element * of the context menu to listen for `openOn` events. */ selector: { type: String }, /** * True if the overlay is currently displayed. */ opened: { type: Boolean, value: false, notify: true, readOnly: true }, /** * Event name to listen for opening the context menu. */ openOn: { type: String, value: 'vaadin-contextmenu' }, /** * The target element that's listened to for context menu opening events. * By default the vaadin-context-menu listens to the target's `vaadin-contextmenu` * events. * @type {HTMLElement} * @default self */ listenOn: { type: Object, value: function() { return this; } }, /** * Event name to listen for closing the context menu. */ closeOn: { type: String, value: 'click', observer: '_closeOnChanged' }, _context: Object, _contentTemplate: Object, _boundClose: Object, _boundOpen: Object, _templateClass: Object }; } static get observers() { return [ '_openedChanged(opened)', '_contextChanged(_context, _instance)', '_targetOrOpenOnChanged(listenOn, openOn)' ]; } constructor() { super(); this._boundOpen = this.open.bind(this); this._boundClose = this.close.bind(this); } ready() { super.ready(); this.$.overlay.addEventListener('contextmenu', e => { this.close(); this._preventDefault(e); }); } _onOverlayOpened(e) { if (e.detail.value === true) { // wait for a microtask before focusing the child element // to allow overlay to position itself correctly first. // Otherwise, the browser window will jump scroll. Polymer.Async.microTask.run(() => { const child = this.$.overlay.root.querySelector('#content :not(style):not(slot)'); if (child) { child.focus(); } }); } else if (e.detail.value === false) { this._setOpened(false); } } _targetOrOpenOnChanged(listenOn, openOn) { if (this._oldListenOn && this._oldOpenOn) { this._unlisten(this._oldListenOn, this._oldOpenOn, this._boundOpen); this._oldListenOn.style.webkitTouchCallout = ''; this._oldListenOn.style.webkitUserSelect = ''; this._oldListenOn = null; this._oldOpenOn = null; } if (listenOn && openOn) { this._listen(listenOn, openOn, this._boundOpen); // note: these styles don't seem to work in Firefox on iOS. listenOn.style.webkitTouchCallout = 'none'; listenOn.style.webkitUserSelect = 'none'; this._oldListenOn = listenOn; this._oldOpenOn = openOn; } } _closeOnChanged(closeOn, oldCloseOn) { // Listen on this.$.overlay.root to workaround issue on // ShadyDOM polyfill: https://github.com/webcomponents/shadydom/issues/159 // Outside click event from overlay const evtOverlay = 'vaadin-overlay-outside-click'; if (oldCloseOn) { this._unlisten(this.$.overlay, oldCloseOn, this._boundClose); this._unlisten(this.$.overlay.root, oldCloseOn, this._boundClose); } if (closeOn) { this._listen(this.$.overlay, closeOn, this._boundClose); this._listen(this.$.overlay.root, closeOn, this._boundClose); this._unlisten(this.$.overlay, evtOverlay, this._preventDefault); } else { this._listen(this.$.overlay, evtOverlay, this._preventDefault); } } _preventDefault(e) { e.preventDefault(); } _openedChanged(opened) { if (opened && !this._instance) { this._contentTemplate = this.querySelector('template'); const Templatizer = Polymer.Templatize.templatize(this._contentTemplate, this, { instanceProps: { detail: true, target: true }, forwardHostProp: function(prop, value) { if (this._instance) { this._instance.forwardHostProp(prop, value); } } }); this._instance = new Templatizer({}); this.$.overlay.content = this._instance.root; } this.$.overlay.opened = opened; } _contextChanged(context, instance) { if (context === undefined || instance === undefined) { return; } instance.detail = context.detail; instance.target = context.target; } /** * Closes the overlay. */ close() { this._setOpened(false); } _contextTarget(e) { if (this.selector) { const targets = this.listenOn.querySelectorAll(this.selector); return Array.prototype.filter.call(targets, el => { return e.composedPath().indexOf(el) > -1; })[0]; } else { return e.target; } } /** * Opens the overlay. * @param {Event} e used as the context for the menu. Overlay coordinates are taken from this event. */ open(e) { if (e && !this.opened) { this._context = { detail: e.detail, target: this._contextTarget(e) }; if (this._context.target) { this._preventDefault(e); e.stopPropagation(); this.$.overlay.opened = true; this.$.overlay.style.left = this._getEventCoordinate(e, 'x') + 'px'; const top = this._getEventCoordinate(e, 'y'); this.$.overlay.style.top = top + 'px'; this._setOpened(true); } } } _getEventCoordinate(event, coord) { if (event.detail instanceof Object) { if (event.detail[coord]) { // Polymer gesture events, get coordinate from detail return event.detail[coord]; } else if (event.detail.sourceEvent) { // Unwrap detailed event return this._getEventCoordinate(event.detail.sourceEvent, coord); } } else { // Native mouse or touch event const prop = 'client' + coord.toUpperCase(); return event.changedTouches ? event.changedTouches[0][prop] : event[prop]; } } _listen(node, evType, handler) { if (Polymer.Gestures.gestures[evType]) { Polymer.Gestures.addListener(node, evType, handler); } else { node.addEventListener(evType, handler); } } _unlisten(node, evType, handler) { if (Polymer.Gestures.gestures[evType]) { Polymer.Gestures.removeListener(node, evType, handler); } else { node.removeEventListener(evType, handler); } } } customElements.define(ContextMenuElement.is, ContextMenuElement); /** * @namespace Vaadin */ window.Vaadin = window.Vaadin || {}; Vaadin.ContextMenuElement = ContextMenuElement; } </script> </dom-module>