mirror of
https://github.com/nasa/openmct.git
synced 2025-06-22 17:08:57 +00:00
* [Menu API] All our drop down menu's now use the new menu api #3607 Co-authored-by: charlesh88 <charles.f.hacskaylo@nasa.gov> Co-authored-by: Andrew Henry <akhenry@gmail.com>
This commit is contained in:
@ -20,7 +20,25 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import Menu from './menu.js';
|
||||
import Menu, { MENU_PLACEMENT } from './menu.js';
|
||||
|
||||
/**
|
||||
* Popup Menu options
|
||||
* @typedef {Object} MenuOptions
|
||||
* @property {String} menuClass Class for popup menu
|
||||
* @property {MENU_PLACEMENT} placement Placement for menu relative to click
|
||||
* @property {Function} onDestroy callback function: invoked when menu is destroyed
|
||||
*/
|
||||
|
||||
/**
|
||||
* Popup Menu Item/action
|
||||
* @typedef {Object} Action
|
||||
* @property {String} cssClass Class for menu item
|
||||
* @property {Boolean} isDisabled adds disable class if true
|
||||
* @property {String} name Menu item text
|
||||
* @property {String} description Menu item description
|
||||
* @property {Function} callBack callback function: invoked when item is clicked
|
||||
*/
|
||||
|
||||
/**
|
||||
* The MenuAPI allows the addition of new context menu actions, and for the context menu to be launched from
|
||||
@ -33,12 +51,46 @@ class MenuAPI {
|
||||
constructor(openmct) {
|
||||
this.openmct = openmct;
|
||||
|
||||
this.menuPlacement = MENU_PLACEMENT;
|
||||
this.showMenu = this.showMenu.bind(this);
|
||||
this.showSuperMenu = this.showSuperMenu.bind(this);
|
||||
|
||||
this._clearMenuComponent = this._clearMenuComponent.bind(this);
|
||||
this._showObjectMenu = this._showObjectMenu.bind(this);
|
||||
}
|
||||
|
||||
showMenu(x, y, actions, onDestroy) {
|
||||
/**
|
||||
* Show popup menu
|
||||
* @param {number} x x-coordinates for popup
|
||||
* @param {number} y x-coordinates for popup
|
||||
* @param {Array.<Action>|Array.<Array.<Action>>} actions collection of actions{@link Action} or collection of groups of actions {@link Action}
|
||||
* @param {MenuOptions} [menuOptions] [Optional] The {@link MenuOptions} options for Menu
|
||||
*/
|
||||
showMenu(x, y, actions, menuOptions) {
|
||||
this._createMenuComponent(x, y, actions, menuOptions);
|
||||
|
||||
this.menuComponent.showMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show popup menu with description of item on hover
|
||||
* @param {number} x x-coordinates for popup
|
||||
* @param {number} y x-coordinates for popup
|
||||
* @param {Array.<Action>|Array.<Array.<Action>>} actions collection of actions {@link Action} or collection of groups of actions {@link Action}
|
||||
* @param {MenuOptions} [menuOptions] [Optional] The {@link MenuOptions} options for Menu
|
||||
*/
|
||||
showSuperMenu(x, y, actions, menuOptions) {
|
||||
this._createMenuComponent(x, y, actions, menuOptions);
|
||||
|
||||
this.menuComponent.showSuperMenu();
|
||||
}
|
||||
|
||||
_clearMenuComponent() {
|
||||
this.menuComponent = undefined;
|
||||
delete this.menuComponent;
|
||||
}
|
||||
|
||||
_createMenuComponent(x, y, actions, menuOptions = {}) {
|
||||
if (this.menuComponent) {
|
||||
this.menuComponent.dismiss();
|
||||
}
|
||||
@ -47,18 +99,13 @@ class MenuAPI {
|
||||
x,
|
||||
y,
|
||||
actions,
|
||||
onDestroy
|
||||
...menuOptions
|
||||
};
|
||||
|
||||
this.menuComponent = new Menu(options);
|
||||
this.menuComponent.once('destroy', this._clearMenuComponent);
|
||||
}
|
||||
|
||||
_clearMenuComponent() {
|
||||
this.menuComponent = undefined;
|
||||
delete this.menuComponent;
|
||||
}
|
||||
|
||||
_showObjectMenu(objectPath, x, y, actionsToBeIncluded) {
|
||||
let applicableActions = this.openmct.actions._groupedAndSortedObjectActions(objectPath, actionsToBeIncluded);
|
||||
|
||||
|
@ -22,10 +22,11 @@
|
||||
|
||||
import MenuAPI from './MenuAPI';
|
||||
import Menu from './menu';
|
||||
import { createOpenMct, resetApplicationState } from '../../utils/testing';
|
||||
import { createOpenMct, createMouseEvent, resetApplicationState } from '../../utils/testing';
|
||||
|
||||
describe ('The Menu API', () => {
|
||||
let openmct;
|
||||
let element;
|
||||
let menuAPI;
|
||||
let actionsArray;
|
||||
let x;
|
||||
@ -33,21 +34,37 @@ describe ('The Menu API', () => {
|
||||
let result;
|
||||
let onDestroy;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach((done) => {
|
||||
const appHolder = document.createElement('div');
|
||||
appHolder.style.display = 'block';
|
||||
appHolder.style.width = '1920px';
|
||||
appHolder.style.height = '1080px';
|
||||
|
||||
openmct = createOpenMct();
|
||||
|
||||
element = document.createElement('div');
|
||||
element.style.display = 'block';
|
||||
element.style.width = '1920px';
|
||||
element.style.height = '1080px';
|
||||
|
||||
openmct.on('start', done);
|
||||
openmct.startHeadless(appHolder);
|
||||
|
||||
menuAPI = new MenuAPI(openmct);
|
||||
actionsArray = [
|
||||
{
|
||||
key: 'test-css-class-1',
|
||||
name: 'Test Action 1',
|
||||
cssClass: 'test-css-class-1',
|
||||
cssClass: 'icon-clock',
|
||||
description: 'This is a test action',
|
||||
callBack: () => {
|
||||
result = 'Test Action 1 Invoked';
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'test-css-class-2',
|
||||
name: 'Test Action 2',
|
||||
cssClass: 'test-css-class-2',
|
||||
cssClass: 'icon-clock',
|
||||
description: 'This is a test action',
|
||||
callBack: () => {
|
||||
result = 'Test Action 2 Invoked';
|
||||
@ -76,7 +93,11 @@ describe ('The Menu API', () => {
|
||||
beforeEach(() => {
|
||||
onDestroy = jasmine.createSpy('onDestroy');
|
||||
|
||||
menuAPI.showMenu(x, y, actionsArray, onDestroy);
|
||||
const menuOptions = {
|
||||
onDestroy
|
||||
};
|
||||
|
||||
menuAPI.showMenu(x, y, actionsArray, menuOptions);
|
||||
vueComponent = menuAPI.menuComponent.component;
|
||||
menuComponent = document.querySelector(".c-menu");
|
||||
|
||||
@ -131,4 +152,62 @@ describe ('The Menu API', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("superMenu method", () => {
|
||||
it("creates a superMenu", () => {
|
||||
menuAPI.showSuperMenu(x, y, actionsArray);
|
||||
|
||||
const superMenu = document.querySelector('.c-super-menu__menu');
|
||||
|
||||
expect(superMenu).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Mouse over a superMenu shows correct description", (done) => {
|
||||
menuAPI.showSuperMenu(x, y, actionsArray);
|
||||
|
||||
const superMenu = document.querySelector('.c-super-menu__menu');
|
||||
const superMenuItem = superMenu.querySelector('li');
|
||||
const mouseOverEvent = createMouseEvent('mouseover');
|
||||
|
||||
superMenuItem.dispatchEvent(mouseOverEvent);
|
||||
const itemDescription = document.querySelector('.l-item-description__description');
|
||||
|
||||
setTimeout(() => {
|
||||
expect(itemDescription.innerText).toEqual(actionsArray[0].description);
|
||||
expect(superMenu).not.toBeNull();
|
||||
done();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Menu Placements", () => {
|
||||
it("default menu position BOTTOM_RIGHT", () => {
|
||||
menuAPI.showMenu(x, y, actionsArray);
|
||||
|
||||
const menu = document.querySelector('.c-menu');
|
||||
|
||||
const boundingClientRect = menu.getBoundingClientRect();
|
||||
const left = boundingClientRect.left;
|
||||
const top = boundingClientRect.top;
|
||||
|
||||
expect(left).toEqual(x);
|
||||
expect(top).toEqual(y);
|
||||
});
|
||||
|
||||
it("menu position BOTTOM_RIGHT", () => {
|
||||
const menuOptions = {
|
||||
placement: openmct.menus.menuPlacement.BOTTOM_RIGHT
|
||||
};
|
||||
|
||||
menuAPI.showMenu(x, y, actionsArray, menuOptions);
|
||||
|
||||
const menu = document.querySelector('.c-menu');
|
||||
const boundingClientRect = menu.getBoundingClientRect();
|
||||
const left = boundingClientRect.left;
|
||||
const top = boundingClientRect.top;
|
||||
|
||||
expect(left).toEqual(x);
|
||||
expect(top).toEqual(y);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="c-menu">
|
||||
<ul v-if="actions.length && actions[0].length">
|
||||
<div class="c-menu"
|
||||
:class="options.menuClass"
|
||||
>
|
||||
<ul v-if="options.actions.length && options.actions[0].length">
|
||||
<template
|
||||
v-for="(actionGroups, index) in actions"
|
||||
v-for="(actionGroups, index) in options.actions"
|
||||
>
|
||||
<li
|
||||
v-for="action in actionGroups"
|
||||
@ -14,7 +16,7 @@
|
||||
{{ action.name }}
|
||||
</li>
|
||||
<div
|
||||
v-if="index !== actions.length - 1"
|
||||
v-if="index !== options.actions.length - 1"
|
||||
:key="index"
|
||||
class="c-menu__section-separator"
|
||||
>
|
||||
@ -30,7 +32,7 @@
|
||||
|
||||
<ul v-else>
|
||||
<li
|
||||
v-for="action in actions"
|
||||
v-for="action in options.actions"
|
||||
:key="action.name"
|
||||
:class="action.cssClass"
|
||||
:title="action.description"
|
||||
@ -38,7 +40,7 @@
|
||||
>
|
||||
{{ action.name }}
|
||||
</li>
|
||||
<li v-if="actions.length === 0">
|
||||
<li v-if="options.actions.length === 0">
|
||||
No actions defined.
|
||||
</li>
|
||||
</ul>
|
||||
@ -47,6 +49,6 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
inject: ['actions']
|
||||
inject: ['options']
|
||||
};
|
||||
</script>
|
||||
|
88
src/api/menu/components/SuperMenu.vue
Normal file
88
src/api/menu/components/SuperMenu.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="c-menu"
|
||||
:class="[options.menuClass, 'c-super-menu']"
|
||||
>
|
||||
<ul v-if="options.actions.length && options.actions[0].length"
|
||||
class="c-super-menu__menu"
|
||||
>
|
||||
<template
|
||||
v-for="(actionGroups, index) in options.actions"
|
||||
>
|
||||
<li
|
||||
v-for="action in actionGroups"
|
||||
:key="action.name"
|
||||
:class="[action.cssClass, action.isDisabled ? 'disabled' : '']"
|
||||
:title="action.description"
|
||||
@click="action.callBack"
|
||||
@mouseover="toggleItemDescription(action)"
|
||||
@mouseleave="toggleItemDescription()"
|
||||
>
|
||||
{{ action.name }}
|
||||
</li>
|
||||
<div
|
||||
v-if="index !== options.actions.length - 1"
|
||||
:key="index"
|
||||
class="c-menu__section-separator"
|
||||
>
|
||||
</div>
|
||||
<li
|
||||
v-if="actionGroups.length === 0"
|
||||
:key="index"
|
||||
>
|
||||
No actions defined.
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<ul v-else
|
||||
class="c-super-menu__menu"
|
||||
>
|
||||
<li
|
||||
v-for="action in options.actions"
|
||||
:key="action.name"
|
||||
:class="action.cssClass"
|
||||
:title="action.description"
|
||||
@click="action.callBack"
|
||||
@mouseover="toggleItemDescription(action)"
|
||||
@mouseleave="toggleItemDescription()"
|
||||
>
|
||||
{{ action.name }}
|
||||
</li>
|
||||
<li v-if="options.actions.length === 0">
|
||||
No actions defined.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="c-super-menu__item-description">
|
||||
<div :class="['l-item-description__icon', 'bg-' + hoveredItem.cssClass]"></div>
|
||||
<div class="l-item-description__name">
|
||||
{{ hoveredItem.name }}
|
||||
</div>
|
||||
<div class="l-item-description__description">
|
||||
{{ hoveredItem.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
inject: ['options'],
|
||||
data: function () {
|
||||
return {
|
||||
hoveredItem: {}
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleItemDescription(action = {}) {
|
||||
const hoveredItem = {
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
cssClass: action.cssClass
|
||||
};
|
||||
|
||||
this.hoveredItem = Object.assign({}, this.hoveredItem, hoveredItem);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
@ -21,32 +21,33 @@
|
||||
*****************************************************************************/
|
||||
import EventEmitter from 'EventEmitter';
|
||||
import MenuComponent from './components/Menu.vue';
|
||||
import SuperMenuComponent from './components/SuperMenu.vue';
|
||||
import Vue from 'vue';
|
||||
|
||||
export const MENU_PLACEMENT = {
|
||||
TOP: 'top',
|
||||
TOP_LEFT: 'top-left',
|
||||
TOP_RIGHT: 'top-right',
|
||||
BOTTOM: 'bottom',
|
||||
BOTTOM_LEFT: 'bottom-left',
|
||||
BOTTOM_RIGHT: 'bottom-right',
|
||||
LEFT: 'left',
|
||||
RIGHT: 'right'
|
||||
};
|
||||
|
||||
class Menu extends EventEmitter {
|
||||
constructor(options) {
|
||||
super();
|
||||
|
||||
this.options = options;
|
||||
|
||||
this.component = new Vue({
|
||||
components: {
|
||||
MenuComponent
|
||||
},
|
||||
provide: {
|
||||
actions: options.actions
|
||||
},
|
||||
template: '<menu-component />'
|
||||
});
|
||||
|
||||
if (options.onDestroy) {
|
||||
this.once('destroy', options.onDestroy);
|
||||
}
|
||||
|
||||
this.dismiss = this.dismiss.bind(this);
|
||||
this.show = this.show.bind(this);
|
||||
|
||||
this.show();
|
||||
this.showMenu = this.showMenu.bind(this);
|
||||
this.showSuperMenu = this.showSuperMenu.bind(this);
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
@ -60,7 +61,7 @@ class Menu extends EventEmitter {
|
||||
this.component.$mount();
|
||||
document.body.appendChild(this.component.$el);
|
||||
|
||||
let position = this._calculatePopupPosition(this.options.x, this.options.y, this.component.$el);
|
||||
let position = this._calculatePopupPosition(this.component.$el);
|
||||
|
||||
this.component.$el.style.left = `${position.x}px`;
|
||||
this.component.$el.style.top = `${position.y}px`;
|
||||
@ -68,11 +69,97 @@ class Menu extends EventEmitter {
|
||||
document.addEventListener('click', this.dismiss);
|
||||
}
|
||||
|
||||
showMenu() {
|
||||
this.component = new Vue({
|
||||
provide: {
|
||||
options: this.options
|
||||
},
|
||||
components: {
|
||||
MenuComponent
|
||||
},
|
||||
template: '<menu-component />'
|
||||
});
|
||||
|
||||
this.show();
|
||||
}
|
||||
|
||||
showSuperMenu() {
|
||||
this.component = new Vue({
|
||||
provide: {
|
||||
options: this.options
|
||||
},
|
||||
components: {
|
||||
SuperMenuComponent
|
||||
},
|
||||
template: '<super-menu-component />'
|
||||
});
|
||||
|
||||
this.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_calculatePopupPosition(eventPosX, eventPosY, menuElement) {
|
||||
_calculatePopupPosition(menuElement) {
|
||||
let menuDimensions = menuElement.getBoundingClientRect();
|
||||
|
||||
if (!this.options.placement) {
|
||||
this.options.placement = MENU_PLACEMENT.BOTTOM_RIGHT;
|
||||
}
|
||||
|
||||
const menuPosition = this._getMenuPositionBasedOnPlacement(menuDimensions);
|
||||
|
||||
return this._preventMenuOverflow(menuPosition, menuDimensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_getMenuPositionBasedOnPlacement(menuDimensions) {
|
||||
let eventPosX = this.options.x;
|
||||
let eventPosY = this.options.y;
|
||||
|
||||
// Adjust popup menu based on placement
|
||||
switch (this.options.placement) {
|
||||
case MENU_PLACEMENT.TOP:
|
||||
eventPosX = this.options.x - Math.floor(menuDimensions.width / 2);
|
||||
eventPosY = this.options.y - menuDimensions.height;
|
||||
break;
|
||||
case MENU_PLACEMENT.BOTTOM:
|
||||
eventPosX = this.options.x - Math.floor(menuDimensions.width / 2);
|
||||
break;
|
||||
case MENU_PLACEMENT.LEFT:
|
||||
eventPosX = this.options.x - menuDimensions.width;
|
||||
eventPosY = this.options.y - Math.floor(menuDimensions.height / 2);
|
||||
break;
|
||||
case MENU_PLACEMENT.RIGHT:
|
||||
eventPosY = this.options.y - Math.floor(menuDimensions.height / 2);
|
||||
break;
|
||||
case MENU_PLACEMENT.TOP_LEFT:
|
||||
eventPosX = this.options.x - menuDimensions.width;
|
||||
eventPosY = this.options.y - menuDimensions.height;
|
||||
break;
|
||||
case MENU_PLACEMENT.TOP_RIGHT:
|
||||
eventPosY = this.options.y - menuDimensions.height;
|
||||
break;
|
||||
case MENU_PLACEMENT.BOTTOM_LEFT:
|
||||
eventPosX = this.options.x - menuDimensions.width;
|
||||
break;
|
||||
case MENU_PLACEMENT.BOTTOM_RIGHT:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
x: eventPosX,
|
||||
y: eventPosY
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_preventMenuOverflow(menuPosition, menuDimensions) {
|
||||
let { x: eventPosX, y: eventPosY } = menuPosition;
|
||||
let overflowX = (eventPosX + menuDimensions.width) - document.body.clientWidth;
|
||||
let overflowY = (eventPosY + menuDimensions.height) - document.body.clientHeight;
|
||||
|
||||
@ -84,6 +171,14 @@ class Menu extends EventEmitter {
|
||||
eventPosY = eventPosY - overflowY;
|
||||
}
|
||||
|
||||
if (eventPosX < 0) {
|
||||
eventPosX = 0;
|
||||
}
|
||||
|
||||
if (eventPosY < 0) {
|
||||
eventPosY = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
x: eventPosX,
|
||||
y: eventPosY
|
||||
|
Reference in New Issue
Block a user