@eclipse-scout/core
Version:
Eclipse Scout runtime
1,475 lines (1,330 loc) • 87.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 {
Action, arrays, DeferredGlassPaneTarget, Desktop, Device, EnumObject, EventDelegator, EventHandler, filters, focusUtils, Form, FullModelOf, graphics, HtmlComponent, icons, InitModelOf, inspector, KeyStroke, KeyStrokeContext, LayoutData,
LoadingSupport, LogicalGrid, ModelAdapter, objectFactoryHints, ObjectIdProvider, ObjectOrChildModel, ObjectOrType, objects, ObjectWithType, ObjectWithUuid, Predicate, PropertyDecoration, PropertyEventEmitter, scout,
ScrollbarInstallOptions, scrollbars, ScrollOptions, ScrollToOptions, Session, SomeRequired, strings, texts, TreeVisitResult, UuidPathOptions, WidgetEventMap, WidgetModel
} from '../index';
import $ from 'jquery';
@objectFactoryHints({ensureId: true})
export class Widget extends PropertyEventEmitter implements WidgetModel, ObjectWithType, ObjectWithUuid {
declare model: WidgetModel;
declare initModel: SomeRequired<this['model'], 'parent'>;
declare eventMap: WidgetEventMap;
declare self: Widget;
declare widgetMap: WidgetMap;
declare propertyDecorations: Record<WidgetPropertyDecoration, Set<string>>;
animateRemoval: boolean;
animateRemovalClass: string;
attached: boolean;
children: Widget[];
/**
* Will be set on the clone after a widget has been cloned.
*/
cloneOf: this;
cssClass: string;
destroyed: boolean;
destroying: boolean;
disabledStyle: DisabledStyle;
/**
* The result of every enabled dimension.
*
* The value will only be true if every dimension is true. If one dimension is false (e.g. 'granted'), the value will be false.
*/
enabled: boolean;
/**
* The computed enabled state.
*
* The difference to the {@link enabled} property is that this member also considers the enabled-states of the ancestor widgets.
* So, if you need to know whether a user can really access a widget, this property is what you need.
*/
enabledComputed: boolean;
eventDelegators: EventDelegatorForCloning[];
focused: boolean;
/**
* Widgets creating a HtmlComponent for the main $container should assign it to this variable.
* This enables the execution of layout related operations like invalidateLayoutTree directly on the widget.
* @type {HtmlComponent}
*/
htmlComp: HtmlComponent;
id: string;
inheritAccessibility: boolean;
keyStrokeContext: KeyStrokeContext;
loading: boolean;
loadingSupport: LoadingSupport;
logicalGrid: LogicalGrid;
objectType: string;
owner: Widget;
parent: Widget;
removalPending: boolean;
removing: boolean;
/**
* The 'rendering' flag is set the true while the _initial_ rendering is performed.
* It is used to something different in a _render* method when the method is
* called for the first time.
*/
rendering: boolean;
scrollLeft: number;
scrollTop: number;
session: Session;
trackFocus: boolean;
uuid: string;
uuidParent: ObjectWithUuid;
visible: boolean;
modelAdapter: ModelAdapter;
$container: JQuery;
$parent: JQuery;
// Inspector infos (are only available for remote widgets)
modelClass: string;
classId: string;
/**
* The 'rendered' flag is set the true when initial rendering of the widget is completed.
*
* @internal
*/
_rendered: boolean;
protected _$lastFocusedElement: JQuery;
protected _focusInListener: (event: FocusEvent | JQuery.FocusInEvent) => void;
protected _glassPaneContributions: GlassPaneContribution[];
protected _parentDestroyHandler: EventHandler;
protected _parentRemovingWhileAnimatingHandler: EventHandler;
protected _postRenderActions: (() => void)[];
protected _scrollHandler: (event: JQuery.ScrollEvent) => void;
protected _storedFocusedWidget: Widget;
constructor() {
super();
this.id = null;
this.uuid = null;
this.objectType = null;
this.session = null;
this.modelClass = null;
this.classId = null;
this.owner = null;
this.parent = null;
this.children = [];
this.cloneOf = null;
this.rendering = false;
this.removing = false;
this.removalPending = false;
this._rendered = false;
this.attached = false;
this.destroyed = false;
this.destroying = false;
this.enabled = true;
this.enabledComputed = true;
this.inheritAccessibility = true;
this.disabledStyle = Widget.DisabledStyle.DEFAULT;
this.visible = true;
this.focused = false;
this.loading = false;
this.cssClass = null;
this.scrollTop = null;
this.scrollLeft = null;
this.$parent = null;
this.$container = null;
this.htmlComp = null;
this.animateRemoval = false;
this.animateRemovalClass = 'animate-remove';
this.propertyDecorations = $.extend(this.propertyDecorations, {
clone: new Set<string>(),
widget: new Set<string>(),
preserveOnPropertyChange: new Set<string>()
});
this.eventDelegators = [];
this._postRenderActions = [];
this._focusInListener = this._onFocusIn.bind(this);
this._parentDestroyHandler = this._onParentDestroy.bind(this);
this._parentRemovingWhileAnimatingHandler = this._onParentRemovingWhileAnimating.bind(this);
this._scrollHandler = this._onScroll.bind(this);
this.loadingSupport = this._createLoadingSupport();
this.keyStrokeContext = this._createKeyStrokeContext();
this.logicalGrid = null;
// focus tracking
this.trackFocus = false;
this._$lastFocusedElement = null;
this._storedFocusedWidget = null;
this._glassPaneContributions = [];
this._addCloneProperties(['visible', 'enabled', 'inheritAccessibility', 'cssClass']);
this._addMultiDimensionalProperty('enabled', true);
this._addMultiDimensionalProperty('visible', true);
this._addPropertyDimensionAlias('enabled', 'enabledGranted', {dimension: 'granted'});
this._addPropertyDimensionAlias('visible', 'visibleGranted', {dimension: 'granted'});
}
/**
* Enum used to define different styles used when the field is disabled.
*/
static DisabledStyle = {
DEFAULT: 0,
/**
* Optimizes the disabled style for readability, meaning higher contrast and fewer borders.
*
* Should in general only be used if the enabled state of the widget cannot change dynamically, because the style is too similar to the enabled style.
*/
READ_ONLY: 1
} as const;
/**
* Initializes the widget instance. All properties of the model parameter (object) are set as properties on the widget instance.
* Calls {@link Widget#_init} and triggers an <em>init</em> event when initialization has been completed.
*/
override init(model: InitModelOf<this>) {
let staticModel = this._jsonModel();
if (staticModel) {
model = $.extend({}, staticModel, model);
}
model = model || {} as InitModelOf<this>;
model = this._prepareModel(model);
this._init(model);
this._initKeyStrokeContext();
this.recomputeEnabled();
this.initialized = true;
this.trigger('init');
}
/**
* Default implementation simply returns the unmodified model. A Subclass
* may override this method to alter the JSON model before the widgets
* are created out of the widgetProperties in the model.
*/
protected _prepareModel(model: InitModelOf<this>): InitModelOf<this> {
return model;
}
buildUuid(useFallback?: boolean): string {
return ObjectIdProvider.get().uuid(this, useFallback);
}
buildUuidPath(options?: UuidPathOptions): string {
return ObjectIdProvider.get().uuidPath(this, options);
}
setUuid(uuid: string) {
this.setProperty('uuid', uuid);
}
/**
* Initializes the widget instance. All properties of the model parameter (object) are set as properties on the widget instance.
* Override this function to initialize widget specific properties in subclasses.
*/
protected override _init(model: InitModelOf<this>) {
if (!model.parent) {
throw new Error('Parent expected: ' + this);
}
this.setOwner(model.owner || model.parent);
this.setParent(model.parent);
this.session = model.session || this.parent.session;
if (!this.session) {
throw new Error('Session expected: ' + this);
}
this._eachProperty(model, (propertyName, value, isWidgetProperty) => {
if (value === undefined) {
// Don't set the value if it is undefined, compared to null which is allowed explicitly ($.extend works in the same way)
return;
}
if (isWidgetProperty) {
value = this._prepareWidgetProperty(propertyName, value);
}
this._initProperty(propertyName, value);
});
this._initMultiDimensionalProperties(model);
this._setCssClass(this.cssClass);
this._setLogicalGrid(this.logicalGrid);
this._setEnabled(this.enabled);
}
/**
* Default implementation simply returns undefined. A Subclass
* may override this method to load or extend a JSON model with models.getModel or models.extend.
*/
protected _jsonModel(): WidgetModel {
return null;
}
protected _createChildren<T extends Widget>(models: ObjectOrChildModel<T> | string): T;
protected _createChildren<T extends Widget>(models: (ObjectOrChildModel<T> | string)[]): T[];
protected _createChildren<T extends Widget>(models: ObjectOrChildModel<T> | string | (ObjectOrChildModel<T> | string)[]): T | T[];
/**
* Creates the widgets using the given models, or returns the widgets if the given models already are widgets.
* @returns an array of created widgets if models was an array. Or the created widget if models is not an array.
*/
protected _createChildren<T extends Widget>(models: ObjectOrChildModel<T> | string | (ObjectOrChildModel<T> | string)[]): T | T[] {
if (!models) {
return null;
}
if (!Array.isArray(models)) {
return this._createChild(models);
}
let widgets = [];
models.forEach((model, i) => {
widgets[i] = this._createChild(model);
});
return widgets;
}
/**
* Calls {@link scout.create} for the given model, or if model is already a Widget simply returns the widget.
* @internal
*/
_createChild<T extends Widget>(widgetOrModel: ObjectOrChildModel<T> | string): T {
if (widgetOrModel instanceof Widget) {
return widgetOrModel;
}
if (typeof widgetOrModel === 'string') {
// Special case: If only an ID is supplied, try to (locally) resolve the corresponding widget
let existingWidget: Widget = this.widget(widgetOrModel);
if (!existingWidget) {
throw new Error('Referenced widget not found: ' + widgetOrModel);
}
return existingWidget as T;
}
let model = widgetOrModel as FullModelOf<T>;
model.parent = this;
return scout.create(model);
}
protected _initKeyStrokeContext() {
if (!this.keyStrokeContext) {
return;
}
this.keyStrokeContext.$scopeTarget = () => this.$container;
this.keyStrokeContext.$bindTarget = () => this.$container;
}
/**
* Destroys the widget including all owned children.<br>
* After destroying, the widget should and cannot be used anymore. Every attempt to render the widget will result in a 'Widget is destroyed' error.
* <p>
* While destroying, the widget will remove itself and its children from the DOM by calling {@link remove}.
* After removing, {@link _destroy} is called which can be used to remove listeners and to do other cleanup tasks.
* Finally, the widget detaches itself from its parent and owner, sets the property {@link destroyed} to true and triggers a 'destroy' event.
* <p>
* <b>Notes</b>
* <ul>
* <li>Children that have a different owner won't be destroyed, just removed.</li>
* <li>The function does nothing if the widget is already destroyed.</li>
* <li>If a remove animation is running or pending, the destroying will be delayed until the removal is done.</li>
* </ul>
*/
destroy() {
if (this.destroyed) {
// Already destroyed, do nothing
return;
}
this.destroying = true;
if (this._rendered && (this.animateRemoval || this._isRemovalPrevented())) {
// Do not destroy yet if the removal happens animated
// Also don't destroy if the removal is pending to keep the parent / child link until removal finishes
this.one('remove', () => {
this.destroy();
});
this.remove();
return;
}
// Destroy children in reverse order
this._destroyChildren(this.children.slice().reverse());
this.remove();
this._destroy();
// Disconnect from owner and parent
this.owner._removeChild(this);
this.owner = null;
this.parent._removeChild(this);
this.parent.off('destroy', this._parentDestroyHandler);
this.parent = null;
this.destroying = false;
this.destroyed = true;
this.trigger('destroy');
}
/**
* Override this function to do clean-up (like removing listeners) when the widget is destroyed.
* The default implementation does nothing.
*/
protected _destroy() {
// NOP
}
protected _destroyChildren(widgets: Widget[] | Widget) {
if (!widgets) {
return;
}
widgets = arrays.ensure(widgets);
widgets.forEach(widget => this._destroyChild(widget));
}
protected _destroyChild(child: Widget) {
if (child.owner !== this) {
return;
}
child.destroy();
}
protected _destroyOrUnlinkChildren(widgets: ObjectOrChildModel<Widget>[] | ObjectOrChildModel<Widget>) {
if (!widgets) {
return;
}
widgets = arrays.ensure(widgets);
widgets.forEach(child => {
if (!(child instanceof Widget) || child.destroyed) {
// Child may not be a widget yet during initializing
return;
}
if (child.owner === this) {
this._destroyChild(child);
} else {
// Disconnect child from parent and re-connect to owner
child.setParent(child.owner);
}
});
}
/**
* Creates the UI by creating HTML elements and appending them to the DOM.
* <p>
* The actual rendering happens in the methods {@link _render}, which appends the main container to the parent element, and {@link _renderProperties}, which calls the methods for each property that needs to be rendered.
* After the rendering, the created {@link $container} will be linked with the widget, so the widget can be found by using {@link scout.widget}.
* Finally, the widget sets the property {@link rendered} to true and triggers a 'render' event.
*
* @param $parent jQuery element which is used as {@link $parent} when rendering this widget.
* It will be put onto the widget and is therefore accessible as {@link $parent} in the {@link _render} method.
* If not specified, the {@link $container} of the parent is used.
*/
render($parent?: JQuery) {
$.log.isTraceEnabled() && $.log.trace('Rendering widget: ' + this);
if (!this.initialized) {
throw new Error('Not initialized: ' + this);
}
if (this._rendered) {
throw new Error('Already rendered: ' + this);
}
if (this.destroyed) {
throw new Error('Widget is destroyed: ' + this);
}
this.rendering = true;
this.$parent = $parent || this.parent.$container;
this._render();
this._renderProperties();
this._renderInspectorInfo();
this._linkWithDOM();
this.session.keyStrokeManager.installKeyStrokeContext(this.keyStrokeContext);
this.rendering = false;
this.rendered = true;
this.attached = true;
this.trigger('render');
this.restoreFocus();
this._postRender();
}
/**
* Creates the UI by creating HTML elements and appending them to the DOM.
* <p>
* A typical widget creates exactly one container element and stores it to {@link $container}.
* If it needs JS based layouting, it creates a {@link HtmlComponent} for that container and stores it to {@link htmlComp}.
* <p>
* The rendering of individual properties should be done in the corresponding render methods of the properties, called by {@link _renderProperties} instead of doing it here.
* This has the advantage that the render methods can also be called on property changes, allowing individual widget parts to be dynamically re-rendered.
* <p>
* The default implementation does nothing.
*/
protected _render() {
// NOP
}
/**
* Returns whether it is allowed to render something on the widget.
* Rendering is only possible if the widget itself is rendered and not about to be removed.
* <p>
* While the removal is pending, no rendering must happen to get a smooth remove animation.
* It also prevents errors on property changes because {@link remove} won't be executed as well.
* Preventing removal but allowing rendering could result in already rendered exceptions.
*
* @returns true if the widget is rendered and not being removed by an animation
*
* @see isRemovalPending
*/
get rendered(): boolean {
return this._rendered && !this.isRemovalPending();
}
set rendered(rendered: boolean) {
this._rendered = rendered;
}
/**
* Calls the render methods for each property that needs to be rendered during the rendering process initiated by {@link render}.
* Each widget has to override this method and call the render methods for its own properties, after doing the super call.
* <p>
* This method is called right after {@link _render} has been executed.
*/
protected _renderProperties() {
this._renderCssClass();
this._renderEnabled();
this._renderVisible();
this._renderTrackFocus();
this._renderFocused();
this._renderLoading();
this._renderScrollTop();
this._renderScrollLeft();
}
/**
* Method invoked once rendering completed and 'rendered' flag is set to 'true'.<p>
* By default executes every action of this._postRenderActions
*/
protected _postRender() {
let actions = this._postRenderActions;
this._postRenderActions = [];
actions.forEach(action => {
action();
});
}
/**
* Removes the widget and all its children from the DOM.
* <p>
* It traverses down the widget hierarchy and calls {@link _remove} for each widget from the bottom up (depth first search).
* <p>
* If the property {@link Widget.animateRemoval} is set to true, the widget won't be removed immediately.
* Instead, it waits for the remove animation to complete, so it's content is still visible while the animation runs.
* During that time, {@link isRemovalPending} returns true.
*/
remove() {
if (!this._rendered || this._isRemovalPrevented()) {
return;
}
if (this.animateRemoval) {
this._removeAnimated();
} else {
this._removeInternal();
}
}
/**
* Removes the element without starting the remove animation or waiting for the remove animation to complete.
* If the remove animation is running it will stop immediately because the element is removed. There will no animationend event be triggered.
* <p>
* <b>Important</b>: You should only use this method if your widget uses remove animations (this.animateRemoval = true)
* and you deliberately want to not execute or abort it. Otherwise, you should use the regular {@link remove} method.
*/
removeImmediately() {
this._removeInternal();
}
/**
* Will be called by {@link #remove()}. If true is returned, the widget won't be removed.<p>
* By default it just delegates to {@link #isRemovalPending}. May be overridden to customize it.
*/
protected _isRemovalPrevented(): boolean {
return this.isRemovalPending();
}
/**
* Returns true if the removal of this or an ancestor widget is pending. Checking the ancestor is omitted if the parent is being removed.
* This may be used to prevent a removal if an ancestor will be removed (e.g. by an animation)
*/
isRemovalPending(): boolean {
if (this.removalPending) {
return true;
}
let parent = this.parent;
if (!parent || parent.removing || parent.rendering) {
// If parent is being removed or rendered, no need to check the ancestors because removing / rendering is already in progress
return false;
}
while (parent) {
if (parent.removalPending) {
return true;
}
parent = parent.parent;
}
return false;
}
/** @internal */
_removeInternal() {
if (!this._rendered) {
return;
}
$.log.isTraceEnabled() && $.log.trace('Removing widget: ' + this);
this.removing = true;
this.removalPending = false;
this.trigger('removing');
// transform last focused element into a scout widget
if (this.$container) {
this.$container.off('focusin', this._focusInListener);
}
if (this._$lastFocusedElement) {
this._storedFocusedWidget = scout.widget(this._$lastFocusedElement);
this._$lastFocusedElement = null;
}
// remove children in reverse order.
this.children.slice().reverse()
.forEach(child => {
// Only remove the child if this widget is the current parent (if that is not the case this widget is the owner)
if (child.parent === this) {
child.remove();
}
});
if (!this._rendered) {
// The widget may have been removed already by one of the above remove() calls (e.g. by a remove listener)
// -> don't try to do it again, it might fail
return;
}
this._cleanup();
this._remove();
this.$parent = null;
this.rendered = false;
this.attached = false;
this.removing = false;
this.trigger('remove');
}
/**
* Starts a "remove" animation for this widget. By default, a CSS class 'animate-remove' is added to the container.
* After the animation is complete, the element gets removed from the DOM using this._removeInternal().
*/
protected _removeAnimated() {
let animateRemovalWhileRemovingParent = this._animateRemovalWhileRemovingParent();
if ((this.parent.removing && !animateRemovalWhileRemovingParent) || !Device.get().supportsCssAnimation() || !this.$container || this.$container.isDisplayNone()) {
// Cannot remove animated, remove regularly
this._removeInternal();
return;
}
// Remove open popups first, they would be positioned wrongly during the animation
// Normally they would be closed automatically by a user interaction (click),
this.session.desktop.removePopupsFor(this);
this.removalPending = true;
// Don't execute immediately to make sure nothing interferes with the animation (e.g. layouting) which could make it lag
setTimeout(() => {
// check if the container has been removed in the meantime
if (!this._rendered) {
return;
}
if (!this.$container.isVisible() || !this.$container.isEveryParentVisible() || !this.$container.isAttached()) {
// If element is not visible, animationEnd would never fire -> remove it immediately
this._removeInternal();
return;
}
this._removeAnimatedImpl();
});
// If the parent is being removed while the animation is running, the animationEnd event will never fire
// -> Make sure remove is called nevertheless. Important: remove it before the parent is removed to maintain the regular remove order
if (!animateRemovalWhileRemovingParent) {
this.parent.one('removing', this._parentRemovingWhileAnimatingHandler);
}
}
protected _removeAnimatedImpl() {
if (!this.animateRemovalClass) {
throw new Error('Missing animate removal class. Cannot remove animated.');
}
this.$container.addClass(this.animateRemovalClass);
this.$container.oneAnimationEnd(() => {
this._removeInternal();
});
}
protected _animateRemovalWhileRemovingParent(): boolean {
// By default, remove animation is prevented when parent is being removed
return false;
}
protected _onParentRemovingWhileAnimating() {
this._removeInternal();
}
protected _renderInspectorInfo() {
inspector.applyInfo(this);
}
/**
* Links $container with the widget.
*/
protected _linkWithDOM() {
if (this.$container) {
this.$container.data('widget', this);
}
}
/**
* Called right before _remove is called.<br>
* Default calls {@link LayoutValidator.cleanupInvalidComponents} to make sure that child components are removed from the invalid components list.
* Also uninstalls keystroke context, loading support and scrollbars.
*/
protected _cleanup() {
this.parent.off('removing', this._parentRemovingWhileAnimatingHandler);
this.session.keyStrokeManager.uninstallKeyStrokeContext(this.keyStrokeContext);
if (this.loadingSupport) {
this.loadingSupport.remove();
}
this._uninstallScrollbars();
if (this.$container) {
this.session.layoutValidator.cleanupInvalidComponents(this.$container);
}
}
protected _remove() {
if (this.$container) {
this.$container.remove();
this.$container = null;
}
}
/** @see WidgetModel.owner */
setOwner(owner: Widget) {
scout.assertParameter('owner', owner);
if (owner === this.owner) {
return;
}
if (this.owner && this.owner !== this.parent) {
// Remove from old owner
// If parent and owner point to the same instance, removing the child from the owner would remove it from the parent as well -> do it only if they are different.
// Both owner and parent have the widget in their children list (parents need it for removing, owners for destroying).
this.owner._removeChild(this);
}
this.owner = owner;
this.owner._addChild(this);
}
/** @see WidgetModel.parent */
setParent(parent: Widget) {
scout.assertParameter('parent', parent);
if (parent === this.parent) {
return;
}
if (this.rendered && !parent.rendered) {
$.log.isInfoEnabled() && $.log.info('rendered child ' + this + ' is added to not rendered parent ' + parent + '. Removing child.', new Error('origin'));
this.remove();
}
if (this.parent) {
// Don't link to new parent yet if removal is still pending.
// After the animation the parent will remove its children.
// If they are already linked to a new parent, removing the children is not possible anymore.
// This may lead to an "Already rendered" exception if the new parent wants to render its children.
if (this.parent.isRemovalPending()) {
this.parent.one('remove', () => {
this.setParent(parent);
});
return;
}
this.parent.off('destroy', this._parentDestroyHandler);
this.parent.off('removing', this._parentRemovingWhileAnimatingHandler);
if (this.parent !== this.owner) {
// Remove from old parent if getting relinked.
// If parent and owner point to the same instance, removing the child from the parent would remove it from the owner as well -> do it only if they are different.
// Both owner and parent have the widget in their children list (parents need it for removing, owners for destroying).
this.parent._removeChild(this);
}
}
let oldParent = this.parent;
this.parent = parent;
this.parent._addChild(this);
this.trigger('hierarchyChange', {
oldParent: oldParent,
parent: parent
});
if (this.initialized) {
this.recomputeEnabled(this.parent.enabledComputed);
}
this.parent.one('destroy', this._parentDestroyHandler);
}
protected _addChild(child: Widget) {
$.log.isTraceEnabled() && $.log.trace('addChild(' + child + ') to ' + this);
arrays.pushSet(this.children, child);
}
protected _removeChild(child: Widget) {
$.log.isTraceEnabled() && $.log.trace('removeChild(' + child + ') from ' + this);
arrays.remove(this.children, child);
}
/**
* @returns a list of all ancestors
*/
ancestors(): Widget[] {
let ancestors = [];
let parent = this.parent;
while (parent) {
ancestors.push(parent);
parent = parent.parent;
}
return ancestors;
}
/**
* @returns true if the given widget is the same as this or a descendant
*/
isOrHas(widget: Widget): boolean {
if (widget === this) {
return true;
}
return this.has(widget);
}
/**
* Checks if the current widget contains the given widget.<br>
* For a good performance, it visits the ancestors of the given widget rather than the descendants of the current widget.
*
* @returns true if the given widget is a descendant
*/
has(widget: Widget): boolean {
while (widget) {
if (widget.parent === this) {
return true;
}
widget = widget.parent;
}
return false;
}
/**
* @returns the form the widget belongs to (returns the first parent which is a {@link Form}).
*/
getForm(): Form {
return Form.findForm(this);
}
/**
* @returns the first form which is not an inner form of a wrapped form field
*/
findNonWrappedForm(): Form {
return Form.findNonWrappedForm(this);
}
/**
* @returns the desktop linked to the current session.
* If the desktop is still initializing, it might not be available yet, in that case, it searches the parent hierarchy for it.
*/
findDesktop(): Desktop {
if (this.session.desktop) {
return this.session.desktop;
}
return this.findParent(Desktop);
}
/**
* Sets the 'default' dimension for the {@link Widget.enabled} property and recomputes its state.
*
* @param enabled
* The new enabled value for the 'default' dimension, or an object containing the new enabled dimensions.
* @param updateParents
* If true, the enabled property of all ancestor widgets are updated to same value as well. Default is false.
* @param updateChildren
* If true, the enabled property of all descendant widgets are updated to same value as well. Default is false.
* *Important:* Use this parameter only if really necessary because traversing every descendant may be costly and overriding their enabled state may not be reverted easily.
* Instead, in most cases you should be able to use {@link WidgetModel.inheritAccessibility} to make a descendant widget independent on its ancestors.
* @see WidgetModel.enabled
*/
setEnabled(enabled: boolean | Record<string, boolean>, updateParents?: boolean, updateChildren?: boolean) {
this.setProperty('enabled', enabled);
if (enabled && updateParents && this.parent) {
this.parent.setEnabled(true, true);
}
if (updateChildren) {
this.visitChildren(widget => {
widget.setEnabled(enabled);
});
}
}
/**
* Called whenever a new value should be set for the {@link Widget.enabled} property (which is computed based on its dimensions).
*
* Sets the actual property and recomputes the {@link enabledComputed} property.
*/
protected _setEnabled(enabled: boolean) {
this._setProperty('enabled', enabled);
if (this.initialized) {
this.recomputeEnabled();
}
}
/**
* Sets the 'granted' dimension for the {@link Widget.enabled} property and recomputes its state.
*
* @param enabledGranted the new enabled value for the 'granted' dimension.
* @see WidgetModel.enabledGranted
*/
setEnabledGranted(enabledGranted: boolean) {
this.setProperty('enabledGranted', enabledGranted);
}
get enabledGranted(): boolean {
return this.getProperty('enabledGranted');
}
recomputeEnabled(parentEnabled?: boolean) {
if (parentEnabled === undefined) {
parentEnabled = true;
if (this.parent && this.parent.initialized && this.parent.enabledComputed !== undefined) {
parentEnabled = this.parent.enabledComputed;
}
}
let enabledComputed = this._computeEnabled(this.inheritAccessibility, parentEnabled);
this._updateEnabledComputed(enabledComputed);
}
protected _updateEnabledComputed(enabledComputed: boolean, enabledComputedForChildren?: boolean) {
if (this.enabledComputed === enabledComputed && enabledComputedForChildren === undefined) {
// no change for this instance. there is no need to propagate to children
// exception: the enabledComputed for the children differs from the one for me. In this case the propagation is necessary.
return;
}
this.setProperty('enabledComputed', enabledComputed);
// Manually call _renderEnabled(), because _renderEnabledComputed() does not exist
if (this.rendered) {
this._renderEnabled();
}
let computedStateForChildren = scout.nvl(enabledComputedForChildren, enabledComputed);
this._childrenForEnabledComputed().forEach(child => {
if (child.inheritAccessibility) {
child.recomputeEnabled(computedStateForChildren);
}
});
}
protected _childrenForEnabledComputed(): Widget[] {
return this.children;
}
protected _computeEnabled(inheritAccessibility: boolean, parentEnabled: boolean): boolean {
return this.enabled && (inheritAccessibility ? parentEnabled : true);
}
protected _renderEnabled() {
if (!this.$container) {
return;
}
this.$container.setEnabled(this.enabledComputed);
this._renderDisabledStyle();
}
/** @see WidgetModel.inheritAccessibility */
setInheritAccessibility(inheritAccessibility: boolean) {
this.setProperty('inheritAccessibility', inheritAccessibility);
}
protected _setInheritAccessibility(inheritAccessibility: boolean) {
this._setProperty('inheritAccessibility', inheritAccessibility);
if (this.initialized) {
this.recomputeEnabled();
}
}
/** @see WidgetModel.disabledStyle */
setDisabledStyle(disabledStyle: DisabledStyle) {
this.setProperty('disabledStyle', disabledStyle);
this.children.forEach(child => {
child.setDisabledStyle(disabledStyle);
});
}
protected _renderDisabledStyle() {
this._renderDisabledStyleInternal(this.$container);
}
protected _renderDisabledStyleInternal($element: JQuery) {
if (!$element) {
return;
}
if (this.enabledComputed) {
$element.removeClass('read-only');
} else {
$element.toggleClass('read-only', this.disabledStyle === Widget.DisabledStyle.READ_ONLY);
}
}
/**
* Sets the 'default' dimension for the {@link Widget.visible} property and recomputes its state.
*
* @param visible the new visible value for the 'default' dimension, or an object containing the new visible dimensions.
* @see WidgetModel.visibleGranted
*/
setVisible(visible: boolean | Record<string, boolean>) {
this.setProperty('visible', visible);
}
/**
* Sets the 'granted' dimension for the {@link Widget.visible} property and recomputes its state.
*
* @param visibleGranted the new visible value for the 'granted' dimension.
* @see WidgetModel.visibleGranted
*/
setVisibleGranted(visibleGranted: boolean) {
this.setProperty('visibleGranted', visibleGranted);
}
get visibleGranted(): boolean {
return this.getProperty('visibleGranted');
}
/**
* @deprecated use {@link visible} directly. Will be removed in an upcoming release.
*/
isVisible(): boolean {
return this.visible;
}
protected _renderVisible() {
if (!this.$container) {
return;
}
this.$container.setVisible(this.visible);
this.invalidateParentLogicalGrid();
}
/**
* @returns true if every parent within the hierarchy is visible.
*/
isEveryParentVisible(): boolean {
let parent = this.parent;
while (parent) {
if (!parent.visible) {
return false;
}
parent = parent.parent;
}
return true;
}
/**
* This function does not set the focus to the field. It toggles the 'focused' class on the field container if present.
* Objects using widget as prototype must call this function onBlur and onFocus to ensure the class gets toggled.
*
* Use {@link focus} to set the focus to the widget.
*/
setFocused(focused: boolean) {
this.setProperty('focused', focused);
}
protected _renderFocused() {
if (this.$container) {
this.$container.toggleClass('focused', this.focused);
}
}
protected _setCssClass(cssClass: string) {
if (this.rendered) {
this._removeCssClass();
}
this._setProperty('cssClass', cssClass);
}
protected _removeCssClass() {
if (!this.$container) {
return;
}
this.$container.removeClass(this.cssClass);
}
protected _renderCssClass() {
if (!this.$container) {
return;
}
this.$container.addClass(this.cssClass);
if (this.htmlComp) {
// Replacing css classes may enlarge or shrink the widget (e.g. setting the font weight to bold makes the text bigger) -> invalidate layout
this.invalidateLayoutTree();
}
}
/**
* @param cssClass may contain multiple css classes separated by space.
* @see WidgetModel.cssClass
*/
setCssClass(cssClass: string) {
this.setProperty('cssClass', cssClass);
}
/**
* @param cssClass may contain multiple css classes separated by space.
*/
addCssClass(cssClass: string) {
let cssClasses = this.cssClassAsArray();
let cssClassesToAdd = Widget.cssClassAsArray(cssClass);
cssClassesToAdd.forEach(newCssClass => {
if (cssClasses.indexOf(newCssClass) >= 0) {
return;
}
cssClasses.push(newCssClass);
});
this.setProperty('cssClass', arrays.format(cssClasses, ' '));
}
/**
* @param cssClass may contain multiple css classes separated by space.
*/
removeCssClass(cssClass: string) {
let cssClasses = this.cssClassAsArray();
let cssClassesToRemove = Widget.cssClassAsArray(cssClass);
if (arrays.removeAll(cssClasses, cssClassesToRemove)) {
this.setProperty('cssClass', arrays.format(cssClasses, ' '));
}
}
/**
* @param cssClass may contain multiple css classes separated by space.
*/
toggleCssClass(cssClass: string, condition: boolean) {
if (condition) {
this.addCssClass(cssClass);
} else {
this.removeCssClass(cssClass);
}
}
/**
* @returns true, if the {@link cssClass} property contains the given css class.
*/
hasCssClass(cssClass: string): boolean {
return this.cssClassAsArray().includes(cssClass);
}
cssClassAsArray(): string[] {
return Widget.cssClassAsArray(this.cssClass);
}
/**
* Creates nothing by default. If a widget needs loading support, override this method and return a loading support.
*/
protected _createLoadingSupport(): LoadingSupport {
return null;
}
/** @see WidgetModel.loading */
setLoading(loading: boolean) {
this.setProperty('loading', loading);
}
isLoading(): boolean {
return this.loading;
}
protected _renderLoading() {
if (!this.loadingSupport) {
return;
}
this.loadingSupport.renderLoading();
}
// --- Layouting / HtmlComponent methods ---
/**
* Delegates the pack request to {@link HtmlComponent#pack}.
*/
pack() {
if (!this.rendered || this.removing) {
return;
}
if (!this.htmlComp) {
throw new Error('Function expects a htmlComp property');
}
this.htmlComp.pack();
}
/**
* Delegates the invalidateLayout request to {@link HtmlComponent#invalidateLayout}.
*/
invalidateLayout() {
if (!this.rendered || this.removing) {
return;
}
if (!this.htmlComp) {
throw new Error('Function expects a htmlComp property');
}
this.htmlComp.invalidateLayout();
}
/**
* Delegates the validateLayout request to {@link HtmlComponent#validateLayout}.
*/
validateLayout() {
if (!this.rendered || this.removing) {
return;
}
if (!this.htmlComp) {
throw new Error('Function expects a htmlComp property');
}
this.htmlComp.validateLayout();
}
/**
* Delegates the revalidateLayout request to {@link HtmlComponent#revalidateLayout}.
*/
revalidateLayout() {
if (!this.rendered || this.removing) {
return;
}
if (!this.htmlComp) {
throw new Error('Function expects a htmlComp property');
}
this.htmlComp.revalidateLayout();
}
/**
* Delegates the invalidation request to {@link HtmlComponent#invalidateLayoutTree}.
* @param invalidateParents Default is true
*/
invalidateLayoutTree(invalidateParents?: boolean) {
if (!this.rendered || this.removing) {
return;
}
if (!this.htmlComp) {
throw new Error('Function expects a htmlComp property');
}
this.htmlComp.invalidateLayoutTree(invalidateParents);
}
/**
* Delegates the invalidation request to {@link HtmlComponent#validateLayoutTree}.
*/
validateLayoutTree() {
if (!this.rendered || this.removing) {
return;
}
if (!this.htmlComp) {
throw new Error('Function expects a htmlComp property');
}
this.htmlComp.validateLayoutTree();
}
/**
* Delegates the invalidation request to {@link HtmlComponent#revalidateLayoutTree}.
* @param invalidateParents Default is true
*/
revalidateLayoutTree(invalidateParents?: boolean) {
if (!this.rendered || this.removing) {
return;
}
if (!this.htmlComp) {
throw new Error('Function expects a htmlComp property');
}
this.htmlComp.revalidateLayoutTree(invalidateParents);
}
/**
* The layout data contains hints for the layout of the parent container to lay out this individual child widget inside the container.<br>
* Note: this is not the same as the LayoutConfig. The LayoutConfig contains constraints for the layout itself and is therefore set on the parent container directly.
* <p>
* Example: The parent container uses a {@link LogicalGridLayout} to lay out its children. Every child has a {@link LogicalGridData} to tell the layout how this specific child should be layouted.
* The parent may have a {@link LogicalGridLayoutConfig} to specify constraints which affect either only the container or every child in the container.
*/
setLayoutData(layoutData: LayoutData) {
if (!this.rendered) {
return;
}
if (!this.htmlComp) {
throw new Error('Function expects a htmlComp property');
}
this.htmlComp.layoutData = layoutData;
}
/**
* If the widget uses a {@link LogicalGridLayout}, the grid may be validated using this method.
*
* If the grid is not dirty, nothing happens.
*/
validateLogicalGrid() {
if (this.logicalGrid) {
this.logicalGrid.validate(this);
}
}
/**
* Marks the logical grid as dirty.<br>
* Does nothing, if there is no logical grid.
* @param invalidateLayout true, to invalidate the layout afterward using {@link invalidateLayoutTree}, false if not. Default is true.
*/
invalidateLogicalGrid(invalidateLayout?: boolean) {
if (!this.initialized) {
return;
}
if (!this.logicalGrid) {
return;
}
this.logicalGrid.setDirty(true);
if (scout.nvl(invalidateLayout, true)) {
this.invalidateLayoutTree();
}
}
/**
* Invalidates the logical grid of the parent widget. Typically, done when the visibility of the widget changes.
* @param invalidateLayout true, to invalidate the layout of the parent of {@link htmlComp}, false if not. Default is true.
*/
invalidateParentLogicalGrid(invalidateLayout?: boolean) {
this.parent.invalidateLogicalGrid(false);
if (!this.rendered || !this.htmlComp) {
return;
}
if (scout.nvl(invalidateLayout, true)) {
let htmlCompParent = this.htmlComp.getParent();
if (htmlCompParent) {
htmlCompParent.invalidateLayoutTree();
}
}
}
revalidateLogicalGrid(invalidateLayout?: boolean) {
this.invalidateLogicalGrid(invalidateLayout);
this.validateLogicalGrid();
}
/**
* @param logicalGrid an instance of {@link LogicalGrid} or a string representing the objectType of a logical grid.
* @see WidgetModel.logicalGrid
*/
setLogicalGrid(logicalGrid: ObjectOrType<LogicalGrid>) {
this.setProperty('logicalGrid', logicalGrid);
}
protected _setLogicalGrid(logicalGrid: ObjectOrType<LogicalGrid>) {
logicalGrid = scout.ensure(logicalGrid);
this._setProperty('logicalGrid', logicalGrid);
this.invalidateLogicalGrid();
}
/**
* @returns the entry-point for this Widget or its parent. If the widget is part of the main-window it returns {@link session.$entryPoint},
* for popup-window this function will return the body of the document in the popup window.
*/
entryPoint(): JQuery {
let $element = scout.nvl(this.$container, this.parent.$container);
if (!$element || !$element.length) {
throw new Error('Cannot resolve entryPoint, $element.length is 0 or undefined');
}
return $element.entryPoint();
}
/**
* Returns the `window` inside which this widget is currently rendered. If the widget is not rendered, `null` (or an empty jQuery
* collection, respectively) is returned.
*
* @param domElement if true the result is returned as DOM element, otherwise it is returned as jQuery object. The default is false.
*/
window<T extends boolean>(domElement?: T): T extends true ? Window : JQuery<Window> {
let $el = this.$container || this.$parent;
// @ts-expect-error $() is not of type JQuery<Document>
return $el ? $el.window(domElement) : (domElement ? null : $());
}
/**
* Returns the `document` inside which this widget is currently rendered. If the widget is not rendered, `null` (or an empty jQuery
* collection, respectively) is returned.
*
* @param domElement if true the result is returned as DOM element, otherwise it is returned as jQuery object. The default is false.
*/
document<T extends boolean>(domElement?: T): T extends true ? Document : JQuery<Document> {
let $el = this.$container || this.$parent;
// @ts-expect-error $() is not of type JQuery<Document>
return $el ? $el.document(domElement) : (domElement ? null : $());
}
/**
* This method attaches the detached {@link $container} to the DOM.
*/
attach() {
if (this.attached || !this.rendered) {
return;
}
this._attach();
this._installFocusContext();
this.restoreFocus();
this.attached = true;
this._postAttach();
this._onAttach();
this._triggerChildrenOnAttach(this);
}
/**
* Override this method to do something when the widget is attached again. Typically,
* you will append {@link $container} to {@link $parent}.
*/
protected _attach() {
// NOP
}
/**
* Override this method to do something after this widget is attached.
* This function is not called on any child of the attached widget.
*/
protected _postAttach() {
// NOP
}
protected _triggerChildrenOnAttach(parent: Widget) {
this.children.forEach(child => {
child._onAttach();
child._triggerChildrenOnAttach(parent);
});
}
/**
* Override this method to do something after this widget or any parent of it is attached.
* This function is called regardless of whether the widget is rendered or not.
*/
protected _onAttach() {
if (this.rendered) {
this._renderOnAttach();
}
}
/**
* Override this method to do something after this widget or any parent of it is attached.
* This function is only called when this widget is rendered.
*/
protected _renderOnAttach() {
this._renderScrollTop();
this._renderScrollLeft();
}
/**
* Detaches the element and all its children from the DOM.<br>
* Compared to {@link remove}, the state of the HTML elements are preserved, so they can be attached again without the need to render them again from scratch.
* {@link attach}/{@link detach} is faster in general than {@link render}/{@link remove}, but it is much more error-prone and should therefore only be used very carefully. Rule of thumb: Don't use it, use {@link remove} instead.<br>
* The main problem with attach/detach is that a widget can change its model anytime. If this happens for a removed widget, only the model will change, and when rendered again, the recent model is used to create the HTML elements.
* If the same happens when a widget is detached, the widget is still considered rendered and the model applied to the currently detached elements.
* This may or may not work because a detached element for example does not have a size or a position.
*
* @see _beforeDetach
* @see _onDetach
* @see _renderOnDetach
* @see _detach
*/
detach() {
if (this.rendering) {
// Defer the execution of detach. If it was detached while rendering the attached flag would be wrong.
this._postRenderActions.push(this.detach.bind(this));
}
if (!this.attached || !this.rendered) {
return;
}
this._beforeDetach();
this._onDetach();
this._triggerChildrenOnDetach();
this._detach();
this.attached = false;
}
/**
* This function is called before a widget gets detached. The function is only called on the detached widget and NOT on
* any of its children.
*/
protected _beforeDetach() {
if (!this.$container) {
return;
}
let activeElement = this.$container.document(true).activeElement;
let isFocused = this.$container.isOrHas(activeElement);
let focusManager = this.session.focusManager;
if (focusManager.isFocusContextInstalled(this.$container)) {
this._uninstallFocusContext();
} else if (isFocused) {
// exclude the container or any of its child elements to gain focus
focusManager.validateFocus(filters.outsideFilter(this.$container));
}
}
protected _triggerChildrenOnDetach() {
this.children.f