diff --git a/packages/devextreme/js/__internal/ui/form/m_form.layout_manager.utils.ts b/packages/devextreme/js/__internal/ui/form/m_form.layout_manager.utils.ts index 1e529640c675..a39176229f65 100644 --- a/packages/devextreme/js/__internal/ui/form/m_form.layout_manager.utils.ts +++ b/packages/devextreme/js/__internal/ui/form/m_form.layout_manager.utils.ts @@ -1,14 +1,43 @@ import Guid from '@js/core/guid'; +import $ from '@js/core/renderer'; import { extend } from '@js/core/utils/extend'; import { captionize } from '@js/core/utils/inflector'; import { each } from '@js/core/utils/iterator'; -import { isDefined } from '@js/core/utils/type'; +import { isBoolean, isDefined, isFunction } from '@js/core/utils/type'; +import type { dxDropDownEditorOptions } from '@js/ui/drop_down_editor/ui.drop_down_editor'; +import type { FormItemComponent } from '@js/ui/form'; +import type { dxOverlayOptions } from '@js/ui/overlay'; +import type dxTextBox from '@js/ui/text_box'; import { SIMPLE_ITEM_TYPE } from './constants'; -const EDITORS_WITH_ARRAY_VALUE = ['dxTagBox', 'dxRangeSlider', 'dxDateRangeBox']; -const EDITORS_WITH_SPECIFIC_LABELS = ['dxRangeSlider', 'dxSlider']; -export const EDITORS_WITHOUT_LABELS = ['dxCalendar', 'dxCheckBox', 'dxHtmlEditor', 'dxRadioGroup', 'dxRangeSlider', 'dxSlider', 'dxSwitch']; +const EDITORS_WITH_ARRAY_VALUE: FormItemComponent[] = [ + 'dxTagBox', + 'dxRangeSlider', + 'dxDateRangeBox', +]; +const EDITORS_WITH_SPECIFIC_LABELS: FormItemComponent[] = ['dxRangeSlider', 'dxSlider']; +export const EDITORS_WITHOUT_LABELS: FormItemComponent[] = [ + 'dxCalendar', + 'dxCheckBox', + 'dxHtmlEditor', + 'dxRadioGroup', + 'dxRangeSlider', + 'dxSlider', + 'dxSwitch', +]; +const DROP_DOWN_EDITORS: FormItemComponent[] = [ + 'dxSelectBox', + 'dxDropDownBox', + 'dxTagBox', + 'dxLookup', + 'dxAutocomplete', + 'dxColorBox', + 'dxDateBox', + 'dxDateRangeBox', +]; + +type DropDownOptions = dxDropDownEditorOptions; export function convertToRenderFieldItemOptions({ $parent, @@ -33,7 +62,9 @@ export function convertToRenderFieldItemOptions({ labelMode, onLabelTemplateRendered, }) { - const isRequired = isDefined(item.isRequired) ? item.isRequired : !!_hasRequiredRuleInSet(item.validationRules); + const isRequired = isDefined(item.isRequired) + ? item.isRequired + : !!_hasRequiredRuleInSet(item.validationRules); const isSimpleItem = item.itemType === SIMPLE_ITEM_TYPE; const helpID = item.helpText ? `dx-${new Guid()}` : null; @@ -49,11 +80,16 @@ export function convertToRenderFieldItemOptions({ onLabelTemplateRendered, }); - const needRenderLabel = labelOptions.visible && (labelOptions.text || (labelOptions.labelTemplate && isSimpleItem)); + const needRenderLabel = labelOptions.visible + && (labelOptions.text || (labelOptions.labelTemplate && isSimpleItem)); const { location: labelLocation, labelID } = labelOptions; - const labelNeedBaselineAlign = labelLocation !== 'top' && ['dxTextArea', 'dxRadioGroup', 'dxCalendar', 'dxHtmlEditor'].includes(item.editorType); + const labelNeedBaselineAlign = labelLocation !== 'top' + && ['dxTextArea', 'dxRadioGroup', 'dxCalendar', 'dxHtmlEditor'].includes( + item.editorType, + ); const editorOptions = _convertToEditorOptions({ + $parent, editorType: item.editorType, editorValue, defaultEditorName: item.dataField, @@ -70,8 +106,9 @@ export function convertToRenderFieldItemOptions({ }); const needRenderOptionalMarkAsHelpText = labelOptions.markOptions.showOptionalMark - && !labelOptions.visible && editorOptions.labelMode !== 'hidden' - && !isDefined(item.helpText); + && !labelOptions.visible + && editorOptions.labelMode !== 'hidden' + && !isDefined(item.helpText); const helpText = needRenderOptionalMarkAsHelpText ? labelOptions.markOptions.optionalMark @@ -102,18 +139,26 @@ export function convertToRenderFieldItemOptions({ } export function getLabelMarkText({ - showRequiredMark, requiredMark, showOptionalMark, optionalMark, + showRequiredMark, + requiredMark, + showOptionalMark, + optionalMark, }) { if (!showRequiredMark && !showOptionalMark) { return ''; } - return String.fromCharCode(160) + (showRequiredMark ? requiredMark : optionalMark); + return ( + String.fromCharCode(160) + (showRequiredMark ? requiredMark : optionalMark) + ); } -export function convertToLabelMarkOptions({ - showRequiredMark, requiredMark, showOptionalMark, optionalMark, -}, isRequired?: boolean) { +export function convertToLabelMarkOptions( + { + showRequiredMark, requiredMark, showOptionalMark, optionalMark, + }, + isRequired?: boolean, +) { return { showRequiredMark: showRequiredMark && isRequired, requiredMark, @@ -122,8 +167,55 @@ export function convertToLabelMarkOptions({ }; } +// eslint-disable-next-line @typescript-eslint/naming-convention +function _getDropDownEditorOptions( + $parent, + editorType: FormItemComponent, + editorInputId: string, + onContentReadyExternal?: DropDownOptions['onContentReady'], +): DropDownOptions { + const isDropDownEditor = DROP_DOWN_EDITORS.includes(editorType); + + if (!isDropDownEditor) { + return {}; + } + + return { + onContentReady: (e) => { + const { component } = e; + const openOnFieldClick = component.option('openOnFieldClick') as DropDownOptions['openOnFieldClick']; + const initialHideOnOutsideClick = component.option('dropDownOptions.hideOnOutsideClick') as dxOverlayOptions['hideOnOutsideClick']; + + if (openOnFieldClick) { + component.option('dropDownOptions', { + hideOnOutsideClick: (e) => { + if (isBoolean(initialHideOnOutsideClick)) { + return initialHideOnOutsideClick; + } + + const $target = $(e.target); + const $label = $parent.find(`label[for="${editorInputId}"]`); + const isLabelClicked = !!$target.closest($label).length; + + if (!isFunction(initialHideOnOutsideClick)) { + return !isLabelClicked; + } + + return !isLabelClicked && initialHideOnOutsideClick(e); + }, + }); + } + + if (isFunction(onContentReadyExternal)) { + onContentReadyExternal(e); + } + }, + }; +} + // eslint-disable-next-line @typescript-eslint/naming-convention function _convertToEditorOptions({ + $parent, editorType, defaultEditorName, editorValue, @@ -153,10 +245,13 @@ function _convertToEditorOptions({ const stylingMode = externalEditorOptions?.stylingMode || editorStylingMode; const useSpecificLabelOptions = EDITORS_WITH_SPECIFIC_LABELS.includes(editorType); + const dropDownEditorOptions = _getDropDownEditorOptions($parent, editorType, editorInputId, externalEditorOptions?.onContentReady); + const result = extend( true, editorOptionsWithValue, externalEditorOptions, + dropDownEditorOptions, { inputAttr: { id: editorInputId }, validationBoundary: editorValidationBoundary, @@ -179,6 +274,7 @@ function _convertToEditorOptions({ if (defaultEditorName && !result.name) { result.name = defaultEditorName; } + return result; } @@ -201,15 +297,27 @@ function _hasRequiredRuleInSet(rules) { // eslint-disable-next-line @typescript-eslint/naming-convention function _convertToLabelOptions({ - item, id, isRequired, managerMarkOptions, showColonAfterLabel, labelLocation, labelTemplate, formLabelMode, onLabelTemplateRendered, + item, + id, + isRequired, + managerMarkOptions, + showColonAfterLabel, + labelLocation, + labelTemplate, + formLabelMode, + onLabelTemplateRendered, }) { - const isEditorWithoutLabels = EDITORS_WITHOUT_LABELS.includes(item.editorType); + const isEditorWithoutLabels = EDITORS_WITHOUT_LABELS.includes( + item.editorType, + ); const labelOptions = extend( { showColon: showColonAfterLabel, location: labelLocation, id, - visible: formLabelMode === 'outside' || (isEditorWithoutLabels && formLabelMode !== 'hidden'), + visible: + formLabelMode === 'outside' + || (isEditorWithoutLabels && formLabelMode !== 'hidden'), isRequired, }, item ? item.label : {}, @@ -220,7 +328,16 @@ function _convertToLabelOptions({ }, ); - const editorsRequiringIdForLabel = ['dxRadioGroup', 'dxCheckBox', 'dxLookup', 'dxSlider', 'dxRangeSlider', 'dxSwitch', 'dxHtmlEditor', 'dxDateRangeBox']; // TODO: support "dxCalendar" + const editorsRequiringIdForLabel: FormItemComponent[] = [ + 'dxRadioGroup', + 'dxCheckBox', + 'dxLookup', + 'dxSlider', + 'dxRangeSlider', + 'dxSwitch', + 'dxHtmlEditor', + 'dxDateRangeBox', + ]; // TODO: support "dxCalendar" if (editorsRequiringIdForLabel.includes(item.editorType)) { labelOptions.labelID = `dx-label-${new Guid()}`; } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.form/form.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.form/form.tests.js index ce3da955d02c..0a28a0451ea2 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.form/form.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.form/form.tests.js @@ -42,14 +42,11 @@ import { renderLabel, } from '__internal/ui/form/components/m_label'; -const EDITOR_LABEL_CLASS = 'dx-texteditor-label'; -const EDITOR_INPUT_CLASS = 'dx-texteditor-input'; -const FIELD_ITEM_HELP_TEXT_CLASS = 'dx-field-item-help-text'; - import { TOOLBAR_CLASS } from '__internal/ui/toolbar/m_constants'; import 'ui/html_editor'; import '../../helpers/ignoreQuillTimers.js'; +import pointerMock from '../../helpers/pointerMock.js'; import 'ui/lookup'; import 'ui/radio_group'; import 'ui/tag_box'; @@ -66,6 +63,11 @@ const FORM_GROUP_CONTENT_CLASS = 'dx-form-group-content'; const MULTIVIEW_ITEM_CONTENT_CLASS = 'dx-multiview-item-content'; const LAST_COL_CLASS = 'dx-last-col'; const SLIDER_LABEL = 'dx-slider-label'; +const EDITOR_LABEL_CLASS = 'dx-texteditor-label'; +const EDITOR_INPUT_CLASS = 'dx-texteditor-input'; +const FIELD_ITEM_HELP_TEXT_CLASS = 'dx-field-item-help-text'; +const DROP_DOWN_EDITOR_BUTTON_CLASS = 'dx-dropdowneditor-button'; +const TEXTBOX_CLASS = 'dx-textbox'; QUnit.testStart(function() { const markup = @@ -644,7 +646,7 @@ QUnit.test('Check aria-labelledby attribute for editors label', function(assert) }); QUnit.test('field1.required -> form.validate() -> form.option("onFieldDataChanged", "newHandler") -> check form is not re-rendered (T1014577)', function(assert) { - const checkEditorIsInvalid = (form) => form.$element().find('.dx-textbox').hasClass(INVALID_CLASS); + const checkEditorIsInvalid = (form) => form.$element().find(`.${TEXTBOX_CLASS}`).hasClass(INVALID_CLASS); const form = $('#form').dxForm({ formData: { field1: '' }, items: [ { @@ -1855,8 +1857,8 @@ QUnit.test('Align with "" required mark, T1031458', function(assert) { }] }); - const $labelText = $testContainer.find('.dx-field-item-label-text'); - const $textBox = $testContainer.find('.dx-textbox'); + const $labelText = $testContainer.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`); + const $textBox = $testContainer.find(`.${TEXTBOX_CLASS}`); assert.roughEqual(getWidth($labelText), 11, 3, 'labelsContent.width'); 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) { }] }); - const $labelText = $testContainer.find('.dx-field-item-label-text'); - const $textBox = $testContainer.find('.dx-textbox'); + const $labelText = $testContainer.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`); + const $textBox = $testContainer.find(`.${TEXTBOX_CLASS}`); assert.roughEqual(getWidth($labelText), 11, 3, 'labelsContent.width'); 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) { }] }); - const $labelText = $testContainer.find('.dx-field-item-label-text'); - const $textBox = $testContainer.find('.dx-textbox'); + const $labelText = $testContainer.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`); + const $textBox = $testContainer.find(`.${TEXTBOX_CLASS}`); assert.roughEqual(getWidth($labelText), 11, 3, 'labelsContent.width'); 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) { }] }); - const $labelText = $testContainer.find('.dx-field-item-label-text'); - const $textBox = $testContainer.find('.dx-textbox'); + const $labelText = $testContainer.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`); + const $textBox = $testContainer.find(`.${TEXTBOX_CLASS}`); assert.roughEqual(getWidth($labelText), 11, 3, 'labelsContent.width'); 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) assert.strictEqual(form.option('isDirty'), false, 'form is not dirty when all editors are back to pristine'); }); +[true, false].forEach((openOnFieldClick) => { + [true, false, undefined].forEach((hideOnOutsideClick) => { + QUnit.test(`Opened DropDownList must hide on input label click, openOnFieldClick: ${openOnFieldClick}, hideOnOutsideClick: ${hideOnOutsideClick} (T1257945)`, function(assert) { + const dropDownOptions = hideOnOutsideClick === undefined ? {} : { hideOnOutsideClick }; + const $form = $('#form').dxForm({ + formData: { CustomerID: 'VINET' }, + items: [{ + itemType: 'group', + colCount: 2, + items: [{ + dataField: 'CustomerID', + editorType: 'dxSelectBox', + editorOptions: { + items: ['VINET', 'VALUE', 'VINS'], + value: '', + openOnFieldClick, + dropDownOptions, + }, + }], + }], + }); + + const $dropDownButton = $form.find(`.${DROP_DOWN_EDITOR_BUTTON_CLASS}`); + + pointerMock($dropDownButton).click(); + + const editorInstance = $form.dxForm('instance').getEditor('CustomerID'); + + assert.true(editorInstance.option('opened'), 'drop down list is visible'); + + const $label = $form.find(`.${FIELD_ITEM_LABEL_TEXT_CLASS}`); + + pointerMock($label).click(); + + // NOTE: In the real environment, clicking the label triggers a click on the editor, + // toggling the popup visibility if openOnFieldClick=true. + // This assertion only takes hideOnOutsideClick into account + if(hideOnOutsideClick === false) { + assert.true(editorInstance.option('opened'), `drop down list ${openOnFieldClick ? 'is hidden by triggered input click' : 'is visible'}`); + } else { + assert.strictEqual(editorInstance.option('opened'), openOnFieldClick, `drop down list is hidden by ${openOnFieldClick ? 'triggered input click' : 'outside click'}`); + } + }); + }); +}); + QUnit.module('reset', () => { [ ['dxCalendar', new Date(2019, 1, 2), { dxCalendar: new Date(2019, 1, 3) } ],