Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import DateBox from '@ts/ui/date_box/date_box';

import { DropDownEditorModel } from './drop_down_editor';

const CLASSES = {
calendarCell: 'dx-calendar-cell',
};

export class DateBoxModel extends DropDownEditorModel {
public getInstance(): DateBox {
return DateBox.getInstance(this.root);
}

public getCalendarCells(): HTMLElement[] {
const overlayContent = this.getOverlay().getElement();

return Array.from(overlayContent?.querySelectorAll<HTMLElement>(`.${CLASSES.calendarCell}`) ?? []);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import DateRangeBox from '@ts/ui/date_range_box/date_range_box';

import { DateBoxModel } from './date_box';
import { DropDownEditorModel } from './drop_down_editor';

const CLASSES = {
startDateBox: 'dx-start-datebox',
endDateBox: 'dx-end-datebox',
calendarCell: 'dx-calendar-cell',
};

export class DateRangeBoxModel extends DropDownEditorModel {
public getInstance(): DateRangeBox {
return DateRangeBox.getInstance(this.root);
}

public getStartDateBox(): DateBoxModel {
return new DateBoxModel(this.root.querySelector(`.${CLASSES.startDateBox}`) as HTMLElement);
}

public getEndDateBox(): DateBoxModel {
return new DateBoxModel(this.root.querySelector(`.${CLASSES.endDateBox}`) as HTMLElement);
}

public getCalendarCells(): HTMLElement[] {
const overlayContent = this.getOverlay().getElement();

return Array.from(overlayContent?.querySelectorAll<HTMLElement>(`.${CLASSES.calendarCell}`) ?? []);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BaseModel } from './base_model';
import { OverlayModel } from './overlay';
import { TextEditorModel } from './text_editor';

const CLASSES = {
button: 'dx-dropdowneditor-button',
Expand All @@ -10,7 +10,7 @@ const ATTR = {
popupContent: 'aria-owns',
};

export class DropDownEditorModel extends BaseModel {
export class DropDownEditorModel extends TextEditorModel {
public open(): void {
const button = this.root.querySelector<HTMLElement>(`.${CLASSES.button}`);
const target = button ?? this.root.querySelector<HTMLElement>(`.${CLASSES.inputWrapper}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { BaseModel } from './base_model';

const CLASSES = {
input: 'dx-texteditor-input',
};

export class TextEditorModel extends BaseModel {
public getInputElement(): HTMLInputElement {
return this.root.querySelector(`.${CLASSES.input}`) as HTMLInputElement;
}

public setInputText(text: string): void {
const input = this.getInputElement();

input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
}

public clearInput(): void {
this.setInputText('');
}

public pressKey(key: string): void {
const input = this.getInputElement();

input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
}

public blurInput(): void {
this.getInputElement().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
afterEach, beforeAll, describe, expect, it, jest,
} from '@jest/globals';
import fx from '@js/common/core/animation/fx';
import type { Properties } from '@js/ui/date_box';
import DateBox from '@js/ui/date_box';
import { DateBoxModel } from '@ts/ui/__tests__/__mock__/model/date_box';

const dateBoxes: DateBox[] = [];

const createDateBox = (options: Partial<Properties> = {}): DateBoxModel => {
const element = document.body.appendChild(document.createElement('div'));

const instance = new DateBox(element, {
type: 'date',
pickerType: 'calendar',
...options,
});

dateBoxes.push(instance);

return new DateBoxModel(element);
};

describe('DateBox commits the input text on focus out when the browser fires no change event', () => {
beforeAll(() => {
fx.off = true;
});

afterEach(() => {
dateBoxes.forEach((instance) => instance.dispose());
dateBoxes.length = 0;
document.body.innerHTML = '';
});

it('resets the value when the input is cleared after a calendar pick (T1334896)', () => {
const dateBox = createDateBox();
const input = dateBox.getInputElement();

dateBox.open();
dateBox.getCalendarCells()[0].click();
expect(input.value).not.toBe('');

dateBox.clearInput();
dateBox.blurInput();

expect(dateBox.getInstance().option('value')).toBeNull();
expect(input.value).toBe('');
});

it('resets the value in mask mode when the input is cleared after a calendar pick (T1334896)', () => {
const dateBox = createDateBox({ useMaskBehavior: true });

dateBox.open();
dateBox.getCalendarCells()[0].click();

dateBox.clearInput();
dateBox.blurInput();

expect(dateBox.getInstance().option('value')).toBeNull();
});

it('commits the original date typed back after a calendar pick (T1334896)', () => {
const initialValue = new Date(2026, 8, 1);
const dateBox = createDateBox({ value: initialValue });
const initialText = dateBox.getInputElement().value;

dateBox.open();
dateBox.getCalendarCells()[10].click();
expect(dateBox.getInstance().option('value')).not.toEqual(initialValue);

dateBox.setInputText(initialText);
dateBox.blurInput();

expect(dateBox.getInstance().option('value')).toEqual(initialValue);
});

it('commits the text once when the browser does fire the change event (T1334896)', () => {
const onValueChanged = jest.fn();
const dateBox = createDateBox({ onValueChanged });
const input = dateBox.getInputElement();

dateBox.open();
dateBox.getCalendarCells()[0].click();
onValueChanged.mockClear();

dateBox.clearInput();
input.dispatchEvent(new Event('change', { bubbles: true }));
dateBox.blurInput();

expect(dateBox.getInstance().option('value')).toBeNull();
expect(onValueChanged).toHaveBeenCalledTimes(1);
});

it('does not validate the same text again on focus out (T1334896)', () => {
const onOptionChanged = jest.fn<(e: { name: string }) => void>();
const dateBox = createDateBox({ onOptionChanged });
const input = dateBox.getInputElement();

input.value = 'not a date';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));

const validationChangesAfterChange = onOptionChanged.mock.calls
.filter(([{ name }]) => name === 'validationError').length;

dateBox.blurInput();

const validationChangesAfterBlur = onOptionChanged.mock.calls
.filter(([{ name }]) => name === 'validationError').length;

expect(dateBox.getInstance().option('isValid')).toBe(false);
expect(validationChangesAfterBlur).toBe(validationChangesAfterChange);
});

it('fires the change once on focus out in mask mode when the typed date is out of range (T1334896)', () => {
const onChange = jest.fn();
const dateBox = createDateBox({
useMaskBehavior: true,
value: new Date(2026, 8, 9),
max: new Date(2026, 8, 10),
onChange,
});

dateBox.pressKey('ArrowUp');
dateBox.pressKey('ArrowUp');
dateBox.pressKey('ArrowUp');
dateBox.blurInput();

expect(dateBox.getInstance().option('isValid')).toBe(false);
expect(onChange).toHaveBeenCalledTimes(1);
});

it('resets the value on a repeated clear after a calendar pick (T1334896)', () => {
const dateBox = createDateBox();

dateBox.open();
dateBox.getCalendarCells()[0].click();
dateBox.clearInput();
dateBox.blurInput();
expect(dateBox.getInstance().option('value')).toBeNull();

dateBox.open();
dateBox.getCalendarCells()[0].click();
expect(dateBox.getInstance().option('value')).not.toBeNull();

dateBox.clearInput();
dateBox.blurInput();

expect(dateBox.getInstance().option('value')).toBeNull();
});

it('keeps the value when valueChangeEvent excludes change (T1334896)', () => {
const dateBox = createDateBox({ valueChangeEvent: 'paste' });

dateBox.open();
dateBox.getCalendarCells()[0].click();
const pickedValue = dateBox.getInstance().option('value');

dateBox.clearInput();
dateBox.blurInput();

expect(dateBox.getInstance().option('value')).toEqual(pickedValue);
});
});
29 changes: 29 additions & 0 deletions packages/devextreme/js/__internal/ui/date_box/date_box.base.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import eventsEngine from '@js/common/core/events/core/events_engine';
import dateLocalization from '@js/common/core/localization/date';
import messageLocalization from '@js/common/core/localization/message';
import config from '@js/core/config';
Expand Down Expand Up @@ -114,6 +115,8 @@ class DateBox<

_pickerType?: DatePickerType;

_committedText?: string;

_storedPadding?: number;

_userOptions?: DateBoxBaseProperties;
Expand Down Expand Up @@ -545,6 +548,7 @@ class DateBox<
_renderValue(): DeferredObj<unknown> {
const value = this.getDateOption('value');

this._committedText = undefined;
this.option('text', this._getDisplayedText(value));
this._strategy.renderValue();

Expand Down Expand Up @@ -583,12 +587,37 @@ class DateBox<
: uiDateUtils.FORMATS_MAP[mode] as string | null;
}

_focusOutHandler(e: DxEvent): void {
if (this._shouldCommitTextOnFocusOut()) {
eventsEngine.triggerHandler(this._input(), { type: 'change' });
}
Comment thread
Raushen marked this conversation as resolved.

super._focusOutHandler(e);
}

_shouldCommitTextOnFocusOut(): boolean {
const { text, valueChangeEvent } = this.option();
const includesChangeEvent = valueChangeEvent?.split(' ').includes('change');
const currentText = text ?? '';

if (!includesChangeEvent || currentText === this._committedText) {
return false;
}

const currentValue = this.getDateOption('value');
const displayedText = this._getDisplayedText(currentValue) ?? '';

return currentText !== displayedText;
}

_valueChangeEventHandler(
e: InteractionEvent,
): void {
const { text, type = 'date', validationError } = this.option();
const currentValue = this.getDateOption('value');

this._committedText = text;
Comment thread
Copilot marked this conversation as resolved.

if (text === this._getDisplayedText(currentValue)) {
this._recallInternalValidation(currentValue, validationError);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,7 @@ class DateBoxMask<
const { text } = this.option();

if (this._useMaskBehavior()) {
this._committedText = text;
this._saveValueChangeEvent(e);
if (!text) {
this._maskValue = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
afterEach, beforeAll, describe, expect, it,
} from '@jest/globals';
import fx from '@js/common/core/animation/fx';
import type { Properties } from '@js/ui/date_range_box';
import DateRangeBox from '@js/ui/date_range_box';
import { DateRangeBoxModel } from '@ts/ui/__tests__/__mock__/model/date_range_box';

const dateRangeBoxes: DateRangeBox[] = [];

const createDateRangeBox = (options: Partial<Properties> = {}): DateRangeBoxModel => {
const element = document.body.appendChild(document.createElement('div'));

const instance = new DateRangeBox(element, options);

dateRangeBoxes.push(instance);

return new DateRangeBoxModel(element);
};

describe('DateRangeBox commits the input text on focus out when the browser fires no change event', () => {
beforeAll(() => {
fx.off = true;
});

afterEach(() => {
dateRangeBoxes.forEach((instance) => instance.dispose());
dateRangeBoxes.length = 0;
document.body.innerHTML = '';
});

it('resets the start date when the start input is cleared after a calendar pick (T1334896)', () => {
const dateRangeBox = createDateRangeBox();
const startDateBox = dateRangeBox.getStartDateBox();

dateRangeBox.open();
dateRangeBox.getCalendarCells()[10].click();
expect(dateRangeBox.getInstance().option('startDate')).not.toBeNull();

startDateBox.clearInput();
startDateBox.blurInput();

expect(dateRangeBox.getInstance().option('startDate')).toBeNull();
expect(startDateBox.getInputElement().value).toBe('');
});
});
Loading