diff --git a/packages/devextreme/js/__internal/core/localization/number.ts b/packages/devextreme/js/__internal/core/localization/number.ts index 53631e00f316..67c8f5bbede1 100644 --- a/packages/devextreme/js/__internal/core/localization/number.ts +++ b/packages/devextreme/js/__internal/core/localization/number.ts @@ -286,7 +286,7 @@ const numberLocalizationBase = { return value.replace(regExp, (char) => String.fromCharCode(char.charCodeAt(0) + (toFirstDigit.charCodeAt(0) - fromFirstDigit.charCodeAt(0)))) as TValue; }, - getNegativeEtalonRegExp(format: FormatConfig | string): RegExp { + getNegativeEtalonRegExp(format: LocalizationFormat): RegExp { const separators: FormatterConfig = this._getSeparators(); const digitalRegExp = new RegExp(`[0-9${escapeRegExp(separators.decimalSeparator + separators.thousandsSeparator)}]+`, 'g'); const specialCharacters = ['\\', '(', ')', '[', ']', '*', '+', '$', '^', '?', '|', '{', '}']; @@ -301,7 +301,7 @@ const numberLocalizationBase = { return new RegExp(negativeEtalon, 'g'); }, - getSign(text: string, format?: FormatConfig | string): 1 | -1 { + getSign(text: string, format?: LocalizationFormat): 1 | -1 { if (!format) { if (text.replace(/[^0-9-]/g, '').startsWith('-')) { return -1; diff --git a/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts b/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts index 6c06ad0d7df3..a1bffd28d3f1 100644 --- a/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts +++ b/packages/devextreme/js/__internal/ui/chat/message_box/chat_text_area.ts @@ -618,7 +618,7 @@ class ChatTextArea extends TextArea { return maxHeight; } - _keyPressHandler(e: { originalEvent: InputEvent & KeyboardEvent }): void { + _keyPressHandler(e: DxEvent): void { super._keyPressHandler(e); this._updateButtonsState(); diff --git a/packages/devextreme/js/__internal/ui/color_box/color_view.ts b/packages/devextreme/js/__internal/ui/color_box/color_view.ts index 349261fdff67..55072f72e081 100644 --- a/packages/devextreme/js/__internal/ui/color_box/color_view.ts +++ b/packages/devextreme/js/__internal/ui/color_box/color_view.ts @@ -18,8 +18,8 @@ import { isCommandKeyPressed } from '@ts/events/utils/index'; import Draggable from '@ts/m_draggable'; import type { EditorProperties, ValueChangedEvent } from '@ts/ui/editor/editor'; import Editor from '@ts/ui/editor/editor'; -import NumberBox from '@ts/ui/number_box/m_number_box'; -import { WIDGET_CLASS as NUMBERBOX_CLASS } from '@ts/ui/number_box/m_number_box.base'; +import NumberBox from '@ts/ui/number_box/number_box'; +import { WIDGET_CLASS as NUMBERBOX_CLASS } from '@ts/ui/number_box/number_box.base'; import TextBox from '@ts/ui/text_box/text_box'; const COLOR_VIEW_CLASS = 'dx-colorview'; diff --git a/packages/devextreme/js/__internal/ui/date_box/date_box.mask.ts b/packages/devextreme/js/__internal/ui/date_box/date_box.mask.ts index d62dd2b23c62..378a4486fb42 100644 --- a/packages/devextreme/js/__internal/ui/date_box/date_box.mask.ts +++ b/packages/devextreme/js/__internal/ui/date_box/date_box.mask.ts @@ -273,7 +273,7 @@ class DateBoxMask< this._caret(this._getActivePartProp('caret')); } - _keyPressHandler(e: { originalEvent: InputEvent & KeyboardEvent }): void { + _keyPressHandler(e: DxEvent): void { const { originalEvent: event } = e; const isBackwardDeletion = event?.inputType === 'deleteContentBackward'; diff --git a/packages/devextreme/js/__internal/ui/date_box/time_view.ts b/packages/devextreme/js/__internal/ui/date_box/time_view.ts index a8ba0626d342..314967f395a4 100644 --- a/packages/devextreme/js/__internal/ui/date_box/time_view.ts +++ b/packages/devextreme/js/__internal/ui/date_box/time_view.ts @@ -9,8 +9,8 @@ import Box from '@ts/ui/box'; import dateUtils from '@ts/ui/date_box/date_utils'; import type { EditorProperties } from '@ts/ui/editor/editor'; import Editor from '@ts/ui/editor/editor'; -import NumberBox from '@ts/ui/number_box/m_number_box'; -import type { NumberBoxMaskProperties } from '@ts/ui/number_box/m_number_box.mask'; +import NumberBox from '@ts/ui/number_box/number_box'; +import type { NumberBoxMaskProperties } from '@ts/ui/number_box/number_box.mask'; import SelectBox from '@ts/ui/select_box'; const TIMEVIEW_CLASS = 'dx-timeview'; diff --git a/packages/devextreme/js/__internal/ui/drop_down_editor/drop_down_button.ts b/packages/devextreme/js/__internal/ui/drop_down_editor/drop_down_button.ts index f528e4b59f2e..4038648987ff 100644 --- a/packages/devextreme/js/__internal/ui/drop_down_editor/drop_down_button.ts +++ b/packages/devextreme/js/__internal/ui/drop_down_editor/drop_down_button.ts @@ -35,9 +35,7 @@ interface EditorWithDropDown { export default class DropDownButton< // eslint-disable-next-line @typescript-eslint/no-explicit-any TComponent extends Editor & EditorWithDropDown = DropDownEditor, -> extends TextEditorButton { - declare instance: Button | null; - +> extends TextEditorButton { currentTemplate: DropDownEditorProperties['dropDownButtonTemplate'] | null; constructor(name: string, editor: TComponent, options: ButtonProperties) { diff --git a/packages/devextreme/js/__internal/ui/number_box/__tests__/utils.test.ts b/packages/devextreme/js/__internal/ui/number_box/__tests__/utils.test.ts new file mode 100644 index 000000000000..a767c38ea29e --- /dev/null +++ b/packages/devextreme/js/__internal/ui/number_box/__tests__/utils.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + asPattern, getNthOccurrence, getRealSeparatorIndex, splitByIndex, +} from '../utils'; + +describe('getNthOccurrence', () => { + it('returns the position of the requested occurrence', () => { + expect(getNthOccurrence('1.234.567', '.', 1)).toBe(1); + expect(getNthOccurrence('1.234.567', '.', 2)).toBe(5); + }); + + it('returns -1 when the character is absent', () => { + expect(getNthOccurrence('1234', '.', 1)).toBe(-1); + expect(getNthOccurrence('', '.', 1)).toBe(-1); + }); + + it('returns -1 when there are fewer occurrences than requested', () => { + expect(getNthOccurrence('abc.d', '.', 2)).toBe(-1); + expect(getNthOccurrence('abc.d', '.', 3)).toBe(-1); + expect(getNthOccurrence('a.b.c', '.', 4)).toBe(-1); + expect(getNthOccurrence('a.b.c', '.', 5)).toBe(-1); + }); + + it('returns -1 when no occurrence is requested', () => { + expect(getNthOccurrence('1.2', '.', 0)).toBe(-1); + }); + + it('counts adjacent occurrences separately', () => { + expect(getNthOccurrence('...', '.', 1)).toBe(0); + expect(getNthOccurrence('...', '.', 3)).toBe(2); + expect(getNthOccurrence('...', '.', 4)).toBe(-1); + }); +}); + +describe('getRealSeparatorIndex', () => { + it('reports the first separator of a plain pattern', () => { + expect(getRealSeparatorIndex('#0.00')).toEqual({ occurrence: 1, index: 2 }); + expect(getRealSeparatorIndex('#,##0.##')).toEqual({ occurrence: 1, index: 5 }); + }); + + it('skips separators inside escaped stubs', () => { + expect(getRealSeparatorIndex("'.'#0.00")).toEqual({ occurrence: 2, index: 5 }); + expect(getRealSeparatorIndex("'..' #0.00")).toEqual({ occurrence: 3, index: 7 }); + }); + + it('reports a missing separator for patterns without a float part', () => { + expect(getRealSeparatorIndex('#,##0')).toEqual({ occurrence: 1, index: -1 }); + }); + + it('treats a format that is not a pattern as having no separator', () => { + expect(getRealSeparatorIndex({ type: 'fixedPoint', precision: 2 })) + .toEqual({ occurrence: 1, index: -1 }); + expect(getRealSeparatorIndex(undefined)).toEqual({ occurrence: 1, index: -1 }); + }); + + it('locates the separator of a stubbed pattern in the formatted text', () => { + const { occurrence } = getRealSeparatorIndex("'...' #0.0"); + + expect(getNthOccurrence('... 12.5', '.', occurrence)).toBe(6); + expect(getNthOccurrence('.. 12', '.', occurrence)).toBe(-1); + }); +}); + +describe('splitByIndex', () => { + it('splits the text around the given index', () => { + expect(splitByIndex('12.34', 2)).toEqual(['12', '34']); + }); + + it('keeps the text whole when there is no split point', () => { + expect(splitByIndex('1234', -1)).toEqual(['1234']); + }); +}); + +describe('asPattern', () => { + it('passes a pattern string through', () => { + expect(asPattern('#0.00')).toBe('#0.00'); + }); + + it('reports an empty pattern for formats that are not strings', () => { + expect(asPattern(undefined)).toBe(''); + expect(asPattern({ type: 'currency' })).toBe(''); + expect(asPattern((value: number): string => String(value))).toBe(''); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/number_box/m_number_box.caret.ts b/packages/devextreme/js/__internal/ui/number_box/m_number_box.caret.ts deleted file mode 100644 index 0ba137034deb..000000000000 --- a/packages/devextreme/js/__internal/ui/number_box/m_number_box.caret.ts +++ /dev/null @@ -1,142 +0,0 @@ -import number from '@js/common/core/localization/number'; -import { escapeRegExp } from '@js/core/utils/common'; -import { fitIntoRange } from '@js/core/utils/math'; - -import { getNthOccurrence, getRealSeparatorIndex, splitByIndex } from './m_utils'; - -export const getCaretBoundaries = function (text, format) { - if (typeof format === 'string') { - const signParts = format.split(';'); - const sign = number.getSign(text, format); - - signParts[1] = signParts[1] || `-${signParts[0]}`; - format = signParts[sign < 0 ? 1 : 0]; - - const mockEscapedStubs = (str) => str.replace(/'([^']*)'/g, (str) => str.split('').map(() => ' ').join('').substr(2)); - - format = mockEscapedStubs(format); - - // @ts-expect-error - const prefixStubLength = /^[^#0.,]*/.exec(format)[0].length; - // @ts-expect-error - const postfixStubLength = /[^#0.,]*$/.exec(format)[0].length; - - return { - start: prefixStubLength, - end: text.length - postfixStubLength, - }; - } - return { start: 0, end: text.length }; -}; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const _getDigitCountBeforeIndex = function (index, text) { - const decimalSeparator = number.getDecimalSeparator(); - const regExp = new RegExp(`[^0-9${escapeRegExp(decimalSeparator)}]`, 'g'); - const textBeforePosition = text.slice(0, index); - - return textBeforePosition.replace(regExp, '').length; -}; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const _reverseText = function (text) { - return text.split('').reverse().join(''); -}; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const _getDigitPositionByIndex = function (digitIndex, text) { - if (!digitIndex) { - return -1; - } - - const regExp = /[0-9]/g; - let counter = 1; - let index: number | null = null; - let result = regExp.exec(text); - - while (result) { - index = result.index; - if (counter >= digitIndex) { - return index; - } - counter++; - result = regExp.exec(text); - } - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return index === null ? text.length : index; -}; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const _trimNonNumericCharsFromEnd = function (text) { - return text.replace(/[^0-9e]+$/, ''); -}; - -export const getCaretWithOffset = function (caret, offset) { - if (caret.start === undefined) { - caret = { start: caret, end: caret }; - } - - return { - start: caret.start + offset, - end: caret.end + offset, - }; -}; - -export const getCaretAfterFormat = function (text, formatted, caret, format) { - caret = getCaretWithOffset(caret, 0); - - const point = number.getDecimalSeparator(); - const isSeparatorBasedText = isSeparatorBasedString(text); - const realSeparatorOccurrenceIndex = getRealSeparatorIndex(format).occurrence; - const pointPosition = isSeparatorBasedText ? 0 : getNthOccurrence(text, point, realSeparatorOccurrenceIndex); - const newPointPosition = getNthOccurrence(formatted, point, realSeparatorOccurrenceIndex); - const textParts = splitByIndex(text, pointPosition); - const formattedParts = splitByIndex(formatted, newPointPosition); - const isCaretOnFloat = pointPosition !== -1 && caret.start > pointPosition; - - if (isCaretOnFloat) { - const relativeIndex = caret.start - pointPosition - 1; - const digitsBefore = _getDigitCountBeforeIndex(relativeIndex, textParts[1]); - const newPosition = formattedParts[1] ? newPointPosition + 1 + _getDigitPositionByIndex(digitsBefore, formattedParts[1]) + 1 : formatted.length; - - return getCaretInBoundaries(newPosition, formatted, format); - } - const formattedIntPart = _trimNonNumericCharsFromEnd(formattedParts[0]); - const positionFromEnd = textParts[0].length - caret.start; - const digitsFromEnd = _getDigitCountBeforeIndex(positionFromEnd, _reverseText(textParts[0])); - const newPositionFromEnd = _getDigitPositionByIndex(digitsFromEnd, _reverseText(formattedIntPart)); - const newPositionFromBegin = formattedIntPart.length - (newPositionFromEnd + 1); - - return getCaretInBoundaries(newPositionFromBegin, formatted, format); -}; - -function isSeparatorBasedString(text) { - return text.length === 1 && !!text.match(/^[,.][0-9]*$/g); -} - -export const isCaretInBoundaries = function (caret, text, format) { - caret = getCaretWithOffset(caret, 0); - - const boundaries = getCaretInBoundaries(caret, text, format); - return caret.start >= boundaries.start && caret.end <= boundaries.end; -}; - -export function getCaretInBoundaries(caret, text, format) { - caret = getCaretWithOffset(caret, 0); - - const boundaries = getCaretBoundaries(text, format); - const adjustedCaret = { - start: fitIntoRange(caret.start, boundaries.start, boundaries.end), - end: fitIntoRange(caret.end, boundaries.start, boundaries.end), - }; - - return adjustedCaret; -} - -export const getCaretOffset = function (previousText, newText, format) { - const previousBoundaries = getCaretBoundaries(previousText, format); - const newBoundaries = getCaretBoundaries(newText, format); - - return newBoundaries.start - previousBoundaries.start; -}; diff --git a/packages/devextreme/js/__internal/ui/number_box/m_number_box.spins.ts b/packages/devextreme/js/__internal/ui/number_box/m_number_box.spins.ts deleted file mode 100644 index 75560e162804..000000000000 --- a/packages/devextreme/js/__internal/ui/number_box/m_number_box.spins.ts +++ /dev/null @@ -1,132 +0,0 @@ -import eventsEngine from '@js/common/core/events/core/events_engine'; -import pointer from '@js/common/core/events/pointer'; -import { addNamespace } from '@js/common/core/events/utils/index'; -import type { dxElementWrapper } from '@js/core/renderer'; -import $ from '@js/core/renderer'; -import { extend } from '@js/core/utils/extend'; -import TextEditorButton from '@ts/ui/text_box/texteditor_button_collection/button'; - -import SpinButton from './m_number_box.spin'; - -const SPIN_CLASS = 'dx-numberbox-spin'; -const SPIN_CONTAINER_CLASS = 'dx-numberbox-spin-container'; -const SPIN_TOUCH_FRIENDLY_CLASS = 'dx-numberbox-spin-touch-friendly'; - -export default class SpinButtons extends TextEditorButton { - _attachEvents( - instance: dxElementWrapper, - $spinContainer: dxElementWrapper, - ): void { - const { editor } = this; - - if (!editor) { - return; - } - - const eventName = addNamespace(pointer.down, editor.NAME ?? ''); - const $spinContainerChildren = $spinContainer.children(); - - const pointerDownAction = editor._createAction( - // @ts-expect-error Private API - (e) => { this.editor?._spinButtonsPointerDownHandler(e); }, - ); - - eventsEngine.off($spinContainer, eventName); - - eventsEngine.on( - $spinContainer, - eventName, - (e) => pointerDownAction({ event: e }), - ); - - SpinButton.getInstance($spinContainerChildren.eq(0)).option( - 'onChange', - // @ts-expect-error Private API - (e) => { this.editor?._spinUpChangeHandler(e); }, - ); - - SpinButton.getInstance($spinContainerChildren.eq(1)).option( - 'onChange', - // @ts-expect-error Private API - (e) => { this.editor?._spinDownChangeHandler(e); }, - ); - } - - _create(): { - $element: dxElementWrapper; - instance: dxElementWrapper; - } { - const { editor } = this; - - const $spinContainer = $('
').addClass(SPIN_CONTAINER_CLASS); - const $spinUp = $('
').appendTo($spinContainer); - const $spinDown = $('
').appendTo($spinContainer); - const options = this._getOptions(); - - this._addToContainer($spinContainer); - - editor?._createComponent($spinUp, SpinButton, extend({ direction: 'up' }, options)); - editor?._createComponent($spinDown, SpinButton, extend({ direction: 'down' }, options)); - - this._legacyRender(editor?.$element(), this._isTouchFriendly(), options.visible); - - return { - instance: $spinContainer, - $element: $spinContainer, - }; - } - - _getOptions() { - const { editor } = this; - - const visible = this._isVisible(); - const disabled = editor?.option('disabled'); - - return { - visible, - disabled, - }; - } - - // @ts-expect-error - _isVisible() { - const { editor } = this; - - return super._isVisible() && editor?.option('showSpinButtons'); - } - - _isTouchFriendly() { - const { editor } = this; - - return editor?.option('showSpinButtons') && editor?.option('useLargeSpinButtons'); - } - - // TODO: get rid of it - _legacyRender($editor, isTouchFriendly, isVisible) { - $editor.toggleClass(SPIN_TOUCH_FRIENDLY_CLASS, isTouchFriendly); - $editor.toggleClass(SPIN_CLASS, isVisible); - } - - // @ts-expect-error - update(): void { - const shouldUpdate = super.update(); - - if (shouldUpdate) { - const { editor, instance } = this; - - const $editor = editor?.$element(); - const isVisible = this._isVisible(); - const isTouchFriendly = this._isTouchFriendly(); - // @ts-expect-error - const $spinButtons = instance.children(); - const spinUp = SpinButton.getInstance($spinButtons.eq(0)); - const spinDown = SpinButton.getInstance($spinButtons.eq(1)); - const options = this._getOptions(); - - spinUp.option(options); - spinDown.option(options); - - this._legacyRender($editor, isTouchFriendly, isVisible); - } - } -} diff --git a/packages/devextreme/js/__internal/ui/number_box/m_utils.ts b/packages/devextreme/js/__internal/ui/number_box/m_utils.ts deleted file mode 100644 index 388587c56f7f..000000000000 --- a/packages/devextreme/js/__internal/ui/number_box/m_utils.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { adjust, roundFloatPart } from '@js/core/utils/math'; - -const getRealSeparatorIndex = function (str) { - let quoteBalance = 0; - let separatorCount = 0; - - for (let i = 0; i < str.length; ++i) { - if (str[i] === '\'') { - quoteBalance++; - } - if (str[i] === '.') { - ++separatorCount; - if (quoteBalance % 2 === 0) { - return { - occurrence: separatorCount, - index: i, - }; - } - } - } - - return { occurrence: 1, index: -1 }; -}; - -const getNthOccurrence = function (str, c, n) { - let i = -1; - - while (n-- && i++ < str.length) { - i = str.indexOf(c, i); - } - - return i; -}; - -const splitByIndex = function (str, index) { - if (index === -1) { - return [str]; - } - - return [str.slice(0, index), str.slice(index + 1)]; -}; - -const adjustPercentValue = function (rawValue, interval) { - if (!rawValue) { - return rawValue; - } - - return adjust(rawValue / 100, interval / 100); -}; - -const roundFloatPartPercentValue = function (rawValue: number, precision: number) { - if (!rawValue) { - return rawValue; - } - return roundFloatPart(rawValue / 100, precision); -}; - -export { - adjustPercentValue, - getNthOccurrence, - getRealSeparatorIndex, - roundFloatPartPercentValue, - splitByIndex, -}; diff --git a/packages/devextreme/js/__internal/ui/number_box/m_number_box.base.ts b/packages/devextreme/js/__internal/ui/number_box/number_box.base.ts similarity index 61% rename from packages/devextreme/js/__internal/ui/number_box/m_number_box.base.ts rename to packages/devextreme/js/__internal/ui/number_box/number_box.base.ts index 76577f3c5d31..c87bcd6f5ad1 100644 --- a/packages/devextreme/js/__internal/ui/number_box/m_number_box.base.ts +++ b/packages/devextreme/js/__internal/ui/number_box/number_box.base.ts @@ -13,25 +13,37 @@ import browser from '@js/core/utils/browser'; import { // @ts-expect-error ts-error applyServerDecimalSeparator, - ensureDefined, } from '@js/core/utils/common'; +import type { DeferredObj } from '@js/core/utils/deferred'; import { Deferred } from '@js/core/utils/deferred'; import { fitIntoRange, inRange } from '@js/core/utils/math'; import { isDefined } from '@js/core/utils/type'; +import type { DxEvent, InteractionEvent } from '@js/events'; +import type { Format } from '@js/localization'; import { getGlobalFormatByDataType } from '@ts/core/global_format_config'; +import type { OptionChanged } from '@ts/core/widget/types'; +import type { SupportedKeys } from '@ts/core/widget/widget'; +import type { DxMouseWheelEvent } from '@ts/ui/scroll_view/types'; import TextEditor from '@ts/ui/text_box/text_editor'; -import type { TextEditorBaseProperties } from '../text_box/text_editor.base'; +import type { TextEditorBaseProperties, TextEditorInputAttributes } from '../text_box/text_editor.base'; import type { TextEditorButtonInfo } from '../text_box/texteditor_button_collection/index'; -import SpinButtons from './m_number_box.spins'; - -const math = Math; +import type { SpinChangeEvent } from './number_box.spin'; +import SpinButtons from './number_box.spins'; export const WIDGET_CLASS = 'dx-numberbox'; const FIREFOX_CONTROL_KEYS = ['tab', 'del', 'backspace', 'leftArrow', 'rightArrow', 'home', 'end', 'enter']; const FORCE_VALUECHANGE_EVENT_NAMESPACE = 'NumberBoxForceValueChange'; +export type NumberBoxValue = number | null; + +export type SpinValueChangeEvent = DxEvent | DxMouseWheelEvent; + +const getSpinEvent = ( + e: SpinChangeEvent | DxEvent, +): SpinValueChangeEvent => ('event' in e ? e.event : e); + export interface NumberBoxBaseProperties extends TextEditorBaseProperties { min?: number; max?: number; @@ -39,6 +51,9 @@ export interface NumberBoxBaseProperties extends TextEditorBaseProperties { showSpinButtons?: boolean; useLargeSpinButtons?: boolean; invalidValueMessage?: string; + format?: Format | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + displayValueFormatter?: ((value: any) => string); } class NumberBoxBase< @@ -48,24 +63,24 @@ class NumberBoxBase< _$submitElement!: dxElementWrapper; - _supportedKeys(): Record void> { + _supportedKeys(): SupportedKeys { return { ...super._supportedKeys(), - upArrow(e): void { + upArrow: (e): void => { if (!isCommandKeyPressed(e)) { e.preventDefault(); e.stopPropagation(); this._spinUpChangeHandler(e); } }, - downArrow(e): void { + downArrow: (e): void => { if (!isCommandKeyPressed(e)) { e.preventDefault(); e.stopPropagation(); this._spinDownChangeHandler(e); } }, - enter(): void {}, + enter: (): void => {}, }; } @@ -80,12 +95,10 @@ class NumberBoxBase< useLargeSpinButtons: true, mode: 'text', invalidValueMessage: messageLocalization.format('dxNumberBox-invalidValueMessage'), - // eslint-disable-next-line no-void - buttons: void 0, + buttons: undefined, }; } - // eslint-disable-next-line class-methods-use-this _useTemplates(): boolean { return false; } @@ -95,32 +108,27 @@ class NumberBoxBase< return super._getDefaultButtons().concat([{ name: 'spins', Ctor: SpinButtons }]); } - _isSupportInputMode() { - // @ts-expect-error ts-error - const version = parseFloat(browser.version); + _isSupportInputMode(): boolean { + const version = parseFloat(browser.version ?? ''); + + const isSupportedChrome = !!browser.chrome && version >= 66; + const isSupportedSafari = !!browser.safari && version >= 12; - return ( - browser.chrome && version >= 66 - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - || browser.safari && version >= 12 - ); + return isSupportedChrome || isSupportedSafari; } _defaultOptionsRules(): DefaultOptionsRule[] { // @ts-expect-error ts-error return super._defaultOptionsRules().concat([ { - device() { - return devices.real().generic && !devices.isSimulator(); - }, + device: (): boolean => !!devices.real().generic && !devices.isSimulator(), options: { useLargeSpinButtons: false, }, }, { - device: function () { - return devices.real().deviceType !== 'desktop' && !this._isSupportInputMode(); - }.bind(this), + device: (): boolean => devices.real().deviceType !== 'desktop' + && !this._isSupportInputMode(), options: { mode: 'number', }, @@ -136,7 +144,7 @@ class NumberBoxBase< this._toggleTabIndex(); } - _getDefaultAttributes() { + _getDefaultAttributes(): TextEditorInputAttributes { const attributes = super._getDefaultAttributes(); // eslint-disable-next-line spellcheck/spell-checker attributes.inputmode = 'decimal'; @@ -144,18 +152,25 @@ class NumberBoxBase< } _renderContentImpl(): void { - this.option('isValid') && this._validateValue(this.option('value')); + const { isValid, value } = this.option(); + + if (isValid) { + this._validateValue(value); + } + this.setAria('role', 'spinbutton'); } _renderSubmitElement(): void { + const { value } = this.option(); + this._$submitElement = $('') .attr('type', 'hidden') .appendTo(this.$element()); - this._setSubmitValue(this.option('value')); + this._setSubmitValue(value); } - _setSubmitValue(value) { + _setSubmitValue(value: NumberBoxValue | undefined): void { this._getSubmitElement().val(applyServerDecimalSeparator(value)); } @@ -163,7 +178,7 @@ class NumberBoxBase< return this._$submitElement; } - _keyPressHandler(e) { + _keyPressHandler(e: DxEvent): void { super._keyPressHandler(); const char = getChar(e); @@ -173,7 +188,7 @@ class NumberBoxBase< if (!isInputCharValid) { const keyName = normalizeKeyName(e); // NOTE: Additional check for Firefox control keys - if (isCommandKeyPressed(e) || keyName && FIREFOX_CONTROL_KEYS.includes(keyName)) { + if (isCommandKeyPressed(e) || (keyName && FIREFOX_CONTROL_KEYS.includes(keyName))) { return; } @@ -188,13 +203,14 @@ class NumberBoxBase< return true; } - _onMouseWheel(dxEvent): void { - dxEvent.delta > 0 ? this._spinValueChange(1, dxEvent) : this._spinValueChange(-1, dxEvent); + _onMouseWheel(e: DxMouseWheelEvent): void { + this._spinValueChange(e.delta > 0 ? 1 : -1, e); } - _renderValue() { + _renderValue(): DeferredObj { const inputValue = this._input().val(); - const value = this.option('value'); + const { value } = this.option(); + if (!inputValue.length || Number(inputValue) !== value) { this._forceValueRender(); this._toggleEmptinessEventHandler(); @@ -203,9 +219,8 @@ class NumberBoxBase< const valueText = isDefined(value) ? null : messageLocalization.format('dxNumberBox-noDataText'); this.setAria({ - // @ts-expect-error ts-error // eslint-disable-next-line spellcheck/spell-checker - valuenow: ensureDefined(value, ''), + valuenow: value ?? '', // eslint-disable-next-line spellcheck/spell-checker valuetext: valueText, }); @@ -217,17 +232,18 @@ class NumberBoxBase< } _forceValueRender(): void { - const value = this.option('value'); - const number = Number(value); - const formattedValue = isNaN(number) + const { value } = this.option(); + const formattedValue = isNaN(Number(value)) ? '' : this._applyDisplayValueFormatter(value); this._renderDisplayText(formattedValue); } - _applyDisplayValueFormatter(value): string | undefined { - if (!this.option('format')) { + _applyDisplayValueFormatter(value: NumberBoxValue | undefined): string | undefined { + const { format, displayValueFormatter } = this.option(); + + if (!format) { const globalNumberFormat = getGlobalFormatByDataType('number'); if (globalNumberFormat) { @@ -238,70 +254,66 @@ class NumberBoxBase< } } - const { displayValueFormatter } = this.option(); - return displayValueFormatter?.(value); } _renderProps(): void { + const { min, max, step } = this.option(); + // @ts-expect-error ts-error - this._input().prop({ - min: this.option('min'), - max: this.option('max'), - step: this.option('step'), - }); + this._input().prop({ min, max, step }); this.setAria({ - // @ts-expect-error ts-error // eslint-disable-next-line spellcheck/spell-checker - valuemin: ensureDefined(this.option('min'), ''), - // @ts-expect-error ts-error + valuemin: min ?? '', // eslint-disable-next-line spellcheck/spell-checker - valuemax: ensureDefined(this.option('max'), ''), + valuemax: max ?? '', }); } _spinButtonsPointerDownHandler(): void { + const { useLargeSpinButtons } = this.option(); const $input = this._input(); - if (!this.option('useLargeSpinButtons') && domAdapter.getActiveElement() !== $input[0]) { + + if (!useLargeSpinButtons && domAdapter.getActiveElement() !== $input[0]) { // @ts-expect-error ts-error eventsEngine.trigger($input, 'focus'); } } - _spinUpChangeHandler(e): void { - if (!this.option('readOnly')) { - this._spinValueChange(1, e.event || e); + _spinUpChangeHandler(e: SpinChangeEvent | DxEvent): void { + const { readOnly } = this.option(); + + if (!readOnly) { + this._spinValueChange(1, getSpinEvent(e)); } } - _spinDownChangeHandler(e): void { - if (!this.option('readOnly')) { - this._spinValueChange(-1, e.event || e); + _spinDownChangeHandler(e: SpinChangeEvent | DxEvent): void { + const { readOnly } = this.option(); + + if (!readOnly) { + this._spinValueChange(-1, getSpinEvent(e)); } } - _spinValueChange(sign, dxEvent): void { - // @ts-expect-error ts-error - const step = parseFloat(this.option('step')); + _spinValueChange(sign: number, dxEvent?: SpinValueChangeEvent): void { + const { step: stepOption, min, max } = this.option(); + const step = parseFloat(String(stepOption)); + if (step === 0) { return; } - // @ts-expect-error ts-error - let value = parseFloat(this._normalizeInputValue()) || 0; - value = this._correctRounding(value, step * sign); + let value = parseFloat(String(this._normalizeInputValue())) || 0; - const min = this.option('min'); - const max = this.option('max'); + value = this._correctRounding(value, step * sign); if (isDefined(min)) { - // @ts-expect-error ts-error value = Math.max(min, value); } if (isDefined(max)) { - // @ts-expect-error ts-error value = Math.min(max, value); } @@ -309,35 +321,28 @@ class NumberBoxBase< this.option('value', value); } - _correctRounding(value, step) { + _correctRounding(value: number, step: number): number { const regex = /[,.](.*)/; - const isFloatValue = regex.test(value); - const isFloatStep = regex.test(step); + const valueText = String(value); + const stepText = String(step); + const isFloatValue = regex.test(valueText); + const isFloatStep = regex.test(stepText); if (isFloatValue || isFloatStep) { - // @ts-expect-error - const valueAccuracy = isFloatValue ? regex.exec(value)[0].length : 0; - // @ts-expect-error - const stepAccuracy = isFloatStep ? regex.exec(step)[0].length : 0; - const accuracy = math.max(valueAccuracy, stepAccuracy); - - value = this._round(value + step, accuracy); + const valueAccuracy = isFloatValue ? regex.exec(valueText)?.[0].length ?? 0 : 0; + const stepAccuracy = isFloatStep ? regex.exec(stepText)?.[0].length ?? 0 : 0; + const accuracy = Math.max(valueAccuracy, stepAccuracy); - return value; + return this._round(value + step, accuracy); } return value + step; } - _round(value, precision) { - precision = precision || 0; - + _round(value: number, precision = 0): number { const multiplier = 10 ** precision; - value *= multiplier; - value = Math.round(value) / multiplier; - - return value; + return Math.round(value * multiplier) / multiplier; } _renderValueChangeEvent(): void { @@ -348,20 +353,21 @@ class NumberBoxBase< eventsEngine.on(this.element(), forceValueChangeEvent, this._forceRefreshInputValue.bind(this)); } - _forceRefreshInputValue() { - const { mode } = this.option(); + _forceRefreshInputValue(): void { + const { mode, value } = this.option(); + if (mode === 'number') { return; } const $input = this._input(); - const formattedValue = this._applyDisplayValueFormatter(this.option('value')); + const formattedValue = this._applyDisplayValueFormatter(value); // @ts-expect-error ts-error $input.val(null); $input.val(formattedValue); } - _valueChangeEventHandler(e) { + _valueChangeEventHandler(e: DxEvent): void { const $input = this._input(); const inputValue = this._normalizeText(); const value = this._parseValue(inputValue); @@ -373,8 +379,7 @@ class NumberBoxBase< } if (valueHasDigits) { - // @ts-expect-error ts-error - super._valueChangeEventHandler(e, isNaN(value) ? null : value); + super._valueChangeEventHandler(e, isNaN(Number(value)) ? null : value); } this._applyValueBoundaries(inputValue, value); @@ -385,9 +390,9 @@ class NumberBoxBase< }); } - _applyValueBoundaries(inputValue, parsedValue) { + _applyValueBoundaries(inputValue: string, parsedValue: NumberBoxValue | undefined): void { const isValueIncomplete = this._isValueIncomplete(inputValue); - const isValueCorrect = this._isValueInRange(inputValue); + const isValueCorrect = this._isValueInRange(Number(inputValue)); if (!isValueIncomplete && !isValueCorrect && parsedValue !== null) { if (Number(inputValue) !== parsedValue) { @@ -396,20 +401,19 @@ class NumberBoxBase< } } - _replaceCommaWithPoint(value): string { + _replaceCommaWithPoint(value: string): string { return value.replace(',', '.'); } _inputIsInvalid(): boolean { const { mode } = this.option(); const isNumberMode = mode === 'number'; - // @ts-expect-error ts-error - const validityState = this._input().get(0).validity; + const input = this._input().get(0) as HTMLInputElement | undefined; - return isNumberMode && validityState?.badInput; + return isNumberMode && !!input?.validity.badInput; } - _renderDisplayText(text) { + _renderDisplayText(text: string | undefined): void { if (this._inputIsInvalid()) { return; } @@ -417,20 +421,23 @@ class NumberBoxBase< super._renderDisplayText(text); } - _isValueIncomplete(value) { + _isValueIncomplete(value: string): boolean { const incompleteRegex = /(^-$)|(^-?\d*\.$)|(\d+e-?$)/i; return incompleteRegex.test(value); } - _isValueInRange(value) { - return inRange(value, this.option('min'), this.option('max')); + _isValueInRange(value: number): boolean { + const { min, max } = this.option(); + + return inRange(value, min, max); } - _isNumber(value) { + _isNumber(value: string): boolean { return this._parseValue(value) !== null; } - _validateValue(value?) { + _validateValue(value?: NumberBoxValue): boolean { + const { invalidValueMessage } = this.option(); const inputValue = this._normalizeText(); const isValueValid = this._isValueValid(); let isValid = true; @@ -450,31 +457,32 @@ class NumberBoxBase< isValid, validationError: isValid ? null : { editorSpecific: true, - message: this.option('invalidValueMessage'), + message: invalidValueMessage, }, }); return isValid; } - _normalizeInputValue(): number | null { + _normalizeInputValue(): NumberBoxValue | undefined { return this._parseValue(this._normalizeText()); } - _normalizeText() { + _normalizeText(): string { const value = this._input().val().trim(); return this._replaceCommaWithPoint(value); } - _parseValue(value): number | null { - const number = parseFloat(value); + _parseValue(value?: string | NumberBoxValue): NumberBoxValue | undefined { + const { min, max } = this.option(); + const parsedValue = parseFloat(String(value ?? '')); - if (isNaN(number)) { + if (isNaN(parsedValue)) { return null; } - return fitIntoRange(number, this.option('min'), this.option('max')); + return fitIntoRange(parsedValue, min, max); } _clearValue(): void { @@ -486,7 +494,9 @@ class NumberBoxBase< } clear(): void { - if (this.option('value') === null) { + const { value } = this.option(); + + if (value === null) { this.option('text', ''); if (this._input().length) { this._renderValue(); @@ -496,22 +506,28 @@ class NumberBoxBase< } } - _optionChanged(args) { + _optionChanged(args: OptionChanged): void { switch (args.name) { - case 'value': - this._validateValue(args.value); - this._setSubmitValue(args.value); + case 'value': { + const value = args.value as NumberBoxValue | undefined; + + this._validateValue(value); + this._setSubmitValue(value); super._optionChanged(args); this._resumeValueChangeAction(); break; + } case 'step': this._renderProps(); break; case 'min': - case 'max': + case 'max': { + const { value } = this.option(); + this._renderProps(); - this.option('value', this._parseValue(this.option('value'))); + this.option('value', this._parseValue(value)); break; + } case 'showSpinButtons': case 'useLargeSpinButtons': this._updateButtons(['spins']); diff --git a/packages/devextreme/js/__internal/ui/number_box/number_box.caret.ts b/packages/devextreme/js/__internal/ui/number_box/number_box.caret.ts new file mode 100644 index 000000000000..426ef848ce10 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/number_box/number_box.caret.ts @@ -0,0 +1,161 @@ +import number from '@js/common/core/localization/number'; +import { escapeRegExp } from '@js/core/utils/common'; +import { fitIntoRange } from '@js/core/utils/math'; +import type { Format } from '@js/localization'; +import type { CaretRange } from '@ts/ui/text_box/utils.caret'; + +import { getNthOccurrence, getRealSeparatorIndex, splitByIndex } from './utils'; + +export interface CaretBoundaries { + start: number; + end: number; +} + +export type CaretPosition = number | CaretRange; + +const mockEscapedStubs = (format: string): string => format.replace( + /'([^']*)'/g, + (stub) => stub.split('').map(() => ' ').join('').substr(2), +); + +const getDigitCountBeforeIndex = (index: number, text: string): number => { + const decimalSeparator: string = number.getDecimalSeparator(); + const regExp = new RegExp(`[^0-9${escapeRegExp(decimalSeparator)}]`, 'g'); + const textBeforePosition = text.slice(0, index); + + return textBeforePosition.replace(regExp, '').length; +}; + +const reverseText = (text: string): string => text.split('').reverse().join(''); + +const getDigitPositionByIndex = (digitIndex: number, text: string): number => { + if (!digitIndex) { + return -1; + } + + const regExp = /[0-9]/g; + let counter = 1; + let index: number | null = null; + let result = regExp.exec(text); + + while (result) { + index = result.index; + if (counter >= digitIndex) { + return index; + } + counter += 1; + result = regExp.exec(text); + } + + return index ?? text.length; +}; + +const trimNonNumericCharsFromEnd = (text: string): string => text.replace(/[^0-9e]+$/, ''); + +const isSeparatorBasedString = (text: string): boolean => text.length === 1 + && !!text.match(/^[,.][0-9]*$/g); + +export const getCaretBoundaries = (text: string, format: Format): CaretBoundaries => { + if (typeof format === 'string') { + const signParts = format.split(';'); + const sign = number.getSign(text, format); + + signParts[1] = signParts[1] || `-${signParts[0]}`; + + const signFormat = mockEscapedStubs(signParts[sign < 0 ? 1 : 0]); + + const prefixStubLength = /^[^#0.,]*/.exec(signFormat)?.[0].length ?? 0; + const postfixStubLength = /[^#0.,]*$/.exec(signFormat)?.[0].length ?? 0; + + return { + start: prefixStubLength, + end: text.length - postfixStubLength, + }; + } + return { start: 0, end: text.length }; +}; + +export const getCaretWithOffset = ( + caret: CaretPosition, + offset: number, +): CaretBoundaries => { + const range = typeof caret === 'number' ? { start: caret, end: caret } : caret; + + return { + start: (range.start ?? 0) + offset, + end: (range.end ?? 0) + offset, + }; +}; + +export const getCaretInBoundaries = ( + caret: CaretPosition, + text: string, + format: Format, +): CaretBoundaries => { + const normalizedCaret = getCaretWithOffset(caret, 0); + const boundaries = getCaretBoundaries(text, format); + + return { + start: fitIntoRange(normalizedCaret.start, boundaries.start, boundaries.end), + end: fitIntoRange(normalizedCaret.end, boundaries.start, boundaries.end), + }; +}; + +export const getCaretAfterFormat = ( + text: string, + formatted: string, + caret: CaretPosition, + format: Format, +): CaretBoundaries => { + const normalizedCaret = getCaretWithOffset(caret, 0); + + const point: string = number.getDecimalSeparator(); + const realSeparatorOccurrenceIndex = getRealSeparatorIndex(format).occurrence; + const pointPosition = isSeparatorBasedString(text) + ? 0 + : getNthOccurrence(text, point, realSeparatorOccurrenceIndex); + const newPointPosition = getNthOccurrence(formatted, point, realSeparatorOccurrenceIndex); + const textParts = splitByIndex(text, pointPosition); + const formattedParts = splitByIndex(formatted, newPointPosition); + const isCaretOnFloat = pointPosition !== -1 && normalizedCaret.start > pointPosition; + + if (isCaretOnFloat) { + const relativeIndex = normalizedCaret.start - pointPosition - 1; + const digitsBefore = getDigitCountBeforeIndex(relativeIndex, textParts[1]); + const newPosition = formattedParts[1] + ? newPointPosition + 1 + getDigitPositionByIndex(digitsBefore, formattedParts[1]) + 1 + : formatted.length; + + return getCaretInBoundaries(newPosition, formatted, format); + } + + const formattedIntPart = trimNonNumericCharsFromEnd(formattedParts[0]); + const positionFromEnd = textParts[0].length - normalizedCaret.start; + const digitsFromEnd = getDigitCountBeforeIndex(positionFromEnd, reverseText(textParts[0])); + const newPositionFromEnd = getDigitPositionByIndex(digitsFromEnd, reverseText(formattedIntPart)); + const newPositionFromBegin = formattedIntPart.length - (newPositionFromEnd + 1); + + return getCaretInBoundaries(newPositionFromBegin, formatted, format); +}; + +export const isCaretInBoundaries = ( + caret: CaretPosition, + text: string, + format: Format, +): boolean => { + const normalizedCaret = getCaretWithOffset(caret, 0); + const boundaries = getCaretInBoundaries(normalizedCaret, text, format); + + return normalizedCaret.start >= boundaries.start && normalizedCaret.end <= boundaries.end; +}; + +export const getCaretOffset = ( + previousText: string, + newText: string, + format: Format, +): number => { + const previousBoundaries = getCaretBoundaries(previousText, format); + const newBoundaries = getCaretBoundaries(newText, format); + + return newBoundaries.start - previousBoundaries.start; +}; diff --git a/packages/devextreme/js/__internal/ui/number_box/m_number_box.mask.ts b/packages/devextreme/js/__internal/ui/number_box/number_box.mask.ts similarity index 61% rename from packages/devextreme/js/__internal/ui/number_box/m_number_box.mask.ts rename to packages/devextreme/js/__internal/ui/number_box/number_box.mask.ts index 11a9995839ad..72f9dabe79e3 100644 --- a/packages/devextreme/js/__internal/ui/number_box/m_number_box.mask.ts +++ b/packages/devextreme/js/__internal/ui/number_box/number_box.mask.ts @@ -3,30 +3,36 @@ import { name as dxDblClickEvent } from '@js/common/core/events/double_click'; import { addNamespace, getChar, isCommandKeyPressed, normalizeKeyName, } from '@js/common/core/events/utils/index'; -import { getFormat as getLDMLFormat } from '@js/common/core/localization/ldml/number'; import number from '@js/common/core/localization/number'; import devices from '@js/core/devices'; -import { ensureDefined, escapeRegExp } from '@js/core/utils/common'; +import { escapeRegExp } from '@js/core/utils/common'; +import type { DeferredObj } from '@js/core/utils/deferred'; import { fitIntoRange, inRange } from '@js/core/utils/math'; import { isDefined, isFunction, isNumeric, isPlainObject, isString, } from '@js/core/utils/type'; -import type { NativeEventInfo } from '@js/events'; +import type { DxEvent, NativeEventInfo } from '@js/events'; import type { Format, FormatObject } from '@js/localization'; import type { Properties } from '@js/ui/number_box'; import { getGlobalFormatByDataType } from '@ts/core/global_format_config'; +import { getFormat as getLDMLFormat } from '@ts/core/localization/ldml/number'; +import type { OptionChanged } from '@ts/core/widget/types'; +import type { SupportedKeys } from '@ts/core/widget/widget'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/keyboard_processor'; import type { TextEditorInternalProperties } from '@ts/ui/text_box/text_editor.base'; +import type { CaretRange } from '@ts/ui/text_box/utils.caret'; -import NumberBoxBase from './m_number_box.base'; +import type { NumberBoxBaseProperties, NumberBoxValue } from './number_box.base'; +import NumberBoxBase from './number_box.base'; import { getCaretAfterFormat, getCaretBoundaries, getCaretInBoundaries, getCaretOffset, getCaretWithOffset, isCaretInBoundaries, -} from './m_number_box.caret'; +} from './number_box.caret'; import { - adjustPercentValue, getNthOccurrence, getRealSeparatorIndex, + adjustPercentValue, asPattern, getNthOccurrence, getRealSeparatorIndex, splitByIndex, -} from './m_utils'; +} from './utils'; const NUMBER_FORMATTER_NAMESPACE = 'dxNumberFormatter'; const MOVE_FORWARD = 1 as const; @@ -46,17 +52,24 @@ const asFormatObject = (format: Format | undefined): FormatObject | undefined => isPlainObject(format) ? format as FormatObject : undefined ); +const isNegativeValue = (value: NumberBoxValue | undefined): boolean => { + const parsedValue = Number(value); + + return parsedValue < 0 || 1 / parsedValue === -Infinity; +}; + export interface NumberBoxMaskProperties extends Omit, Omit { +| 'onOptionChanged' | 'onInitialized' | 'format' >, Omit { + format?: Format | null; + useMaskBehavior?: boolean; - displayValueFormatter?: ((value: any) => string); + displayValueFormatter?: NumberBoxBaseProperties['displayValueFormatter']; onValueChanged?: ( - e: NativeEventInfo & { value?: number | null; previousValue?: number | null }, + e: NativeEventInfo & { value?: NumberBoxValue; previousValue?: NumberBoxValue }, ) => void; - } class NumberBoxMask extends NumberBoxBase { @@ -64,7 +77,7 @@ class NumberBoxMask extends NumberBoxBase { _focusOutOccurs?: boolean; - _parsedValue?: number | null; + _parsedValue?: NumberBoxValue; _lastKeyName?: string | null; @@ -72,40 +85,35 @@ class NumberBoxMask extends NumberBoxBase { _formattedValue?: string; - _isValuePasted?: boolean; - - _currentFormat?: any; + _currentFormat?: Format; _getDefaultOptions(): NumberBoxMaskProperties { return { ...super._getDefaultOptions(), useMaskBehavior: true, - // @ts-expect-error ts-error format: null, }; } - _isDeleteKey(key): boolean { + _isDeleteKey(key: string | null | undefined): boolean { return key === 'del'; } - _supportedKeys() { + _supportedKeys(): SupportedKeys { if (!this._useMaskBehavior()) { return super._supportedKeys(); } - const that = this; - return { ...super._supportedKeys(), - minus: that._revertSign.bind(that), - del: that._removeHandler.bind(that), - backspace: that._removeHandler.bind(that), - leftArrow: that._arrowHandler.bind(that, MOVE_BACKWARD), - rightArrow: that._arrowHandler.bind(that, MOVE_FORWARD), - home: that._boundaryKeyHandler.bind(that, MOVE_FORWARD), - enter: that._updateFormattedValue.bind(that), - end: that._boundaryKeyHandler.bind(that, MOVE_BACKWARD), + minus: (e): void => this._revertSign(e), + del: (e): void => this._removeHandler(e), + backspace: (e): void => this._removeHandler(e), + leftArrow: (e): void => this._arrowHandler(MOVE_BACKWARD, e), + rightArrow: (e): void => this._arrowHandler(MOVE_FORWARD, e), + home: (e): void => this._boundaryKeyHandler(MOVE_FORWARD, e), + enter: (): void => this._updateFormattedValue(), + end: (e): void => this._boundaryKeyHandler(MOVE_BACKWARD, e), }; } @@ -117,15 +125,13 @@ class NumberBoxMask extends NumberBoxBase { : getGlobalFormatByDataType('number'); } - _getTextSeparatorIndex(text) { - const decimalSeparator = number.getDecimalSeparator(); - const formatPattern = this._getFormatPattern(); - const patternString = isString(formatPattern) ? formatPattern : ''; - const realSeparatorOccurrenceIndex = getRealSeparatorIndex(patternString).occurrence; + _getTextSeparatorIndex(text: string): number { + const decimalSeparator: string = number.getDecimalSeparator(); + const realSeparatorOccurrenceIndex = getRealSeparatorIndex(this._getFormatPattern()).occurrence; return getNthOccurrence(text, decimalSeparator, realSeparatorOccurrenceIndex); } - _focusInHandler(e): void { + _focusInHandler(e: DxEvent): void { if (!this._preventNestedFocusEvent(e)) { this.clearCaretTimeout(); this._caretTimeout = setTimeout(() => { @@ -148,7 +154,7 @@ class NumberBoxMask extends NumberBoxBase { super._focusInHandler(e); } - _focusOutHandler(e): void { + _focusOutHandler(e: DxEvent): void { const shouldHandleEvent = !this._preventNestedFocusEvent(e); if (shouldHandleEvent) { @@ -165,15 +171,15 @@ class NumberBoxMask extends NumberBoxBase { } } - _hasValueBeenChanged(inputValue) { + _hasValueBeenChanged(inputValue: string): boolean { const format = this._getFormatPattern(); - const value = this.option('value'); + const { value } = this.option(); const formatted = this._format(value, format) || ''; return formatted !== inputValue; } - _updateFormattedValue() { + _updateFormattedValue(): void { const inputValue = this._getInputVal(); if (this._hasValueBeenChanged(inputValue)) { @@ -191,19 +197,26 @@ class NumberBoxMask extends NumberBoxBase { } } - _arrowHandler(step, e) { + _arrowHandler(step: CaretMoveDirection, e: DxEvent): void { if (!this._useMaskBehavior()) { return; } + const caret = this._caret(); + + if (!caret) { + return; + } + const text = this._getInputVal(); const format = this._getFormatPattern(); - let nextCaret = getCaretWithOffset(this._caret(), step); + const nextCaret = getCaretWithOffset(caret, step); if (!isCaretInBoundaries(nextCaret, text, format)) { - nextCaret = step === MOVE_FORWARD ? nextCaret.end : nextCaret.start; + const nextCaretPosition = step === MOVE_FORWARD ? nextCaret.end : nextCaret.start; + e.preventDefault(); - this._caret(getCaretInBoundaries(nextCaret, text, format)); + this._caret(getCaretInBoundaries(nextCaretPosition, text, format)); this._scrollInputTo(step === MOVE_FORWARD ? 'end' : 'start'); } } @@ -232,7 +245,7 @@ class NumberBoxMask extends NumberBoxBase { this._caret(newCaret); } - _boundaryKeyHandler(direction: CaretMoveDirection, e: KeyboardEvent): void { + _boundaryKeyHandler(direction: CaretMoveDirection, e: DxEvent): void { if (!this._useMaskBehavior() || e.shiftKey) { return; } @@ -242,19 +255,25 @@ class NumberBoxMask extends NumberBoxBase { this._scrollInputTo(direction === MOVE_FORWARD ? 'start' : 'end'); } - _shouldMoveCaret(text, caret) { + _shouldMoveCaret(text: string, caret: CaretRange | undefined): boolean { + if (!caret) { + return false; + } + const decimalSeparator = number.getDecimalSeparator(); - const isDecimalSeparatorNext = text.charAt(caret.end) === decimalSeparator; - const moveToFloat = (this._lastKey === decimalSeparator || this._lastKey === '.' || this._lastKey === ',') && isDecimalSeparatorNext; + const isDecimalSeparatorNext = text.charAt(caret.end ?? 0) === decimalSeparator; + const isSeparatorKey = this._lastKey === decimalSeparator || this._lastKey === '.' || this._lastKey === ','; - return moveToFloat; + return isSeparatorKey && isDecimalSeparatorNext; } - _getInputVal() { - return number.convertDigits(this._input().val(), true); + _getInputVal(): string { + const inputValue: string = number.convertDigits(this._input().val(), true); + + return inputValue; } - _keyboardHandler(e): boolean { + _keyboardHandler(e: KeyboardKeyDownEvent): boolean { this.clearCaretTimeout(); this._lastKey = number.convertDigits(getChar(e), true); @@ -267,11 +286,11 @@ class NumberBoxMask extends NumberBoxBase { const normalizedText = this._getInputVal(); const caret = this._caret(); - let enteredChar; + let enteredChar = this._lastKey; if (this._lastKeyName === MINUS_KEY) { enteredChar = ''; - } else { - enteredChar = e.which === NUMPAD_DOT_KEY_CODE ? number.getDecimalSeparator() : this._lastKey; + } else if (e.which === NUMPAD_DOT_KEY_CODE) { + enteredChar = number.getDecimalSeparator(); } const newValue = this._tryParse(normalizedText, caret, enteredChar); @@ -291,13 +310,13 @@ class NumberBoxMask extends NumberBoxBase { return super._keyboardHandler(e); } - _keyPressHandler(e): void { + _keyPressHandler(e: DxEvent): void { if (!this._useMaskBehavior()) { super._keyPressHandler(e); } } - _removeHandler(e) { + _removeHandler(e: DxEvent): void { const caret = this._caret(); const text = this._getInputVal(); @@ -312,14 +331,15 @@ class NumberBoxMask extends NumberBoxBase { if (start === end) { const caretPosition = start; - const canDelete = isBackspaceKey - && caretPosition > 0 - || isDeleteKey - && caretPosition < text.length; + const canDelete = (isBackspaceKey && caretPosition > 0) + || (isDeleteKey && caretPosition < text.length); if (canDelete) { - isDeleteKey && end++; - isBackspaceKey && start--; + if (isDeleteKey) { + end += 1; + } else { + start -= 1; + } } else { e.preventDefault(); return; @@ -330,12 +350,11 @@ class NumberBoxMask extends NumberBoxBase { if (this._isStub(char)) { this._moveCaret(isDeleteKey ? 1 : -1); - // @ts-expect-error ts-error - if (this._parsedValue < 0 || 1 / this._parsedValue === -Infinity) { + if (isNegativeValue(this._parsedValue)) { this._revertSign(e); this._setTextByParsedValue(); - // @ts-expect-error ts-error - const shouldTriggerInputEvent = this.option('valueChangeEvent').split(' ').includes('input'); + const { valueChangeEvent } = this.option(); + const shouldTriggerInputEvent = valueChangeEvent?.split(' ').includes('input'); if (shouldTriggerInputEvent) { // @ts-expect-error ts-error eventsEngine.trigger(this._input(), 'input'); @@ -345,7 +364,7 @@ class NumberBoxMask extends NumberBoxBase { return; } - const decimalSeparator = number.getDecimalSeparator(); + const decimalSeparator: string = number.getDecimalSeparator(); if (char === decimalSeparator) { const decimalSeparatorIndex = text.indexOf(decimalSeparator); if (this._isNonStubAfter(decimalSeparatorIndex + 1)) { @@ -360,8 +379,7 @@ class NumberBoxMask extends NumberBoxBase { const noDigits = editedText.search(/[0-9]/) < 0; if (noDigits && this._isValueInRange(0)) { - // @ts-expect-error ts-error - this._parsedValue = this._parsedValue < 0 || 1 / this._parsedValue === -Infinity ? -0 : 0; + this._parsedValue = isNegativeValue(this._parsedValue) ? -0 : 0; return; } } @@ -374,48 +392,52 @@ class NumberBoxMask extends NumberBoxBase { } } - _isPercentFormat() { - const format = this._getFormatPattern(); - const noEscapedFormat = format.replace(/'[^']+'/g, ''); + _isPercentFormat(): boolean { + const noEscapedFormat = asPattern(this._getFormatPattern()).replace(/'[^']+'/g, ''); - return noEscapedFormat.indexOf('%') !== -1; + return noEscapedFormat.includes('%'); } - _parse(text, format) { - const formatOption = this._getEffectiveFormatOption(); - const customParser = asFormatObject(formatOption)?.parser; - const isCustomParser = isFunction(customParser); - const parser = isCustomParser ? customParser : number.parse; + _parse(text: string, format: Format): NumberBoxValue | undefined { + const customParser = asFormatObject(this._getEffectiveFormatOption())?.parser; + + if (isFunction(customParser)) { + const customParsedValue = customParser(text); + + return customParsedValue instanceof Date ? undefined : customParsedValue; + } + let integerPartStartIndex = 0; - if (!isCustomParser) { + if (isString(format)) { const formatPointIndex = getRealSeparatorIndex(format).index; const textPointIndex = this._getTextSeparatorIndex(text); const formatIntegerPartLength = formatPointIndex !== -1 ? formatPointIndex : format.length; const textIntegerPartLength = textPointIndex !== -1 ? textPointIndex : text.length; - if (textIntegerPartLength > formatIntegerPartLength && format.indexOf('#') === -1) { + if (textIntegerPartLength > formatIntegerPartLength && !format.includes('#')) { integerPartStartIndex = textIntegerPartLength - formatIntegerPartLength; } } - text = text.substr(integerPartStartIndex); - - return parser(text, format); + return number.parse( + text.substr(integerPartStartIndex), + isString(format) ? format : asFormatObject(format), + ); } - _format(value, format) { + _format(value: NumberBoxValue | undefined, format: Format): string | undefined { const formatOption = this._getEffectiveFormatOption(); const customFormatter = asFormatObject(formatOption)?.formatter ?? formatOption; - const formatter = isFunction(customFormatter) ? customFormatter : number.format; - - const formattedValue = value === null ? '' : formatter(value, format); + const formatter = (isFunction(customFormatter) ? customFormatter : number.format) as ( + value: number, valueFormat: Format, + ) => string | undefined; - return formattedValue; + return isDefined(value) ? formatter(value, format) : ''; } - _getFormatPattern() { + _getFormatPattern(): Format { if (!this._currentFormat) { this._updateFormat(); } @@ -433,28 +455,30 @@ class NumberBoxMask extends NumberBoxBase { this._currentFormat = shouldUseFormatAsIs ? format - : getLDMLFormat((value) => { - const text = this._format(value, format); - return number.convertDigits(text, true); + : getLDMLFormat((value: number): string => { + const text = this._format(value, format) ?? ''; + const convertedText: string = number.convertDigits(text, true); + + return convertedText; }); } - _getFormatForSign(text) { + _getFormatForSign(text: string): string { const format = this._getFormatPattern(); if (isString(format)) { const signParts = format.split(';'); - const sign = number.getSign(text, format); + const sign: number = number.getSign(text, format); signParts[1] = signParts[1] || `-${signParts[0]}`; return sign < 0 ? signParts[1] : signParts[0]; } - const sign = number.getSign(text); + const sign: number = number.getSign(text); return sign < 0 ? '-' : ''; } - _removeStubs(text, excludeComma?): string { + _removeStubs(text: string, excludeComma?: boolean): string { const format = this._getFormatForSign(text); - const thousandsSeparator = number.getThousandsSeparator(); + const thousandsSeparator: string = number.getThousandsSeparator(); const stubs = this._getStubs(format); let result = text; @@ -472,42 +496,54 @@ class NumberBoxMask extends NumberBoxBase { return result; } - _getStubs(format) { - const regExpResult = /[^']([#0.,]+)/g.exec(format); - const pattern = regExpResult && regExpResult[0].trim(); + _getStubs(format: string): string[] { + const patternMatch = /[^']([#0.,]+)/g.exec(format); + const pattern = patternMatch?.[0].trim(); + const stubs = isDefined(pattern) ? format.split(pattern) : [format]; - return format - .split(pattern) - .map((stub) => stub.replace(/'/g, '')); + return stubs.map((stub) => stub.replace(/'/g, '')); } - _truncateToPrecision(value, maxPrecision) { + _truncateToPrecision( + value: NumberBoxValue | undefined, + maxPrecision: number, + ): NumberBoxValue | undefined { if (isDefined(value)) { const strValue = value.toString(); const decimalSeparatorIndex = strValue.indexOf('.'); if (strValue && decimalSeparatorIndex > -1) { - const parsedValue = parseFloat(strValue.substr(0, decimalSeparatorIndex + maxPrecision + 1)); + const truncatedValue = strValue.substr(0, decimalSeparatorIndex + maxPrecision + 1); + const parsedValue = parseFloat(truncatedValue); + return isNaN(parsedValue) ? value : parsedValue; } } return value; } - _tryParse(text, selection, char?) { - const isTextSelected = selection.start !== selection.end; - const isWholeTextSelected = isTextSelected && selection.start === 0 && selection.end === text.length; - const decimalSeparator = number.getDecimalSeparator(); + _tryParse( + text: string, + selection: CaretRange | undefined, + char?: string | null, + ): NumberBoxValue | undefined { + const { start = 0, end = 0 } = selection ?? {}; + const isTextSelected = start !== end; + const isWholeTextSelected = isTextSelected && start === 0 && end === text.length; + const decimalSeparator: string = number.getDecimalSeparator(); if (isWholeTextSelected && char === decimalSeparator) { return 0; } - const editedText = this._replaceSelectedText(text, selection, char); + const editedText = this._replaceSelectedText(text, { start, end }, char ?? undefined); const format = this._getFormatPattern(); + const hasCustomParser = isFunction(asFormatObject(format)?.parser); let parsedValue = this._getParsedValue(editedText, format); - const maxPrecision = !format.parser && this._getPrecisionLimits(editedText).max; + const maxPrecision = hasCustomParser + ? undefined + : this._getPrecisionLimits(this._getFormatForSign(editedText)).max; const isValueChanged = parsedValue !== this._parsedValue; const isDecimalPointRestricted = char === decimalSeparator && maxPrecision === 0; @@ -521,8 +557,7 @@ class NumberBoxMask extends NumberBoxBase { } if (this._removeStubs(editedText) === '') { - // @ts-expect-error ts-error - parsedValue = Math.abs(this._parsedValue * 0); + parsedValue = Math.abs(Number(this._parsedValue) * 0); } if (isNaN(Number(parsedValue))) { @@ -532,8 +567,8 @@ class NumberBoxMask extends NumberBoxBase { const value = parsedValue === null ? this._parsedValue : parsedValue; parsedValue = maxPrecision ? this._truncateToPrecision(value, maxPrecision) : parsedValue; - if (!format.parser && this._isPercentFormat()) { - const interval = this._getIntervalFromPrecision(maxPrecision); + if (!hasCustomParser && this._isPercentFormat()) { + const interval = this._getIntervalFromPrecision(maxPrecision ?? 0); return adjustPercentValue(parsedValue, interval); } @@ -541,7 +576,7 @@ class NumberBoxMask extends NumberBoxBase { return parsedValue; } - _getIntervalFromPrecision(precision) { + _getIntervalFromPrecision(precision: number): number { if (precision < 1) { return 1; } @@ -549,23 +584,24 @@ class NumberBoxMask extends NumberBoxBase { return 10 ** -precision; } - _getParsedValue(text, format) { - const sign = number.getSign(text, format?.formatter || format); + _getParsedValue(text: string, format: Format): NumberBoxValue | undefined { + const signFormat = asFormatObject(format)?.formatter ?? format; + const sign: number = number.getSign(text, signFormat); const textWithoutStubs = this._removeStubs(text, true); const parsedValue = this._parse(textWithoutStubs, format); - const parsedValueSign = parsedValue != null && parsedValue < 0 ? -1 : 1; - const parsedValueWithSign = isNumeric(parsedValue) && sign !== parsedValueSign ? sign * parsedValue : parsedValue; + const parsedValueSign = Number(parsedValue) < 0 ? -1 : 1; + const shouldRevertSign = isNumeric(parsedValue) && sign !== parsedValueSign; - return parsedValueWithSign; + return shouldRevertSign ? sign * parsedValue : parsedValue; } - _isValueIncomplete(text) { + _isValueIncomplete(text: string): boolean { if (!this._useMaskBehavior()) { return super._isValueIncomplete(text); } const caret = this._caret(); - const point = number.getDecimalSeparator(); + const point: string = number.getDecimalSeparator(); const pointIndex = this._getTextSeparatorIndex(text); const isCaretOnFloat = pointIndex >= 0 && pointIndex < (caret?.start ?? 0); const textParts = this._removeStubs(text, true).split(point); @@ -575,34 +611,41 @@ class NumberBoxMask extends NumberBoxBase { } const floatLength = textParts[1].length; - const format = this._getFormatPattern(); - const isCustomParser = !!format.parser; - const precision = !isCustomParser && this._getPrecisionLimits(this._getFormatPattern()); - const isPrecisionInRange = isCustomParser - ? true - // @ts-expect-error ts-error - : inRange(floatLength, precision.min, precision.max); const endsWithZero = textParts[1].charAt(floatLength - 1) === '0'; + const isFloatPartComplete = endsWithZero || !floatLength; + + const format = this._getFormatPattern(); - return isPrecisionInRange && (endsWithZero || !floatLength); + if (isFunction(asFormatObject(format)?.parser)) { + return isFloatPartComplete; + } + + const precision = this._getPrecisionLimits(this._getFormatForSign(asPattern(format))); + + return inRange(floatLength, precision.min, precision.max) && isFloatPartComplete; } - _isValueInRange(value) { - const min = ensureDefined(this.option('min'), -Infinity); - const max = ensureDefined(this.option('max'), Infinity); + _isValueInRange(value: number): boolean { + const { min, max } = this.option(); - return inRange(value, min, max); + return inRange(value, min ?? -Infinity, max ?? Infinity); } - _setInputText(text): void { - const normalizedText = number.convertDigits(text, true); - const newCaret = getCaretAfterFormat(this._getInputVal(), normalizedText, this._caret(), this._getFormatPattern()); + _setInputText(text: string): void { + const normalizedText: string = number.convertDigits(text, true); + const caret = this._caret(); + const newCaret = caret && getCaretAfterFormat( + this._getInputVal(), + normalizedText, + caret, + this._getFormatPattern(), + ); this._input().val(text); this._toggleEmptinessEventHandler(); this._formattedValue = text; - if (!this._focusOutOccurs) { + if (newCaret && !this._focusOutOccurs) { this._caret(newCaret); } } @@ -625,24 +668,31 @@ class NumberBoxMask extends NumberBoxBase { } } - _isChar(str) { + _isChar(str: string | null | undefined): boolean { return isString(str) && str.length === 1; } - _moveCaret(offset?): void { - if (!offset) { + _moveCaret(offset?: number): void { + const caret = this._caret(); + + if (!offset || !caret) { return; } - const newCaret = getCaretWithOffset(this._caret(), offset); - const adjustedCaret = getCaretInBoundaries(newCaret, this._getInputVal(), this._getFormatPattern()); + const newCaret = getCaretWithOffset(caret, offset); + const adjustedCaret = getCaretInBoundaries( + newCaret, + this._getInputVal(), + this._getFormatPattern(), + ); this._caret(adjustedCaret); } - _shouldHandleKey(e): boolean { + _shouldHandleKey(e: KeyboardEvent): boolean { const keyName = normalizeKeyName(e); - const isSpecialChar = isCommandKeyPressed(e) || e.altKey || e.shiftKey || !this._isChar(keyName); + const isSpecialChar = isCommandKeyPressed(e) || e.altKey || e.shiftKey + || !this._isChar(keyName); const isMinusKey = keyName === MINUS_KEY; const useMaskBehavior = this._useMaskBehavior(); @@ -654,7 +704,7 @@ class NumberBoxMask extends NumberBoxBase { this._renderFormatter(); } - _renderFormatter() { + _renderFormatter(): void { this._clearCache(); this._detachFormatterEvents(); @@ -663,32 +713,37 @@ class NumberBoxMask extends NumberBoxBase { } } - _detachFormatterEvents() { + _detachFormatterEvents(): void { eventsEngine.off(this._input(), `.${NUMBER_FORMATTER_NAMESPACE}`); } - _isInputFromPaste(e) { - const inputType = e.originalEvent?.inputType; - - if (isDefined(inputType)) { - return inputType === 'insertFromPaste'; - } - return this._isValuePasted; + _isInputFromPaste(e: DxEvent): boolean { + return e.originalEvent?.inputType === 'insertFromPaste'; } _attachFormatterEvents(): void { const $input = this._input(); - eventsEngine.on($input, addNamespace(INPUT_EVENT, NUMBER_FORMATTER_NAMESPACE), (e) => { - this._formatValue(e); - this._isValuePasted = false; - }); + eventsEngine.on( + $input, + addNamespace(INPUT_EVENT, NUMBER_FORMATTER_NAMESPACE), + (e: DxEvent) => { this._formatValue(e); }, + ); eventsEngine.on($input, addNamespace('dxclick', NUMBER_FORMATTER_NAMESPACE), () => { if (!this._caretTimeout) { this._caretTimeout = setTimeout(() => { this._caretTimeout = undefined; - this._caret(getCaretInBoundaries(this._caret(), this._getInputVal(), this._getFormatPattern())); + + const caret = this._caret(); + + if (caret) { + this._caret(getCaretInBoundaries( + caret, + this._getInputVal(), + this._getFormatPattern(), + )); + } }, CARET_TIMEOUT_DURATION); } }); @@ -703,44 +758,44 @@ class NumberBoxMask extends NumberBoxBase { this._caretTimeout = undefined; } - _forceRefreshInputValue() { + _forceRefreshInputValue(): void { if (!this._useMaskBehavior()) { - return super._forceRefreshInputValue(); + super._forceRefreshInputValue(); } } - _isNonStubAfter(index) { + _isNonStubAfter(index: number): boolean { const text = this._getInputVal().slice(index); - return text && !this._isStub(text, true); + + return !!text && !this._isStub(text, true); } - _isStub(str, isString?) { + _isStub(str: string | null | undefined, allowMultipleChars?: boolean): boolean { const escapedDecimalSeparator = escapeRegExp(number.getDecimalSeparator()); const regExpString = `^[^0-9${escapedDecimalSeparator}]+$`; const stubRegExp = new RegExp(regExpString, 'g'); - return stubRegExp.test(str) && (isString || this._isChar(str)); + return stubRegExp.test(str ?? '') && (!!allowMultipleChars || this._isChar(str)); } - _parseValue(text?): number | null { + _parseValue(text?: string | NumberBoxValue): NumberBoxValue | undefined { if (!this._useMaskBehavior()) { return super._parseValue(text); } - // @ts-expect-error ts-error + return this._parsedValue; } - _getPrecisionLimits(text): { min: number; max: number } { - const currentFormat = this._getFormatForSign(text); - const realSeparatorIndex = getRealSeparatorIndex(currentFormat).index; - const floatPart = (splitByIndex(currentFormat, realSeparatorIndex)[1] || '').replace(/[^#0]/g, ''); + _getPrecisionLimits(pattern: string): { min: number; max: number } { + const realSeparatorIndex = getRealSeparatorIndex(pattern).index; + const floatPart = (splitByIndex(pattern, realSeparatorIndex)[1] || '').replace(/[^#0]/g, ''); const minPrecision = floatPart.replace(/^(0*)#*/, '$1').length; const maxPrecision = floatPart.length; return { min: minPrecision, max: maxPrecision }; } - _revertSign(e) { + _revertSign(e: DxEvent): void { if (!this._useMaskBehavior()) { return; } @@ -758,8 +813,12 @@ class NumberBoxMask extends NumberBoxBase { this._applyRevertedSign(e, caret); } - _applyRevertedSign(e, caret, preserveSelectedText?): void { - const newValue = -1 * ensureDefined(this._parsedValue, null); + _applyRevertedSign( + e: DxEvent, + caret: CaretRange | undefined, + preserveSelectedText?: boolean, + ): void { + const newValue = -1 * (this._parsedValue ?? 0); if (this._isValueInRange(newValue) || newValue === 0) { this._parsedValue = newValue; @@ -771,25 +830,26 @@ class NumberBoxMask extends NumberBoxBase { this._setTextByParsedValue(); e.preventDefault(); - const currentText = this._getInputVal(); - const offset = getCaretOffset(previousText, currentText, format); - - caret = getCaretWithOffset(caret, offset); + if (caret) { + const currentText = this._getInputVal(); + const offset = getCaretOffset(previousText, currentText, format); + const caretWithOffset = getCaretWithOffset(caret, offset); + const caretInBoundaries = getCaretInBoundaries(caretWithOffset, currentText, format); - const caretInBoundaries = getCaretInBoundaries(caret, currentText, format); - - this._caret(caretInBoundaries); + this._caret(caretInBoundaries); + } } } } - _removeMinusFromText(text, caret) { - const isMinusPressed = this._lastKeyName === MINUS_KEY && text.charAt(caret.start - 1) === MINUS; + _removeMinusFromText(text: string, caret: CaretRange | undefined): string { + const caretStart = caret?.start ?? 0; + const isMinusPressed = this._lastKeyName === MINUS_KEY + && text.charAt(caretStart - 1) === MINUS; - return isMinusPressed ? this._replaceSelectedText(text, { - start: caret.start - 1, - end: caret.start, - }, '') : text; + return isMinusPressed + ? this._replaceSelectedText(text, { start: caretStart - 1, end: caretStart }, '') + : text; } _setTextByParsedValue(): void { @@ -800,7 +860,7 @@ class NumberBoxMask extends NumberBoxBase { this._setInputText(formatted); } - _formatValue(e): void { + _formatValue(e: DxEvent): void { let normalizedText = this._getInputVal(); const caret = this._caret(); const textWithoutMinus = this._removeMinusFromText(normalizedText, caret); @@ -817,7 +877,8 @@ class NumberBoxMask extends NumberBoxBase { return; } - const textWasChanged = number.convertDigits(this._formattedValue, true) !== normalizedText; + const formattedValue: string | undefined = number.convertDigits(this._formattedValue, true); + const textWasChanged = formattedValue !== normalizedText; if (textWasChanged) { const value = this._tryParse(normalizedText, caret, ''); @@ -830,18 +891,18 @@ class NumberBoxMask extends NumberBoxBase { this._setTextByParsedValue(); } - _renderDisplayText(): void { + _renderDisplayText(text?: string): void { if (this._useMaskBehavior()) { this._toggleEmptinessEventHandler(); } else { - // @ts-expect-error ts-error - super._renderDisplayText.apply(this, arguments); + super._renderDisplayText(text); } } - _renderValue() { + _renderValue(): DeferredObj { if (this._useMaskBehavior()) { const { value } = this.option(); + this._parsedValue = value; this._setTextByParsedValue(); } @@ -867,12 +928,15 @@ class NumberBoxMask extends NumberBoxBase { return; } - this._parsedValue = fitIntoRange(parsedValue, this.option('min'), this.option('max')); + const { min, max } = this.option(); + + this._parsedValue = fitIntoRange(parsedValue, min, max); } - _valueChangeEventHandler(e): void { + _valueChangeEventHandler(e: DxEvent): void { if (!this._useMaskBehavior()) { - return super._valueChangeEventHandler(e); + super._valueChangeEventHandler(e); + return; } const caret = this._caret(); @@ -890,7 +954,7 @@ class NumberBoxMask extends NumberBoxBase { } } - _optionChanged(args): void { + _optionChanged(args: OptionChanged): void { switch (args.name) { case 'format': case 'useMaskBehavior': diff --git a/packages/devextreme/js/__internal/ui/number_box/m_number_box.spin.ts b/packages/devextreme/js/__internal/ui/number_box/number_box.spin.ts similarity index 92% rename from packages/devextreme/js/__internal/ui/number_box/m_number_box.spin.ts rename to packages/devextreme/js/__internal/ui/number_box/number_box.spin.ts index d739bae81ba5..2ec292543b26 100644 --- a/packages/devextreme/js/__internal/ui/number_box/m_number_box.spin.ts +++ b/packages/devextreme/js/__internal/ui/number_box/number_box.spin.ts @@ -8,6 +8,7 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import type { DeferredObj } from '@js/core/utils/deferred'; import { Deferred } from '@js/core/utils/deferred'; +import type { DxEvent, PointerInteractionEvent } from '@js/events/events.types'; import type { OptionChanged } from '@ts/core/widget/types'; import type { WidgetProperties } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; @@ -21,10 +22,14 @@ const NUMBER_BOX = 'dxNumberBox'; const POINTERUP_EVENT_NAME = addNamespace(pointerEvents.up, NUMBER_BOX); const POINTERCANCEL_EVENT_NAME = addNamespace(pointerEvents.cancel, NUMBER_BOX); +export interface SpinChangeEvent { + event: DxEvent; +} + export interface SpinButtonProperties extends WidgetProperties { direction?: string; - onChange?: () => void; + onChange?: ((e: SpinChangeEvent) => void) | null; } class SpinButton extends Widget { @@ -40,7 +45,6 @@ class SpinButton extends Widget { return { ...super._getDefaultOptions(), direction: 'up', - // @ts-expect-error ts-error onChange: null, activeStateEnabled: true, hoverStateEnabled: true, @@ -73,7 +77,7 @@ class SpinButton extends Widget { this._spinChangeHandler = this._createActionByOption('onChange'); } - _spinDownHandler(e): void { + _spinDownHandler(e: DxEvent): void { e.preventDefault(); this._clearTimer(); diff --git a/packages/devextreme/js/__internal/ui/number_box/number_box.spins.ts b/packages/devextreme/js/__internal/ui/number_box/number_box.spins.ts new file mode 100644 index 000000000000..1c289dd3c24a --- /dev/null +++ b/packages/devextreme/js/__internal/ui/number_box/number_box.spins.ts @@ -0,0 +1,140 @@ +import eventsEngine from '@js/common/core/events/core/events_engine'; +import pointer from '@js/common/core/events/pointer'; +import { addNamespace } from '@js/common/core/events/utils/index'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { DxEvent, PointerInteractionEvent } from '@js/events/events.types'; +import TextEditorButton from '@ts/ui/text_box/texteditor_button_collection/button'; + +import type NumberBoxBase from './number_box.base'; +import type { SpinChangeEvent } from './number_box.spin'; +import SpinButton from './number_box.spin'; + +const SPIN_CLASS = 'dx-numberbox-spin'; +const SPIN_CONTAINER_CLASS = 'dx-numberbox-spin-container'; +const SPIN_TOUCH_FRIENDLY_CLASS = 'dx-numberbox-spin-touch-friendly'; + +interface SpinButtonsOptions { + visible: boolean; + disabled: boolean; +} + +export default class SpinButtons extends TextEditorButton { + _attachEvents( + instance: dxElementWrapper, + $spinContainer: dxElementWrapper, + ): void { + const { editor } = this; + + if (!editor) { + return; + } + + const eventName = addNamespace(pointer.down, editor.NAME ?? ''); + const $spinContainerChildren = $spinContainer.children(); + + const pointerDownAction = editor._createAction( + () => { this.editor?._spinButtonsPointerDownHandler(); }, + ); + + eventsEngine.off($spinContainer, eventName); + + eventsEngine.on( + $spinContainer, + eventName, + (e: DxEvent) => pointerDownAction({ event: e }), + ); + + SpinButton.getInstance($spinContainerChildren.eq(0)).option( + 'onChange', + (e: SpinChangeEvent) => { this.editor?._spinUpChangeHandler(e); }, + ); + + SpinButton.getInstance($spinContainerChildren.eq(1)).option( + 'onChange', + (e: SpinChangeEvent) => { this.editor?._spinDownChangeHandler(e); }, + ); + } + + _create(): { + $element: dxElementWrapper; + instance: dxElementWrapper; + } | undefined { + const { editor } = this; + + if (!editor) { + return undefined; + } + + const $spinContainer = $('
').addClass(SPIN_CONTAINER_CLASS); + const $spinUp = $('
').appendTo($spinContainer); + const $spinDown = $('
').appendTo($spinContainer); + const options = this._getOptions(); + + this._addToContainer($spinContainer); + + editor._createComponent($spinUp, SpinButton, { direction: 'up', ...options }); + editor._createComponent($spinDown, SpinButton, { direction: 'down', ...options }); + + this._legacyRender(editor.$element(), this._isTouchFriendly(), options.visible); + + return { + instance: $spinContainer, + $element: $spinContainer, + }; + } + + _getOptions(): SpinButtonsOptions { + const { editor } = this; + + const { disabled } = editor?.option() ?? {}; + + return { + visible: this._isVisible(), + disabled: !!disabled, + }; + } + + _isVisible(): boolean { + const { editor } = this; + const { showSpinButtons } = editor?.option() ?? {}; + + return super._isVisible() && !!showSpinButtons; + } + + _isTouchFriendly(): boolean { + const { editor } = this; + const { showSpinButtons, useLargeSpinButtons } = editor?.option() ?? {}; + + return !!showSpinButtons && !!useLargeSpinButtons; + } + + // TODO: get rid of it + _legacyRender( + $editor: dxElementWrapper, + isTouchFriendly: boolean, + isVisible: boolean, + ): void { + $editor.toggleClass(SPIN_TOUCH_FRIENDLY_CLASS, isTouchFriendly); + $editor.toggleClass(SPIN_CLASS, isVisible); + } + + update(): boolean { + const shouldUpdate = super.update(); + const { editor, instance } = this; + + if (shouldUpdate && instance && editor) { + const $spinButtons = instance.children(); + const spinUp = SpinButton.getInstance($spinButtons.eq(0)); + const spinDown = SpinButton.getInstance($spinButtons.eq(1)); + const options = this._getOptions(); + + spinUp.option(options); + spinDown.option(options); + + this._legacyRender(editor.$element(), this._isTouchFriendly(), options.visible); + } + + return shouldUpdate; + } +} diff --git a/packages/devextreme/js/__internal/ui/number_box/m_number_box.ts b/packages/devextreme/js/__internal/ui/number_box/number_box.ts similarity index 77% rename from packages/devextreme/js/__internal/ui/number_box/m_number_box.ts rename to packages/devextreme/js/__internal/ui/number_box/number_box.ts index 9b862fa02055..1b484d08e202 100644 --- a/packages/devextreme/js/__internal/ui/number_box/m_number_box.ts +++ b/packages/devextreme/js/__internal/ui/number_box/number_box.ts @@ -1,6 +1,6 @@ import registerComponent from '@js/core/component_registrator'; -import NumberBoxMask from './m_number_box.mask'; +import NumberBoxMask from './number_box.mask'; // STYLE numberBox diff --git a/packages/devextreme/js/__internal/ui/number_box/utils.ts b/packages/devextreme/js/__internal/ui/number_box/utils.ts new file mode 100644 index 000000000000..ca4d4ec140f2 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/number_box/utils.ts @@ -0,0 +1,74 @@ +import { adjust } from '@js/core/utils/math'; +import { isString } from '@js/core/utils/type'; +import type { Format } from '@js/localization'; + +export interface SeparatorPosition { + occurrence: number; + index: number; +} + +const asPattern = (format: Format): string => (isString(format) ? format : ''); + +const getRealSeparatorIndex = (format: Format): SeparatorPosition => { + const pattern = asPattern(format); + let quoteBalance = 0; + let separatorCount = 0; + + for (let i = 0; i < pattern.length; i += 1) { + if (pattern[i] === '\'') { + quoteBalance += 1; + } + if (pattern[i] === '.') { + separatorCount += 1; + if (quoteBalance % 2 === 0) { + return { + occurrence: separatorCount, + index: i, + }; + } + } + } + + return { occurrence: 1, index: -1 }; +}; + +const getNthOccurrence = (str: string, char: string, occurrence: number): number => { + let index = -1; + + for (let remaining = occurrence; remaining > 0; remaining -= 1) { + index = str.indexOf(char, index + 1); + + if (index === -1) { + return -1; + } + } + + return index; +}; + +const splitByIndex = (str: string, index: number): string[] => { + if (index === -1) { + return [str]; + } + + return [str.slice(0, index), str.slice(index + 1)]; +}; + +const adjustPercentValue = ( + rawValue: number | null | undefined, + interval: number, +): number | null | undefined => { + if (!rawValue) { + return rawValue; + } + + return adjust(rawValue / 100, interval / 100); +}; + +export { + adjustPercentValue, + asPattern, + getNthOccurrence, + getRealSeparatorIndex, + splitByIndex, +}; diff --git a/packages/devextreme/js/__internal/ui/text_box/text_editor.base.ts b/packages/devextreme/js/__internal/ui/text_box/text_editor.base.ts index a95ae65b7456..6fe15ab75575 100644 --- a/packages/devextreme/js/__internal/ui/text_box/text_editor.base.ts +++ b/packages/devextreme/js/__internal/ui/text_box/text_editor.base.ts @@ -34,6 +34,15 @@ import { TextEditorLabel } from './text_editor.label'; import type { TextEditorButtonInfo } from './texteditor_button_collection/index'; import TextEditorButtonCollection from './texteditor_button_collection/index'; +export interface TextEditorInputAttributes { + autocomplete: string; + + placeholder: string | null; + + // eslint-disable-next-line spellcheck/spell-checker + inputmode?: string; +} + export interface TextEditorInternalProperties extends EditorInternalProperties { // eslint-disable-next-line @typescript-eslint/no-explicit-any displayValueFormatter?: ((value: string | any[]) => string); @@ -446,12 +455,7 @@ class TextEditorBase< return value; } - _getDefaultAttributes(): { - autocomplete: string; - placeholder: string | null; - // eslint-disable-next-line spellcheck/spell-checker - inputmode?: string; - } { + _getDefaultAttributes(): TextEditorInputAttributes { const defaultAttributes = { autocomplete: 'off', placeholder: this._getPlaceholderAttr(), @@ -773,7 +777,7 @@ class TextEditorBase< } // eslint-disable-next-line @typescript-eslint/no-unused-vars - _keyPressHandler(e?: { originalEvent: InputEvent & KeyboardEvent }): void { + _keyPressHandler(e?: DxEvent): void { this.option('text', this._input().val()); } diff --git a/packages/devextreme/js/__internal/ui/text_box/text_editor.clear.ts b/packages/devextreme/js/__internal/ui/text_box/text_editor.clear.ts index f072220c37d5..4935f48cee4f 100644 --- a/packages/devextreme/js/__internal/ui/text_box/text_editor.clear.ts +++ b/packages/devextreme/js/__internal/ui/text_box/text_editor.clear.ts @@ -32,7 +32,7 @@ interface EditorWithClearButton { export default class ClearButton< // eslint-disable-next-line @typescript-eslint/no-explicit-any TComponent extends Editor & EditorWithClearButton = TextEditorBase, -> extends TextEditorButton { +> extends TextEditorButton { _create(): { instance: dxElementWrapper; $element: dxElementWrapper; @@ -101,7 +101,6 @@ export default class ClearButton< const isVisible = this._isVisible(); if (instance) { - // @ts-expect-error instance is dxElementWrapper instance.toggleClass(STATE_INVISIBLE_CLASS, !isVisible); } diff --git a/packages/devextreme/js/__internal/ui/text_box/text_editor.mask.ts b/packages/devextreme/js/__internal/ui/text_box/text_editor.mask.ts index 46d0e038133a..4a18e79436ff 100644 --- a/packages/devextreme/js/__internal/ui/text_box/text_editor.mask.ts +++ b/packages/devextreme/js/__internal/ui/text_box/text_editor.mask.ts @@ -340,7 +340,7 @@ class TextEditorMask< _replaceSelectedText( text: string, selection: CaretRange, - char: string, + char: string | undefined, ): string { if (char === undefined) { return text; diff --git a/packages/devextreme/js/__internal/ui/text_box/texteditor_button_collection/button.ts b/packages/devextreme/js/__internal/ui/text_box/texteditor_button_collection/button.ts index f519c2a6e2e2..04f31a4fb39e 100644 --- a/packages/devextreme/js/__internal/ui/text_box/texteditor_button_collection/button.ts +++ b/packages/devextreme/js/__internal/ui/text_box/texteditor_button_collection/button.ts @@ -5,7 +5,7 @@ import Button from '@js/ui/button'; import type Widget from '@ts/core/widget/widget'; import type TextEditorBase from '@ts/ui/text_box/text_editor.base'; -type TextEditorButtonInstance = dxElementWrapper | Button; +export type TextEditorButtonInstance = dxElementWrapper | Button; export const isButtonInstance = ( instance: unknown, @@ -14,12 +14,13 @@ export const isButtonInstance = ( export default class TextEditorButton< // eslint-disable-next-line @typescript-eslint/no-explicit-any TComponent extends Widget = TextEditorBase, + TInstance extends TextEditorButtonInstance = TextEditorButtonInstance, > { $container!: dxElementWrapper | null; $placeMarker?: dxElementWrapper | null; - instance?: TextEditorButtonInstance | null; + instance?: TInstance | null; editor!: TComponent | null; @@ -55,12 +56,12 @@ export default class TextEditorButton< } // eslint-disable-next-line @typescript-eslint/no-unused-vars - _attachEvents(instance: unknown, $element?: dxElementWrapper): void { + _attachEvents(instance: TInstance | undefined, $element?: dxElementWrapper): void { throw new Error('Not implemented'); } _create(): { - instance: Button | dxElementWrapper; + instance: TInstance; $element: dxElementWrapper; } | undefined { throw new Error('Not implemented'); diff --git a/packages/devextreme/js/__internal/ui/text_box/texteditor_button_collection/custom.ts b/packages/devextreme/js/__internal/ui/text_box/texteditor_button_collection/custom.ts index fdb6748a60fb..225953ea1f41 100644 --- a/packages/devextreme/js/__internal/ui/text_box/texteditor_button_collection/custom.ts +++ b/packages/devextreme/js/__internal/ui/text_box/texteditor_button_collection/custom.ts @@ -6,11 +6,11 @@ import $ from '@js/core/renderer'; import type { Properties as ButtonProperties } from '@js/ui/button'; import Button from '@js/ui/button'; import type TextEditorBase from '@ts/ui/text_box/text_editor.base'; -import TextEditorButton, { isButtonInstance } from '@ts/ui/text_box/texteditor_button_collection/button'; +import TextEditorButton from '@ts/ui/text_box/texteditor_button_collection/button'; const CUSTOM_BUTTON_HOVERED_CLASS = 'dx-custom-button-hovered'; -export default class CustomButton extends TextEditorButton { +export default class CustomButton extends TextEditorButton { _attachEvents( instance: Button, $element: dxElementWrapper, @@ -70,7 +70,7 @@ export default class CustomButton extends TextEditorButton { update(): boolean { const isUpdated = super.update(); - if (isButtonInstance(this.instance)) { + if (this.instance) { this.instance.option('disabled', this._isDisabled()); } @@ -86,7 +86,7 @@ export default class CustomButton extends TextEditorButton { _isDisabled(): boolean | undefined { const isDefinedByUser = this.options.disabled !== undefined; if (isDefinedByUser) { - if (isButtonInstance(this.instance)) { + if (this.instance) { return this.instance.option('disabled'); } diff --git a/packages/devextreme/js/ui/number_box.js b/packages/devextreme/js/ui/number_box.js index 5ea396acda04..a682bbaa8a92 100644 --- a/packages/devextreme/js/ui/number_box.js +++ b/packages/devextreme/js/ui/number_box.js @@ -1,4 +1,4 @@ -import NumberBox from '../__internal/ui/number_box/m_number_box'; +import NumberBox from '../__internal/ui/number_box/number_box'; export default NumberBox; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/common.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/common.tests.js index 9e82f2737153..63d935a73456 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/common.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/common.tests.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import SpinButton from '__internal/ui/number_box/m_number_box.spin'; +import SpinButton from '__internal/ui/number_box/number_box.spin'; import config from 'core/config'; import eventsEngine from 'common/core/events/core/events_engine'; import keyboardMock from '../../../helpers/keyboardMock.js'; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/mask.caret.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/mask.caret.tests.js index 546b5dd3d923..b070db9ed5b1 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/mask.caret.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/mask.caret.tests.js @@ -4,7 +4,7 @@ import { getCaretInBoundaries, isCaretInBoundaries, getCaretAfterFormat, -} from '__internal/ui/number_box/m_number_box.caret'; +} from '__internal/ui/number_box/number_box.caret'; QUnit.module('format caret', () => { const customFormat = {