UNPKG

@eclipse-scout/core

Version:
509 lines (451 loc) 15.5 kB
/* * 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 { aria, Dimension, EnumObject, graphics, GroupEventMap, GroupLayout, GroupModel, GroupToggleCollapseKeyStroke, HtmlComponent, Icon, InitModelOf, Insets, KeyStrokeContext, LoadingSupport, ObjectOrChildModel, scout, tooltips, Widget } from '../index'; import $ from 'jquery'; import MouseDownEvent = JQuery.MouseDownEvent; export type GroupCollapseStyle = EnumObject<typeof Group.CollapseStyle>; export class Group<TBody extends Widget = Widget> extends Widget implements GroupModel<TBody> { declare model: GroupModel<TBody>; declare eventMap: GroupEventMap; declare self: Group; bodyAnimating: boolean; collapsed: boolean; collapsible: boolean; title: string; titleHtmlEnabled: boolean; titleSuffix: string; header: Widget; headerFocusable: boolean; headerVisible: boolean; body: TBody; collapseStyle: GroupCollapseStyle; htmlHeader: HtmlComponent; htmlFooter: HtmlComponent; iconId: string; icon: Icon; $header: JQuery; $footer: JQuery; $collapseIcon: JQuery; $collapseBorderLeft: JQuery; $collapseBorderRight: JQuery; $title: JQuery; $titleSuffix: JQuery; constructor() { super(); this.bodyAnimating = false; this.collapsed = false; this.collapsible = true; this.title = null; this.titleHtmlEnabled = false; this.titleSuffix = null; this.header = null; this.headerFocusable = false; this.headerVisible = true; this.body = null; this.$container = null; this.$header = null; this.$footer = null; this.$collapseIcon = null; this.$collapseBorderLeft = null; this.$collapseBorderRight = null; this.collapseStyle = Group.CollapseStyle.LEFT; this.htmlComp = null; this.htmlHeader = null; this.htmlFooter = null; this.iconId = null; this.icon = null; this._addWidgetProperties(['header', 'body']); } static CollapseStyle = { LEFT: 'left', RIGHT: 'right', BOTTOM: 'bottom' } as const; protected override _init(model: InitModelOf<this>) { super._init(model); this.resolveTextKeys(['title', 'titleSuffix']); this.resolveIconIds(['iconId']); this._setBody(this.body); this._setHeader(this.header); } protected override _createKeyStrokeContext(): KeyStrokeContext { return new KeyStrokeContext(); } protected override _initKeyStrokeContext() { super._initKeyStrokeContext(); // Keystroke should only work when header is focused this.keyStrokeContext.$bindTarget = () => this.$header; this.keyStrokeContext.registerKeyStrokes([ new GroupToggleCollapseKeyStroke(this) ]); } protected override _render() { this.$container = this.$parent.appendDiv('group'); this.htmlComp = HtmlComponent.install(this.$container, this.session); this.htmlComp.setLayout(new GroupLayout(this)); this._renderHeader(); this.$collapseIcon = this.$header.appendDiv('group-collapse-icon'); this.$footer = this.$container.appendDiv('group-footer'); this.$collapseBorderLeft = this.$footer.appendDiv('group-collapse-border'); this.$collapseBorderRight = this.$footer.appendDiv('group-collapse-border'); this.htmlFooter = HtmlComponent.install(this.$footer, this.session); this.$footer.on('mousedown', this._onFooterMouseDown.bind(this)); this._addAriaFieldDescription(); } protected _addAriaFieldDescription() { aria.addHiddenDescriptionAndLinkToElement(this.$header, this.id + '-func-desc', this.session.text('ui.AriaGroupDescription')); } protected override _renderProperties() { super._renderProperties(); this._renderIconId(); this._renderTitle(); this._renderTitleSuffix(); this._renderHeaderVisible(); this._renderHeaderFocusable(); this._renderCollapsed(); this._renderCollapseStyle(); this._renderCollapsible(); } protected override _remove() { this.$header = null; this.$title = null; this.$titleSuffix = null; this.$footer = null; this.$collapseIcon = null; this.$collapseBorderLeft = null; this.$collapseBorderRight = null; this._removeIconId(); super._remove(); } protected override _renderEnabled() { super._renderEnabled(); this.$header.setTabbable(this.enabledComputed); } /** @see GroupModel.iconId */ setIconId(iconId: string) { this.setProperty('iconId', iconId); } /** * Adds an image or font-based icon to the group header by adding either an IMG or SPAN element. */ protected _renderIconId() { let iconId = this.iconId || ''; // If the icon is an image (and not a font icon), the Icon class will invalidate the layout when the image has loaded if (!iconId) { this._removeIconId(); this._updateIconStyle(); return; } if (this.icon) { this.icon.setIconDesc(iconId); this._updateIconStyle(); return; } this.icon = scout.create(Icon, { parent: this, iconDesc: iconId, prepend: true }); this.icon.one('destroy', () => { this.icon = null; }); this.icon.render(this.$header); this._updateIconStyle(); } protected _updateIconStyle() { let hasTitle = !!this.title; this.get$Icon().toggleClass('with-title', hasTitle); this.get$Icon().addClass('group-icon'); this._renderCollapseStyle(); } get$Icon(): JQuery { if (this.icon) { return this.icon.$container; } return $(); } protected _removeIconId() { if (this.icon) { this.icon.destroy(); } } /** @see GroupModel.header */ setHeader(header: ObjectOrChildModel<Widget>) { this.setProperty('header', header); } protected _setHeader(header: Widget) { this._setProperty('header', header); } /** @see GroupModel.headerFocusable */ setHeaderFocusable(headerFocusable: boolean) { this.setProperty('headerFocusable', headerFocusable); } protected _renderHeaderFocusable() { this.$header.toggleClass('unfocusable', !this.headerFocusable); } setTitle(title: string) { this.setProperty('title', title); } protected _renderTitle() { if (this.$title) { if (this.titleHtmlEnabled) { this.$title.htmlOrNbsp(this.title); } else { this.$title.textOrNbsp(this.title); } this._updateIconStyle(); } } setTitleSuffix(titleSuffix: string) { this.setProperty('titleSuffix', titleSuffix); } protected _renderTitleSuffix() { if (this.$titleSuffix) { this.$titleSuffix.text(this.titleSuffix || ''); } } setHeaderVisible(headerVisible: boolean) { this.setProperty('headerVisible', headerVisible); } protected _renderHeaderVisible() { this.$header.setVisible(this.headerVisible); this._renderCollapsible(); this.invalidateLayoutTree(); } /** @see GroupModel.body */ setBody(body: ObjectOrChildModel<TBody>) { this.setProperty('body', body); } protected _setBody(body: TBody) { if (!body) { // Create empty body if none was provided body = scout.create(EmptyBody, { parent: this }) as unknown as TBody; } this._setProperty('body', body); } protected override _createLoadingSupport(): LoadingSupport { return new LoadingSupport({ widget: this, $container: () => this.$header }); } protected _renderHeader() { if (this.$header) { this.$header.remove(); this._removeIconId(); } if (this.header) { this.header.render(); this.$header = this.header.$container .addClass('group-header') .addClass('custom-header-widget') .prependTo(this.$container); this.htmlHeader = this.header.htmlComp; } else { this.$header = this.$container .prependDiv('group-header') .unfocusable() .addClass('prevent-initial-focus'); this.$title = this.$header.appendDiv('group-title'); this.$titleSuffix = this.$header.appendDiv('group-title-suffix'); tooltips.installForEllipsis(this.$title, { parent: this }); this.htmlHeader = HtmlComponent.install(this.$header, this.session); if (!this.rendering) { this._renderIconId(); this._renderTitle(); this._renderTitleSuffix(); } } aria.role(this.$header, 'button'); aria.linkElementWithLabel(this.$header, this.$title); this.$header.on('mousedown', this._onHeaderMouseDown.bind(this)); this.invalidateLayoutTree(); } protected _renderBody() { if (this.collapsed) { return; } this.body.render(); this.body.$container.insertAfter(this.$header); this.body.$container.addClass('group-body'); aria.linkElementWithLabel(this.body.$container, this.$title); aria.linkElementWithControls(this.$header, this.body.$container); this.body.invalidateLayoutTree(); } override getFocusableElement(): HTMLElement | JQuery { if (!this.rendered) { return null; } return this.$header; } toggleCollapse() { this.setCollapsed(!this.collapsed && this.collapsible); } setCollapsed(collapsed: boolean) { this.setProperty('collapsed', collapsed); } protected _renderCollapsed() { this.$container.toggleClass('collapsed', this.collapsed); this.$collapseIcon.toggleClass('collapsed', this.collapsed); aria.expanded(this.$header, !this.collapsed); if (!this.collapsed && !this.bodyAnimating) { this._renderBody(); } if (this.rendered) { this.resizeBody(); } else if (this.collapsed) { // Body will be removed after the animation, if there is no animation, remove it now this.body.remove(); } this.invalidateLayoutTree(); } setCollapsible(collapsible: boolean) { this.setProperty('collapsible', collapsible); } protected _renderCollapsible() { this.$container.toggleClass('collapsible', this.collapsible); this.$header.toggleClass('disabled', !this.collapsible); aria.disabled(this.$header, !this.collapsible || null); // footer is visible if collapseStyle is 'bottom' and either header is visible or has a (collapsible) body this.$footer.setVisible(this.collapseStyle === Group.CollapseStyle.BOTTOM && (this.headerVisible || this.collapsible)); this.$collapseIcon.setVisible(this.collapsible); this.invalidateLayoutTree(); } setCollapseStyle(collapseStyle: GroupCollapseStyle) { this.setProperty('collapseStyle', collapseStyle); } protected _renderCollapseStyle() { this.$header.toggleClass('collapse-right', this.collapseStyle === Group.CollapseStyle.RIGHT); this.$container.toggleClass('collapse-bottom', this.collapseStyle === Group.CollapseStyle.BOTTOM); if (this.collapseStyle === Group.CollapseStyle.RIGHT) { this.$collapseIcon.appendTo(this.$header); } else if (this.collapseStyle === Group.CollapseStyle.LEFT) { this.$collapseIcon.prependTo(this.$header); } else if (this.collapseStyle === Group.CollapseStyle.BOTTOM) { let sibling = this.body.$container ? this.body.$container : this.$header; this.$footer.insertAfter(sibling); this.$collapseIcon.insertAfter(this.$collapseBorderLeft); } this._renderCollapsible(); this.invalidateLayoutTree(); } protected _onHeaderMouseDown(event: MouseDownEvent) { if (this.collapsible && (!this.header || this.collapseStyle !== Group.CollapseStyle.BOTTOM)) { this.setCollapsed(!this.collapsed && this.collapsible); } } protected _onFooterMouseDown(event: MouseDownEvent) { if (this.collapsible) { this.setCollapsed(!this.collapsed && this.collapsible); } } /** * Resizes the body to its preferred size by animating the height. */ resizeBody() { this.animateToggleCollapse().done(() => { if (this.bodyAnimating) { // Another animation has been started in the meantime -> ignore done event return; } if (this.collapsed) { this.body.remove(); } this.invalidateLayoutTree(); }); } animateToggleCollapse(): JQuery.Promise<JQuery> { let currentSize = graphics.cssSize(this.body.$container); let currentMargins = graphics.margins(this.body.$container); let currentPaddings = graphics.paddings(this.body.$container); let targetHeight, targetMargins, targetPaddings; if (this.collapsed) { // Collapsing // Set target values to 0 when collapsing targetHeight = 0; targetMargins = new Insets(); targetPaddings = new Insets(); } else { // Expand to preferred size of the body targetHeight = this.body.htmlComp.prefSize({ widthHint: currentSize.width }).height; // Make sure body is layouted correctly before starting the animation (with the target size) // Use setSize to explicitly call its layout (this might even be necessary during the animation, see GroupLayout.invalidate) this.body.htmlComp.setSize(new Dimension(this.body.$container.outerWidth(), targetHeight)); if (this.bodyAnimating) { // The group may be expanded while being collapsed or vice versa. // In that case, use the current values of the inline style as starting values // Clear current insets to read target insets from CSS anew this.body.$container .cssMarginY('') .cssPaddingY(''); targetMargins = graphics.margins(this.body.$container); targetPaddings = graphics.paddings(this.body.$container); } else { // If toggling is not already in progress, start expanding from 0 currentSize.height = 0; currentMargins = new Insets(); currentPaddings = new Insets(); targetMargins = graphics.margins(this.body.$container); targetPaddings = graphics.paddings(this.body.$container); } } this.bodyAnimating = true; if (this.collapsed) { this.$container.addClass('collapsing'); } return this.body.$container .stop(true) .cssHeight(currentSize.height) .cssMarginTop(currentMargins.top) .cssMarginBottom(currentMargins.bottom) .cssPaddingTop(currentPaddings.top) .cssPaddingBottom(currentPaddings.bottom) .animate({ height: targetHeight, marginTop: targetMargins.top, marginBottom: targetMargins.bottom, paddingTop: targetPaddings.top, paddingBottom: targetPaddings.bottom }, { duration: 350, progress: () => { this.trigger('bodyHeightChange'); this.revalidateLayoutTree(); }, complete: () => { this.bodyAnimating = false; if (this.body.rendered) { // Remove inline styles when finished this.body.$container.cssMarginY(''); this.body.$container.cssPaddingY(''); } if (this.rendered) { this.$container.removeClass('collapsing'); } this.trigger('bodyHeightChangeDone'); } }) .promise(); } } export class EmptyBody extends Widget { protected override _render() { this.$container = this.$parent.appendDiv('group'); this.htmlComp = HtmlComponent.install(this.$container, this.session); } }