@eclipse-scout/core
Version:
Eclipse Scout runtime
539 lines (463 loc) • 17.1 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 {
aria, arrays, CloneOptions, FormField, HorizontalGrid, HtmlComponent, InitModelOf, LoadingSupport, LogicalGrid, LogicalGridData, LogicalGridLayout, LogicalGridLayoutConfig, LookupCall, LookupCallOrModel, LookupResult, LookupRow,
ObjectIdProvider, ObjectOrChildModel, ObjectOrModel, objects, PropertyChangeEvent, RadioButton, RadioButtonGroupEventMap, RadioButtonGroupGridConfig, RadioButtonGroupLeftOrUpKeyStroke, RadioButtonGroupModel,
RadioButtonGroupRightOrDownKeyStroke, scout, Status, ValueField
} from '../../../index';
import $ from 'jquery';
export class RadioButtonGroup<TValue> extends ValueField<TValue> implements RadioButtonGroupModel<TValue> {
declare model: RadioButtonGroupModel<TValue>;
declare eventMap: RadioButtonGroupEventMap<TValue>;
declare self: RadioButtonGroup<any>;
layoutConfig: LogicalGridLayoutConfig;
fields: FormField[];
radioButtons: RadioButton<TValue>[];
gridColumnCount: number;
selectedButton: RadioButton<TValue>;
lookupStatus: Status;
lookupCall: LookupCall<TValue>;
htmlBody: HtmlComponent;
$body: JQuery;
protected _lookupExecuted: boolean;
protected _selectButtonLocked: boolean;
protected _lookupInProgress: boolean;
protected _currentLookupCall: LookupCall<TValue>;
protected _buttonPropertyChangeHandler: (event: PropertyChangeEvent<any, RadioButton<TValue>>) => void;
constructor() {
super();
this.logicalGrid = scout.create(HorizontalGrid);
this.layoutConfig = null;
this.fields = [];
this.radioButtons = [];
this.gridColumnCount = RadioButtonGroup.DEFAULT_GRID_COLUMN_COUNT;
this.selectedButton = null;
this.$body = null;
this.lookupStatus = null;
this.lookupCall = null;
this._lookupExecuted = false;
this._selectButtonLocked = false;
this._lookupInProgress = false;
this._currentLookupCall = null;
this._addWidgetProperties(['fields']);
this._addCloneProperties(['lookupCall', 'layoutConfig', 'gridColumnCount']);
this._buttonPropertyChangeHandler = this._onButtonPropertyChange.bind(this);
}
static DEFAULT_GRID_COLUMN_COUNT = -1;
static ErrorCode = {
NO_DATA: 1
} as const;
protected override _init(model: InitModelOf<this>) {
super._init(model);
this._setLayoutConfig(this.layoutConfig);
this._setGridColumnCount(this.gridColumnCount);
}
protected override _initValue(value: TValue) {
if (this.lookupCall) {
this._setLookupCall(this.lookupCall);
}
// must be called before value is set
this._setFields(this.fields);
super._initValue(value);
}
protected override _initKeyStrokeContext() {
super._initKeyStrokeContext();
this.keyStrokeContext.registerKeyStrokes([
new RadioButtonGroupLeftOrUpKeyStroke(this),
new RadioButtonGroupRightOrDownKeyStroke(this)
]);
}
protected _initButtons() {
this.radioButtons = this.fields.filter(formField => formField instanceof RadioButton) as RadioButton<TValue>[];
this.radioButtons.forEach(this._initButton.bind(this));
}
protected _initButton(button: RadioButton<TValue>) {
if (button.events.count('propertyChange', this._buttonPropertyChangeHandler) === 0) {
button.on('propertyChange', this._buttonPropertyChangeHandler);
}
if (button.selected) {
this.setValue(button.radioValue);
this.selectButton(button);
if (button.focused) {
this.setFocused(true);
}
}
}
protected override _render() {
this.addContainer(this.$parent, 'radiobutton-group');
this.addLabel();
this.addMandatoryIndicator();
this.$body = this.$container.appendDiv('radiobutton-group-body');
aria.role(this.$body, 'radiogroup');
this.htmlBody = HtmlComponent.install(this.$body, this.session);
this.htmlBody.setLayout(this._createBodyLayout());
// fields are rendered in _renderFields
this.addField(this.$body);
this.addStatus();
}
protected override _renderProperties() {
super._renderProperties();
this._renderFields();
this._renderLayoutConfig();
}
protected _createBodyLayout(): LogicalGridLayout {
return new LogicalGridLayout(this, this.layoutConfig);
}
protected override _setLogicalGrid(logicalGrid: LogicalGrid | string) {
super._setLogicalGrid(logicalGrid);
if (this.logicalGrid) {
this.logicalGrid.setGridConfig(new RadioButtonGroupGridConfig());
}
}
override invalidateLogicalGrid(invalidateLayout?: boolean) {
super.invalidateLogicalGrid(false);
if (scout.nvl(invalidateLayout, true) && this.rendered) {
this.htmlBody.invalidateLayoutTree();
}
}
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();
}
}
override isClearable(): boolean {
return false;
}
getFields(): FormField[] {
return this.fields;
}
override getFocusableElement(): HTMLElement | JQuery {
// The first button may not be focusable because it is not selected and therefore has no tab index -> find the first focusable button
return this.session.focusManager.findFirstFocusableElement(this.$container);
}
setFields(fields: ObjectOrChildModel<FormField>[]) {
this.setProperty('fields', fields);
}
protected _setFields(fields: FormField[]) {
this._setProperty('fields', fields);
this._initButtons();
}
protected _renderFields() {
this._ensureLookupCallExecuted();
this.fields.forEach(function(formField) {
formField.render(this.$body);
// set each children layout data to logical grid data
formField.setLayoutData(new LogicalGridData(formField));
this._linkWithLabel(formField.$field);
}, this);
this._provideTabIndex(); // depends on rendered fields
this.invalidateLogicalGrid();
}
protected override _renderEnabled() {
super._renderEnabled();
this._provideTabIndex();
}
/**
* Set the selected (or first if none is selected) to tabbable
*/
protected _provideTabIndex() {
let tabSet;
this.radioButtons.forEach(function(radioButton) {
if (radioButton.enabledComputed && this.enabledComputed && !tabSet) {
radioButton.setTabbable(true);
tabSet = radioButton;
} else if (tabSet && this.enabledComputed && radioButton.enabledComputed && radioButton.selected) {
tabSet.setTabbable(false);
radioButton.setTabbable(true);
tabSet = radioButton;
} else {
radioButton.setTabbable(false);
}
}, this);
}
setGridColumnCount(gridColumnCount: number) {
this.setProperty('gridColumnCount', gridColumnCount);
}
protected _setGridColumnCount(gridColumnCount: number): boolean {
if (gridColumnCount < 0) {
gridColumnCount = this._calcDefaultGridColumnCount();
}
if (gridColumnCount === this.gridColumnCount) {
return false;
}
this._setProperty('gridColumnCount', gridColumnCount);
this.invalidateLogicalGrid();
return true;
}
protected _calcDefaultGridColumnCount(): number {
let height = 1,
hints = this.gridDataHints;
if (hints && hints.h > 1) {
height = hints.h;
}
return Math.ceil(this.fields.length / height);
}
getButtonForRadioValue(radioValue: TValue): RadioButton<TValue> {
if (radioValue === null) {
return null;
}
return arrays.find(this.radioButtons, button => {
return objects.equals(button.radioValue, radioValue);
});
}
/**
* Search and then select the button with the corresponding radioValue
*/
protected override _validateValue(value: TValue): TValue {
super._validateValue(value);
if (!this.initialized && this.lookupCall) {
// lookup call may not be started during field initialization. otherwise lookup prepare listeners cannot be attached.
// do not validate now (as there are no buttons yet, because the lookup call has not yet been executed).
// validation will be done later again when the lookup call is executed.
return value;
}
let lookupScheduled = this._ensureLookupCallExecuted();
if (lookupScheduled) {
// the first lookup was scheduled now: buttons are not yet available, not possible to select one. will be done later as soon as the lookup call is finished.
return value;
}
// only show error if value is not null or undefined
let buttonToSelect = this.getButtonForRadioValue(value);
if (!buttonToSelect && value !== null && value !== undefined && !this._lookupInProgress) {
throw this.session.text('InvalidValueMessageX', value);
}
return value;
}
protected override _valueChanged() {
super._valueChanged();
// Don't select button during initialization if value is null to not override selected state of a button
if (this.value !== null || this.initialized) {
this.selectButton(this.getButtonForRadioValue(this.value));
}
}
selectFirstButton() {
this.selectButtonByIndex(0);
}
selectLastButton() {
this.selectButtonByIndex(this.radioButtons.length - 1);
}
selectButtonByIndex(index: number) {
if (this.radioButtons.length && index >= 0 && index < this.radioButtons.length) {
this.selectButton(this.radioButtons[index]);
}
}
selectButton(radioButton: RadioButton<TValue>) {
if (this.selectedButton === radioButton) {
// Already selected
return;
}
if (this._selectButtonLocked) {
// Don't execute when triggered by this function to make sure the states are updated before firing the selectButton event
return;
}
this._selectButtonLocked = true;
// Deselect previously selected button
if (this.selectedButton) {
// Do not unset tabbable here, because at least one button has to be tabbable even if the button is deselected
this.selectedButton.setSelected(false);
}
// Select new button
if (radioButton) {
let tabbableButton = this.getTabbableButton();
let needsFocus = false;
if (tabbableButton) {
// Only one button in the group should have a tab index -> remove it from the current tabbable button after the new button is tabbable.
// If that button is focused the newly selected button needs to gain the focus otherwise the focus would fall back to the body.
needsFocus = tabbableButton.isFocused();
}
radioButton.setSelected(true);
radioButton.setTabbable(true);
if (needsFocus) {
radioButton.focus();
}
if (tabbableButton && tabbableButton !== radioButton) {
tabbableButton.setTabbable(false);
}
}
this._selectButtonLocked = false;
this.setProperty('selectedButton', radioButton);
}
getTabbableButton(): RadioButton<TValue> {
return arrays.find(this.radioButtons, button => {
return button.visible && button.isTabbable();
});
}
insertButton(radioButton: RadioButton<TValue>) {
let newFields = this.fields.slice();
newFields.push(radioButton);
this.setFields(newFields);
}
protected _onButtonPropertyChange(event: PropertyChangeEvent<any, RadioButton<TValue>>) {
if (event.propertyName === 'selected') {
let selected = event.newValue;
if (selected) {
this.setValue(event.source.radioValue);
this.selectButton(event.source);
} else if (event.source === this.selectedButton) {
this.selectButton(null);
}
} else if (event.propertyName === 'focused') {
this.setFocused(event.newValue);
}
}
setLookupCall(lookupCall: LookupCallOrModel<TValue>) {
this.setProperty('lookupCall', lookupCall);
}
protected _setLookupCall(lookupCall: LookupCallOrModel<TValue>) {
this._setProperty('lookupCall', LookupCall.ensure(lookupCall, this.session));
this._lookupExecuted = false;
if (this.rendered) {
this._ensureLookupCallExecuted();
}
}
/**
* @returns true if a lookup call execution has been scheduled now. false otherwise.
*/
protected _ensureLookupCallExecuted(): boolean {
if (!this.lookupCall) {
return false;
}
if (this._lookupExecuted) {
return false;
}
this._lookupByAll();
return true;
}
protected override _createLoadingSupport(): LoadingSupport {
return new LoadingSupport({
widget: this,
$container: () => this.$body
});
}
protected _lookupByAll(): JQuery.Promise<LookupResult<TValue>> {
if (!this.lookupCall) {
return;
}
let deferred = $.Deferred();
this._executeLookup(this.lookupCall.cloneForAll(), true)
.done(result => {
this._lookupByAllDone(result);
deferred.resolve(result);
});
return deferred.promise();
}
/**
* A wrapper function around lookup calls used to set the _lookupInProgress flag, and display the state in the UI.
*/
protected _executeLookup(lookupCall: LookupCall<TValue>, abortExisting: boolean): JQuery.Promise<LookupResult<TValue>> {
if (abortExisting && this._currentLookupCall) {
this._currentLookupCall.abort();
}
this._lookupInProgress = true;
this.setLoading(true);
this._currentLookupCall = lookupCall;
this.trigger('prepareLookupCall', {
lookupCall: lookupCall
});
return lookupCall
.execute()
.always(() => {
this._lookupInProgress = false;
this._lookupExecuted = true;
this._currentLookupCall = null;
this.setLoading(false);
this._clearLookupStatus();
});
}
protected _lookupByAllDone(result: LookupResult<TValue>) {
try {
if (result.exception) {
// Oops! Something went wrong while the lookup has been processed.
this.setLookupStatus(Status.warning({
message: result.exception
}));
return;
}
this._populateRadioButtonGroup(result);
} finally {
this.trigger('lookupCallDone', {
result: result
});
}
}
protected _populateRadioButtonGroup(result: LookupResult<TValue>) {
let lookupRows = result.lookupRows;
let newFields = this.fields.slice();
lookupRows.forEach(lookupRow => {
newFields.push(this._createLookupRowRadioButton(lookupRow));
});
this.setFields(newFields);
// because the lookup call is asynchronous, reset the value so that it is revalidated.
this.setValue(this.value);
// also select the button (the line above does not change the value, therefore _valueChanged is not called)
this.selectButton(this.getButtonForRadioValue(this.value));
}
protected _clearLookupStatus() {
this.setLookupStatus(null);
}
setLookupStatus(lookupStatus: Status) {
this.setProperty('lookupStatus', lookupStatus);
if (this.rendered) {
this._renderErrorStatus();
}
}
protected override _errorStatus(): Status {
return this.lookupStatus || this.errorStatus;
}
protected _createLookupRowRadioButton(lookupRow: LookupRow<TValue>): RadioButton<TValue> {
let button: InitModelOf<RadioButton<TValue>> = {
parent: this,
label: lookupRow.text,
radioValue: lookupRow.key
};
if (lookupRow.iconId) {
button.iconId = lookupRow.iconId;
}
if (lookupRow.tooltipText) {
button.tooltipText = lookupRow.tooltipText;
}
if (lookupRow.backgroundColor) {
button.backgroundColor = lookupRow.backgroundColor;
}
if (lookupRow.foregroundColor) {
button.foregroundColor = lookupRow.foregroundColor;
}
if (lookupRow.font) {
button.font = lookupRow.font;
}
if (lookupRow.enabled === false) {
button.enabled = false;
}
if (lookupRow.active === false) {
button.visible = false;
}
if (lookupRow.cssClass) {
button.cssClass = lookupRow.cssClass;
}
let radioButton = scout.create((RadioButton<TValue>), button);
radioButton.lookupRow = lookupRow;
return radioButton;
}
override clone(model: RadioButtonGroupModel<TValue>, options?: CloneOptions): this {
let clone = super.clone(model, options);
this._deepCloneProperties(clone, 'fields', options);
clone._initButtons();
return clone;
}
}
ObjectIdProvider.uuidPathSkipWidgets.add(RadioButtonGroup);