@vaadin/context-menu 22.0.0-alpha7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2021 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { register, prevent } from '@polymer/polymer/lib/utils/gestures.js';
7
+
8
+ register({
9
+ name: 'vaadin-contextmenu',
10
+ deps: ['touchstart', 'touchmove', 'touchend', 'contextmenu'],
11
+ flow: {
12
+ start: ['touchstart', 'contextmenu'],
13
+ end: ['contextmenu']
14
+ },
15
+
16
+ emits: ['vaadin-contextmenu'],
17
+
18
+ info: {
19
+ sourceEvent: null,
20
+ _ios:
21
+ (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream) ||
22
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
23
+ },
24
+
25
+ reset: function () {
26
+ this.info.sourceEvent = null;
27
+ this._cancelTimer();
28
+ this.info.touchJob = null;
29
+ this.info.touchStartCoords = null;
30
+ },
31
+
32
+ _cancelTimer: function () {
33
+ if (this._timerId) {
34
+ clearTimeout(this._timerId);
35
+ delete this._fired;
36
+ }
37
+ },
38
+
39
+ touchstart: function (e) {
40
+ this.info.sourceEvent = e;
41
+ this.info.touchStartCoords = {
42
+ x: e.changedTouches[0].clientX,
43
+ y: e.changedTouches[0].clientY
44
+ };
45
+
46
+ // After timeout event is already retargeted to the parent element in case there is one.
47
+ // So we are assigning the target synchronously on event dispatched.
48
+ const t = e.composedPath()[0] || e.target;
49
+
50
+ this._timerId = setTimeout(() => {
51
+ const ct = e.changedTouches[0];
52
+ if (!e.shiftKey) {
53
+ if (this.info._ios) {
54
+ this._fired = true;
55
+ this.fire(t, ct.clientX, ct.clientY);
56
+ }
57
+
58
+ // needed to prevent any 'tap' gesture events from firing
59
+ // which could potentially cancel/close the overlay.
60
+ prevent('tap');
61
+ }
62
+ }, 500); // default setting for Android and iOS.
63
+ },
64
+
65
+ touchmove: function (e) {
66
+ const moveThreshold = 15;
67
+ const touchStartCoords = this.info.touchStartCoords;
68
+ if (
69
+ Math.abs(touchStartCoords.x - e.changedTouches[0].clientX) > moveThreshold ||
70
+ Math.abs(touchStartCoords.y - e.changedTouches[0].clientY) > moveThreshold
71
+ ) {
72
+ this._cancelTimer();
73
+ }
74
+ },
75
+
76
+ touchend: function (e) {
77
+ if (this._fired) {
78
+ e.preventDefault();
79
+ }
80
+ this._cancelTimer();
81
+ },
82
+
83
+ contextmenu: function (e) {
84
+ if (!e.shiftKey) {
85
+ this.info.sourceEvent = e;
86
+ this.fire(e.target, e.clientX, e.clientY);
87
+ prevent('tap');
88
+ }
89
+ },
90
+
91
+ fire: function (target, x, y) {
92
+ // NOTE(web-padawan): the code below is copied from `Polymer.Gestures._fire`,
93
+ // which is not exported from `gestures.js` module for Polymer 3.
94
+ const sourceEvent = this.info.sourceEvent;
95
+ const ev = new Event('vaadin-contextmenu', { bubbles: true, cancelable: true, composed: true });
96
+ ev.detail = { x, y, sourceEvent };
97
+ target.dispatchEvent(ev);
98
+ // forward `preventDefault` in a clean way
99
+ if (ev.defaultPrevented && sourceEvent && sourceEvent.preventDefault) {
100
+ sourceEvent.preventDefault();
101
+ }
102
+ }
103
+ });
@@ -0,0 +1,74 @@
1
+ import { Item } from '@vaadin/item/src/vaadin-item.js';
2
+
3
+ import { ListBox } from '@vaadin/list-box/src/vaadin-list-box.js';
4
+
5
+ import { ContextMenu } from './vaadin-context-menu.js';
6
+
7
+ import { ContextMenuItem, ContextMenuRendererContext } from './interfaces';
8
+
9
+ /**
10
+ * An element used internally by `<vaadin-context-menu>`. Not intended to be used separately.
11
+ *
12
+ * @protected
13
+ */
14
+ declare class ContextMenuItemElement extends Item {}
15
+
16
+ declare global {
17
+ interface HTMLElementTagNameMap {
18
+ 'vaadin-context-menu-item': ContextMenuItemElement;
19
+ 'vaadin-context-menu-list-box': ContextMenuListBox;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * An element used internally by `<vaadin-context-menu>`. Not intended to be used separately.
25
+ *
26
+ * @protected
27
+ */
28
+ declare class ContextMenuListBox extends ListBox {}
29
+
30
+ declare function ItemsMixin<T extends new (...args: any[]) => {}>(base: T): T & ItemsMixinConstructor;
31
+
32
+ interface ItemsMixinConstructor {
33
+ new (...args: any[]): ItemsMixin;
34
+ }
35
+
36
+ interface ItemsMixin {
37
+ readonly __isRTL: boolean;
38
+
39
+ /**
40
+ * Defines a (hierarchical) menu structure for the component.
41
+ * If a menu item has a non-empty `children` set, a sub-menu with the child items is opened
42
+ * next to the parent menu on mouseover, tap or a right arrow keypress.
43
+ *
44
+ * The items API can't be used together with a renderer!
45
+ *
46
+ * #### Example
47
+ *
48
+ * ```javascript
49
+ * contextMenu.items = [
50
+ * {text: 'Menu Item 1', theme: 'primary', children:
51
+ * [
52
+ * {text: 'Menu Item 1-1', checked: true},
53
+ * {text: 'Menu Item 1-2'}
54
+ * ]
55
+ * },
56
+ * {component: 'hr'},
57
+ * {text: 'Menu Item 2', children:
58
+ * [
59
+ * {text: 'Menu Item 2-1'},
60
+ * {text: 'Menu Item 2-2', disabled: true}
61
+ * ]
62
+ * },
63
+ * {text: 'Menu Item 3', disabled: true}
64
+ * ];
65
+ * ```
66
+ */
67
+ items: ContextMenuItem[] | undefined;
68
+
69
+ __forwardFocus(): void;
70
+
71
+ __itemsRenderer(root: HTMLElement, menu: ContextMenu, context: ContextMenuRendererContext): void;
72
+ }
73
+
74
+ export { ItemsMixin, ItemsMixinConstructor };
@@ -0,0 +1,397 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2021 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { flush } from '@polymer/polymer/lib/utils/flush.js';
7
+ import { Item } from '@vaadin/item/src/vaadin-item.js';
8
+ import { ListBox } from '@vaadin/list-box/src/vaadin-list-box.js';
9
+
10
+ /**
11
+ * An element used internally by `<vaadin-context-menu>`. Not intended to be used separately.
12
+ *
13
+ * @extends Item
14
+ * @protected
15
+ */
16
+ class ContextMenuItemElement extends Item {
17
+ static get is() {
18
+ return 'vaadin-context-menu-item';
19
+ }
20
+ }
21
+
22
+ customElements.define(ContextMenuItemElement.is, ContextMenuItemElement);
23
+
24
+ /**
25
+ * An element used internally by `<vaadin-context-menu>`. Not intended to be used separately.
26
+ *
27
+ * @extends ListBox
28
+ * @protected
29
+ */
30
+ class ContextMenuListBox extends ListBox {
31
+ static get is() {
32
+ return 'vaadin-context-menu-list-box';
33
+ }
34
+ }
35
+
36
+ customElements.define(ContextMenuListBox.is, ContextMenuListBox);
37
+
38
+ /**
39
+ * @polymerMixin
40
+ */
41
+ export const ItemsMixin = (superClass) =>
42
+ class ItemsMixin extends superClass {
43
+ static get properties() {
44
+ return {
45
+ /**
46
+ * @typedef ContextMenuItem
47
+ * @type {object}
48
+ * @property {string} text - Text to be set as the menu item component's textContent
49
+ * @property {union: string | object} component - The component to represent the item.
50
+ * Either a tagName or an element instance. Defaults to "vaadin-context-menu-item".
51
+ * @property {boolean} disabled - If true, the item is disabled and cannot be selected
52
+ * @property {boolean} checked - If true, the item shows a checkmark next to it
53
+ * @property {union: string | string[]} theme - If set, sets the given theme(s) as an attribute to the menu item component, overriding any theme set on the context menu.
54
+ * @property {MenuItem[]} children - Array of child menu items
55
+ */
56
+
57
+ /**
58
+ * Defines a (hierarchical) menu structure for the component.
59
+ * If a menu item has a non-empty `children` set, a sub-menu with the child items is opened
60
+ * next to the parent menu on mouseover, tap or a right arrow keypress.
61
+ *
62
+ * The items API can't be used together with a renderer!
63
+ *
64
+ * #### Example
65
+ *
66
+ * ```javascript
67
+ * contextMenu.items = [
68
+ * {text: 'Menu Item 1', theme: 'primary', children:
69
+ * [
70
+ * {text: 'Menu Item 1-1', checked: true},
71
+ * {text: 'Menu Item 1-2'}
72
+ * ]
73
+ * },
74
+ * {component: 'hr'},
75
+ * {text: 'Menu Item 2', children:
76
+ * [
77
+ * {text: 'Menu Item 2-1'},
78
+ * {text: 'Menu Item 2-2', disabled: true}
79
+ * ]
80
+ * },
81
+ * {text: 'Menu Item 3', disabled: true}
82
+ * ];
83
+ * ```
84
+ *
85
+ * @type {!Array<!ContextMenuItem> | undefined}
86
+ *
87
+ *
88
+ * ### Styling
89
+ *
90
+ * The `<vaadin-context-menu-item>` sub-menu elements have the following additional state attributes on top of
91
+ * the built-in `<vaadin-item>` state attributes (see `<vaadin-item>` documentation for full listing).
92
+ *
93
+ * Part name | Attribute | Description
94
+ * ----------------|----------------|----------------
95
+ * `:host` | expanded | Expanded parent item
96
+ */
97
+ items: Array
98
+ };
99
+ }
100
+
101
+ /** @protected */
102
+ ready() {
103
+ super.ready();
104
+
105
+ // Overlay's outside click listener doesn't work with modeless
106
+ // overlays (submenus) so we need additional logic for it
107
+ this.__itemsOutsideClickListener = (e) => {
108
+ if (!e.composedPath().filter((el) => el.localName === 'vaadin-context-menu-overlay')[0]) {
109
+ this.dispatchEvent(new CustomEvent('items-outside-click'));
110
+ }
111
+ };
112
+ this.addEventListener('items-outside-click', () => this.items && this.close());
113
+ }
114
+
115
+ /** @protected */
116
+ connectedCallback() {
117
+ super.connectedCallback();
118
+ // Firefox leaks click to document on contextmenu even if prevented
119
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=990614
120
+ document.documentElement.addEventListener('click', this.__itemsOutsideClickListener);
121
+ }
122
+
123
+ /** @protected */
124
+ disconnectedCallback() {
125
+ super.disconnectedCallback();
126
+ document.documentElement.removeEventListener('click', this.__itemsOutsideClickListener);
127
+ }
128
+
129
+ /**
130
+ * @return {boolean}
131
+ * @protected
132
+ */
133
+ get __isRTL() {
134
+ return this.getAttribute('dir') === 'rtl';
135
+ }
136
+
137
+ /** @protected */
138
+ __forwardFocus() {
139
+ const overlay = this.$.overlay;
140
+ const child = overlay.getFirstChild();
141
+ // if parent item is not focused, do not focus submenu
142
+ if (overlay.parentOverlay) {
143
+ const parent = overlay.parentOverlay.querySelector('[expanded]');
144
+ if (parent && parent.hasAttribute('focused') && child) {
145
+ child.focus();
146
+ } else {
147
+ overlay.$.overlay.focus();
148
+ }
149
+ } else if (child) {
150
+ child.focus();
151
+ }
152
+ }
153
+
154
+ /** @private */
155
+ __openSubMenu(subMenu, itemElement) {
156
+ subMenu.items = itemElement._item.children;
157
+ subMenu.listenOn = itemElement;
158
+
159
+ const itemRect = itemElement.getBoundingClientRect();
160
+
161
+ const content = subMenu.$.overlay.$.content;
162
+ const style = getComputedStyle(content);
163
+ const parent = this.$.overlay;
164
+ const y = parent.hasAttribute('bottom-aligned')
165
+ ? itemRect.bottom + parseFloat(style.paddingBottom)
166
+ : itemRect.top - parseFloat(style.paddingTop);
167
+
168
+ // Store the reference to align based on parent overlay coordinates
169
+ subMenu.$.overlay._setParentOverlay(parent);
170
+
171
+ // Set theme attribute from parent element
172
+ if (parent.theme) {
173
+ subMenu.setAttribute('theme', parent.theme);
174
+ } else {
175
+ subMenu.removeAttribute('theme');
176
+ }
177
+
178
+ let x;
179
+ content.style.minWidth = '';
180
+ if (document.documentElement.clientWidth - itemRect.right > itemRect.width) {
181
+ // There's room on the right side
182
+ x = itemRect.right;
183
+ } else {
184
+ // Open on the left side
185
+ x = itemRect.left - itemRect.width;
186
+ // Make sure there's no gaps between the menus
187
+ content.style.minWidth = parent.$.content.clientWidth + 'px';
188
+ }
189
+ x = Math.max(x, 0);
190
+
191
+ itemElement.dispatchEvent(
192
+ new CustomEvent('opensubmenu', {
193
+ detail: {
194
+ x,
195
+ y,
196
+ children: itemElement._item.children
197
+ }
198
+ })
199
+ );
200
+ }
201
+
202
+ /**
203
+ * @param {!HTMLElement} root
204
+ * @param {!ContextMenu} menu
205
+ * @param {!ContextMenuRendererContext} context
206
+ * @protected
207
+ */
208
+ __itemsRenderer(root, menu, context) {
209
+ this.__initMenu(root, menu);
210
+
211
+ const subMenu = root.querySelector(this.constructor.is);
212
+ subMenu.closeOn = menu.closeOn;
213
+
214
+ const listBox = root.querySelector('vaadin-context-menu-list-box');
215
+
216
+ listBox.innerHTML = '';
217
+
218
+ const items = Array.from(context.detail.children || menu.items);
219
+
220
+ items.forEach((item) => {
221
+ let component;
222
+ if (item.component instanceof HTMLElement) {
223
+ component = item.component;
224
+ } else {
225
+ component = document.createElement(item.component || 'vaadin-context-menu-item');
226
+ }
227
+
228
+ if (component instanceof Item) {
229
+ component.setAttribute('role', 'menuitem');
230
+ component.classList.add('vaadin-menu-item');
231
+ } else if (component.localName === 'hr') {
232
+ component.setAttribute('role', 'separator');
233
+ }
234
+
235
+ this._setMenuItemTheme(component, item, this.theme);
236
+
237
+ component._item = item;
238
+
239
+ if (item.text) {
240
+ component.textContent = item.text;
241
+ }
242
+
243
+ this.__toggleMenuComponentAttribute(component, 'menu-item-checked', item.checked);
244
+ this.__toggleMenuComponentAttribute(component, 'disabled', item.disabled);
245
+
246
+ component.setAttribute('aria-haspopup', 'false');
247
+ component.classList.remove('vaadin-context-menu-parent-item');
248
+ if (item.children && item.children.length) {
249
+ component.classList.add('vaadin-context-menu-parent-item');
250
+ component.setAttribute('aria-haspopup', 'true');
251
+ component.setAttribute('aria-expanded', 'false');
252
+ component.removeAttribute('expanded');
253
+ }
254
+
255
+ listBox.appendChild(component);
256
+ });
257
+ }
258
+
259
+ /** @protected */
260
+ _setMenuItemTheme(component, item, hostTheme) {
261
+ let theme = hostTheme;
262
+
263
+ // item theme takes precedence over host theme even if it's empty, as long as it's not undefined or null
264
+ if (item.theme != null) {
265
+ theme = Array.isArray(item.theme) ? item.theme.join(' ') : item.theme;
266
+ }
267
+
268
+ if (theme) {
269
+ component.setAttribute('theme', theme);
270
+ } else {
271
+ component.removeAttribute('theme');
272
+ }
273
+ }
274
+
275
+ /** @private */
276
+ __toggleMenuComponentAttribute(component, attribute, on) {
277
+ if (on) {
278
+ component.setAttribute(attribute, '');
279
+ component['__has-' + attribute] = true;
280
+ } else if (component['__has-' + attribute]) {
281
+ component.removeAttribute(attribute);
282
+ component['__has-' + attribute] = false;
283
+ }
284
+ }
285
+
286
+ /** @private */
287
+ __initMenu(root, menu) {
288
+ if (!root.firstElementChild) {
289
+ const is = this.constructor.is;
290
+ root.innerHTML = `
291
+ <vaadin-context-menu-list-box></vaadin-context-menu-list-box>
292
+ <${is} hidden></${is}>
293
+ `;
294
+ flush();
295
+ const listBox = root.querySelector('vaadin-context-menu-list-box');
296
+ this.theme && listBox.setAttribute('theme', this.theme);
297
+ listBox.classList.add('vaadin-menu-list-box');
298
+ requestAnimationFrame(() => listBox.setAttribute('role', 'menu'));
299
+
300
+ const subMenu = root.querySelector(is);
301
+ subMenu.$.overlay.modeless = true;
302
+ subMenu.openOn = 'opensubmenu';
303
+
304
+ menu.addEventListener('opened-changed', (e) => !e.detail.value && subMenu.close());
305
+ subMenu.addEventListener('opened-changed', (e) => {
306
+ if (!e.detail.value) {
307
+ const expandedItem = listBox.querySelector('[expanded]');
308
+ if (expandedItem) {
309
+ expandedItem.setAttribute('aria-expanded', 'false');
310
+ expandedItem.removeAttribute('expanded');
311
+ }
312
+ }
313
+ });
314
+
315
+ listBox.addEventListener('selected-changed', (e) => {
316
+ if (typeof e.detail.value === 'number') {
317
+ const item = e.target.items[e.detail.value]._item;
318
+ if (!item.children) {
319
+ const detail = { value: item };
320
+ menu.dispatchEvent(new CustomEvent('item-selected', { detail }));
321
+ }
322
+ listBox.selected = null;
323
+ }
324
+ });
325
+
326
+ subMenu.addEventListener('item-selected', (e) => {
327
+ menu.dispatchEvent(new CustomEvent('item-selected', { detail: e.detail }));
328
+ });
329
+
330
+ subMenu.addEventListener('close-all-menus', () => {
331
+ menu.dispatchEvent(new CustomEvent('close-all-menus'));
332
+ });
333
+ menu.addEventListener('close-all-menus', menu.close);
334
+ menu.addEventListener('item-selected', menu.close);
335
+ menu.$.overlay.$.backdrop.addEventListener('click', () => menu.close());
336
+
337
+ menu.$.overlay.addEventListener('keydown', (e) => {
338
+ const isRTL = this.__isRTL;
339
+ if ((!isRTL && e.keyCode === 37) || (isRTL && e.keyCode === 39)) {
340
+ menu.close();
341
+ menu.listenOn.focus();
342
+ } else if (e.keyCode === 27) {
343
+ menu.dispatchEvent(new CustomEvent('close-all-menus'));
344
+ }
345
+ });
346
+
347
+ requestAnimationFrame(() => (this.__openListenerActive = true));
348
+ const openSubMenu = (
349
+ e,
350
+ itemElement = e.composedPath().filter((e) => e.localName === 'vaadin-context-menu-item')[0]
351
+ ) => {
352
+ // Delay enabling the mouseover listener to avoid it from triggering on parent menu open
353
+ if (!this.__openListenerActive) {
354
+ return;
355
+ }
356
+
357
+ // Don't open sub-menus while the menu is still opening
358
+ if (menu.$.overlay.hasAttribute('opening')) {
359
+ requestAnimationFrame(() => openSubMenu(e, itemElement));
360
+ return;
361
+ }
362
+
363
+ if (itemElement) {
364
+ if (subMenu.items !== itemElement._item.children) {
365
+ subMenu.close();
366
+ }
367
+ if (!menu.opened) {
368
+ return;
369
+ }
370
+ if (itemElement._item.children && itemElement._item.children.length) {
371
+ itemElement.setAttribute('aria-expanded', 'true');
372
+ itemElement.setAttribute('expanded', '');
373
+ this.__openSubMenu(subMenu, itemElement);
374
+ } else {
375
+ subMenu.listenOn.focus();
376
+ }
377
+ }
378
+ };
379
+
380
+ menu.$.overlay.addEventListener('mouseover', openSubMenu);
381
+ menu.$.overlay.addEventListener('keydown', (e) => {
382
+ const isRTL = this.__isRTL;
383
+ const shouldOpenSubMenu =
384
+ (!isRTL && e.keyCode === 39) || (isRTL && e.keyCode === 37) || e.keyCode === 13 || e.keyCode === 32;
385
+
386
+ shouldOpenSubMenu && openSubMenu(e);
387
+ });
388
+ } else {
389
+ const listBox = root.querySelector('vaadin-context-menu-list-box');
390
+ if (this.theme) {
391
+ listBox.setAttribute('theme', this.theme);
392
+ } else {
393
+ listBox.removeAttribute('theme');
394
+ }
395
+ }
396
+ }
397
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2021 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+ import { PolymerElement, html } from '@polymer/polymer/polymer-element.js';
7
+ import '@polymer/iron-media-query/iron-media-query.js';
8
+
9
+ /**
10
+ * Element for internal use only.
11
+ *
12
+ * @private
13
+ */
14
+ class DeviceDetector extends PolymerElement {
15
+ static get template() {
16
+ return html`<iron-media-query query="min-device-width: 750px" query-matches="{{wide}}"></iron-media-query>`;
17
+ }
18
+
19
+ static get is() {
20
+ return 'vaadin-device-detector';
21
+ }
22
+
23
+ static get properties() {
24
+ return {
25
+ /**
26
+ * `true`, when running in a phone.
27
+ */
28
+ phone: {
29
+ type: Boolean,
30
+ computed: '_phone(wide, touch)',
31
+ notify: true
32
+ },
33
+
34
+ /**
35
+ * `true`, when running in a touch device.
36
+ * @default false
37
+ */
38
+ touch: {
39
+ type: Boolean,
40
+ notify: true,
41
+ value: () => this._touch()
42
+ },
43
+
44
+ /**
45
+ * `true`, when running in a tablet/desktop device.
46
+ */
47
+ wide: {
48
+ type: Boolean,
49
+ notify: true
50
+ }
51
+ };
52
+ }
53
+
54
+ static _touch() {
55
+ try {
56
+ document.createEvent('TouchEvent');
57
+ return true;
58
+ } catch (err) {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ _phone(wide, touch) {
64
+ return !wide && touch;
65
+ }
66
+ }
67
+
68
+ customElements.define(DeviceDetector.is, DeviceDetector);