@eclipse-scout/core
Version:
Eclipse Scout runtime
1,174 lines (1,045 loc) • 41.7 kB
text/typescript
/*
* Copyright (c) 2010, 2025 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {
AbstractLayout, CloseKeyStroke, DesktopPopupOpenEvent, DialogLayout, Dimension, EnumObject, Event, EventHandler, FocusRule, GlassPaneRenderer, graphics, HtmlComponent, InitModelOf, Insets, KeyStroke, KeyStrokeContext, Point,
PopupEventMap, PopupLayout, PopupModel, Rectangle, scout, scrollbars, strings, Widget, widgets
} from '../index';
import $ from 'jquery';
export type PopupAlignment = EnumObject<typeof Popup.Alignment>;
export class Popup extends Widget implements PopupModel {
declare model: PopupModel;
declare eventMap: PopupEventMap;
declare self: Popup;
anchorBounds: Rectangle;
animateOpening: boolean;
animateResize: boolean;
anchor: Widget;
windowPaddingX: number;
windowPaddingY: number;
withGlassPane: boolean;
withFocusContext: boolean;
initialFocus: () => FocusRule | HTMLElement;
focusableContainer: boolean;
horizontalAlignment: PopupAlignment;
verticalAlignment: PopupAlignment;
calculatedHorizontalAlignment: PopupAlignment; // Gives the current alignment after applying horizontal and vertical switch options
calculatedVerticalAlignment: PopupAlignment;
horizontalSwitch: boolean;
verticalSwitch: boolean;
trimWidth: boolean;
trimHeight: boolean;
scrollType: PopupScrollType;
windowResizeType: PopupWindowResizeType;
boundToAnchor: boolean;
withArrow: boolean;
closeOnAnchorMouseDown: boolean;
closeOnMouseDownOutside: boolean;
closeOnOtherPopupOpen: boolean;
modal: boolean;
$anchor: JQuery;
$arrow: JQuery;
$arrowOverlay: JQuery;
protected _documentMouseDownHandler: (event: MouseEvent) => void;
protected _anchorScrollHandler: (event: JQuery.ScrollEvent) => void;
protected _anchorLocationChangeHandler: EventHandler;
protected _popupOpenHandler: EventHandler<DesktopPopupOpenEvent>;
protected _glassPaneRenderer: GlassPaneRenderer;
protected _openLater: boolean;
protected _windowResizeHandler: (event: JQuery.ResizeEvent<Window>) => void;
protected _anchorRenderHandler: EventHandler<Event<Widget>>;
protected _withGlassPane: boolean;
protected _closeOnAnchorMouseDown: boolean;
protected _closeOnMouseDownOutside: boolean;
protected _closeOnOtherPopupOpen: boolean;
constructor() {
super();
this._documentMouseDownHandler = null;
this._anchorScrollHandler = null;
this._anchorLocationChangeHandler = null;
this._popupOpenHandler = null;
this._glassPaneRenderer = null;
this.anchorBounds = null;
this.animateOpening = false;
this.animateResize = false;
this.anchor = null;
this.$anchor = null;
this.windowPaddingX = 10;
this.windowPaddingY = 5;
this.withGlassPane = false;
this._withGlassPane = null;
this.withFocusContext = true;
this.initialFocus = () => FocusRule.AUTO;
this.focusableContainer = false;
this.horizontalAlignment = Popup.Alignment.LEFTEDGE;
this.verticalAlignment = Popup.Alignment.BOTTOM;
this.calculatedHorizontalAlignment = this.horizontalAlignment;
this.calculatedVerticalAlignment = this.verticalAlignment;
this.horizontalSwitch = false;
this.verticalSwitch = true;
this.trimWidth = false;
this.trimHeight = true;
this.scrollType = 'remove';
this.windowResizeType = null;
this.boundToAnchor = true;
this.withArrow = false;
this.closeOnAnchorMouseDown = true;
this._closeOnAnchorMouseDown = null;
this.closeOnMouseDownOutside = true;
this._closeOnMouseDownOutside = null;
this.closeOnOtherPopupOpen = true;
this._closeOnOtherPopupOpen = null;
this.modal = false;
this._openLater = false;
this.$arrow = null;
this.$arrowOverlay = null;
this._windowResizeHandler = this._onWindowResize.bind(this);
this._anchorRenderHandler = this._onAnchorRender.bind(this);
this._addWidgetProperties(['anchor']);
this._addPreserveOnPropertyChangeProperties(['anchor']);
}
// Note that these strings are also used as CSS classes
static Alignment = {
/**
* The entire popup is positioned horizontally left of the anchor.
*/
LEFT: 'left',
/**
* With arrow: The arrow at the left edge of the popup is aligned horizontally with the center of the anchor.
* <p>
* Without arrow: The left edges of both the popup and the anchor are aligned horizontally.
*/
LEFTEDGE: 'leftedge',
/**
* The entire popup is positioned vertically above the anchor.
*/
TOP: 'top',
/**
* With arrow: The arrow at the top edge of the popup is aligned vertically with the center of the anchor.
* <p>
* Without arrow: The top edges of both the popup and the anchor are aligned vertically.
*/
TOPEDGE: 'topedge',
/**
* The centers of both the popup and the anchor are aligned in the respective dimension.
*/
CENTER: 'center',
/**
* The entire popup is positioned horizontally to the right of the anchor.
*/
RIGHT: 'right',
/**
* With arrow: The arrow at the right edge of the popup is aligned horizontally with the center of the anchor.
* <p>
* Without arrow: The right edges of both the popup and the anchor are aligned horizontally.
*/
RIGHTEDGE: 'rightedge',
/**
* The entire popup is positioned vertically below the anchor.
*/
BOTTOM: 'bottom',
/**
* With arrow: The arrow at the bottom edge of the popup is aligned vertically with the center of the anchor.
* <p>
* Without arrow: The bottom edges of both the popup and the anchor are aligned vertically.
*/
BOTTOMEDGE: 'bottomedge'
} as const;
static SwitchRule = {};
protected override _init(options: InitModelOf<this>) {
super._init(options);
if (options.location) {
this.anchorBounds = new Rectangle(options.location.x, options.location.y, 0, 0);
}
this._setAnchor(this.anchor);
this._setModal(this.modal);
}
protected override _createKeyStrokeContext(): KeyStrokeContext {
return new KeyStrokeContext();
}
protected override _initKeyStrokeContext() {
super._initKeyStrokeContext();
this.keyStrokeContext.registerKeyStroke(this._createCloseKeyStroke());
}
/**
* Override this method to provide a key stroke which closes the popup.
* The default impl. returns a CloseKeyStroke which handles the ESC key.
*/
protected _createCloseKeyStroke(): KeyStroke {
return new CloseKeyStroke(this);
}
protected _createLayout(): AbstractLayout {
return new PopupLayout(this);
}
protected _openWithoutParent() {
// resolve parent for entry-point (don't change the actual property)
if (this.parent.destroyed) {
return;
}
if (this.parent.rendered || this.parent.rendering) {
this.open(this._getDefaultOpen$Parent());
return;
}
// This is important for popups rendered in another (native) browser window. The DOM in the popup window
// is rendered later, so we must wait until that window is rendered and layouted. See popup-window.html.
this.parent.one('render', () => {
this.session.layoutValidator.schedulePostValidateFunction(() => {
if (this.destroyed || this.rendered) {
return;
}
this.open();
});
});
}
/**
* Only called if parent.rendered or parent.rendering
*/
protected _getDefaultOpen$Parent(): JQuery {
return this.parent.entryPoint();
}
open($parent?: JQuery) {
if (!$parent) {
this._openWithoutParent();
return;
}
this._triggerPopupOpenEvent();
this._open($parent);
if (this._openLater) {
return;
}
if (!this.animateOpening) {
// It is important that focusing happens after layouting and positioning, otherwise we'd focus an element
// that is currently not on the screen. Which would cause the whole desktop to
// be shifted for a few pixels.
this.validateFocus();
return;
}
// Give the browser time to layout properly before starting the animation to make sure it will be smooth.
// The before-animate-open class will make the popup invisible (cannot use the invisible class because it is already used by _validateVisibility)
this.$container.addClass('before-animate-open');
setTimeout(() => {
if (!this.rendered || this.removing) {
return;
}
this.$container.removeClass('before-animate-open');
this.validateFocus(); // Need to be done after popup is visible again because focus cannot be set on invisible elements.
if (!this.rendered) {
// Validate again, focus change could have removed the popup
return;
}
this.$container.addClassForAnimation('animate-open');
this.$container.oneAnimationEnd(() => this.findDesktop().repositionTooltips());
});
}
validateFocus() {
if (!this.withFocusContext) {
return;
}
let context = this.session.focusManager.getFocusContext(this.$container);
context.ready();
if (!context.lastValidFocusedElement) {
// No widget requested focus -> try to determine the initial focus
this._requestInitialFocus();
}
}
protected _requestInitialFocus() {
let initialFocusElement = this.session.focusManager.evaluateFocusRule(this.$container, this.initialFocus());
if (!initialFocusElement) {
return;
}
this.session.focusManager.requestFocus(initialFocusElement);
}
protected _open($parent: JQuery) {
this.render($parent);
if (this._openLater) {
return;
}
this.revalidateLayout();
this.position();
}
override render($parent?: JQuery) {
let $popupParent = $parent || this.entryPoint();
// when the parent is detached it is not possible to render the popup -> do it later
if (!$popupParent || !$popupParent.length || !$popupParent.isAttached()) {
this._openLater = true;
return;
}
super.render($popupParent);
}
protected override _render() {
this.$container = this.$parent.appendDiv('popup');
this.htmlComp = HtmlComponent.install(this.$container, this.session);
this.htmlComp.validateRoot = true;
this.htmlComp.setLayout(this._createLayout());
this.$container.window().on('resize', this._windowResizeHandler);
}
protected override _renderProperties() {
super._renderProperties();
this._renderAnchor();
this._renderWithArrow();
this._renderWithFocusContext();
this._renderWithGlassPane();
this._renderModal();
}
protected override _postRender() {
super._postRender();
this._attachCloseHandlers();
this._attachAnchorHandlers();
this._handleGlassPanes();
}
protected override _onAttach() {
super._onAttach();
if (this._openLater && !this.rendered) {
this._openLater = false;
// Don't animate the opening when parent is attached. It doesn't look right if popups "pop up" when they are not really opening but only displayed again.
// The same applies for detaching, see _renderOnDetach
let currentAnimateOpening = this.animateOpening;
this.animateOpening = false;
this.open(this.$parent);
this.animateOpening = currentAnimateOpening;
}
}
protected override _renderOnDetach() {
this._openLater = true;
// keep $parent so that the DOM structure stays unchanged when re-attaching
const $parent = this.$parent;
// If parent is detached, popup should be removed immediately, otherwise animation would still be visible even though parent has already gone.
super.removeImmediately();
this.$parent = $parent;
super._renderOnDetach();
}
override remove() {
let currentAnimateRemoval = this.animateRemoval;
if ((this.boundToAnchor && this.$anchor) && !this._isAnchorInView()) {
this.animateRemoval = false;
}
super.remove();
this.animateRemoval = currentAnimateRemoval;
}
protected override _remove() {
this.$container.window().off('resize', this._windowResizeHandler);
if (this._glassPaneRenderer) {
this._glassPaneRenderer.removeGlassPanes();
}
if (this.withFocusContext) {
this.session.focusManager.uninstallFocusContext(this.$container);
}
if (this.$arrow) {
this.$arrow.remove();
this.$arrow = null;
}
if (this.anchor) {
// reopen when the anchor gets rendered again
this.anchor.one('render', this._anchorRenderHandler);
}
// remove all clean-up handlers
this._detachAnchorHandlers();
this._detachCloseHandlers();
super._remove();
}
protected override _destroy() {
if (this.anchor) {
this.anchor.off('render', this._anchorRenderHandler);
}
super._destroy();
}
protected _renderWithFocusContext() {
if (!this.withFocusContext) {
return;
}
// Add programmatic 'tabindex' if the $container itself should be focusable (used by context menu popups with no focusable elements)
if (this.focusableContainer) {
this.$container.attr('tabindex', -1);
}
// Don't allow an element to be focused while the popup is opened.
// The popup will focus the element as soon as the opening is finished (see open());
// The context needs to be already installed so that child elements don't try to focus an element outside of this context
this.session.focusManager.installFocusContext(this.$container, FocusRule.PREPARE);
}
setModal(modal: boolean) {
this.setProperty('modal', modal);
}
protected _setModal(modal: boolean) {
this._setProperty('modal', modal);
if (modal) {
widgets.preserveAndSetProperty(() => this.setProperty('withGlassPane', true), () => this.withGlassPane, this, '_withGlassPane');
widgets.preserveAndSetProperty(() => this.setProperty('closeOnAnchorMouseDown', false), () => this.closeOnAnchorMouseDown, this, '_closeOnAnchorMouseDown');
widgets.preserveAndSetProperty(() => this.setProperty('closeOnMouseDownOutside', false), () => this.closeOnMouseDownOutside, this, '_closeOnMouseDownOutside');
widgets.preserveAndSetProperty(() => this.setProperty('closeOnOtherPopupOpen', false), () => this.closeOnOtherPopupOpen, this, '_closeOnOtherPopupOpen');
} else {
widgets.resetProperty(v => this.setWithGlassPane(v), this, '_withGlassPane');
widgets.resetProperty(v => this.setCloseOnAnchorMouseDown(v), this, '_closeOnAnchorMouseDown');
widgets.resetProperty(v => this.setCloseOnMouseDownOutside(v), this, '_closeOnMouseDownOutside');
widgets.resetProperty(v => this.setCloseOnOtherPopupOpen(v), this, '_closeOnOtherPopupOpen');
}
}
protected _renderModal() {
this.$container.toggleClass('modal', this.modal);
}
setWithGlassPane(withGlassPane: boolean) {
if (!this.modal) {
this.setProperty('withGlassPane', withGlassPane);
} else {
this._withGlassPane = withGlassPane;
}
}
protected _renderWithGlassPane() {
if (this.withGlassPane && !this._glassPaneRenderer) {
this._glassPaneRenderer = new GlassPaneRenderer(this);
this._glassPaneRenderer.renderGlassPanes();
} else if (!this.withGlassPane && this._glassPaneRenderer) {
this._glassPaneRenderer.removeGlassPanes();
this._glassPaneRenderer = null;
}
}
setCloseOnMouseDownOutside(closeOnMouseDownOutside: boolean) {
if (!this.modal) {
this.setProperty('closeOnMouseDownOutside', closeOnMouseDownOutside);
} else {
this._closeOnMouseDownOutside = closeOnMouseDownOutside;
}
}
protected _renderCloseOnMouseDownOutside() {
// The listener needs to be executed in the capturing phase -> prevents that _onDocumentMouseDown will be executed right after the popup gets opened using mouse down, otherwise the popup would be closed immediately
if (this.closeOnMouseDownOutside && !this._documentMouseDownHandler) {
this._documentMouseDownHandler = this._onDocumentMouseDown.bind(this);
this.$container.document(true).addEventListener('mousedown', this._documentMouseDownHandler, true); // true=the event handler is executed in the capturing phase
} else if (!this.closeOnMouseDownOutside && this._documentMouseDownHandler) {
this.$container.document(true).removeEventListener('mousedown', this._documentMouseDownHandler, true);
this._documentMouseDownHandler = null;
}
}
setCloseOnAnchorMouseDown(closeOnAnchorMouseDown: boolean) {
if (!this.modal) {
this.setProperty('closeOnAnchorMouseDown', closeOnAnchorMouseDown);
} else {
this._closeOnAnchorMouseDown = closeOnAnchorMouseDown;
}
}
setCloseOnOtherPopupOpen(closeOnOtherPopupOpen: boolean) {
if (!this.modal) {
this.setProperty('closeOnOtherPopupOpen', closeOnOtherPopupOpen);
} else {
this._closeOnOtherPopupOpen = closeOnOtherPopupOpen;
}
}
protected _renderCloseOnOtherPopupOpen() {
if (this.closeOnOtherPopupOpen && !this._popupOpenHandler) {
this._popupOpenHandler = this._onPopupOpen.bind(this);
this.session.desktop.on('popupOpen', this._popupOpenHandler);
} else if (!this.closeOnOtherPopupOpen && this._popupOpenHandler) {
this.session.desktop.off('popupOpen', this._popupOpenHandler);
this._popupOpenHandler = null;
}
}
setWithArrow(withArrow: boolean) {
this.setProperty('withArrow', withArrow);
}
protected _renderWithArrow() {
if (this.$arrow) {
this.$arrow.remove();
this.$arrow = null;
}
if (this.$arrowOverlay) {
this.$arrowOverlay.remove();
this.$arrowOverlay = null;
}
if (this.withArrow) {
this.$arrowOverlay = this.$container.prependDiv('popup-arrow-overlay');
this.$arrow = this.$container.prependDiv('popup-arrow');
this._updateArrowClass();
}
this.$container.toggleClass('with-arrow', this.withArrow);
this.invalidateLayoutTree();
}
protected _updateArrowClass(verticalAlignment?: PopupAlignment, horizontalAlignment?: PopupAlignment) {
if (this.$arrow) {
this.$arrow.removeClass(this._alignClasses());
this.$arrow.addClass(this._computeArrowPositionClass(verticalAlignment, horizontalAlignment));
}
}
protected _computeArrowPositionClass(verticalAlignment?: PopupAlignment, horizontalAlignment?: PopupAlignment): string {
let Alignment = Popup.Alignment;
let cssClass = '';
horizontalAlignment = horizontalAlignment || this.horizontalAlignment;
verticalAlignment = verticalAlignment || this.verticalAlignment;
switch (horizontalAlignment) {
case Alignment.LEFT:
cssClass = Alignment.RIGHT;
break;
case Alignment.RIGHT:
cssClass = Alignment.LEFT;
break;
default:
cssClass = horizontalAlignment;
break;
}
switch (verticalAlignment) {
case Alignment.BOTTOM:
cssClass += ' ' + Alignment.TOP;
break;
case Alignment.TOP:
cssClass += ' ' + Alignment.BOTTOM;
break;
default:
cssClass += ' ' + verticalAlignment;
break;
}
return cssClass;
}
protected override _animateRemovalWhileRemovingParent(): boolean {
if (!this.$anchor) {
// Allow remove animations for popups without an anchor
return true;
}
// If parent is the anchor, prevent remove animation to ensure popup will be removed together with the anchor
return widgets.get(this.$anchor) !== this.parent;
}
protected override _isRemovalPrevented(): boolean {
// If removal of a parent is pending due to an animation then don't return true to make sure popups are closed before the parent animation starts.
// However, if the popup itself is removed by an animation, removal should be prevented to ensure remove() won't run multiple times.
return this.removalPending;
}
close() {
if (this.destroyed || this.destroying) {
// Already closed, do nothing
return;
}
let event = this.trigger('close');
if (!event.defaultPrevented) {
this.destroy();
}
}
/**
* Install listeners to close the popup once clicking outside the popup,
* or changing the anchor's scroll position, or another popup is opened.
*/
protected _attachCloseHandlers() {
// Install mouse close handler
this._renderCloseOnMouseDownOutside();
// Install popup open close handler
this._renderCloseOnOtherPopupOpen();
}
protected _attachAnchorHandlers() {
if (!this.$anchor || !this.boundToAnchor || !this.scrollType) {
return;
}
// Attach a scroll handler to each scrollable parent of the anchor
this._anchorScrollHandler = this._onAnchorScroll.bind(this);
scrollbars.onScroll(this.$anchor, this._anchorScrollHandler);
// Attach a location change handler as well (will only work if the anchor is a widget which triggers a locationChange event, e.g. another Popup)
let anchor = scout.widget(this.$anchor);
if (anchor) {
this._anchorLocationChangeHandler = this._onAnchorLocationChange.bind(this);
anchor.on('locationChange', this._anchorLocationChangeHandler);
}
}
protected _detachAnchorHandlers() {
if (this._anchorScrollHandler) {
scrollbars.offScroll(this._anchorScrollHandler);
this._anchorScrollHandler = null;
}
if (this._anchorLocationChangeHandler) {
let anchor = scout.widget(this.$anchor);
if (anchor) {
anchor.off('locationChange', this._anchorLocationChangeHandler);
this._anchorLocationChangeHandler = null;
}
}
}
protected _detachCloseHandlers() {
// Uninstall popup open close handler
if (this._popupOpenHandler) {
this.session.desktop.off('popupOpen', this._popupOpenHandler);
this._popupOpenHandler = null;
}
// Uninstall mouse close handler
if (this._documentMouseDownHandler) {
this.$container.document(true).removeEventListener('mousedown', this._documentMouseDownHandler, true);
this._documentMouseDownHandler = null;
}
}
protected _onDocumentMouseDown(event: MouseEvent) {
// in some cases the mousedown handler is executed although it has been already
// detached on the _remove() method. However, since we're in the middle of
// processing the mousedown event, it's too late to detach the event and we must
// deal with that situation by checking the rendered flag. Otherwise we would
// run into an error later, since the $container is not available anymore.
// Use the internal flag because popup should be closed even if the parent removal is pending due to a remove animation
if (!this._rendered) {
return;
}
if (this._isMouseDownOutside(event)) {
this._onMouseDownOutside(event);
}
}
protected _isMouseDownOutside(event: MouseEvent): boolean {
let eventTarget = event.target as HTMLElement;
let $target = $(eventTarget),
targetWidget;
if (!this.closeOnAnchorMouseDown && this._isMouseDownOnAnchor(event)) {
// 1. Often times, click on the anchor opens and 2. click closes the popup
// If we were closing the popup here, it would not be possible to achieve the described behavior anymore -> let anchor handle open and close.
return false;
}
targetWidget = scout.widget($target);
// close the popup only if the click happened outside of the popup and its children
// It is not sufficient to check the dom hierarchy using $container.has($target)
// because the popup may open other popups which probably is not a dom child but a sibling
// Also ignore clicks if the popup is covert by a glasspane
return !this.isOrHas(targetWidget) && !this.session.focusManager.isElementCovertByGlassPane(this.$container[0]);
}
protected _isMouseDownOnAnchor(event: MouseEvent): boolean {
let eventTarget = event.target as HTMLElement;
return !!this.$anchor && this.$anchor.isOrHas(eventTarget);
}
/**
* Method invoked once a mouse down event occurs outside the popup.
*/
protected _onMouseDownOutside(event: MouseEvent) {
this.close();
}
/**
* Method invoked once the 'options.$anchor' is scrolled.
*/
protected _onAnchorScroll(event: JQuery.ScrollEvent) {
if (!this.rendered) {
// Scroll events may be fired delayed, even if scroll listeners are already removed.
return;
}
this._handleAnchorPositionChange(event);
}
protected _handleAnchorPositionChange(event: JQuery.ScrollEvent | Event) {
if (scout.isOneOf(this.scrollType, 'position', 'layoutAndPosition') && this.isOpeningAnimationRunning()) {
// If the popup is opened with an animation which transforms the popup the sizes used by prefSize and position will likely be wrong.
// In that case it is not possible to layout and position it correctly -> do nothing.
return;
}
if (this.scrollType === 'position') {
this.position();
} else if (this.scrollType === 'layoutAndPosition') {
this.revalidateLayout();
this.position();
} else if (this.scrollType === 'remove') {
this.close();
}
}
isOpeningAnimationRunning(): boolean {
return this.rendered && this.animateOpening && this.$container.hasClass('animate-open');
}
protected _onAnchorLocationChange(event: Event) {
this._handleAnchorPositionChange(event);
}
/**
* Method invoked once a popup is opened.
*/
protected _onPopupOpen(event: DesktopPopupOpenEvent) {
// Make sure child popups don't close the parent popup, we must check parent hierarchy in both directions
// Use case: Opening of a context menu or cell editor in a form popup
// Also, popups covered by a glass pane (a modal dialog is open) must never be closed
// Use case: popup opens a modal dialog. User clicks on a smartfield on this dialog -> underlying popup must not get closed
let closable = !this.isOrHas(event.popup) && !event.popup.isOrHas(this);
if (this.rendered) {
closable = closable && !this.session.focusManager.isElementCovertByGlassPane(this.$container[0]);
}
if (closable) {
this.close();
}
}
setHorizontalAlignment(horizontalAlignment: PopupAlignment) {
this.setProperty('horizontalAlignment', horizontalAlignment);
}
protected _renderHorizontalAlignment() {
this._updateArrowClass();
this.invalidateLayoutTree();
}
setVerticalAlignment(verticalAlignment: PopupAlignment) {
this.setProperty('verticalAlignment', verticalAlignment);
}
protected _renderVerticalAlignment() {
this._updateArrowClass();
this.invalidateLayoutTree();
}
setHorizontalSwitch(horizontalSwitch: boolean) {
this.setProperty('horizontalSwitch', horizontalSwitch);
}
protected _renderHorizontalSwitch() {
this.invalidateLayoutTree();
}
setVerticalSwitch(verticalSwitch: boolean) {
this.setProperty('verticalSwitch', verticalSwitch);
}
protected _renderVerticalSwitch() {
this.invalidateLayoutTree();
}
setTrimWidth(trimWidth: boolean) {
this.setProperty('trimWidth', trimWidth);
}
protected _renderTrimWidth() {
this.invalidateLayoutTree();
}
setTrimHeight(trimHeight: boolean) {
this.setProperty('trimHeight', trimHeight);
}
protected _renderTrimHeight() {
this.invalidateLayoutTree();
}
prefLocation(verticalAlignment?: PopupAlignment, horizontalAlignment?: PopupAlignment): Point {
if (!this.boundToAnchor || (!this.anchorBounds && !this.$anchor)) {
return this._prefLocationWithoutAnchor();
}
return this._prefLocationWithAnchor(verticalAlignment, horizontalAlignment);
}
protected _prefLocationWithoutAnchor(): Point {
return DialogLayout.positionContainerInWindow(this.$container);
}
protected _prefLocationWithAnchor(verticalAlignment?: PopupAlignment, horizontalAlignment?: PopupAlignment): Point {
let $container = this.$container;
horizontalAlignment = horizontalAlignment || this.horizontalAlignment;
verticalAlignment = verticalAlignment || this.verticalAlignment;
let anchorBounds = this.getAnchorBounds();
let size = graphics.size($container, {exact: true});
let margins = graphics.margins($container);
let Alignment = Popup.Alignment;
let arrowBounds = null;
if (this.$arrow) {
// Ensure the arrow has the correct class
this._updateArrowClass(verticalAlignment, horizontalAlignment);
// Remove margin added by moving logic, otherwise the bounds would not be correct
graphics.setMargins(this.$arrow, new Insets());
arrowBounds = graphics.bounds(this.$arrow);
}
$container.removeClass(this._alignClasses());
$container.addClass(verticalAlignment + ' ' + horizontalAlignment);
let widthWithMargin = size.width + margins.horizontal();
let width = size.width;
let x = anchorBounds.x;
if (horizontalAlignment === Alignment.LEFT) {
x -= widthWithMargin;
} else if (horizontalAlignment === Alignment.LEFTEDGE) {
if (this.withArrow) {
x += anchorBounds.width / 2 - arrowBounds.center().x - margins.left;
} else {
x = anchorBounds.x - margins.left;
}
} else if (horizontalAlignment === Alignment.CENTER) {
x += anchorBounds.width / 2 - width / 2 - margins.left;
} else if (horizontalAlignment === Alignment.RIGHT) {
x += anchorBounds.width;
} else if (horizontalAlignment === Alignment.RIGHTEDGE) {
if (this.withArrow) {
x += anchorBounds.width / 2 - arrowBounds.center().x - margins.left;
} else {
x = anchorBounds.x + anchorBounds.width - width - margins.left;
}
}
let heightWithMargin = size.height + margins.vertical();
let height = size.height;
let y = anchorBounds.y;
if (verticalAlignment === Alignment.TOP) {
y -= heightWithMargin;
} else if (verticalAlignment === Alignment.TOPEDGE) {
if (this.withArrow) {
y += anchorBounds.height / 2 - arrowBounds.center().y - margins.top;
} else {
y = anchorBounds.y - margins.top;
}
} else if (verticalAlignment === Alignment.CENTER) {
y += anchorBounds.height / 2 - height / 2 - margins.top;
} else if (verticalAlignment === Alignment.BOTTOM) {
y += anchorBounds.height;
} else if (verticalAlignment === Alignment.BOTTOMEDGE) {
if (this.withArrow) {
y += anchorBounds.height / 2 - arrowBounds.center().y - margins.top;
} else {
y = anchorBounds.y + anchorBounds.height - height - margins.top;
}
}
// this.$parent might not be at (0,0) of the document
let parentOffset = this.$parent.offset();
x -= parentOffset.left;
y -= parentOffset.top;
return new Point(x, y);
}
protected _alignClasses(): string {
let Alignment = Popup.Alignment;
return strings.join(' ', Alignment.LEFT, Alignment.LEFTEDGE, Alignment.CENTER, Alignment.RIGHT, Alignment.RIGHTEDGE,
Alignment.TOP, Alignment.TOPEDGE, Alignment.CENTER, Alignment.BOTTOM, Alignment.BOTTOMEDGE);
}
getAnchorBounds(): Rectangle {
let anchorBounds = this.anchorBounds;
if (!this.$anchor) {
// Use manually set anchor bounds
return anchorBounds;
}
let realAnchorBounds = graphics.offsetBounds(this.$anchor, {
exact: true
});
if (!anchorBounds) {
// Use measured anchor bounds
anchorBounds = realAnchorBounds;
} else {
// Fill incomplete anchorBounds from measured anchor bounds. This allows setting one
// coordinate to a fixed value (e.g. the current mouse cursor position) while still
// aligning the other coordinate to the $anchor element.
//
// Implementation note:
// A coordinate is considered "undefined", when it is 0. Technically, this is not 100%
// correct, but will give the desired result in most of the cases. If would require too
// many code changes to correctly set missing values to undefined/null.
if (!anchorBounds.x) {
anchorBounds.x = realAnchorBounds.x;
anchorBounds.width = realAnchorBounds.width;
}
if (!anchorBounds.y) {
anchorBounds.y = realAnchorBounds.y;
anchorBounds.height = realAnchorBounds.height;
}
}
return anchorBounds;
}
getWindowSize(): Dimension {
let $window = this.$parent.window();
return new Dimension($window.width(), $window.height());
}
/**
* @returns Point the amount of overlap at the window borders.
* A positive value indicates that it is overlapping the right / bottom border, a negative value indicates that it is overlapping the left / top border.
* Prefers the right and bottom over the left and top border, meaning if a positive value is returned it does not mean that the left border is overlapping as well.
*/
overlap(location: Point, includeMargin?: boolean): Point {
let $container = this.$container;
if (!$container || !location) {
return null;
}
includeMargin = scout.nvl(includeMargin, true);
let containerSize = graphics.size($container, {exact: true, includeMargin: includeMargin});
let width = containerSize.width;
let height = containerSize.height;
let popupBounds = new Rectangle(location.x, location.y, width, height);
let bounds = graphics.offsetBounds($container.entryPoint(), true);
let overlapX = popupBounds.right() + this.windowPaddingX - bounds.width;
if (overlapX < 0) {
overlapX = Math.min(popupBounds.x - this.windowPaddingX - bounds.x, 0);
}
let overlapY = popupBounds.bottom() + this.windowPaddingY - bounds.height;
if (overlapY < 0) {
overlapY = Math.min(popupBounds.y - this.windowPaddingY - bounds.y, 0);
}
return new Point(overlapX, overlapY);
}
adjustLocation(location: Point, switchIfNecessary?: boolean): Point {
this.calculatedVerticalAlignment = this.verticalAlignment;
this.calculatedHorizontalAlignment = this.horizontalAlignment;
let overlap = this.overlap(location);
// Reset arrow style
if (this.$arrow) {
this._updateArrowClass(this.calculatedVerticalAlignment, this.calculatedHorizontalAlignment);
graphics.setMargins(this.$arrow, new Insets());
}
location = location.clone();
// Ignore very small overlaps (e.g. 0.3px). This could happen if anchor position is fractional and popup has a margin that is not
// Example: anchor has top: 10px and margin-top: 10px, browser renders it at 9.984px (due to zoom) but margin stays at 10px
// -> location.y would be -0.16px resulting in a popup switch so that the popup will be displayed outside of the window
if (Math.abs(overlap.y) >= 1) {
let verticalSwitch = scout.nvl(switchIfNecessary, this.verticalSwitch);
if (verticalSwitch) {
// Switch vertical alignment
this.calculatedVerticalAlignment = Popup.SwitchRule[this.calculatedVerticalAlignment];
location.y = this.prefLocation(this.calculatedVerticalAlignment).y;
} else {
// Move popup to the top until it gets fully visible (if switch is disabled)
location.y -= overlap.y;
}
}
// Reason for >= 1 see above
if (Math.abs(overlap.x) >= 1) {
let horizontalSwitch = scout.nvl(switchIfNecessary, this.horizontalSwitch);
if (horizontalSwitch) {
// Switch horizontal alignment
this.calculatedHorizontalAlignment = Popup.SwitchRule[this.calculatedHorizontalAlignment];
location.x = this.prefLocation(this.calculatedVerticalAlignment, this.calculatedHorizontalAlignment).x;
} else {
// Move popup to the left until it gets fully visible (if switch is disabled)
location.x -= overlap.x;
}
}
// Also move arrow so that it still points to the center of the anchor
if (this.$arrow) {
if (overlap.y !== 0 && (this.$arrow.hasClass(Popup.Alignment.LEFT) || this.$arrow.hasClass(Popup.Alignment.RIGHT))) {
if (overlap.y > 0) {
this.$arrow.cssMarginTop(overlap.y);
} else {
this.$arrow.cssMarginBottom(-overlap.y);
}
}
if (overlap.x !== 0 && (this.$arrow.hasClass(Popup.Alignment.TOP) || this.$arrow.hasClass(Popup.Alignment.BOTTOM))) {
if (overlap.x > 0) {
this.$arrow.cssMarginLeft(overlap.x);
} else {
this.$arrow.cssMarginRight(-overlap.x);
}
}
}
return location;
}
position(switchIfNecessary?: boolean) {
if (!this.rendered) {
return;
}
this._validateVisibility();
this._position(switchIfNecessary);
}
protected _position(switchIfNecessary?: boolean) {
let location = this.prefLocation();
if (!location) {
return;
}
location = this.adjustLocation(location, switchIfNecessary);
this.setLocation(location);
}
setLocation(location: Point) {
if (!this.rendered) {
return;
}
this.$container
.css('left', location.x)
.css('top', location.y);
this._triggerLocationChange();
}
/**
* Popups with an anchor must only be visible if the anchor is in view (prevents that the popup points at an invisible anchor)
*/
protected _validateVisibility() {
if (!this.boundToAnchor || !this.$anchor) {
return;
}
let inView = this._isAnchorInView();
let needsLayouting = this.$container.hasClass('invisible') === inView && inView;
this.$container.toggleClass('invisible', !inView); // Use visibility: hidden to not break layouting / size measurement
if (needsLayouting) {
let currentAnimateResize = this.animateResize;
this.animateResize = false;
this.revalidateLayout();
this.animateResize = currentAnimateResize;
if (this.withFocusContext) {
this.session.focusManager.validateFocus();
}
}
}
protected _isAnchorInView(): boolean {
if (!this.boundToAnchor || !this.$anchor) {
return;
}
let anchorBounds = this.getAnchorBounds();
return scrollbars.isLocationInView(anchorBounds.center(), this.$anchor.scrollParents());
}
protected _triggerLocationChange() {
this.trigger('locationChange');
}
/**
* Fire event that this popup is about to open.
*/
protected _triggerPopupOpenEvent() {
this.session.desktop.trigger('popupOpen', {
popup: this
});
}
belongsTo($anchor: JQuery): boolean {
return this.$anchor[0] === $anchor[0];
}
set$Anchor($anchor: JQuery) {
if (this.$anchor) {
this._detachAnchorHandlers();
}
this.setProperty('$anchor', $anchor);
if (this.rendered) {
this._attachAnchorHandlers();
this.revalidateLayout();
if (!this.animateResize) { // PopupLayout will move it -> don't break move animation
this.position();
}
}
}
isOpen(): boolean {
return this.rendered;
}
ensureOpen() {
if (!this.isOpen()) {
this.open();
}
}
setAnchor(anchor: Widget) {
this.setProperty('anchor', anchor);
}
protected _setAnchor(anchor: Widget) {
if (anchor) {
this.setParent(anchor);
}
this._setProperty('anchor', anchor);
}
protected _onAnchorRender(event: Event<Widget>) {
this.session.layoutValidator.schedulePostValidateFunction(() => {
if (this.rendered || this.destroyed) {
return;
}
if (this.anchor && !this.anchor.rendered) {
// Anchor was removed again while this function was scheduled -> wait again for rendering
this.anchor.one('render', this._anchorRenderHandler);
return;
}
let currentAnimateOpening = this.animateOpening;
this.animateOpening = false;
this.open();
this.animateOpening = currentAnimateOpening;
});
}
protected _renderAnchor() {
if (this.anchor) {
this.set$Anchor(this.anchor.$container);
}
}
protected _onWindowResize(event: JQuery.ResizeEvent<Window>) {
if (!this.rendered) {
// may already be removed if a parent popup is closed during the resize event
return;
}
if (this.windowResizeType === 'position') {
this.position();
} else if (this.windowResizeType === 'layoutAndPosition') {
this.revalidateLayoutTree(false);
this.position();
} else if (this.windowResizeType === 'remove') {
this.close();
}
}
protected _handleGlassPanes() {
let parentCoveredByGlassPane = this.session.focusManager.isElementCovertByGlassPane(this.parent.$container);
// if a popup is covered by a glass pane the glass pane's need to be re-rendered to ensure a glass pane is also painted over the popup
if (parentCoveredByGlassPane) {
this.session.focusManager.rerenderGlassPanes();
}
}
}
((() => {
// Initialize switch rules (wrapped in IIFE to have local function scope for the variables)
let SwitchRule = Popup.SwitchRule;
let Alignment = Popup.Alignment;
SwitchRule[Alignment.LEFT] = Alignment.RIGHT;
SwitchRule[Alignment.LEFTEDGE] = Alignment.RIGHTEDGE;
SwitchRule[Alignment.TOP] = Alignment.BOTTOM;
SwitchRule[Alignment.TOPEDGE] = Alignment.BOTTOMEDGE;
SwitchRule[Alignment.CENTER] = Alignment.CENTER;
SwitchRule[Alignment.RIGHT] = Alignment.LEFT;
SwitchRule[Alignment.RIGHTEDGE] = Alignment.LEFTEDGE;
SwitchRule[Alignment.BOTTOM] = Alignment.TOP;
SwitchRule[Alignment.BOTTOMEDGE] = Alignment.TOPEDGE;
})());
export type PopupScrollType = 'position' | 'layoutAndPosition' | 'remove' | 'none';
export type PopupWindowResizeType = 'position' | 'layoutAndPosition' | 'remove';