Ghost/ghost/admin/app/components/gh-context-menu.js
Simon Backx 20d2722f04 Updated context menu to swap position if outside window
refs https://github.com/TryGhost/Team/issues/2677

When opening a context menu close to the border of the window, the menu went outside the window. This PR updates the position mirror horizontally or vertically if the menu would go outside the window.
2023-04-14 11:04:11 +02:00

209 lines
5.4 KiB
JavaScript

import Component from '@glimmer/component';
import SelectionList from '../utils/selection-list';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class GhContextMenu extends Component {
@service dropdown;
@service modals;
@tracked isOpen = false;
@tracked left = 0;
@tracked top = 0;
@tracked yPlacement = 'bottom';
@tracked xPlacement = 'right';
@tracked selectionList = new SelectionList();
element = null;
/**
* The current state of the context menu
* @type {'default'|'open'|'modal'|'loading'}
* default: default state
* open: menu open
* modal: modal open
* loading: performing an action
*/
state = 'default';
#originalConfirm = null;
#modal = null;
setState(state) {
switch (state) {
case this.state:
return;
case 'default':
this.isOpen = false;
this.#closeModal();
this.selectionList.unfreeze();
this.state = state;
return;
case 'open':
if (this.state !== 'default') {
return;
}
this.isOpen = true;
this.selectionList.freeze();
this.#closeModal();
this.state = state;
return;
case 'modal':
if (this.state !== 'open') {
return;
}
this.isOpen = false;
this.selectionList.freeze();
this.state = state;
return;
case 'loading':
if (this.state !== 'open' && this.state !== 'modal') {
return;
}
this.isOpen = false;
this.selectionList.freeze();
this.state = state;
return;
}
}
get name() {
return this.args.name;
}
get style() {
return `left: ${this.left}px; top: ${this.top}px;`;
}
get class() {
return `gh-placement-${this.yPlacement} gh-placement-${this.xPlacement}`;
}
@action
setup(element) {
this.element = element;
const dropdownService = this.dropdown;
dropdownService.on('close', this, this.close);
dropdownService.on('toggle', this, this.toggle);
}
willDestroy() {
super.willDestroy(...arguments);
this.element = null;
const dropdownService = this.dropdown;
dropdownService.off('close', this, this.close);
dropdownService.off('toggle', this, this.toggle);
}
@action
open() {
this.setState('open');
}
@action
close() {
if (this.state === 'open') {
this.setState('default');
}
}
@action
onContextMenuOutside(event) {
this.close();
event.preventDefault();
event.stopPropagation();
}
// Called by the dropdown service when the context menu should open
@action
toggle(options) {
const targetDropdownName = options.target;
if (this.name === targetDropdownName) {
if (options.left !== undefined) {
this.left = options.left;
this.top = options.top;
}
if (options.selectionList) {
this.selectionList = options.selectionList;
}
this.calculatePlacement();
this.open();
} else {
this.close();
}
}
get listElement() {
return this.element?.firstElementChild?.firstElementChild;
}
calculatePlacement() {
if (!this.element || !this.listElement) {
return;
}
const windowHeight = window.innerHeight;
const windowWidth = window.innerWidth;
const menuHeight = this.listElement.offsetHeight;
const menuWidth = this.listElement.offsetWidth;
const padding = 10;
// Do we have enough place to place the menu below?
if (this.top + menuHeight + padding < windowHeight) {
this.yPlacement = 'bottom';
} else {
this.yPlacement = 'top';
}
// Do we have enough place to place the menu to the right?
if (this.left + menuWidth + padding < windowWidth) {
this.xPlacement = 'right';
} else {
this.xPlacement = 'left';
}
}
@action
stopClicks(event) {
event.stopPropagation();
}
@task
*confirmWrapperTask(...args) {
this.setState('loading');
let result = yield this.#originalConfirm.perform(...args);
this.#originalConfirm = null;
this.setState('default');
return result;
}
openModal(Modal, data) {
this.#originalConfirm = data.confirm;
data.confirm = this.confirmWrapperTask;
this.setState('modal');
this.#modal = this.modals.open(Modal, data);
this.#modal.then(() => {
// We need to delay a little bit for the click event to be processed
// Since the click event is bubbling back to window, where it will trigger a list deselect
setTimeout(() => {
this.setState('default');
}, 10);
});
}
#closeModal() {
this.#modal?.close();
this.#modal = null;
}
async performTask(taskObj) {
this.setState('loading');
await taskObj.perform();
this.setState('default');
}
}