Skip to content

Commit e46b8d6

Browse files
Stepper: add navigation modes (#29262)
1 parent a3788bf commit e46b8d6

File tree

5 files changed

+190
-8
lines changed

5 files changed

+190
-8
lines changed

packages/devextreme/js/__internal/ui/collection/collection_widget.base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export interface CollectionWidgetBaseProperties<
112112
_itemAttributes?: Record<string, unknown>;
113113

114114
selectOnFocus?: boolean;
115+
116+
loopItemFocus?: boolean;
115117
}
116118

117119
class CollectionWidget<

packages/devextreme/js/__internal/ui/collection/m_collection_widget.edit.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface CollectionWidgetEditProperties<
4444
TKey = any,
4545
> extends CollectionWidgetBaseProperties<TComponent, TItem, TKey> {
4646
selectionMode?: SingleMultipleOrNone | SingleMultipleAllOrNone;
47+
48+
selectionRequired?: boolean;
4749
}
4850

4951
class CollectionWidget<
@@ -502,7 +504,6 @@ class CollectionWidget<
502504

503505
const $itemElement = e.currentTarget;
504506

505-
// @ts-expect-error ts-error
506507
if (this.isItemSelected($itemElement)) {
507508
this.unselectItem(e.currentTarget);
508509
} else {
@@ -785,8 +786,7 @@ class CollectionWidget<
785786
this._optionChangedAction?.({ name: optionName, fullName: optionName, value: optionValue });
786787
}
787788

788-
isItemSelected(itemElement: dxElementWrapper | number): boolean {
789-
// @ts-expect-error ts-error
789+
isItemSelected(itemElement: Element | number): boolean {
790790
return this._isItemSelected(this._editStrategy.getNormalizedIndex(itemElement));
791791
}
792792

packages/devextreme/js/__internal/ui/stepper/stepper.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import type { Orientation } from '@js/common';
22
import registerComponent from '@js/core/component_registrator';
33
import type { DxElement } from '@js/core/element';
44
import $, { type dxElementWrapper } from '@js/core/renderer';
5+
import { Deferred } from '@js/core/utils/deferred';
56
import { isDefined } from '@js/core/utils/type';
7+
import type { DxEvent } from '@js/events';
68
import { BindableTemplate } from '@ts/core/templates/m_bindable_template';
79
import type { Template } from '@ts/core/templates/m_template';
810
import { getImageContainer } from '@ts/core/utils/m_icon';
11+
import type { ActionConfig } from '@ts/core/widget/component';
912
import type { OptionChanged } from '@ts/core/widget/types';
1013
import type { ItemRenderInfo } from '@ts/ui/collection/collection_widget.base';
1114
import CollectionWidgetAsync from '@ts/ui/collection/m_collection_widget.async';
@@ -17,9 +20,6 @@ import type { CollectionWidgetEditProperties } from '../collection/m_collection_
1720

1821
export const STEPPER_CLASS = 'dx-stepper';
1922
export const STEP_LIST_CLASS = 'dx-step-list';
20-
export const STEPPER_PROGRESS_BAR_CLASS = 'dx-stepper-progressbar';
21-
export const STEPPER_PROGRESS_BAR_CONTAINER_CLASS = 'dx-stepper-progressbar-container';
22-
export const STEPPER_PROGRESS_BAR_STATUS_CLASS = 'dx-stepper-progressbar-status';
2323
export const STEP_CLASS = 'dx-step';
2424
export const STEP_SELECTED_CLASS = 'dx-step-selected';
2525
export const STEPPER_HORIZONTAL_ORIENTATION_CLASS = 'dx-stepper-horizontal';
@@ -39,6 +39,7 @@ export const ORIENTATION: Record<string, Orientation> = {
3939

4040
export interface StepperProperties extends CollectionWidgetEditProperties<Stepper> {
4141
orientation?: Orientation;
42+
linear?: boolean;
4243
}
4344

4445
class Stepper extends CollectionWidgetAsync<StepperProperties> {
@@ -48,6 +49,17 @@ class Stepper extends CollectionWidgetAsync<StepperProperties> {
4849

4950
_$stepsContainer!: dxElementWrapper;
5051

52+
_supportedKeys(): Record<string, (e: KeyboardEvent, options?: Record<string, unknown>) => void> {
53+
const defaultHandlers = super._supportedKeys();
54+
const { linear } = this.option();
55+
56+
return {
57+
...defaultHandlers,
58+
home: linear ? defaultHandlers.leftArrow : defaultHandlers.home,
59+
end: linear ? defaultHandlers.rightArrow : defaultHandlers.end,
60+
};
61+
}
62+
5163
_prepareDefaultItemTemplate(data: StepperItemProperties, $container: dxElementWrapper): void {
5264
const $indicatorElement = $('<div>').addClass(STEP_INDICATOR_CLASS);
5365
const $iconElement = getImageContainer(data.icon) ?? $('<div>').addClass(STEP_TEXT_CLASS).text(data.text ?? '');
@@ -93,11 +105,14 @@ class Stepper extends CollectionWidgetAsync<StepperProperties> {
93105
return {
94106
...super._getDefaultOptions(),
95107
orientation: 'horizontal',
108+
linear: true,
96109
selectionMode: 'single',
97110
selectOnFocus: true,
98111
activeStateEnabled: true,
99112
hoverStateEnabled: true,
100113
focusStateEnabled: true,
114+
loopItemFocus: false,
115+
selectionRequired: true,
101116
};
102117
}
103118

@@ -185,6 +200,43 @@ class Stepper extends CollectionWidgetAsync<StepperProperties> {
185200
return orientation === ORIENTATION.horizontal;
186201
}
187202

203+
_shouldPreventItemEvent(itemElement: Element): boolean {
204+
const itemIndex = this._editStrategy.getNormalizedIndex(itemElement);
205+
const { linear, selectedIndex = 0 } = this.option();
206+
207+
return !!linear && Math.abs(selectedIndex - itemIndex) > 1;
208+
}
209+
210+
_itemClickHandler(
211+
e: DxEvent,
212+
args?: Record<string, unknown>,
213+
config?: ActionConfig,
214+
): void {
215+
if (!this._shouldPreventItemEvent(e.currentTarget)) {
216+
super._itemClickHandler(e, args, config);
217+
}
218+
}
219+
220+
_itemPointerDownHandler(e: DxEvent): void {
221+
if (!this._shouldPreventItemEvent(e.currentTarget)) {
222+
super._itemPointerDownHandler(e);
223+
}
224+
}
225+
226+
_itemSelectHandler(e: DxEvent): void {
227+
if (!this._shouldPreventItemEvent(e.currentTarget)) {
228+
super._itemSelectHandler(e);
229+
}
230+
}
231+
232+
_syncSelectionOptions(byOption?: string): Promise<unknown> {
233+
super._syncSelectionOptions(byOption).done(() => {
234+
this._connector.option('value', this._getConnectorValue());
235+
});
236+
237+
return Deferred().resolve().promise();
238+
}
239+
188240
_itemOptionChanged(
189241
item: StepperItemProperties,
190242
property: keyof StepperItemProperties,
@@ -206,6 +258,8 @@ class Stepper extends CollectionWidgetAsync<StepperProperties> {
206258

207259
this._connector.option(name, value);
208260
break;
261+
case 'linear':
262+
break;
209263
default:
210264
super._optionChanged(args);
211265
}

packages/devextreme/testing/tests/DevExpress.ui.widgets/stepper.tests.js

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import $ from 'jquery';
2-
import 'ui/splitter';
3-
42
import Stepper from 'ui/stepper';
53

4+
import keyboardMock from '../../helpers/keyboardMock.js';
65
import Connector, {
76
STEPPER_CONNECTOR_CLASS,
87
} from '__internal/ui/stepper/connector';
@@ -71,6 +70,131 @@ QUnit.module('Initialization', moduleConfig, () => {
7170
});
7271
});
7372

73+
QUnit.module('Navigation', moduleConfig, () => {
74+
QUnit.test('In linear mode only next or previous steps can be selected on click', function(assert) {
75+
this.reinit({
76+
items: [{}, {}, {}, {}],
77+
selectedIndex: 1,
78+
linear: true,
79+
});
80+
81+
this.getItems().eq(3).trigger('dxclick');
82+
83+
assert.equal(this.instance.option('selectedIndex'), 1, 'selectedIndex not changed');
84+
85+
this.getItems().eq(2).trigger('dxclick');
86+
87+
assert.equal(this.instance.option('selectedIndex'), 2, 'selectedIndex changed');
88+
});
89+
90+
[true, false].forEach((linear) => {
91+
QUnit.test(`selectionChanged callback should not be triggered when is already selected, linear=${linear}`, function(assert) {
92+
let count = 0;
93+
94+
this.reinit({
95+
items: [{}, {}, {}, {}],
96+
selectedIndex: 1,
97+
linear,
98+
onSelectionChanged: function(e) {
99+
count += 1;
100+
},
101+
});
102+
103+
this.getItems().eq(2)
104+
.trigger('dxclick')
105+
.trigger('dxclick');
106+
107+
assert.equal(count, 1, 'action triggered only once');
108+
assert.equal(this.instance.option('selectedIndex'), 2, 'selectedIndex changed');
109+
});
110+
});
111+
112+
QUnit.test('In linear mode only next or previous steps can be selected by keyboard (selectOnFocus=false)', function(assert) {
113+
this.reinit({
114+
items: [{}, {}, {}, {}],
115+
selectedIndex: 1,
116+
linear: true,
117+
selectOnFocus: false,
118+
});
119+
120+
const keyboard = keyboardMock(this.$element);
121+
122+
keyboard
123+
.keyDown('right')
124+
.keyDown('right')
125+
.keyDown('enter');
126+
127+
assert.equal(this.instance.option('selectedIndex'), 1, 'selectedIndex not changed');
128+
129+
keyboard
130+
.keyDown('left')
131+
.keyDown('enter');
132+
133+
assert.equal(this.instance.option('selectedIndex'), 2, 'selectedIndex changed');
134+
});
135+
136+
QUnit.test('In linear mode Home/End keys should select previous/next item', function(assert) {
137+
this.reinit({
138+
items: [{}, {}, {}, {}],
139+
selectedIndex: 1,
140+
linear: true,
141+
});
142+
143+
const keyboard = keyboardMock(this.$element);
144+
145+
keyboard.keyDown('end');
146+
147+
assert.equal(this.instance.option('selectedIndex'), 2, 'selected next item');
148+
149+
keyboard.keyDown('home');
150+
151+
assert.equal(this.instance.option('selectedIndex'), 1, 'selected previous item');
152+
});
153+
154+
QUnit.test('Connector value should change on selection changed', function(assert) {
155+
this.reinit({
156+
items: [{}, {}, {}, {}, {}],
157+
selectedIndex: 1,
158+
linear: false,
159+
});
160+
161+
assert.equal(this.getConnector().option('value'), '25%', 'initial connector value is correct');
162+
163+
this.getItems().eq(3).trigger('dxclick');
164+
165+
assert.equal(this.getConnector().option('value'), '75%', 'connector value changed');
166+
});
167+
168+
QUnit.test('Connector value should change if selectedIndex changed in runtime', function(assert) {
169+
this.reinit({
170+
items: [{}, {}, {}, {}, {}],
171+
selectedIndex: 1,
172+
linear: false,
173+
});
174+
175+
assert.equal(this.getConnector().option('value'), '25%', 'initial connector value is correct');
176+
177+
this.instance.option('selectedIndex', 3);
178+
179+
assert.equal(this.getConnector().option('value'), '75%', 'connector value changed');
180+
});
181+
182+
QUnit.test('Connector value should change if selectedItem changed in runtime', function(assert) {
183+
const items = [{}, {}, {}, {}, {}];
184+
this.reinit({
185+
items,
186+
selectedIndex: 1,
187+
linear: false,
188+
});
189+
190+
assert.equal(this.getConnector().option('value'), '25%', 'initial connector value is correct');
191+
192+
this.instance.option('selectedItem', items[3]);
193+
194+
assert.equal(this.getConnector().option('value'), '75%', 'connector value changed');
195+
});
196+
});
197+
74198
QUnit.module('Item data', moduleConfig, () => {
75199
QUnit.test('Item indicator should contain item 1-based index by default', function(assert) {
76200
this.reinit({

packages/devextreme/testing/tests/DevExpress.ui/defaultOptions.tests.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,11 +1547,13 @@ testComponentDefaults(Stepper,
15471547
{},
15481548
{
15491549
orientation: 'horizontal',
1550+
linear: true,
15501551
selectionMode: 'single',
15511552
selectOnFocus: true,
15521553
activeStateEnabled: true,
15531554
hoverStateEnabled: true,
15541555
focusStateEnabled: true,
1556+
loopItemFocus: false,
15551557
}
15561558
);
15571559

0 commit comments

Comments
 (0)