diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index 48becc761..c5415d655 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -50,6 +50,9 @@ --space-m: 1rem; --space-l: 2rem; --space-xl: 4rem; + + /* fixed size */ + --ui-component-large: 850px; } /* reset what specific browsers do */ @@ -308,6 +311,10 @@ svg.icon { height: 1em; width: 1em; margin-bottom: -0.2em; } .dropup-open>.dropup-menu { display: block; } .dropup-menu { box-shadow: 0 8px 17px 2px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12), 0 5px 5px -3px rgba(0, 0, 0, 0.2); display: none; position: absolute; bottom: calc(100% + 0.1rem); left: 0.1rem; z-index: 1000; font-size: 1rem; background-color: #fff; border: 1px solid rgba(0, 0, 0, .15); border-radius: .25rem; } +/* popover */ + +.popover { flex-shrink: 0;flex-wrap: wrap;align-items: flex-start;position: absolute;top: 0;left: 0;z-index: 1002;max-width: var(--ui-component-large); } + /* tooltip */ .tooltip {position: relative; display: inline-block; border-bottom: 1px dotted black;} .tooltip .tooltiptext {visibility: hidden; width: 118px; background-color: #555; color: #fff; text-align: center; padding: 3px; border-radius: 6px; position: absolute; z-index: 1;} diff --git a/Framework/Frontend/js/src/components/Component.js b/Framework/Frontend/js/src/components/Component.js new file mode 100644 index 000000000..91818c81f --- /dev/null +++ b/Framework/Frontend/js/src/components/Component.js @@ -0,0 +1,16 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * @typedef {(vnode|string|number|Array<(vnode|string|number)>|null)} Component + */ diff --git a/Framework/Frontend/js/src/components/CopyToClipboardComponent.js b/Framework/Frontend/js/src/components/CopyToClipboardComponent.js index cf7a64c35..f2207d194 100644 --- a/Framework/Frontend/js/src/components/CopyToClipboardComponent.js +++ b/Framework/Frontend/js/src/components/CopyToClipboardComponent.js @@ -93,7 +93,7 @@ export class CopyToClipboardComponent extends StatefulComponent { * Renders the button that allows copying text to the clipboard. * * @param {vnode} vnode The virtual DOM node containing the attrs and children. - * @returns {vnode} The copyToClipboard button component + * @returns {Component} The copyToClipboard button component */ view(vnode) { const { attrs, children } = vnode; diff --git a/Framework/Frontend/js/src/components/DropdownComponent.js b/Framework/Frontend/js/src/components/DropdownComponent.js new file mode 100644 index 000000000..5008c9e31 --- /dev/null +++ b/Framework/Frontend/js/src/components/DropdownComponent.js @@ -0,0 +1,45 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { h } from '../renderer.js'; +import { PopoverTriggerPreConfiguration } from './PopoverPreConfigurations.js'; +import { PopoverAnchors } from './PopoverEngine.js'; +import { popover } from './popover.js'; + +/** + * Renders a dropdown component + * + * @param {Component} trigger the component triggering the dropdown opening trigger + * @param {Component} content the content of the dropdown + * @param {Object} [configuration] dropdown configuration + * @param {'left'|'right'} [configuration.alignment='left'] defines the alignment of the dropdown + * @param {popoverVisibilityChangeCallback} [configuration.onVisibilityChange] function called when the visibility changes + * @return {Component} the dropdown component + */ +export const DropdownComponent = ( + trigger, + content, + configuration, +) => { + configuration = configuration || {}; + const { alignment = 'left' } = configuration; + return popover( + trigger, + h('.dropdown', content), + { + ...PopoverTriggerPreConfiguration.click, + anchor: alignment === 'left' ? PopoverAnchors.BOTTOM_START : PopoverAnchors.BOTTOM_END, + onVisibilityChange: configuration.onVisibilityChange, + }, + ); +}; diff --git a/Framework/Frontend/js/src/components/PopoverEngine.js b/Framework/Frontend/js/src/components/PopoverEngine.js new file mode 100644 index 000000000..545551b10 --- /dev/null +++ b/Framework/Frontend/js/src/components/PopoverEngine.js @@ -0,0 +1,491 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * @typedef BoundingBoxProjector + * + * Object able to project a bounding box 2D data to only one of its dimensions + * + * @template T + * + * @property {(subject: {left: T, top: T} | {x: T, y: T}) => T} getPosition return the position of the subject + * @property {(subject: {left: T, top: T} | {x: T, y: T}, position: T) => void} setPosition set the position of the subject to a given value + * @property {(subject: {width: T, height: T}) => t} getSize return the size of the subject + * @property {(subject: {width: T, height: T}, size: T) => void} setSize set the size of the subject to a given value + * @property {(subject: {width: T, height: T}, size: T) => void} setMaxSize set the maximum size of the subject to a given value + */ + +/** + * @type {BoundingBoxProjector} + */ +const verticalProjector = Object.freeze({ + getPosition: (subject) => 'top' in subject ? subject.top : subject.y, + + setPosition: (subject, position) => { + if ('top' in subject) { + subject.top = position; + } else { + subject.y = position; + } + }, + + getSize: (subject) => subject.height, + + setSize: (subject, size) => { + subject.height = size; + }, + + setMaxSize: (subject, size) => { + subject.maxHeight = size; + }, +}); + +/** + * @type {BoundingBoxProjector} + */ +const horizontalProjector = Object.freeze({ + getPosition: (subject) => 'left' in subject ? subject.left : subject.x, + + setPosition: (subject, position) => { + if ('left' in subject) { + subject.left = position; + } else { + subject.y = position; + } + }, + + getSize: (subject) => subject.width, + + setSize: (subject, size) => { + subject.width = size; + }, + + setMaxSize: (subject, size) => { + subject.maxWidth = size; + }, +}); + +/** + * @typedef {number} PopoverAnchor + */ + +/** + * @typedef {number} MainAxisPopoverPosition + */ + +/** + * @typedef {number} CrossAxisPopoverPosition + */ + +/** + * Available popover position related to the trigger on main axis + * + * @type {{BEFORE: MainAxisPopoverPosition, AFTER: MainAxisPopoverPosition}} + */ +const MainAxisPositions = { + BEFORE: 0, // Popover will be before the trigger on the main axis + AFTER: 1, // Popover will be after the trigger on the main axis +}; + +/** + * Available popover position related to the trigger on cross axis + * + * @type {{START: CrossAxisPopoverPosition, END: CrossAxisPopoverPosition, MIDDLE: CrossAxisPopoverPosition}} + */ +const CrossAxisPositions = { + START: 0, // The start of the popover will be aligned with the start of the trigger on the cross axis + MIDDLE: 1, // The popover will be centered against the trigger on the cross axis + END: 2, // The end of the popover will be aligned with the end of the trigger on the cross axis +}; + +/** + * @type {Object} + * + * Defines the position of the popover related to the trigger + */ +export const PopoverAnchors = { + // Main axis vertical + TOP_START: 0, + TOP_MIDDLE: 1, + TOP_END: 2, + + BOTTOM_START: 3, + BOTTOM_MIDDLE: 4, + BOTTOM_END: 5, + + // Main axis horizontal + LEFT_START: 6, + LEFT_MIDDLE: 7, + LEFT_END: 8, + + RIGHT_START: 9, + RIGHT_MIDDLE: 10, + RIGHT_END: 11, +}; + +/** + * Class to position and resize a popover element relatively to a trigger in a given display zone + * + * Popover position is done around 2 axis: + * - Main axis, which is the side where the popover will be placed. For example if the main axis is horizontal, the popover will either be + * on the right or on the left of the trigger + * - Cross axis, which is the other axis + */ +class PopoverEngine { + /** + * Constructor + * + * @param {HTMLElement} trigger the bounding box of the trigger + * @param {HTMLElement} popover the bounding box of the popover + * @param {BoundingBox} displayBoundingBox the bounding box of the display zone, representing the limits where the popover may be drawn + * @param {{x: number, y: number}} displayZoneMargins the margins to apply at the edge of the display zone + * @param {{main: MainAxisPopoverPosition, cross: CrossAxisPopoverPosition}} position the position of the popover relative to the trigger + * @param {{main: BoundingBoxProjector, cross: BoundingBoxProjector}} projectors the axis projectors + * @param {object} [configuration] additional configuration + * @param {boolean} [configuration.imperativeSize=false] if true, size of the popover will be removed before re-computing size and position. + * This is useful when the popover content size is based on the popover size (displaying an image for example) but this should not be + * used with scrollable content, because it will reset scroll on every render + */ + constructor( + trigger, + popover, + displayBoundingBox, + displayZoneMargins, + position, + projectors, + configuration, + ) { + this._popover = popover; + + this._triggerBoundingBox = trigger.getBoundingClientRect(); + + this._mainAxisPosition = position.main; + this._crossAxisPosition = position.cross; + + this._mainAxisProjector = projectors.main; + this._crossAxisProjector = projectors.cross; + + this._displayBoundingBox = displayBoundingBox; + this._displayZoneMargins = displayZoneMargins; + + const { imperativeSize = false } = configuration ?? {}; + this._imperativeSize = imperativeSize; + } + + /** + * Fit the popover to the drawing zone then position it + * + * @return {void} + */ + fitAndPosition() { + this.resizeAlongMainAxis(); + this.resizeAlongCrossAxis(); + this.positionAlongMainAxis(); + this.positionAlongCrossAxis(); + } + + /** + * Reset the popover to its default size and position + * + * @return {void} + */ + reset() { + this._popover.style.removeProperty('left'); + this._popover.style.removeProperty('top'); + if (this._imperativeSize) { + this._popover.style.removeProperty('width'); + this._popover.style.removeProperty('height'); + } + } + + /** + * Resize the popover along the main axis to not overflow of the display bounding box + * + * @return {void} + */ + resizeAlongMainAxis() { + // Round pessimistically the space available because bounding client rect might return float-values + const availableSpaceBefore = Math.floor(this._triggerStartMainAxis); + const availableSpaceAfter = Math.floor(this._availableSpaceInMainAxis - this._triggerEndMainAxis); + + let sizeToSet = null; + if ( + availableSpaceBefore <= this._popoverContentSizeMainAxis + && availableSpaceAfter <= this._popoverContentSizeMainAxis + ) { + let mainAxisSize; + + // Put where there is the most space + if (availableSpaceBefore > availableSpaceAfter) { + // More space before + this._mainAxisPosition = MainAxisPositions.BEFORE; + mainAxisSize = availableSpaceBefore; + } else if (availableSpaceBefore < availableSpaceAfter) { + // More space after + this._mainAxisPosition = MainAxisPositions.AFTER; + mainAxisSize = availableSpaceAfter; + } else { + // Same space + mainAxisSize = availableSpaceBefore; + } + + sizeToSet = `${mainAxisSize}px`; + } + (this._imperativeSize ? this._mainAxisProjector.setSize : this._mainAxisProjector.setMaxSize)(this._popover.style, sizeToSet); + } + + /** + * Resize the popover along the cross axis to not overflow of the display bounding box + * + * @return {void} + */ + resizeAlongCrossAxis() { + const availableSpaceInCrossAxis = Math.floor(this._availableSpaceInCrossAxis); + + let sizeToSet = null; + if (this._popoverContentSizeCrossAxis >= availableSpaceInCrossAxis) { + sizeToSet = `${availableSpaceInCrossAxis}px`; + } + (this._imperativeSize ? this._crossAxisProjector.setSize : this._crossAxisProjector.setMaxSize)(this._popover.style, sizeToSet); + } + + /** + * Position the popover along the main axis + * + * @return {void} + */ + positionAlongMainAxis() { + let mainAxisPosition; + + /* + * Round optimistically, to avoid the popover being resized to be put before/after not fitting because size imprecision make it + * bigger than newly computed available space + */ + const doesFitBeforeAlongMainAxis = Math.ceil(this._triggerStartMainAxis) >= Math.floor(this._popoverSizeMainAxis); + const doesFitAfterAlongMainAxis = Math.ceil(this._availableSpaceInMainAxis - this._triggerEndMainAxis) + >= Math.floor(this._popoverSizeMainAxis); + + if (this._mainAxisPosition === MainAxisPositions.BEFORE && doesFitBeforeAlongMainAxis || !doesFitAfterAlongMainAxis) { + mainAxisPosition = this._triggerStartMainAxis - this._popoverSizeMainAxis; + } else { + mainAxisPosition = this._triggerEndMainAxis; + } + + // Popover is placed absolutely relatively to the document, and its position should be offset by the window scroll + mainAxisPosition += this._mainAxisProjector.getPosition(this._offsets); + + this._mainAxisProjector.setPosition(this._popover.style, `${mainAxisPosition}px`); + } + + /** + * Position the popover along the cross axis + * + * @return {void} + */ + positionAlongCrossAxis() { + let targetStartCrossAxis; + + if (this._crossAxisPosition === CrossAxisPositions.START) { + targetStartCrossAxis = this._triggerStartCrossAxis; + } else if (this._crossAxisPosition === CrossAxisPositions.END) { + targetStartCrossAxis = this._triggerStartCrossAxis + this._triggerSizeCrossAxis - this._popoverSizeCrossAxis; + } else { + targetStartCrossAxis = this._triggerStartCrossAxis + (this._triggerSizeCrossAxis - this._popoverSizeCrossAxis) / 2; + } + + targetStartCrossAxis += this._crossAxisProjector.getPosition(this._offsets); + + const crossMargin = this._crossAxisProjector.getPosition(this._displayZoneMargins); + const crossAxisMinPosition = this._crossAxisProjector.getPosition(this._displayBoundingBox) + crossMargin; + + // Add the start margin that is deduced when computing the available space + const crossAxisMaxPosition = crossMargin + this._availableSpaceInCrossAxis - this._popoverSizeCrossAxis; + + const crossAxisPosition = Math.max( + crossAxisMinPosition, + Math.min( + crossAxisMaxPosition, + targetStartCrossAxis, + ), + ); + this._crossAxisProjector.setPosition(this._popover.style, `${crossAxisPosition}px`); + } + + /** + * Returns the total available space along the main axis + * + * @return {number} the available space + * @private + */ + get _availableSpaceInMainAxis() { + return this._mainAxisProjector.getSize(this._displayBoundingBox) + - this._mainAxisProjector.getPosition(this._displayZoneMargins); + } + + /** + * Returns the total available space along the cross axis + * + * @return {number} the available space + * @private + */ + get _availableSpaceInCrossAxis() { + return this._crossAxisProjector.getSize(this._displayBoundingBox) + - this._crossAxisProjector.getPosition(this._displayZoneMargins) * 2; + } + + /** + * Return the position of the start of the trigger along the main axis + * + * @return {number} the start position + * @private + */ + get _triggerStartMainAxis() { + return this._mainAxisProjector.getPosition(this._triggerBoundingBox); + } + + /** + * Return the position of the end of the trigger along the main axis + * + * @return {number} the end position + * @private + */ + get _triggerEndMainAxis() { + return this._triggerStartMainAxis + this._mainAxisProjector.getSize(this._triggerBoundingBox); + } + + /** + * Return the position of the trigger along the cross axis + * + * @return {number} the start position + * @private + */ + get _triggerStartCrossAxis() { + return this._crossAxisProjector.getPosition(this._triggerBoundingBox); + } + + /** + * Return the size of the trigger along the cross axis + * + * @return {number} the size + * @private + */ + get _triggerSizeCrossAxis() { + return this._crossAxisProjector.getSize(this._triggerBoundingBox); + } + + /** + * Return the current bounding box of the popover + * + * @return {DOMRect} The bounding box + */ + get popoverBoundingBox() { + return this._popover.getBoundingClientRect(); + } + + /** + * Return the size of the popover along the main axis + * + * @return {number} the size + * @private + */ + get _popoverSizeMainAxis() { + return this._mainAxisProjector.getSize(this.popoverBoundingBox); + } + + /** + * Return the content size of the popover along the main axis + * + * @return {number} the size + * @private + */ + get _popoverContentSizeMainAxis() { + return this._mainAxisProjector.getSize(this._popoverContentSize); + } + + /** + * Return the size of the popover along the cross axis + * + * @return {number} the size + * @private + */ + get _popoverSizeCrossAxis() { + return this._crossAxisProjector.getSize(this.popoverBoundingBox); + } + + /** + * Return the content size of the popover along the cross axis + * + * @return {number} the size + * @private + */ + get _popoverContentSizeCrossAxis() { + return this._crossAxisProjector.getSize(this._popoverContentSize); + } + + /** + * The popover is placed absolutely relatively to the document, and it should be offset by the window's scroll + * + * @return {{top: number, left: number}} the offsets + * @private + */ + get _offsets() { + return { top: window.scrollY, left: window.scrollX }; + } + + /** + * Return the content size of the popover + * + * @return {{width: number, height: number}} the size + * @private + */ + get _popoverContentSize() { + return { width: this._popover.scrollWidth, height: this._popover.scrollHeight }; + } +} + +/** + * Creates and return a new popover engine + * + * @param {HTMLElement} trigger the bounding box of the trigger + * @param {HTMLElement} popover the bounding box of the popover + * @param {BoundingBox} displayBoundingBox the bounding box of the display zone, representing the limits where the popover may be drawn + * @param {{x: number, y: number}} displayZoneMargins the margins to apply at the edge of the display zone + * @param {PopoverAnchor} anchor the anchor to place the popover + * @param {object} [configuration] additional configuration + * @param {boolean} [configuration.imperativeSize=false] if true, size of the popover will be removed before re-computing size and position, + * and size will be imperative (width instead of max-width). This is useful when the popover content size is based on the popover size + * (displaying an image for example) but this should not be used with scrollable content, because it will reset scroll on every render, + * or with popover for which height might be reduced dynamically (dropdown options being filtered) + * @return {PopoverEngine} the created popover engine + */ +export const createPopoverEngine = (trigger, popover, displayBoundingBox, displayZoneMargins, anchor, configuration) => { + // Top and Bottom are the 6 first anchors + const mainAxisProjector = anchor / 6 < 1 ? verticalProjector : horizontalProjector; + const crossAxisProjector = anchor / 6 < 1 ? horizontalProjector : verticalProjector; + + // Top goes from 0 to 2 and Left goes from 6 to 8 + const mainAxisPosition = Math.floor(anchor / 3) % 2 === 0 ? MainAxisPositions.BEFORE : MainAxisPositions.AFTER; + + // Anchors are always START, MIDDLE, END, START, MIDDLE, END and so on + const crossAxisPosition = [CrossAxisPositions.START, CrossAxisPositions.MIDDLE, CrossAxisPositions.END][anchor % 3]; + + return new PopoverEngine( + trigger, + popover, + displayBoundingBox, + displayZoneMargins, + { main: mainAxisPosition, cross: crossAxisPosition }, + { main: mainAxisProjector, cross: crossAxisProjector }, + configuration, + ); +}; diff --git a/Framework/Frontend/js/src/components/PopoverPreConfigurations.js b/Framework/Frontend/js/src/components/PopoverPreConfigurations.js new file mode 100644 index 000000000..31848d0c5 --- /dev/null +++ b/Framework/Frontend/js/src/components/PopoverPreConfigurations.js @@ -0,0 +1,70 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +import { documentClickTaggedEventRegistry } from '../utilities/documentClickTaggedEventRegistry.js'; + +/** + * @type {Readonly<{click: Readonly>, hover: Readonly>}>} + */ +export const PopoverTriggerPreConfiguration = Object.freeze({ + click: Object.freeze({ + onTriggerNodeChange: (previousTriggerNode, newTriggerNode, popoverComponent) => { + const hideDropdownOnEscape = (e) => e.key === 'Escape' && popoverComponent.hidePopover(); + const handleClick = (e) => { + documentClickTaggedEventRegistry.tagEvent(e, popoverComponent.key); + popoverComponent.togglePopover(); + }; + + if (previousTriggerNode) { + documentClickTaggedEventRegistry.removeListener(popoverComponent.hidePopover); + window.removeEventListener('keyup', hideDropdownOnEscape); + previousTriggerNode.removeEventListener('click', handleClick); + } + + if (newTriggerNode) { + newTriggerNode.addEventListener('click', handleClick); + documentClickTaggedEventRegistry.addListenerForAnyExceptTagged(popoverComponent.hidePopover, popoverComponent.key); + window.addEventListener('keyup', hideDropdownOnEscape); + } + }, + onPopoverNodeChange: (previousPopoverNode, newPopoverNode, popoverComponent) => { + const handleClick = (e) => documentClickTaggedEventRegistry.tagEvent(e, popoverComponent.key); + + if (previousPopoverNode) { + previousPopoverNode.removeEventListener('click', handleClick); + } + + if (newPopoverNode) { + newPopoverNode.addEventListener('click', handleClick); + } + }, + }), + + /** + * Partial popover configuration for hover-based popover + * + * @type {Readonly>} + */ + hover: Object.freeze({ + onTriggerNodeChange: (previousTriggerNode, newTriggerNode, popoverComponent) => { + if (previousTriggerNode) { + previousTriggerNode.removeEventListener('mouseenter', popoverComponent.showPopover); + previousTriggerNode.removeEventListener('mouseleave', popoverComponent.hidePopover); + } + + if (newTriggerNode) { + newTriggerNode.addEventListener('mouseenter', popoverComponent.showPopover); + newTriggerNode.addEventListener('mouseleave', popoverComponent.hidePopover); + } + }, + }), +}); diff --git a/Framework/Frontend/js/src/components/createPortal.js b/Framework/Frontend/js/src/components/createPortal.js new file mode 100644 index 000000000..09cf122b4 --- /dev/null +++ b/Framework/Frontend/js/src/components/createPortal.js @@ -0,0 +1,134 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import Observable from '../Observable.js'; +import { documentClickTaggedEventRegistry } from '../utilities/documentClickTaggedEventRegistry.js'; +import { h, mount } from '../renderer.js'; + +/** + * Mithril component that displays its content as child of custom dom elements (document's root by default) that can be out of the current + * elements tree + */ +class Portal { + /** + * Component's constructor + * @param {{attrs: {container: (HTMLElement|undefined)}}} props the component's properties + */ + constructor({ attrs: { container } }) { + // Container is the actual element that will be the parent of the portal's target + this.container = container || document.body; + // Because portal's target will be mounted on its own dom tree, it has its own model to trigger re-render when the portal is re-rendered + this.model = new Observable(); + + /** + * @type {HTMLElement|null} + * @private + */ + this._portalSource = null; + this._propagateClick = this._propagateClick.bind(this); + } + + /** + * Lifecycle event + * + * @param {vnode} vnode the vnode of the component + * @return {void} + */ + oncreate({ dom, children, text }) { + /* + * For simplicity, create a div that will serve as root for portal's target. + * Doing so, it will be easier to clean it when portal is removed + * If this div breaks tree, use `oncreate` and `onupdate` on children to use the target's actual dom element as rootNode + * (this cannot work with text) + */ + this.rootNode = document.createElement('div'); + this.rootNode.addEventListener('click', this._propagateClick); + + this._portalSource = dom; + + this.container.append(this.rootNode); + // Wrap children in a component to be able to update the mounted view function when portal is updated without changing mounting point + this.content = { + view: () => children || text, + }; + mount(this.rootNode, this.content, this.model, false); + } + + /** + * Lifecycle event + * + * @param {vnode} vnode the vnode of the component + * @return {void} + */ + onupdate({ dom, children, text }) { + if (!this.content) { + return; + } + + this._portalSource = dom; + + // Update the view then notify, which will trigger a rendering of the new children + this.content.view = () => children || text; + this.model.notify(); + } + + /** + * Lifecycle event + * + * @return {void} + */ + onremove() { + this._portalSource = null; + if (this.container.contains(this.rootNode)) { + // Unmount the node in order for its lifecycle functions to be called + mount(this.rootNode, () => null, false); + this.rootNode.removeEventListener('click', this._propagateClick); + this.rootNode.remove(); + } + } + + /** + * Lifecycle event + * + * @return {Component} the component view + */ + view() { + return h('.d-none'); + } + + /** + * Propagate the given click event to the portal source if it exists + * + * @param {Event} event the click event to propagate + * @return {void} + * @private + */ + _propagateClick(event) { + if (this._portalSource) { + const eventToPropagate = new Event('click', { bubbles: true }); + const tags = documentClickTaggedEventRegistry.getEventTags(event); + documentClickTaggedEventRegistry.tagEvent(eventToPropagate, tags); + event.stopPropagation(); + this._portalSource.dispatchEvent(eventToPropagate); + } + } +} + +/** + * Create a portal to render a component outside its parent + * + * @param {Component} component the component to render + * @param {Element} [container] the container in which component should be rendered (document's body by default) + * @return {Component} the created portal that proxy the component + */ +export const createPortal = (component, container) => h(Portal, { container }, component); diff --git a/Framework/Frontend/js/src/components/getUniqueId.js b/Framework/Frontend/js/src/components/getUniqueId.js new file mode 100644 index 000000000..a4e3c4852 --- /dev/null +++ b/Framework/Frontend/js/src/components/getUniqueId.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const generator = window.crypto && window.crypto.randomUUID + ? () => window.crypto.randomUUID() + : () => `${Math.random()}-${Date.now()}`.replace('.', ''); + +/** + * Returns a probably unique identifier + * + * Uses crypto if available, but uniqueness is not guaranteed, do not use this for security purpose! + * + * @return {string} a (probably) unique identifier + */ +export const getUniqueId = () => generator(); diff --git a/Framework/Frontend/js/src/components/popover.js b/Framework/Frontend/js/src/components/popover.js new file mode 100644 index 000000000..75dff6554 --- /dev/null +++ b/Framework/Frontend/js/src/components/popover.js @@ -0,0 +1,330 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { h } from '../renderer.js'; +import { createPortal } from './createPortal.js'; +import { createPopoverEngine } from './PopoverEngine.js'; +import { StatefulComponent } from './StatefulComponent.js'; +import { getUniqueId } from './getUniqueId.js'; + +/** + * Default margin to apply at the edge of the display zone + * + * @type {{left: number, top: number}} + */ +const DEFAULT_DISPLAY_ZONE_MARGIN = { left: 15, top: 15 }; + +/** + * @callback onTriggerNodeChangeCallback Function called when the trigger node change + * + * @param {HTMLElement|null} previousTriggerNode the previous trigger node if it exists (null if the node is created) + * @param {HTMLElement|null} newTriggerNode the new trigger node if it exists (null if the node is removed) + * @param {PopoverComponent} popoverComponent the popover component instance + * @return {void} + */ + +/** + * @callback onPopoverNodeChangeCallback Function called when the popover node change + * + * @param {HTMLElement|null} previousPopoverNode the previous popover node if it exists (null if the node is created) + * @param {HTMLElement|null} newPopoverNode the new popover node if it exists (null if the node is removed) + * @param {PopoverComponent} popoverComponent the popover component instance + * @return {void} + */ + +/** + * @callback popoverShowConditionCallback Function called before displaying the popover to eventually prevent the display + * + * @param {PopoverComponent} popoverComponent the popover component instance + * @return {boolean} if false, popover will not be displayed + */ + +/** + * @callback onPopoverVisibilityChangeCallback function called when the visibility of the popover changes + * + * @param {boolean} visibility the new visibility + * @return {void} + */ + +/** + * @typedef PopoverConfiguration the configuration of the popover + * + * @property {PopoverAnchor} anchor the anchor of the popover + * @property {onTriggerNodeChangeCallback} onTriggerNodeChange function called when the trigger DOM node changes + * @property {{x: number, y: number}} displayZoneMargins the margins to apply at the edges to display zone + * @property {onPopoverNodeChangeCallback} [onPopoverNodeChange] function called when the popover DOM node changes + * @property {onPopoverVisibilityChangeCallback} [onVisibilityChange] function called when the visibility changes + * @property {popoverShowConditionCallback} [showCondition] function called before showing the popover + * @property {string[]|string} [popoverClass] css classes to apply to the popover (in addition to the default ones) + * @property {boolean} [scroll=true] if true, popover overflow will be set to scroll + */ + +/** + * Component to display a popover triggered on specific actions + */ +class PopoverComponent extends StatefulComponent { + /** + * Constructor + * + * @param {vnode} vnode the vnode of the component + * @param {Component} vnode.attrs.trigger the trigger component + * @param {Component} vnode.attrs.content the popover component + * @param {PopoverConfiguration} vnode.attrs.configuration the popover options + */ + constructor({ attrs: { trigger, content, configuration } }) { + super(); + + this._triggerComponent = trigger; + this._contentComponent = content; + + this._isVisible = false; + + /** @type {HTMLElement|null} */ + this._popoverNode = null; + + /** @type {HTMLElement|null} */ + this._triggerNode = null; + + this.configuration = configuration; + + this._popoverKey = getUniqueId(); + + this.showPopover = this.showPopover.bind(this); + this.hidePopover = this.hidePopover.bind(this); + this.togglePopover = this.togglePopover.bind(this); + } + + /** + * Lifecycle event + * + * @param {vnode} vnode the vnode of the component + * @return {void} + */ + onbeforeupdate({ attrs: { trigger, content, configuration } }) { + this._triggerComponent = trigger; + this._contentComponent = content; + this.configuration = configuration; + + this.updatePopover(); + } + + /** + * Update the popover's node visibility and position + * + * @return {void} + */ + updatePopover() { + if (this._popoverNode === null || this._triggerNode === null) { + return; + } + + if (this._isVisible) { + this._popoverNode.style.display = 'flex'; + + const engine = createPopoverEngine( + this._triggerNode, + this._popoverNode, + { + width: window.innerWidth, + height: window.innerHeight, + left: 0, + top: 0, + }, + this._displayZoneMargins, + this._anchor, + { imperativeSize: !this._scroll }, + ); + engine.reset(); + engine.fitAndPosition(); + } else { + this._popoverNode.style.display = 'none'; + } + } + + /** + * Sets the visibility of the popover + * + * @param {boolean} visibility the visibility to set + * @return {void} + */ + setVisibility(visibility) { + if (visibility !== this._isVisible) { + this._isVisible = visibility; + this.updatePopover(); + this._onVisibilityChange(visibility); + this.notify(); + } + } + + /** + * Toggle the visibility of the popover + * + * @return {void} + */ + togglePopover() { + this.setVisibility(!this._isVisible); + } + + /** + * Shows the popover + * + * @return {void} + */ + showPopover() { + this.setVisibility(this._showCondition(this)); + } + + /** + * Hides the popover + * + * @return {void} + */ + hidePopover() { + this.setVisibility(false); + } + + /** + * Returns the current trigger node + * + * @return {HTMLElement} the trigger node + */ + get triggerNode() { + return this._triggerNode; + } + + /** + * Set the current trigger node + * + * @param {HTMLElement} node the new trigger element + */ + set triggerNode(node) { + if (this._triggerNode !== node) { + this._onTriggerNodeChange(this._triggerNode, node, this); + this._triggerNode = node; + } + } + + /** + * Set the current popover node + * + * @param {HTMLElement} node the new popover element + */ + set popoverNode(node) { + if (this._popoverNode !== node) { + this._onPopoverNodeChange(this._popoverNode, node, this); + this._popoverNode = node; + this.updatePopover(); + } + } + + /** + * Renders the component + * + * @return {Component} the popover component + */ + view() { + return [ + h('.popover-trigger', { + ['data-popover-key']: this._popoverKey, + oncreate: ({ dom }) => { + this.triggerNode = dom; + }, + onupdate: ({ dom }) => { + this.triggerNode = dom; + }, + onremove: () => { + this.triggerNode = null; + }, + }, this._triggerComponent), + createPortal(h('', { + class: [ + 'popover', + 'shadow-level3', + 'br2', + 'bg-white', + ...this._scroll ? ['scroll-x', 'scroll-y'] : [], + ...this._popoverClasses, + ].join(' '), + ['data-popover-key']: this._popoverKey, + oncreate: ({ dom }) => { + this.popoverNode = dom; + }, + onupdate: ({ dom }) => { + this.popoverNode = dom; + }, + onremove: () => { + this.popoverNode = null; + }, + }, this._contentComponent)), + ]; + } + + /** + * Return the unique popover key + * + * @return {string} the key + */ + get key() { + return this._popoverKey; + } + + /** + * Set the current configuration + * + * @param {PopoverConfiguration} configuration the popover options + */ + set configuration(configuration) { + const { + anchor, + onTriggerNodeChange, + onPopoverNodeChange = () => { + }, + onVisibilityChange = () => { + }, + showCondition = () => true, + popoverClass = [], + displayZoneMargins = {}, + scroll = true, + } = configuration; + this._anchor = anchor; + + /** @type {onTriggerNodeChangeCallback} */ + this._onTriggerNodeChange = onTriggerNodeChange; + + /** @type {onPopoverNodeChangeCallback} */ + this._onPopoverNodeChange = onPopoverNodeChange; + + /** @type {onPopoverVisibilityChangeCallback} */ + this._onVisibilityChange = onVisibilityChange; + + /** @type {popoverShowConditionCallback} */ + this._showCondition = showCondition; + + this._popoverClasses = Array.isArray(popoverClass) ? popoverClass : [popoverClass]; + this._displayZoneMargins = { + ...DEFAULT_DISPLAY_ZONE_MARGIN, + ...displayZoneMargins, + }; + this._scroll = scroll; + } +} + +/** + * Display a popover and its trigger (trigger is always displayed) + * + * @param {Component} trigger the element which will display the popover when popover is active + * @param {Component} content the actual content of the popover + * @param {PopoverConfiguration} configuration the popover configuration + * @returns {Component} the resulting trigger and popover + */ +export const popover = (trigger, content, configuration) => h(PopoverComponent, { trigger, content, configuration }); diff --git a/Framework/Frontend/js/src/index.js b/Framework/Frontend/js/src/index.js index 42740b866..25671cdd3 100644 --- a/Framework/Frontend/js/src/index.js +++ b/Framework/Frontend/js/src/index.js @@ -23,6 +23,7 @@ export { default as QueryRouter } from './QueryRouter.js'; // Utils export { default as switchCase } from './switchCase.js'; +export { documentClickTaggedEventRegistry } from './utilities/documentClickTaggedEventRegistry.js'; // Formatters export { formatTimeDuration } from './formatter/formatTimeDuration.js'; @@ -39,6 +40,12 @@ export { default as BrowserStorage } from './BrowserStorage.js'; // Reusable components export { StatefulComponent } from './components/StatefulComponent.js'; export { CopyToClipboardComponent } from './components/CopyToClipboardComponent.js'; +export { createPortal } from './components/createPortal.js'; +export { DropdownComponent } from './components/DropdownComponent.js'; +export { getUniqueId } from './components/getUniqueId.js'; +export { popover } from './components/popover.js'; +export { PopoverAnchors } from './components/PopoverEngine.js'; +export { PopoverTriggerPreConfiguration } from './components/PopoverPreConfigurations.js'; // All icons helpers, namespaced with prefix 'icon*' export * from './icons.js'; diff --git a/Framework/Frontend/js/src/renderer.js b/Framework/Frontend/js/src/renderer.js index fdb526c03..584b10567 100644 --- a/Framework/Frontend/js/src/renderer.js +++ b/Framework/Frontend/js/src/renderer.js @@ -128,7 +128,7 @@ function h(...args) { * @param {Element} element - The DOM element * @param {function|{view: function}} view - The view which produces a vnode tree * @param {Observable} model - The model containing the state - * @param {boolean} debug - Facultative. Shows the rendering time each time + * @param {boolean} [debug] - Facultative. Shows the rendering time each time * @example * import {h, mount, Observable} from '/js/src/index.js'; * const model = new Observable(); diff --git a/Framework/Frontend/js/src/utilities/documentClickTaggedEventRegistry.js b/Framework/Frontend/js/src/utilities/documentClickTaggedEventRegistry.js new file mode 100644 index 000000000..d2a88ed20 --- /dev/null +++ b/Framework/Frontend/js/src/utilities/documentClickTaggedEventRegistry.js @@ -0,0 +1,104 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Registry to handle tagged events + * This system allow to + * - add tags on some events at one point of the bubbling + * - listen to events that DO not have a given tags + * + * In order to work, an event listener must flush the registry in the last handler of the bubbling list + */ +class TaggedEventRegistry { + /** + * Constructor + */ + constructor() { + this._listenersExceptTagged = new Map(); + this._eventTagsMap = new Map(); + } + + /** + * Add a listener that will be triggered when registry is flushed with an event that contains NONE of the given tags + * To avoid memory leaks, do not forget to unregister the listener when needed + * + * @param {function} listener the listener to call if none of the tags is applied to the event + * @param {...string[]} tags the list of tags that the event must NOT have to trigger listener + * + * @return {void} + */ + addListenerForAnyExceptTagged(listener, ...tags) { + const unifiedTags = [ + ...tags, + ...this._listenersExceptTagged.get(listener) || [], + ]; + this._listenersExceptTagged.set(listener, unifiedTags); + } + + /** + * Remove the given listener from the notification list (it will NEVER be called anymore) + * The given function must be the same reference that the one passed to {@see addListenerForAnyExceptTagged} + * + * @param {function} listener the listener for which restriction must be edited + * + * @return {void} + */ + removeListener(listener) { + this._listenersExceptTagged.delete(listener); + } + + /** + * Add a tag to a given event + * + * @param {Event} e the event to tag + * @param {string|string[]} tag the tag to add + * + * @return {void} + */ + tagEvent(e, tag) { + if (!this._eventTagsMap.has(e)) { + this._eventTagsMap.set(e, []); + } + this._eventTagsMap.get(e).push(...Array.isArray(tag) ? tag : [tag]); + } + + /** + * Call all the registered listeners for which the given event's tags match the restrictions + * + * @param {Event} e the event to listen to + * + * @return {void} + */ + flush(e) { + const eventTags = this._eventTagsMap.get(e) || []; + this._listenersExceptTagged.forEach((tags, listener) => { + if (!tags.some((tag) => eventTags.includes(tag))) { + listener(e); + } + }); + this._eventTagsMap = new Map(); + } + + /** + * Returns the tags related to a given event + * + * @param {Event} e the event + * @return {string[]} the tags attached to the event + */ + getEventTags(e) { + return this._eventTagsMap.get(e); + } +} + +export const documentClickTaggedEventRegistry = new TaggedEventRegistry(); +document.addEventListener('click', documentClickTaggedEventRegistry.flush.bind(documentClickTaggedEventRegistry));