@furo/ui5
Version:
SAP UI5 Web Components data bindings for furo-web
430 lines (382 loc) • 11.6 kB
JavaScript
import { LitElement, html, css } from 'lit';
import { FBP } from '@furo/fbp';
import '@furo/fbp/src/flow-repeat';
import '@furo/util/src/furo-navigation-pad';
import { RepeaterNode } from '@furo/data/src/lib/RepeaterNode.js';
import './subcomponents/furo-ui5-context-menu-item.js';
import './subcomponents/furo-ui5-context-submenu.js';
/**
* The furo-ui5-context-menu-display is the display element for furo-ui5-context-menu and submenus.
* Place this component as high as needed in your dom.
*
* You should not interact with this component directly. Use [`furo-ui5-context-menu`](?t=FuroUi5ContextMenu) to show a context menu.
*
* There is nothing more to do. The menu creates a transparent "backdrop" with absolute positions 0 0 0 0
*
* ```html
* <furo-ui5-context-menu-display></furo-ui5-context-menu-display>
* ```
*
* @summary context menu
* @element furo-ui5-context-menu-display
* @demo demo-furo-ui5-context-menu Basic usage
* @appliesMixin FBP
*/
export class FuroUi5ContextMenuDisplay extends FBP(LitElement) {
constructor(props) {
super(props);
/**
* Distance for the sub menus in pixel. This value is used to calculate if the menu or sub menu should apear on the left or on the right.
* @type {number}
*/
this.borderDistance = 48;
/**
*
* @private
*/
this._repeatsChanged = () => {
/**
* add flag _noicon if none of the group (between dividers) has no icons
*/
let noicon = true;
let stage = [];
this._children.repeats.forEach((item, i, items) => {
/**
* if next item has a leading separator push before, otherwise push after loop
*/
let pushed = false;
if (
item.leading_divider &&
item.leading_divider._value &&
items[i + 1] &&
items[i + 1].leading_divider._value
) {
stage.push(item);
pushed = true;
}
if (item.icon?._value) {
noicon = false;
}
if (item.leading_divider?._value) {
stage.forEach(stg => {
// eslint-disable-next-line no-param-reassign
stg._noicon = noicon;
});
stage = [];
noicon = true;
}
if (!pushed) {
stage.push(item);
}
});
noicon = true;
// do the last stage
stage.forEach(item => {
if (item.icon?._value) {
noicon = false;
}
stage.forEach(i => {
// eslint-disable-next-line no-param-reassign
i._noicon = noicon;
});
});
this._FBPTriggerWire('--menuObject', this._children.repeats);
};
}
/**
* flow is ready lifecycle method
* @private
*/
_FBPReady() {
super._FBPReady();
// this._FBPTraceWires()
this._repeater = this.shadowRoot.getElementById('repeater');
const menucontainer = this.shadowRoot.getElementById('menu');
this.addEventListener('opensub-requested', e => {
const submenu = document.createElement('furo-ui5-context-submenu');
this.shadowRoot.appendChild(submenu);
setTimeout(() => {
submenu.init(e, this, e.byKeyboard);
}, 10);
// add submenu to event, so the requester can close it
e.submenu = submenu;
});
this.parentNode.addEventListener('open-furo-ui5-menu-requested', e => {
e.stopPropagation();
this.menuObject = e.detail;
// if menuObject.menu is an RepeaterNode, a children field was passed in to the menu, otherwise a menuitem itself was passed in
if (e.detail.menu instanceof RepeaterNode) {
this._children = this.menuObject.menu;
} else {
this._children = this.menuObject.menu.children;
}
// listener is de registred in hideMenu()
this._children.addEventListener(
'this-repeated-field-changed',
this._repeatsChanged
);
this._repeatsChanged();
this.setAttribute('backdrop', '');
const { initiator } = e.detail;
const thisCR = this.getBoundingClientRect();
const initiatorCR = initiator.getBoundingClientRect();
menucontainer.style.removeProperty('maxHeight');
menucontainer.style.removeProperty('bottom');
menucontainer.style.removeProperty('height');
menucontainer.style.removeProperty('left');
menucontainer.style.removeProperty('right');
// find the ideal position and direction
const initiatorCoordinates = {
left: {
x: initiatorCR.left,
y: (initiatorCR.top + initiatorCR.bottom) / 2,
},
right: {
x: initiatorCR.right,
y: (initiatorCR.top + initiatorCR.bottom) / 2,
},
};
if (
initiatorCoordinates.left.x >
thisCR.width - initiatorCoordinates.right.x
) {
menucontainer.style.right = `${
thisCR.width - initiatorCoordinates.left.x
}px`;
menucontainer.style.top = `${initiatorCoordinates.left.y}px`;
} else {
menucontainer.style.removeProperty('right');
menucontainer.style.left = `${initiatorCoordinates.right.x}px`;
menucontainer.style.top = `${initiatorCoordinates.right.y}px`;
}
// calculate container positions from bottom when the initator is in the under third of the screen
let onUpperSide = true;
if (initiatorCoordinates.right.y * 2.5 > thisCR.height) {
// we are in the under half of the screen
menucontainer.style.removeProperty('top');
menucontainer.style.bottom = `${
thisCR.height - initiatorCoordinates.right.y
}px`;
onUpperSide = false;
}
this._start = true;
this.requestUpdate();
// delay fade in effect set height
setTimeout(() => {
const menucontainerCr = menucontainer.getBoundingClientRect();
let maxHeight =
thisCR.height - initiatorCoordinates.left.y - this.borderDistance;
if (!onUpperSide) {
maxHeight = initiatorCoordinates.left.y - this.borderDistance;
}
menucontainer.style.maxHeight = `${maxHeight}px`;
// max height of ctxmenu should not go outside the screen
if (menucontainerCr.height > maxHeight) {
menucontainer.style.height = `${maxHeight}px`;
}
this._show = true;
this.requestUpdate();
setTimeout(() => {
menucontainer.focus();
// mark first element if opened by keyboard
if (e.byKeyboard) {
this._repeater.select(0);
}
}, 100);
}, 10);
});
/**
* Register hook on wire --nav to
* listen to the navigation pad
*/
this._FBPAddWireHook('--nav', e => {
this.triggerNavigation(e);
});
/**
* Register hook on wire --backdropClick to
* remove the menu
*/
this._FBPAddWireHook('--backdropClick', () => {
this.hideMenu();
});
/**
* Listen to item-selected to pass it back to furo-context-menu (callback)
*
*/
this.addEventListener('item-selected', e => {
this.menuObject.selectCallback(e);
this.hideMenu();
});
}
/**
* Hides the menu witout selecting anything.
*
* If you click somewhere on the background, the menu will also disappear.
*/
hideMenu() {
this.removeAttribute('backdrop');
this._start = false;
this._show = false;
this.requestUpdate();
// remove all submenus
this.shadowRoot
.querySelectorAll('furo-ui5-context-submenu')
.forEach(sub => {
sub.remove();
});
// unregister the event listener from open-furo-ui5-menu-requested
this._children.removeEventListener(
'this-repeated-field-changed',
this._repeatsChanged
);
this.menuObject.onClose();
}
/**
* Interface for the furo navigation pad
* @param key
* @private
*/
triggerNavigation(key) {
switch (key) {
case 'Enter':
this._repeater.triggerSelected(key);
break;
case 'ArrowDown':
this._repeater.selectNextIndex();
break;
case 'ArrowUp':
this._repeater.selectPreviousIndex();
break;
case 'PageDown':
break;
case 'PageUp':
this._repeater.select(0);
break;
case 'End':
this.focusLast();
break;
case 'Home':
this._repeater.select(0);
break;
case 'ArrowLeft':
// closes subnav
this._repeater.triggerSelected(key);
break;
case 'ArrowRight':
// opens subnav
this._repeater.triggerSelected(key);
break;
case 'Escape':
// closes the menu
this.hideMenu();
break;
default:
}
}
/**
* Themable Styles
* @private
* @return {CSSResult}
*/
static get styles() {
// language=CSS
return css`
:host {
display: none;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
font-family: var(--sapFontFamily), sans-serif;
font-size: var(--sapFontSize);
}
:host([backdrop]) {
display: block;
}
#menu {
outline: none;
position: absolute;
display: none;
transition: opacity 0.03s linear,
transform 0.12s cubic-bezier(0, 0, 0.2, 1),
-webkit-transform 0.12s cubic-bezier(0, 0, 0.2, 1);
opacity: 0;
background-color: var(--sapList_Background);
overflow-y: auto;
box-sizing: border-box;
box-shadow: var(--sapContent_Shadow2);
border-radius: var(--_ui5-popup-border-radius);
}
#menu[start] {
display: block;
opacity: 0;
}
#menu[show] {
opacity: 1;
display: block;
}
.clickcatcher {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
/* only focus is needed, because the menu closes on select */
furo-ui5-context-menu-item:hover,
furo-ui5-context-menu-item[focused] {
background: var(--sapList_Hover_Background);
}
.separator {
height: 8px;
box-sizing: border-box;
border-bottom: var(--ui5-listitem-border-bottom);
margin-bottom: 8px;
}
`;
}
/**
* The host is the backdrop
*
* @returns {TemplateResult}
* @private
*/
render() {
// language=HTML
return html`
<div class="clickcatcher" at-click="--backdropClick"></div>
<div
id="menu"
tabindex="0"
?start="${this._start}"
?show="${this._show}"
at-mousefocus="--mousefocus"
>
<!-- the wires --itemSelected and --itemDeSelected means focus, they come from flow-repeat -->
<flow-repeat
id="repeater"
fn-inject-items="--menuObject"
fn-select="--mousefocus"
>
<template>
<div>
<furo-ui5-context-menu-item
fn-index="--index"
fn-select="--trigger"
fn-set-focused="--itemSelected"
fn-unset-focused="--itemDeSelected"
fn-bind-data="--itemInjected(*.item)"
></furo-ui5-context-menu-item>
</div>
</template>
</flow-repeat>
<furo-navigation-pad at-navigated="--nav"></furo-navigation-pad>
</div>
`;
}
}
window.customElements.define(
'furo-ui5-context-menu-display',
FuroUi5ContextMenuDisplay
);