Skip to content

Numberbox: Refactor and improve typing - #34779

Open
Raushen wants to merge 2 commits into
DevExpress:mainfrom
Raushen:NumberBox-refactoring
Open

Numberbox: Refactor and improve typing#34779
Raushen wants to merge 2 commits into
DevExpress:mainfrom
Raushen:NumberBox-refactoring

Conversation

@Raushen

@Raushen Raushen commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@Raushen Raushen self-assigned this Aug 14, 2026
Copilot AI lite review requested due to automatic review settings August 14, 2026 14:25
@Raushen
Raushen requested a review from a team as a code owner August 14, 2026 14:25
@Raushen Raushen added the 26_2 label Aug 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the internal NumberBox implementation by removing legacy m_* modules, introducing new shared utilities, and tightening TypeScript typings across NumberBox-related components and their consumers.

Changes:

  • Replaced legacy m_number_box* imports with the new number_box* module structure across internal widgets and tests.
  • Extracted NumberBox helper logic into new reusable modules (utils.ts, number_box.caret.ts) and removed obsolete ones.
  • Improved TypeScript typing for NumberBox base/mask/spin/spins implementations and updated downstream consumers (e.g., ColorBox, DateBox).

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/devextreme/testing/tests/DevExpress.ui.widgets/toolbar.kbn.tests.js Updates NumberBox class import to new internal module path.
packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/mask.caret.tests.js Updates caret helper import to new module path.
packages/devextreme/testing/tests/DevExpress.ui.widgets.editors/numberBoxParts/common.tests.js Updates SpinButton import to new module path.
packages/devextreme/js/ui/number_box.js Updates public entry reexport to point at new internal implementation.
packages/devextreme/js/__internal/ui/text_box/text_editor.mask.ts Adjusts typing to allow char to be undefined in replace logic.
packages/devextreme/js/__internal/ui/number_box/utils.ts Adds new shared NumberBox utility helpers (separator parsing, percent adjustment, etc.).
packages/devextreme/js/__internal/ui/number_box/number_box.ts Switches NumberBox to use the renamed mask module.
packages/devextreme/js/__internal/ui/number_box/number_box.spins.ts Refactors spin buttons to stronger typing and cleaner option handling.
packages/devextreme/js/__internal/ui/number_box/number_box.spin.ts Strengthens typing around spin change events and pointer handling.
packages/devextreme/js/__internal/ui/number_box/number_box.mask.ts Major typing improvements + refactors using new caret/utils modules.
packages/devextreme/js/__internal/ui/number_box/number_box.caret.ts Adds a new caret utility module (replacing legacy caret implementation).
packages/devextreme/js/__internal/ui/number_box/number_box.base.ts Improves typing for NumberBox base behavior (spin events, parsing, ARIA).
packages/devextreme/js/__internal/ui/number_box/m_utils.ts Removes legacy NumberBox utilities (replaced by utils.ts).
packages/devextreme/js/__internal/ui/number_box/m_number_box.caret.ts Removes legacy caret implementation (replaced by number_box.caret.ts).
packages/devextreme/js/__internal/ui/date_box/time_view.ts Updates DateBox TimeView to import the new NumberBox module/types.
packages/devextreme/js/__internal/ui/color_box/color_view.ts Updates ColorBox to import the new NumberBox module/constants.
Suppressed comments (1)

packages/devextreme/js/__internal/ui/number_box/number_box.mask.ts:451

  • In _updateFormat, _format() is typed to return string | undefined, but the LDML callback passes its result directly to number.convertDigits and returns it as a string. If a custom formatter ever returns undefined, this will propagate (or become a non-string) and can break formatting/caret logic at runtime. Normalize the formatted text to a string before calling convertDigits and returning it.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@Raushen
Raushen force-pushed the NumberBox-refactoring branch from 67b2041 to 31d3fa9 Compare August 17, 2026 08:42
Copilot AI review requested due to automatic review settings August 17, 2026 08:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

const moveToFloat = (this._lastKey === decimalSeparator || this._lastKey === '.' || this._lastKey === ',') && isDecimalSeparatorNext;
_shouldMoveCaret(text: string, caret: CaretRange | undefined): boolean {
const decimalSeparator: string = number.getDecimalSeparator();
const isDecimalSeparatorNext = text.charAt(caret?.end ?? 0) === decimalSeparator;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] This ?? 0 isn't a neutral fallback, it's a semantically wrong one: 0 is a real index, so
"there is no caret" becomes "the caret is at the start", and the method can answer true and
move the caret. The original was text.charAt(caret.end), which would have thrown instead.
Honest handling:
_shouldMoveCaret(text: string, caret: CaretRange | undefined): boolean {
if (!caret) {
return false;
}
...
}
This is one of five new places where an absent caret is silently substituted with zero
(see also mask.ts, caret.ts). The root cause is that
_caret() (text_editor.mask.ts) fuses getter and setter into one method dispatching on
arguments.length, so the getter is typed CaretRange | undefined - even though undefined only
happens there when !$input.length, at which point _getInputVal() would already have thrown.
Overloads at the source:
_caret(): CaretRange | undefined;
_caret(position: CaretRange, force?: boolean): void;
or splitting into _getCaret()/_setCaret() would remove most of those five fallbacks and let
the remaining case be handled once.

_getInputVal() {
return number.convertDigits(this._input().val(), true);
_getInputVal(): string {
const inputValue: string = number.convertDigits(this._input().val(), true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] number is any here, not a typed API. Root cause: numberLocalization is built through
dependencyInjector, and function injector(object) in core/utils/m_dependency_injector.ts is
untyped and returns object, which under noImplicitAny: false collapses to any. I probed it -
convertDigits / getSign / parse are all any via both import paths, @js/common/... and
@ts/core/... alike.
So the added annotations are unchecked, and three of them are demonstrably false:
const inputValue: string = number.convertDigits(this._input().val(), true);
the real signature is (value: string | number) => string | number
const convertedText: string = number.convertDigits(text, true); // in _updateFormat
text = this._format(...), which this PR itself declares as string | undefined -
the annotation contradicts its own return type two lines up
const formattedValue: string = number.convertDigits(this._formattedValue, true); // _formatValue
_formattedValue?: string, so the input can be undefined and convertDigits returns it
unchanged, yet the result is declared string
This is worse than leaving them unannotated: the falsehood is now asserted explicitly, and
turning on no-unsafe-assignment later won't catch it.
Options: type injector generically -
function injector(object: T): T & { inject(o: object): void; resetInjection(): void }
which fixes this for the whole codebase but belongs in its own PR; or, locally, a narrow typed
wrapper such as
const toStandardDigits = (value: string): string => String(number.convertDigits(value, true));
Either way, please drop the hand-written annotations on any.

}

_getFormatPattern() {
_getFormatPattern(): Format {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically not a lie - I checked, Format already includes undefined
(const x: Format = undefined compiles) - but it doesn't say what the method actually
returns: either an LDML pattern string, or the user's format passed through as-is.
The price is four identical guards at the call sites, all of the shape
isString(format) ? format : '', in _getTextSeparatorIndex, _isPercentFormat,
_isValueIncomplete, and getCaretAfterFormat in number_box.caret.ts.
Behaviour matches the old code (I verified that getRealSeparatorIndex('') and
getRealSeparatorIndex(object) both yield { occurrence: 1, index: -1 }), but it's "I don't know
what this is" written four times.
Modelling the return explicitly - e.g.
type FormatPattern = { kind: 'ldml'; pattern: string } | { kind: 'raw'; format: Format }
or at minimum returning the LDML string and handling the raw case once - collapses all four.
Side note: because Format already admits undefined, the | undefined in
_getEffectiveFormatOption() and in asFormatObject is redundant, and _currentFormat?: Format
is optional twice over.

}

_isInputFromPaste(e) {
_isInputFromPaste(e: DxEvent<InputEvent>): boolean | undefined {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boolean | undefined documents the tri-state instead of removing it: _isValuePasted is never
initialized. The only caller does if (isFromPaste), so returning boolean with ?? false
is both shorter and honest.


const FORCE_VALUECHANGE_EVENT_NAMESPACE = 'NumberBoxForceValueChange';

export type NumberBoxValue = number | null | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] This widens rather than sharpens. _parseValue and _normalizeInputValue previously returned
number | null and cannot return undefined, so every caller now has an unreachable case to
consider.
Two knock-on effects:

  • _parseValue(value?: string | NumberBoxValue) - a ? on top of a union that already
    contains undefined
  • _parsedValue?: number | null in number_box.mask.ts doesn't use the alias, so the codebase
    now has two spellings for the same concept
    Suggest type NumberBoxValue = number | null, and express optionality with ? / | undefined
    only where it genuinely exists.

}

export default class SpinButtons extends TextEditorButton<NumberBoxBase> {
declare instance?: dxElementWrapper | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is effectively an @ts-expect-error, just an invisible one. The base declares
instance?: dxElementWrapper | Button | null, and the base render() assigns the result of
_create() (typed Button | dxElementWrapper) into it - so narrowing it in the subclass
provides no guarantee and has no runtime effect.
Better: make TextEditorButton generic over its instance type
(TextEditorButton<TComponent, TInstance>), so SpinButtons states this once and
_create()/render() stay consistent.

_legacyRender($editor, isTouchFriendly, isVisible) {
$editor.toggleClass(SPIN_TOUCH_FRIENDLY_CLASS, isTouchFriendly);
$editor.toggleClass(SPIN_CLASS, isVisible);
_legacyRender(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three parameters were made optional, even though both call sites always pass all three.
The optionality exists purely because _create() passes editor?.$element().
Consequences: $editor?.toggleClass(...) now silently no-ops instead of failing, and
toggleClass(cls, undefined) toggles the class rather than setting it.
_attachEvents already does if (!editor) return; - doing the same in _create() lets the
parameters stay required:
_legacyRender($editor: dxElementWrapper, isTouchFriendly: boolean, isVisible: boolean): void

const spinUp = SpinButton.getInstance($spinButtons.eq(0));
const spinDown = SpinButton.getInstance($spinButtons.eq(1));
const spinUp = SpinButton.getInstance<SpinButton>($spinButtons.eq(0));
const spinDown = SpinButton.getInstance<SpinButton>($spinButtons.eq(1));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

static getInstance<T = any>(element): T in dom_component.ts - the type argument asserts the
result isn't undefined, but getInstanceByElement can return undefined and the following
.option() would throw. Here and in the three sibling calls an @ts-expect-error was replaced by
an explicit type argument, i.e. a visible escape hatch traded for an invisible one.
Strictly speaking this is no worse than the original, but it isn't a typing improvement either.
The real fix - getInstance(): T | undefined - is probably its own PR; for this one it's enough
not to count it as resolved.

const isVisible = this._isVisible();
const isTouchFriendly = this._isTouchFriendly();
// @ts-expect-error
if (shouldUpdate && instance) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

&& instance is redundant: the base update() in texteditor_button_collection/button.ts ends
with return !!this.instance, so shouldUpdate already implies it. It reads as a semantic guard
but is really a type narrowing.

end: number;
}

export type CaretPosition = number | CaretRange | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admitting undefined here changed getCaretWithOffset's behaviour: (range?.start ?? 0) + offset
now silently returns { start: offset, end: offset } for a missing caret, where the original
(caret.start === undefined check on a required argument) would have thrown.
That's the same pattern as the ?? 0 fallbacks in number_box.mask.ts - see the
_shouldMoveCaret comment. If _caret() gets proper getter/setter overloads, this type can go
back to number | CaretRange and the ?./?? 0 here disappears.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants