Skip to content

Commit b7d1099

Browse files
Form: Hide opened DropDownEditor popup on label click in form (T1257945) (#28572)
1 parent 02eafa8 commit b7d1099

File tree

2 files changed

+196
-31
lines changed

2 files changed

+196
-31
lines changed

packages/devextreme/js/__internal/ui/form/m_form.layout_manager.utils.ts

Lines changed: 135 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,43 @@
11
import Guid from '@js/core/guid';
2+
import $ from '@js/core/renderer';
23
import { extend } from '@js/core/utils/extend';
34
import { captionize } from '@js/core/utils/inflector';
45
import { each } from '@js/core/utils/iterator';
5-
import { isDefined } from '@js/core/utils/type';
6+
import { isBoolean, isDefined, isFunction } from '@js/core/utils/type';
7+
import type { dxDropDownEditorOptions } from '@js/ui/drop_down_editor/ui.drop_down_editor';
8+
import type { FormItemComponent } from '@js/ui/form';
9+
import type { dxOverlayOptions } from '@js/ui/overlay';
10+
import type dxTextBox from '@js/ui/text_box';
611

712
import { SIMPLE_ITEM_TYPE } from './constants';
813

9-
const EDITORS_WITH_ARRAY_VALUE = ['dxTagBox', 'dxRangeSlider', 'dxDateRangeBox'];
10-
const EDITORS_WITH_SPECIFIC_LABELS = ['dxRangeSlider', 'dxSlider'];
11-
export const EDITORS_WITHOUT_LABELS = ['dxCalendar', 'dxCheckBox', 'dxHtmlEditor', 'dxRadioGroup', 'dxRangeSlider', 'dxSlider', 'dxSwitch'];
14+
const EDITORS_WITH_ARRAY_VALUE: FormItemComponent[] = [
15+
'dxTagBox',
16+
'dxRangeSlider',
17+
'dxDateRangeBox',
18+
];
19+
const EDITORS_WITH_SPECIFIC_LABELS: FormItemComponent[] = ['dxRangeSlider', 'dxSlider'];
20+
export const EDITORS_WITHOUT_LABELS: FormItemComponent[] = [
21+
'dxCalendar',
22+
'dxCheckBox',
23+
'dxHtmlEditor',
24+
'dxRadioGroup',
25+
'dxRangeSlider',
26+
'dxSlider',
27+
'dxSwitch',
28+
];
29+
const DROP_DOWN_EDITORS: FormItemComponent[] = [
30+
'dxSelectBox',
31+
'dxDropDownBox',
32+
'dxTagBox',
33+
'dxLookup',
34+
'dxAutocomplete',
35+
'dxColorBox',
36+
'dxDateBox',
37+
'dxDateRangeBox',
38+
];
39+
40+
type DropDownOptions = dxDropDownEditorOptions<dxTextBox>;
1241

1342
export function convertToRenderFieldItemOptions({
1443
$parent,
@@ -33,7 +62,9 @@ export function convertToRenderFieldItemOptions({
3362
labelMode,
3463
onLabelTemplateRendered,
3564
}) {
36-
const isRequired = isDefined(item.isRequired) ? item.isRequired : !!_hasRequiredRuleInSet(item.validationRules);
65+
const isRequired = isDefined(item.isRequired)
66+
? item.isRequired
67+
: !!_hasRequiredRuleInSet(item.validationRules);
3768
const isSimpleItem = item.itemType === SIMPLE_ITEM_TYPE;
3869
const helpID = item.helpText ? `dx-${new Guid()}` : null;
3970

@@ -49,11 +80,16 @@ export function convertToRenderFieldItemOptions({
4980
onLabelTemplateRendered,
5081
});
5182

52-
const needRenderLabel = labelOptions.visible && (labelOptions.text || (labelOptions.labelTemplate && isSimpleItem));
83+
const needRenderLabel = labelOptions.visible
84+
&& (labelOptions.text || (labelOptions.labelTemplate && isSimpleItem));
5385
const { location: labelLocation, labelID } = labelOptions;
54-
const labelNeedBaselineAlign = labelLocation !== 'top' && ['dxTextArea', 'dxRadioGroup', 'dxCalendar', 'dxHtmlEditor'].includes(item.editorType);
86+
const labelNeedBaselineAlign = labelLocation !== 'top'
87+
&& ['dxTextArea', 'dxRadioGroup', 'dxCalendar', 'dxHtmlEditor'].includes(
88+
item.editorType,
89+
);
5590

5691
const editorOptions = _convertToEditorOptions({
92+
$parent,
5793
editorType: item.editorType,
5894
editorValue,
5995
defaultEditorName: item.dataField,
@@ -70,8 +106,9 @@ export function convertToRenderFieldItemOptions({
70106
});
71107

72108
const needRenderOptionalMarkAsHelpText = labelOptions.markOptions.showOptionalMark
73-
&& !labelOptions.visible && editorOptions.labelMode !== 'hidden'
74-
&& !isDefined(item.helpText);
109+
&& !labelOptions.visible
110+
&& editorOptions.labelMode !== 'hidden'
111+
&& !isDefined(item.helpText);
75112

76113
const helpText = needRenderOptionalMarkAsHelpText
77114
? labelOptions.markOptions.optionalMark
@@ -102,18 +139,26 @@ export function convertToRenderFieldItemOptions({
102139
}
103140

104141
export function getLabelMarkText({
105-
showRequiredMark, requiredMark, showOptionalMark, optionalMark,
142+
showRequiredMark,
143+
requiredMark,
144+
showOptionalMark,
145+
optionalMark,
106146
}) {
107147
if (!showRequiredMark && !showOptionalMark) {
108148
return '';
109149
}
110150

111-
return String.fromCharCode(160) + (showRequiredMark ? requiredMark : optionalMark);
151+
return (
152+
String.fromCharCode(160) + (showRequiredMark ? requiredMark : optionalMark)
153+
);
112154
}
113155

114-
export function convertToLabelMarkOptions({
115-
showRequiredMark, requiredMark, showOptionalMark, optionalMark,
116-
}, isRequired?: boolean) {
156+
export function convertToLabelMarkOptions(
157+
{
158+
showRequiredMark, requiredMark, showOptionalMark, optionalMark,
159+
},
160+
isRequired?: boolean,
161+
) {
117162
return {
118163
showRequiredMark: showRequiredMark && isRequired,
119164
requiredMark,
@@ -122,8 +167,55 @@ export function convertToLabelMarkOptions({
122167
};
123168
}
124169

170+
// eslint-disable-next-line @typescript-eslint/naming-convention
171+
function _getDropDownEditorOptions(
172+
$parent,
173+
editorType: FormItemComponent,
174+
editorInputId: string,
175+
onContentReadyExternal?: DropDownOptions['onContentReady'],
176+
): DropDownOptions {
177+
const isDropDownEditor = DROP_DOWN_EDITORS.includes(editorType);
178+
179+
if (!isDropDownEditor) {
180+
return {};
181+
}
182+
183+
return {
184+
onContentReady: (e) => {
185+
const { component } = e;
186+
const openOnFieldClick = component.option('openOnFieldClick') as DropDownOptions['openOnFieldClick'];
187+
const initialHideOnOutsideClick = component.option('dropDownOptions.hideOnOutsideClick') as dxOverlayOptions<dxTextBox>['hideOnOutsideClick'];
188+
189+
if (openOnFieldClick) {
190+
component.option('dropDownOptions', {
191+
hideOnOutsideClick: (e) => {
192+
if (isBoolean(initialHideOnOutsideClick)) {
193+
return initialHideOnOutsideClick;
194+
}
195+
196+
const $target = $(e.target);
197+
const $label = $parent.find(`label[for="${editorInputId}"]`);
198+
const isLabelClicked = !!$target.closest($label).length;
199+
200+
if (!isFunction(initialHideOnOutsideClick)) {
201+
return !isLabelClicked;
202+
}
203+
204+
return !isLabelClicked && initialHideOnOutsideClick(e);
205+
},
206+
});
207+
}
208+
209+
if (isFunction(onContentReadyExternal)) {
210+
onContentReadyExternal(e);
211+
}
212+
},
213+
};
214+
}
215+
125216
// eslint-disable-next-line @typescript-eslint/naming-convention
126217
function _convertToEditorOptions({
218+
$parent,
127219
editorType,
128220
defaultEditorName,
129221
editorValue,
@@ -153,10 +245,13 @@ function _convertToEditorOptions({
153245
const stylingMode = externalEditorOptions?.stylingMode || editorStylingMode;
154246
const useSpecificLabelOptions = EDITORS_WITH_SPECIFIC_LABELS.includes(editorType);
155247

248+
const dropDownEditorOptions = _getDropDownEditorOptions($parent, editorType, editorInputId, externalEditorOptions?.onContentReady);
249+
156250
const result = extend(
157251
true,
158252
editorOptionsWithValue,
159253
externalEditorOptions,
254+
dropDownEditorOptions,
160255
{
161256
inputAttr: { id: editorInputId },
162257
validationBoundary: editorValidationBoundary,
@@ -179,6 +274,7 @@ function _convertToEditorOptions({
179274
if (defaultEditorName && !result.name) {
180275
result.name = defaultEditorName;
181276
}
277+
182278
return result;
183279
}
184280

@@ -201,15 +297,27 @@ function _hasRequiredRuleInSet(rules) {
201297

202298
// eslint-disable-next-line @typescript-eslint/naming-convention
203299
function _convertToLabelOptions({
204-
item, id, isRequired, managerMarkOptions, showColonAfterLabel, labelLocation, labelTemplate, formLabelMode, onLabelTemplateRendered,
300+
item,
301+
id,
302+
isRequired,
303+
managerMarkOptions,
304+
showColonAfterLabel,
305+
labelLocation,
306+
labelTemplate,
307+
formLabelMode,
308+
onLabelTemplateRendered,
205309
}) {
206-
const isEditorWithoutLabels = EDITORS_WITHOUT_LABELS.includes(item.editorType);
310+
const isEditorWithoutLabels = EDITORS_WITHOUT_LABELS.includes(
311+
item.editorType,
312+
);
207313
const labelOptions = extend(
208314
{
209315
showColon: showColonAfterLabel,
210316
location: labelLocation,
211317
id,
212-
visible: formLabelMode === 'outside' || (isEditorWithoutLabels && formLabelMode !== 'hidden'),
318+
visible:
319+
formLabelMode === 'outside'
320+
|| (isEditorWithoutLabels && formLabelMode !== 'hidden'),
213321
isRequired,
214322
},
215323
item ? item.label : {},
@@ -220,7 +328,16 @@ function _convertToLabelOptions({
220328
},
221329
);
222330

223-
const editorsRequiringIdForLabel = ['dxRadioGroup', 'dxCheckBox', 'dxLookup', 'dxSlider', 'dxRangeSlider', 'dxSwitch', 'dxHtmlEditor', 'dxDateRangeBox']; // TODO: support "dxCalendar"
331+
const editorsRequiringIdForLabel: FormItemComponent[] = [
332+
'dxRadioGroup',
333+
'dxCheckBox',
334+
'dxLookup',
335+
'dxSlider',
336+
'dxRangeSlider',
337+
'dxSwitch',
338+
'dxHtmlEditor',
339+
'dxDateRangeBox',
340+
]; // TODO: support "dxCalendar"
224341
if (editorsRequiringIdForLabel.includes(item.editorType)) {
225342
labelOptions.labelID = `dx-label-${new Guid()}`;
226343
}

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

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,11 @@ import {
4242
renderLabel,
4343
} from '__internal/ui/form/components/m_label';
4444

45-
const EDITOR_LABEL_CLASS = 'dx-texteditor-label';
46-
const EDITOR_INPUT_CLASS = 'dx-texteditor-input';
47-
const FIELD_ITEM_HELP_TEXT_CLASS = 'dx-field-item-help-text';
48-
4945
import { TOOLBAR_CLASS } from '__internal/ui/toolbar/m_constants';
5046

5147
import 'ui/html_editor';
5248
import '../../helpers/ignoreQuillTimers.js';
49+
import pointerMock from '../../helpers/pointerMock.js';
5350
import 'ui/lookup';
5451
import 'ui/radio_group';
5552
import 'ui/tag_box';
@@ -66,6 +63,11 @@ const FORM_GROUP_CONTENT_CLASS = 'dx-form-group-content';
6663
const MULTIVIEW_ITEM_CONTENT_CLASS = 'dx-multiview-item-content';
6764
const LAST_COL_CLASS = 'dx-last-col';
6865
const SLIDER_LABEL = 'dx-slider-label';
66+
const EDITOR_LABEL_CLASS = 'dx-texteditor-label';
67+
const EDITOR_INPUT_CLASS = 'dx-texteditor-input';
68+
const FIELD_ITEM_HELP_TEXT_CLASS = 'dx-field-item-help-text';
69+
const DROP_DOWN_EDITOR_BUTTON_CLASS = 'dx-dropdowneditor-button';
70+
const TEXTBOX_CLASS = 'dx-textbox';
6971

7072
QUnit.testStart(function() {
7173
const markup =
@@ -644,7 +646,7 @@ QUnit.test('Check aria-labelledby attribute for editors label', function(assert)
644646
});
645647

646648
QUnit.test('field1.required -> form.validate() -> form.option("onFieldDataChanged", "newHandler") -> check form is not re-rendered (T1014577)', function(assert) {
647-
const checkEditorIsInvalid = (form) => form.$element().find('.dx-textbox').hasClass(INVALID_CLASS);
649+
const checkEditorIsInvalid = (form) => form.$element().find(`.${TEXTBOX_CLASS}`).hasClass(INVALID_CLASS);
648650
const form = $('#form').dxForm({
649651
formData: { field1: '' },
650652
items: [ {
@@ -1855,8 +1857,8 @@ QUnit.test('Align with "" required mark, T1031458', function(assert) {
18551857
}]
18561858
});
18571859

1858-
const $labelText = $testContainer.find('.dx-field-item-label-text');
1859-
const $textBox = $testContainer.find('.dx-textbox');
1860+
const $labelText = $testContainer.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`);
1861+
const $textBox = $testContainer.find(`.${TEXTBOX_CLASS}`);
18601862

18611863
assert.roughEqual(getWidth($labelText), 11, 3, 'labelsContent.width');
18621864
assert.roughEqual($textBox.offset().left, $labelText.offset().left + 25, 3, 'textBox.left');
@@ -1872,8 +1874,8 @@ QUnit.test('Align with " " required mark, T1031458', function(assert) {
18721874
}]
18731875
});
18741876

1875-
const $labelText = $testContainer.find('.dx-field-item-label-text');
1876-
const $textBox = $testContainer.find('.dx-textbox');
1877+
const $labelText = $testContainer.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`);
1878+
const $textBox = $testContainer.find(`.${TEXTBOX_CLASS}`);
18771879

18781880
assert.roughEqual(getWidth($labelText), 11, 3, 'labelsContent.width');
18791881
assert.roughEqual($textBox.offset().left, $labelText.offset().left + 25, 3, 'textBox.left');
@@ -1889,8 +1891,8 @@ QUnit.test('Align with "!" required mark, T1031458', function(assert) {
18891891
}]
18901892
});
18911893

1892-
const $labelText = $testContainer.find('.dx-field-item-label-text');
1893-
const $textBox = $testContainer.find('.dx-textbox');
1894+
const $labelText = $testContainer.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`);
1895+
const $textBox = $testContainer.find(`.${TEXTBOX_CLASS}`);
18941896

18951897
assert.roughEqual(getWidth($labelText), 11, 3, 'labelsContent.width');
18961898
assert.roughEqual($textBox.offset().left, $labelText.offset().left + 29, 3, 'textBox.left');
@@ -1906,8 +1908,8 @@ QUnit.test('Align with "×" required mark, T1031458', function(assert) {
19061908
}]
19071909
});
19081910

1909-
const $labelText = $testContainer.find('.dx-field-item-label-text');
1910-
const $textBox = $testContainer.find('.dx-textbox');
1911+
const $labelText = $testContainer.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`);
1912+
const $textBox = $testContainer.find(`.${TEXTBOX_CLASS}`);
19111913

19121914
assert.roughEqual(getWidth($labelText), 11, 3, 'labelsContent.width');
19131915
assert.roughEqual($textBox.offset().left, $labelText.offset().left + 35, 3, 'textBox.left');
@@ -4540,6 +4542,52 @@ QUnit.test('form should be dirty when some editors are dirty', function(assert)
45404542
assert.strictEqual(form.option('isDirty'), false, 'form is not dirty when all editors are back to pristine');
45414543
});
45424544

4545+
[true, false].forEach((openOnFieldClick) => {
4546+
[true, false, undefined].forEach((hideOnOutsideClick) => {
4547+
QUnit.test(`Opened DropDownList must hide on input label click, openOnFieldClick: ${openOnFieldClick}, hideOnOutsideClick: ${hideOnOutsideClick} (T1257945)`, function(assert) {
4548+
const dropDownOptions = hideOnOutsideClick === undefined ? {} : { hideOnOutsideClick };
4549+
const $form = $('#form').dxForm({
4550+
formData: { CustomerID: 'VINET' },
4551+
items: [{
4552+
itemType: 'group',
4553+
colCount: 2,
4554+
items: [{
4555+
dataField: 'CustomerID',
4556+
editorType: 'dxSelectBox',
4557+
editorOptions: {
4558+
items: ['VINET', 'VALUE', 'VINS'],
4559+
value: '',
4560+
openOnFieldClick,
4561+
dropDownOptions,
4562+
},
4563+
}],
4564+
}],
4565+
});
4566+
4567+
const $dropDownButton = $form.find(`.${DROP_DOWN_EDITOR_BUTTON_CLASS}`);
4568+
4569+
pointerMock($dropDownButton).click();
4570+
4571+
const editorInstance = $form.dxForm('instance').getEditor('CustomerID');
4572+
4573+
assert.true(editorInstance.option('opened'), 'drop down list is visible');
4574+
4575+
const $label = $form.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`);
4576+
4577+
pointerMock($label).click();
4578+
4579+
// NOTE: In the real environment, clicking the label triggers a click on the editor,
4580+
// toggling the popup visibility if openOnFieldClick=true.
4581+
// This assertion only takes hideOnOutsideClick into account
4582+
if(hideOnOutsideClick === false) {
4583+
assert.true(editorInstance.option('opened'), `drop down list ${openOnFieldClick ? 'is hidden by triggered input click' : 'is visible'}`);
4584+
} else {
4585+
assert.strictEqual(editorInstance.option('opened'), openOnFieldClick, `drop down list is hidden by ${openOnFieldClick ? 'triggered input click' : 'outside click'}`);
4586+
}
4587+
});
4588+
});
4589+
});
4590+
45434591
QUnit.module('reset', () => {
45444592
[
45454593
['dxCalendar', new Date(2019, 1, 2), { dxCalendar: new Date(2019, 1, 3) } ],

0 commit comments

Comments
 (0)