@eclipse-scout/core
Version:
Eclipse Scout runtime
428 lines (382 loc) • 16.8 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 {
Button, CheckBoxField, CloneOptions, CompositeField, DateField, dates, EventHandler, FormField, FormFieldSuppressStatus, HorizontalGrid, HtmlComponent, InitModelOf, LogicalGrid, LogicalGridData, LogicalGridLayout, LogicalGridLayoutConfig,
Menu, ObjectIdProvider, ObjectOrChildModel, ObjectOrModel, PropertyChangeEvent, scout, SequenceBoxEventMap, SequenceBoxGridConfig, SequenceBoxModel, StatusOrModel, strings, ValueField, Widget
} from '../../../index';
export class SequenceBox extends CompositeField implements SequenceBoxModel {
declare model: SequenceBoxModel;
declare eventMap: SequenceBoxEventMap;
declare self: SequenceBox;
layoutConfig: LogicalGridLayoutConfig;
fields: FormField[];
htmlBody: HtmlComponent;
boxErrorStatus: StatusOrModel;
boxTooltipText: string;
boxMenus: Menu[];
boxMenusVisible: boolean;
protected _lastVisibleField: FormField;
protected _isOverwritingStatusFromField: boolean;
protected _isErrorStatusOverwritten: boolean;
protected _isTooltipTextOverwritten: boolean;
protected _isMenusOverwritten: boolean;
protected _fieldPropertyChangeHandler: EventHandler<PropertyChangeEvent<any, FormField>>;
protected _lastVisibleFieldSuppressStatusHandler: EventHandler<PropertyChangeEvent<FormFieldSuppressStatus>>;
constructor() {
super();
this._addWidgetProperties('fields');
this._addCloneProperties(['layoutConfig']);
this.logicalGrid = scout.create(HorizontalGrid);
this.layoutConfig = null;
this.fields = [];
this._fieldPropertyChangeHandler = this._onFieldPropertyChange.bind(this);
this._lastVisibleFieldSuppressStatusHandler = this._onLastVisibleFieldSuppressStatusChange.bind(this);
}
protected override _init(model: InitModelOf<this>) {
super._init(model);
this._setLayoutConfig(this.layoutConfig);
this._initDateFields();
this.setErrorStatus(this.errorStatus);
this.setTooltipText(this.tooltipText);
this.setMenus(this.menus);
this.setMenusVisible(this.menusVisible);
}
/**
* Initialize all DateFields in this SequenceBox with a meaningful autoDate, except fields which already have an autoDate provided by the model.
*/
protected _initDateFields() {
let dateFields = this._getDateFields();
let newAutoDate: Date = null;
for (let i = 0; i < dateFields.length; i++) {
let currField = dateFields[i];
if (currField.autoDate) {
// is the autoDate already set by the field's model remember to not change this value.
currField.hasModelAutoDateSet = true;
}
if (!currField.hasModelAutoDateSet) {
currField.setAutoDate(newAutoDate);
}
newAutoDate = this._getAutoDateProposal(currField);
}
}
protected override _render() {
this.addContainer(this.$parent, 'sequence-box');
this.addLabel();
this.addField(this.$parent.makeDiv());
this.addStatus();
this._handleStatus();
this.htmlBody = HtmlComponent.install(this.$field, this.session);
this.htmlBody.setLayout(this._createBodyLayout());
for (let i = 0; i < this.fields.length; i++) {
let field = this.fields[i];
field.labelUseUiWidth = true;
field.on('propertyChange', this._fieldPropertyChangeHandler);
field.render(this.$field);
this._modifyLabel(field);
// set each children layout data to logical grid data
field.setLayoutData(new LogicalGridData(field));
}
}
protected override _renderProperties() {
super._renderProperties();
this._renderLayoutConfig();
}
protected _createBodyLayout(): LogicalGridLayout {
return new LogicalGridLayout(this, this.layoutConfig);
}
protected override _remove() {
this.fields.forEach(f => f.off('propertyChange', this._fieldPropertyChangeHandler));
if (this._lastVisibleField) {
this._lastVisibleField.off('propertyChange:suppressStatus', this._lastVisibleFieldSuppressStatusHandler);
}
super._remove();
}
override invalidateLogicalGrid(invalidateLayout?: boolean) {
super.invalidateLogicalGrid(false);
if (scout.nvl(invalidateLayout, true) && this.rendered) {
this.htmlBody.invalidateLayoutTree();
}
}
protected override _setLogicalGrid(logicalGrid: LogicalGrid | string) {
super._setLogicalGrid(logicalGrid);
if (this.logicalGrid) {
this.logicalGrid.setGridConfig(new SequenceBoxGridConfig());
}
}
setLayoutConfig(layoutConfig: ObjectOrModel<LogicalGridLayoutConfig>) {
this.setProperty('layoutConfig', layoutConfig);
}
protected _setLayoutConfig(layoutConfig: ObjectOrModel<LogicalGridLayoutConfig>) {
this._setProperty('layoutConfig', LogicalGridLayoutConfig.ensure(layoutConfig || {}).withSmallHgapDefaults());
LogicalGridLayoutConfig.initHtmlEnvChangeHandler(this, () => this.layoutConfig, layoutConfig => this.setLayoutConfig(layoutConfig));
}
protected _renderLayoutConfig() {
this.layoutConfig.applyToLayout(this.htmlBody.layout as LogicalGridLayout);
if (this.rendered) {
this.htmlBody.invalidateLayoutTree();
}
}
protected _onFieldPropertyChange(event: PropertyChangeEvent<any, FormField>) {
let visibilityChanged = (event.propertyName === 'visible');
if (scout.isOneOf(event.propertyName, ['errorStatus', 'tooltipText', 'visible', 'menus', 'menusVisible'])) {
this._handleStatus(visibilityChanged);
} else if (event.propertyName === 'value') {
this._onFieldValueChange(event as PropertyChangeEvent<any, ValueField<any>>);
} else if (event.propertyName === 'focused') {
this._updateFocusedFromField(event.source);
}
}
/**
* Moves the status relevant properties from the last visible field to the SequenceBox. This makes sure that the fields inside the SequenceBox have the same size.
*/
protected _handleStatus(visibilityChanged?: boolean) {
if (visibilityChanged && this._lastVisibleField) {
// if there is a new last visible field, make sure the status is shown on the previously last one
this._lastVisibleField.off('propertyChange:suppressStatus', this._lastVisibleFieldSuppressStatusHandler);
this._lastVisibleField.setSuppressStatus(null);
if (this._lastVisibleField.rendered) {
this._lastVisibleField._renderErrorStatus();
this._lastVisibleField._renderTooltipText();
this._lastVisibleField._renderMenus();
}
}
this._lastVisibleField = this._getLastVisibleField();
if (!this._lastVisibleField) {
return;
}
// Update the SequenceBox with the status relevant flags
this._isOverwritingStatusFromField = true;
if (this._lastVisibleField.errorStatus) {
this.setErrorStatus(this._lastVisibleField.errorStatus);
this._isErrorStatusOverwritten = true;
} else {
this.setErrorStatus(this.boxErrorStatus);
this._isErrorStatusOverwritten = false;
}
if (this._lastVisibleField.hasStatusTooltip()) {
this.setTooltipText(this._lastVisibleField.tooltipText);
this._isTooltipTextOverwritten = true;
} else {
this.setTooltipText(this.boxTooltipText);
this._isTooltipTextOverwritten = false;
}
let menuItems = this._lastVisibleField.getContextMenuItems(false);
if (menuItems && menuItems.length > 0) {
// Change owner to make sure menu won't be destroyed when setMenus is called
this._updateBoxMenuOwner(this.fieldStatus);
this.setMenus(menuItems);
this.setMenusVisible(this._lastVisibleField.menusVisible);
this._isMenusOverwritten = true;
} else {
this._updateBoxMenuOwner(this);
this.setMenus(this.boxMenus);
this.setMenusVisible(this.boxMenusVisible);
this._isMenusOverwritten = false;
}
this._isOverwritingStatusFromField = false;
// Make sure the last field won't display a status (but shows status CSS class)
this._lastVisibleField.setSuppressStatus(FormField.SuppressStatus.ICON);
this._lastVisibleField.on('propertyChange:suppressStatus', this._lastVisibleFieldSuppressStatusHandler);
if (visibilityChanged) {
// If the last field got invisible, make sure the new last field does not display a status anymore (now done by the seq box)
if (this._lastVisibleField.rendered) {
this._lastVisibleField._renderErrorStatus();
this._lastVisibleField._renderTooltipText();
this._lastVisibleField._renderMenus();
}
}
}
/**
* Marks the sequence box as "focused" when the given inner field is focused but does not have a visible label.
* This allows the focus to be indicated on the sequence box label instead.
*/
protected _updateFocusedFromField(field: FormField) {
if (field.visible && field.focused) {
this.setFocused(this._computeSequenceBoxFocused(field));
} else {
this.setFocused(false);
}
}
/**
* Called when the given inner field gained the focus. Computes whether the sequence box should be marked as "focused" as well.
* Normally, we want to do this when the inner field does not have a visible label.
*/
protected _computeSequenceBoxFocused(focusedField: FormField) {
// Special marker classes to override the default behavior
if (this.hasCssClass('consider-inner-focus')) {
return true;
}
if (this.hasCssClass('never-consider-inner-focus')) {
return false;
}
// Traverse the list of visible inner fields until we reach the focused field. If we did not encounter a field with a visible
// label until that point, mark the sequence box as focused.
for (let field of this.fields.filter(f => f.visible)) {
if (hasLabel(field)) {
return false;
}
if (field === focusedField) {
return true;
}
}
return false;
function hasLabel(field: FormField) {
// Special marker classes to override the default behavior
if (field.hasCssClass('consider-for-outer-focus')) {
return false;
}
if (field.hasCssClass('never-consider-for-outer-focus') || field instanceof Button || field instanceof CheckBoxField) {
return true;
}
if (!field.labelVisible || field.labelPosition === FormField.LabelPosition.ON_FIELD) {
return false;
}
// Consider a label with only a single punctuation character as empty
return strings.hasText(field.label) && !/^\s*\p{Punctuation}?\s*$/u.test(field.label);
}
}
protected _onLastVisibleFieldSuppressStatusChange(e: PropertyChangeEvent<FormFieldSuppressStatus>) {
// do not change suppressStatus
e.preventDefault();
}
override setErrorStatus(errorStatus: StatusOrModel) {
if (this._isOverwritingStatusFromField && !this._isErrorStatusOverwritten) {
// was not overwritten, will be overwritten now -> backup old value
this.boxErrorStatus = this.errorStatus;
} else if (!this._isOverwritingStatusFromField) {
// directly changed on seq box -> update backed-up value
this.boxErrorStatus = errorStatus;
}
if (this._isOverwritingStatusFromField || !this._isErrorStatusOverwritten) {
// prevent setting value if directly changed on seq box and is already overwritten
super.setErrorStatus(errorStatus);
}
}
override setTooltipText(tooltipText: string) {
if (this._isOverwritingStatusFromField && !this._isTooltipTextOverwritten) {
// was not overwritten, will be overwritten now -> backup old value
this.boxTooltipText = this.tooltipText;
} else if (!this._isOverwritingStatusFromField) {
// directly changed on seq box -> update backed-up value
this.boxTooltipText = tooltipText;
}
if (this._isOverwritingStatusFromField || !this._isTooltipTextOverwritten) {
// prevent setting value if directly changed on seq box and is already overwritten
super.setTooltipText(tooltipText);
}
}
override setMenus(menusOrModels: ObjectOrChildModel<Menu>[]) {
// ensure menus are real and not just model objects
let menus = this._createChildren(menusOrModels);
if (this._isOverwritingStatusFromField && !this._isMenusOverwritten) {
// was not overwritten, will be overwritten now -> backup old value
this.boxMenus = this.menus;
} else if (!this._isOverwritingStatusFromField) {
// directly changed on seq box -> update backed-up value
this.boxMenus = menus;
}
if (this._isOverwritingStatusFromField || !this._isMenusOverwritten) {
// prevent setting value if directly changed on seq box and is already overwritten
super.setMenus(menus);
}
}
protected _updateBoxMenuOwner(newOwner: Widget) {
this.boxMenus.forEach(menu => menu.setOwner(newOwner));
}
override setMenusVisible(menusVisible: boolean) {
if (this._isOverwritingStatusFromField && !this._isMenusOverwritten) {
// was not overwritten, will be overwritten now -> backup old value
this.boxMenusVisible = this.menusVisible;
} else if (!this._isOverwritingStatusFromField) {
// directly changed on seq box -> update backed-up value
this.boxMenusVisible = menusVisible;
}
if (this._isOverwritingStatusFromField || !this._isMenusOverwritten) {
// prevent setting value if directly changed on seq box and is already overwritten
super.setMenusVisible(menusVisible);
}
}
protected _getLastVisibleField(): FormField {
let visibleFields = this.fields.filter(field => field.visible);
if (visibleFields.length === 0) {
return;
}
return visibleFields[visibleFields.length - 1];
}
protected _onFieldValueChange(event: PropertyChangeEvent<any, ValueField<any>>) {
if (event.source instanceof DateField) {
this._onDateFieldValueChange(event as PropertyChangeEvent<Date, DateField>);
}
}
protected _onDateFieldValueChange(event: PropertyChangeEvent<Date, DateField>) {
// For a better user experience preselect a meaningful date on all following DateFields in the sequence box.
let field = event.source;
let dateFields = this._getDateFields();
let newAutoDate = this._getAutoDateProposal(field);
for (let i = dateFields.indexOf(field) + 1; i < dateFields.length; i++) {
let currField = dateFields[i];
if (!currField.hasModelAutoDateSet) {
currField.setAutoDate(newAutoDate);
}
if (currField.value) {
// only update fields in between the current field and the next field with a value set. Otherwise, already set autoDates would be overwritten.
break;
}
}
}
protected _getDateFields(): (DateField & { hasModelAutoDateSet?: boolean })[] {
return this.fields.filter(field => field instanceof DateField) as (DateField & { hasModelAutoDateSet?: boolean })[];
}
protected _getAutoDateProposal(field: DateField): Date {
let newAutoDate: Date = null;
// if it's only a time field, add one hour, otherwise add one day
if (field && field.value) {
if (!field.hasDate && field.hasTime) {
newAutoDate = dates.shiftTime(field.value, 1, 0, 0);
} else {
newAutoDate = dates.shift(field.value, 0, 0, 1);
}
}
return newAutoDate;
}
// The new sequence-box sets the label to invisible on the model.
protected _modifyLabel(field: FormField) {
if (field instanceof CheckBoxField) {
field.labelVisible = false;
}
if (field instanceof DateField) {
// The DateField has two inputs ($dateField and $timeField), field.$field refers to the composite which is irrelevant here
// In order to support aria-labelledby for date fields also, the individual inputs have to be linked with the label rather than the composite
if (field.$dateField) {
this._linkWithLabel(field.$dateField);
}
if (field.$timeField) {
this._linkWithLabel(field.$timeField);
}
} else if (field.$field) { // If $field is set depends on the concrete field e.g. a group box does not have a $field
this._linkWithLabel(field.$field);
}
}
setFields(fields: ObjectOrChildModel<FormField>[]) {
if (this.rendered) {
throw new Error('Setting fields is not supported if sequence box is already rendered.');
}
this.setProperty('fields', fields);
}
getFields(): FormField[] {
return this.fields;
}
override clone(model: SequenceBoxModel, options?: CloneOptions): this {
let clone = super.clone(model, options);
this._deepCloneProperties(clone, 'fields', options);
return clone;
}
}
ObjectIdProvider.uuidPathSkipWidgets.add(SequenceBox);