@eclipse-scout/core
Version:
Eclipse Scout runtime
318 lines (278 loc) • 11.3 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, Device, Form, FormField, FormFieldLayout, GroupBox, icons, InitModelOf, inspector, ObjectModelWithUuid, scout, scrollbars, strings, tooltips, WizardProgressFieldEventMap, WizardProgressFieldLayout, WizardProgressFieldModel
} from '../../../index';
import $ from 'jquery';
export class WizardProgressField extends FormField implements WizardProgressFieldModel {
declare model: WizardProgressFieldModel;
declare eventMap: WizardProgressFieldEventMap;
declare self: WizardProgressField;
activeStepIndex: number;
steps: WizardStep[];
/** Used to determine direction of transition ("going backward" or "going forward") */
previousActiveStepIndex: number;
/**
* Helper map to find a step by step index. The step index does not necessarily correspond to the
* array index, because invisible model steps can produce "holes" in the sequence of indices.
*/
stepsMap: Record<number, WizardStep>;
keepActiveStepAtLeftBorder: boolean;
animateScrolling: boolean;
$wizardStepsBody: JQuery;
$screenReaderStatus: JQuery;
constructor() {
super();
this.activeStepIndex = -1;
this.steps = [];
this.previousActiveStepIndex = -1;
this.stepsMap = {};
this.$wizardStepsBody = null;
this.keepActiveStepAtLeftBorder = Device.get().type === Device.Type.MOBILE;
this.animateScrolling = Device.get().type === Device.Type.MOBILE;
this.$screenReaderStatus = null;
}
protected override _init(model: InitModelOf<this>) {
super._init(model);
this._updateStepsMap();
}
protected override _render() {
this.addContainer(this.$parent, 'wizard-progress-field', new WizardProgressFieldLayout(this));
this.addField(this.$parent.makeDiv('wizard-steps'));
this.addStatus();
this.addLabel();
// Add compact class on mobile. It is not based on width because height will be smaller too which is not desired on desktop or tablet
// (field would not be correctly aligned with other components anymore)
this.$field.toggleClass('compact', Device.get().type === Device.Type.MOBILE);
let layout = this.htmlComp.layout as FormFieldLayout;
layout.compactFieldWidth = -1; // disable compact toggling
this.$wizardStepsBody = this.$field.appendDiv('wizard-steps-body');
this._addScreenReaderStatus();
this._installScrollbars({
axis: 'x',
scrollShadow: 'none'
});
// If this field is the first field in a form's main box, mark the form as "wizard-container-form"
if (this.parent instanceof GroupBox && this.parent.controls[0] === this && this.parent.parent instanceof Form) {
let form = this.parent.parent;
form.$container.addClass('wizard-container-form');
}
}
protected _addScreenReaderStatus() {
this.$screenReaderStatus = this.$field.appendDiv('screen-reader-steps-description');
aria.role(this.$screenReaderStatus, 'status');
aria.screenReaderOnly(this.$screenReaderStatus);
}
protected override _renderProperties() {
super._renderProperties();
this._renderSteps();
this._renderActiveStepIndex();
}
protected _setSteps(steps: WizardStep[]) {
this._setProperty('steps', steps);
this._updateStepsMap();
}
protected _renderSteps() {
this.$wizardStepsBody.children('.wizard-step').each(function() {
// Tooltips are only uninstalled if user clicked outside container. However, the steps
// may be updated by clicking inside the container. Therefore, manually make sure all
// tooltips are uninstalled before destroying the DOM elements.
tooltips.uninstall($(this));
});
this.$wizardStepsBody.empty();
this.steps.forEach((step, index) => {
// Step
let $step = this.$wizardStepsBody
.appendDiv('wizard-step')
.addClass(step.cssClass)
.data('wizard-step', step);
step.$step = $step;
this._updateStepClasses(step);
// Inspector info
inspector.applyInfo(step, $step, this.session);
if (this.session.inspector) {
$step.attr('data-step-index', step.index);
}
if (strings.hasText(step.tooltipText)) {
tooltips.install($step, {
parent: this,
text: step.tooltipText,
tooltipPosition: 'bottom'
});
}
// Icon
let $icon = $step.appendDiv('icon');
if (step.iconId) {
$icon.icon(step.iconId);
} else if (step.finished) {
$icon.icon(icons.CHECKED_BOLD);
} else {
$icon.text(index + 1);
}
// Text
let $text = $step.appendDiv('text');
$text.appendDiv('title').textOrNbsp(step.title).attr('data-text', step.title);
if (step.subTitle) {
$text.appendDiv('sub-title').textOrNbsp(step.subTitle);
}
// Separator
if (index < this.steps.length - 1) {
this.$wizardStepsBody
.appendDiv('wizard-step-separator');
}
});
this.invalidateLayoutTree(false);
}
protected _renderScreenReaderStatus($statusContainer: JQuery) {
$statusContainer.empty();
let activeStepIndex = this.steps.indexOf(this.stepsMap[this.activeStepIndex]);
this.steps.forEach((step, index) => {
let $stepDescription = $statusContainer.appendDiv('sr-step-description');
let stepIndex = this.steps.indexOf(step);
let stepType = '';
if (stepIndex === activeStepIndex) {
stepType += this.session.text('ui.Current');
} else if (stepIndex > activeStepIndex) {
stepType += this.session.text('ui.Subsequent');
} else {
stepType += this.session.text('ui.Previous');
}
$stepDescription.appendDiv('text').text(strings.join(' ', this.session.text('ui.Step'), String(index + 1), stepType));
if (strings.hasText(step.title)) {
$stepDescription.appendDiv('text').text(step.title);
}
if (strings.hasText(step.subTitle)) {
$stepDescription.appendDiv('text').text(step.subTitle);
}
if (strings.hasText(step.tooltipText)) {
$stepDescription.appendDiv('text').text(step.tooltipText);
}
});
}
protected _setActiveStepIndex(activeStepIndex: number) {
this.previousActiveStepIndex = this.activeStepIndex;
// Ensure this.activeStepIndex always has a value. If the server has no active step set (may
// happen during transition between steps), we use -1 as dummy value
this._setProperty('activeStepIndex', scout.nvl(activeStepIndex, -1));
}
protected _renderActiveStepIndex() {
this.steps.forEach(this._updateStepClasses.bind(this));
this._renderScreenReaderStatus(this.$screenReaderStatus);
this.invalidateLayoutTree(false);
}
protected _updateStepClasses(step: WizardStep) {
let $step = step.$step;
$step.removeClass('selected first last action-enabled disabled');
$step.off('click.selected');
// Important: those indices correspond to the UI's data structures (this.steps) and are not necessarily
// consistent with the server indices (because the server does not send invisible steps).
let stepIndex = this.steps.indexOf(step);
let activeStepIndex = this.steps.indexOf(this.stepsMap[this.activeStepIndex]);
if (this.enabledComputed && step.enabled && step.actionEnabled && stepIndex !== this.activeStepIndex) {
$step.addClass('action-enabled');
$step.on('click.selected', this._onStepClick.bind(this));
} else if (!this.enabledComputed || !step.enabled) {
$step.addClass('disabled');
}
$step.toggleClass('finished', step.finished);
if (stepIndex >= 0 && activeStepIndex >= 0) {
// Active
if (stepIndex === activeStepIndex) {
$step.addClass('selected');
}
// First / last
if (stepIndex === 0) {
$step.addClass('first');
}
if (stepIndex === this.steps.length - 1) {
$step.addClass('last');
}
}
// update background color for this.$wizardStepsBody, use same as for last step (otherwise there might be white space after last step)
if (stepIndex === this.steps.length - 1) {
this.$wizardStepsBody.css('background-color', $step.css('background-color'));
}
}
protected _stepIndex($step: JQuery): number {
if ($step) {
let step = $step.data('wizard-step') as WizardStep;
if (step) {
return step.index;
}
}
return -1;
}
protected _updateStepsMap() {
this.stepsMap = {};
this.steps.forEach(step => {
this.stepsMap[step.index] = step;
});
}
protected _resolveStep(stepIndex: number): WizardStep {
// Because "step index" does not necessarily correspond to the array indices
// (invisible model steps produce "holes"), we have to loop over the array.
for (let i = 0; i < this.steps.length; i++) {
let step = this.steps[i];
if (step.index === stepIndex) {
return step;
}
}
return null;
}
protected _onStepClick(event: JQuery.ClickEvent) {
let $step = $(event.currentTarget); // currentTarget instead of target to support event bubbling from inner divs
let targetStepIndex = this._stepIndex($step);
if (targetStepIndex >= 0 && targetStepIndex !== this.activeStepIndex) {
this.trigger('stepAction', {
stepIndex: targetStepIndex
});
}
}
scrollToActiveStep() {
let currentStep = this.stepsMap[this.activeStepIndex];
if (!currentStep) {
return;
}
let $currentStep = currentStep.$step;
let currentStepLeft = $currentStep.position().left;
let animate = this.animateScrolling && this.htmlComp.layouted;
if (this.keepActiveStepAtLeftBorder) {
let $firstStep = this.steps[0].$step;
let scrollLeft = currentStepLeft - $firstStep.cssPaddingLeft() + $currentStep.cssPaddingLeft();
scrollbars.scrollLeft(this.$field, scrollLeft, {animate: animate});
} else {
let scrollLeft = this.$field.scrollLeft();
let currentStepWidth = $currentStep.width();
let fieldWidth = this.$field.width();
// If going forward, try to scroll the steps such that the center of active step is not after 75% of the available space.
// If going backward, try to scroll the steps such that the center of the active step is not before 25% of the available space.
let goingBack = (this.previousActiveStepIndex > this.activeStepIndex);
let p1 = scrollLeft + Math.floor(fieldWidth * (goingBack ? 0.25 : 0.75));
let p2 = currentStepLeft + Math.floor(currentStepWidth / 2);
if ((goingBack && p2 < p1) || (!goingBack && p2 > p1)) {
scrollbars.scrollLeft(this.$field, scrollLeft + (p2 - p1), {animate: animate});
}
}
}
}
export interface WizardStep extends ObjectModelWithUuid<WizardStep> {
index?: number;
title?: string;
subTitle?: string;
tooltipText?: string;
iconId?: string;
enabled?: boolean;
actionEnabled?: boolean;
cssClass?: string;
finished?: boolean;
modelClass?: string;
classId?: string;
$step?: JQuery;
}