diff --git a/docs/data/date-pickers/calendar-systems/calendar-systems.md b/docs/data/date-pickers/calendar-systems/calendar-systems.md
index ea25175d36bde..063eaa5eba15e 100644
--- a/docs/data/date-pickers/calendar-systems/calendar-systems.md
+++ b/docs/data/date-pickers/calendar-systems/calendar-systems.md
@@ -21,6 +21,11 @@ The following demo shows how to use the date-fns plugin:
## Hijri
+:::error
+The adapter with `moment-hijri` does not support the new fields components because the date library seems buggy when parsing a month only.
+If you want to help on the support of hijri calendar, please have a look at [this PR](https://github.com/xsoh/moment-hijri/issues/83).
+:::
+
You can use the `AdapterMomentHijri` adapter, which is based on [moment-hijri](https://www.npmjs.com/package/moment-hijri):
{{"demo": "AdapterHijri.js"}}
diff --git a/docs/data/date-pickers/getting-started/getting-started.md b/docs/data/date-pickers/getting-started/getting-started.md
index 29b8d784c3f59..8eedb8436fafa 100644
--- a/docs/data/date-pickers/getting-started/getting-started.md
+++ b/docs/data/date-pickers/getting-started/getting-started.md
@@ -133,9 +133,29 @@ Native date (`type="date"`), time (`type="time"`) and date&time (`type="datetime
## Testing caveats
+### Responsive components
+
:::info
Some test environments (i.e. `jsdom`) do not support media query. In such cases, components will be rendered in desktop mode. To modify this behavior you can fake the `window.matchMedia`.
:::
Be aware that running tests in headless browsers might not pass the default mediaQuery (`pointer: fine`).
In such case you can [force pointer precision](https://github.com/microsoft/playwright/issues/7769#issuecomment-1205106311) via browser flags or preferences.
+
+### Field components
+
+:::info
+To support RTL and some keyboard interactions, field components add some Unicode character that are invisible, but appears in the input value.
+:::
+
+To add tests about a field value without having to care about those characters, you can remove the specific character before testing the equality.
+Here is an example about how to do it.
+
+```js
+// Helper removing specific characters
+const cleanText = (string) =>
+ string.replace(/\u200e|\u2066|\u2067|\u2068|\u2069/g, '');
+
+// Example of a test using the helper
+expect(cleanText(input.value)).to.equal('10-11-2021');
+```
diff --git a/packages/x-date-pickers-pro/src/internal/utils/valueManagers.ts b/packages/x-date-pickers-pro/src/internal/utils/valueManagers.ts
index a3e572a4c8983..fd842d96c9e84 100644
--- a/packages/x-date-pickers-pro/src/internal/utils/valueManagers.ts
+++ b/packages/x-date-pickers-pro/src/internal/utils/valueManagers.ts
@@ -5,6 +5,7 @@ import {
splitFormatIntoSections,
addPositionPropertiesToSections,
createDateStrFromSections,
+ getSectionOrder,
} from '@mui/x-date-pickers/internals';
import { DateRange, DateRangeFieldSection } from '../models/range';
import { splitDateRangeSections, removeLastSeparator } from './date-fields-utils';
@@ -142,4 +143,6 @@ export const rangeFieldValueManager: FieldValueManager<
};
},
hasError: (error) => error[0] != null || error[1] != null,
+ getSectionOrder: (utils, localeText, format, isRTL) =>
+ getSectionOrder(splitFormatIntoSections(utils, localeText, format, null), isRTL),
};
diff --git a/packages/x-date-pickers/src/AdapterMomentJalaali/index.ts b/packages/x-date-pickers/src/AdapterMomentJalaali/index.ts
index cead29160780f..868b50181f6e1 100644
--- a/packages/x-date-pickers/src/AdapterMomentJalaali/index.ts
+++ b/packages/x-date-pickers/src/AdapterMomentJalaali/index.ts
@@ -3,6 +3,8 @@ import BaseAdapterMomentJalaali from '@date-io/jalaali';
import defaultMoment, { LongDateFormatKey } from 'moment-jalaali';
import { MuiFormatTokenMap, MuiPickersAdapter } from '../internals/models';
+type Moment = defaultMoment.Moment;
+
// From https://momentjs.com/docs/#/displaying/format/
const formatTokenMap: MuiFormatTokenMap = {
// Month
@@ -89,4 +91,25 @@ export class AdapterMomentJalaali
public getWeekNumber = (date: defaultMoment.Moment) => {
return date.jWeek();
};
+
+ public addYears = (date: Moment, count: number) => {
+ return count < 0
+ ? date.clone().subtract(Math.abs(count), 'jYear')
+ : date.clone().add(count, 'jYear');
+ };
+
+ public addMonths = (date: Moment, count: number) => {
+ return count < 0
+ ? date.clone().subtract(Math.abs(count), 'jMonth')
+ : date.clone().add(count, 'jMonth');
+ };
+
+ public isValid = (value: any) => {
+ // We can't to `this.moment(value)` because moment-jalaali looses the invalidity information when creating a new moment object from an existing one
+ if (!this.moment.isMoment(value)) {
+ return false;
+ }
+
+ return value.isValid(value);
+ };
}
diff --git a/packages/x-date-pickers/src/DateField/tests/describes.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/describes.DateField.test.tsx
index 88900de3a7358..05c22afb35983 100644
--- a/packages/x-date-pickers/src/DateField/tests/describes.DateField.test.tsx
+++ b/packages/x-date-pickers/src/DateField/tests/describes.DateField.test.tsx
@@ -54,7 +54,7 @@ describe(' - Describes', () => {
setNewValue: (value) => {
const newValue = adapterToUse.addDays(value, 1);
const input = screen.getByRole('textbox');
- clickOnInput(input, 5); // Update the day
+ clickOnInput(input, 10); // Update the day
userEvent.keyPress(input, { key: 'ArrowUp' });
return newValue;
},
diff --git a/packages/x-date-pickers/src/DateField/tests/editing.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/editing.DateField.test.tsx
index 9a2b915bd980b..12a5953989394 100644
--- a/packages/x-date-pickers/src/DateField/tests/editing.DateField.test.tsx
+++ b/packages/x-date-pickers/src/DateField/tests/editing.DateField.test.tsx
@@ -7,11 +7,9 @@ import {
createPickerRenderer,
adapterToUse,
buildFieldInteractions,
+ expectInputValue,
} from 'test/utils/pickers-utils';
-const expectInputValue = (input: HTMLInputElement, expectedValue: string) =>
- expect(input.value.replace(//g, '')).to.equal(expectedValue);
-
describe(' - Editing', () => {
const { render, clock } = createPickerRenderer({
clock: 'fake',
@@ -24,11 +22,23 @@ describe(' - Editing', () => {
key,
expectedValue,
cursorPosition = 1,
+ valueToSelect,
...props
- }: DateFieldProps & { key: string; expectedValue: string; cursorPosition?: number }) => {
+ }: DateFieldProps & {
+ key: string;
+ expectedValue: string;
+ cursorPosition?: number;
+ valueToSelect?: string;
+ }) => {
render();
const input = screen.getByRole('textbox');
- clickOnInput(input, cursorPosition);
+ const clickPosition = valueToSelect ? input.value.indexOf(valueToSelect) : cursorPosition;
+ if (clickPosition === -1) {
+ throw new Error(
+ `Failed to find value to select "${valueToSelect}" in input value: ${input.value}`,
+ );
+ }
+ clickOnInput(input, clickPosition);
userEvent.keyPress(input, { key });
expectInputValue(input, expectedValue);
};
@@ -118,7 +128,7 @@ describe(' - Editing', () => {
key: 'ArrowDown',
expectedValue: 'May 31',
// To select the date and not the month
- cursorPosition: 5,
+ valueToSelect: '1',
});
});
@@ -210,7 +220,7 @@ describe(' - Editing', () => {
key: 'ArrowUp',
expectedValue: 'July 1',
// To select the date and not the month
- cursorPosition: 5,
+ valueToSelect: '30',
});
});
@@ -638,7 +648,7 @@ describe(' - Editing', () => {
/>,
);
const input = screen.getByRole('textbox');
- clickOnInput(input, 10);
+ clickOnInput(input, input.value.indexOf('2010'));
userEvent.keyPress(input, { key: 'ArrowDown' });
expect(onChange.lastCall.firstArg).toEqualDateTime(new Date(2009, 3, 3, 3, 3, 3));
diff --git a/packages/x-date-pickers/src/DateField/tests/selection.DateField.test.tsx b/packages/x-date-pickers/src/DateField/tests/selection.DateField.test.tsx
index 0c9f4fdc8e68e..0ebfdcb6aa2e0 100644
--- a/packages/x-date-pickers/src/DateField/tests/selection.DateField.test.tsx
+++ b/packages/x-date-pickers/src/DateField/tests/selection.DateField.test.tsx
@@ -2,22 +2,29 @@ import * as React from 'react';
import { expect } from 'chai';
import { Unstable_DateField as DateField } from '@mui/x-date-pickers/DateField';
import { screen, act, userEvent, fireEvent } from '@mui/monorepo/test/utils';
-import { createPickerRenderer } from 'test/utils/pickers-utils';
-
-const getSelectedContent = (input: HTMLInputElement) =>
- input.value.slice(input.selectionStart ?? 0, input.selectionEnd ?? 0);
+import {
+ createPickerRenderer,
+ expectInputValue,
+ getCleanedSelectedContent,
+} from 'test/utils/pickers-utils';
describe(' - Selection', () => {
const { render, clock } = createPickerRenderer({ clock: 'fake' });
- const clickOnInput = (input: HTMLInputElement, cursorPosition: number) => {
+ const clickOnInput = (input: HTMLInputElement, position: number | string) => {
+ const clickPosition = typeof position === 'string' ? input.value.indexOf(position) : position;
+ if (clickPosition === -1) {
+ throw new Error(
+ `Failed to find value to select "${position}" in input value: ${input.value}`,
+ );
+ }
act(() => {
fireEvent.mouseDown(input);
if (document.activeElement !== input) {
input.focus();
}
fireEvent.mouseUp(input);
- input.setSelectionRange(cursorPosition, cursorPosition);
+ input.setSelectionRange(clickPosition, clickPosition);
fireEvent.click(input);
clock.runToLast();
@@ -34,7 +41,7 @@ describe(' - Selection', () => {
input.select();
});
- expect(input.value).to.equal('MM / DD / YYYY');
+ expectInputValue(input, 'MM / DD / YYYY');
expect(input.selectionStart).to.equal(0);
expect(input.selectionEnd).to.equal(input.value.length);
});
@@ -45,22 +52,22 @@ describe(' - Selection', () => {
// Simulate a touch focus interaction on mobile
act(() => {
input.focus();
- input.setSelectionRange(6, 8);
+ input.setSelectionRange(9, 10);
clock.runToLast();
});
- expect(input.value).to.equal('MM / DD / YYYY');
- expect(input.selectionStart).to.equal(5);
- expect(input.selectionEnd).to.equal(7);
+ expectInputValue(input, 'MM / DD / YYYY');
+ expect(input.selectionStart).to.equal(9);
+ expect(input.selectionEnd).to.equal(11);
});
it('should select day on desktop', () => {
render();
const input = screen.getByRole('textbox');
- clickOnInput(input, 6);
+ clickOnInput(input, 'DD');
- expect(input.value).to.equal('MM / DD / YYYY');
- expect(getSelectedContent(input)).to.equal('DD');
+ expectInputValue(input, 'MM / DD / YYYY');
+ expect(getCleanedSelectedContent(input)).to.equal('DD');
});
});
@@ -68,21 +75,21 @@ describe(' - Selection', () => {
it('should select the clicked selection when the input is already focused', () => {
render();
const input = screen.getByRole('textbox');
- clickOnInput(input, 7);
- expect(getSelectedContent(input)).to.equal('DD');
+ clickOnInput(input, 'DD');
+ expect(getCleanedSelectedContent(input)).to.equal('DD');
- clickOnInput(input, 1);
- expect(getSelectedContent(input)).to.equal('MM');
+ clickOnInput(input, 'MM');
+ expect(getCleanedSelectedContent(input)).to.equal('MM');
});
it('should not change the selection when clicking on the only already selected section', () => {
render();
const input = screen.getByRole('textbox');
- clickOnInput(input, 7);
- expect(getSelectedContent(input)).to.equal('DD');
+ clickOnInput(input, 'DD');
+ expect(getCleanedSelectedContent(input)).to.equal('DD');
- clickOnInput(input, 8);
- expect(getSelectedContent(input)).to.equal('DD');
+ clickOnInput(input, input.value.indexOf('DD') + 1);
+ expect(getCleanedSelectedContent(input)).to.equal('DD');
});
});
@@ -93,7 +100,7 @@ describe(' - Selection', () => {
clickOnInput(input, 1);
userEvent.keyPress(input, { key: 'a', ctrlKey: true });
- expect(getSelectedContent(input)).to.equal('MM / DD / YYYY');
+ expect(getCleanedSelectedContent(input)).to.equal('MM / DD / YYYY');
});
});
@@ -101,19 +108,19 @@ describe(' - Selection', () => {
it('should move selection to the next section when one section is selected', () => {
render();
const input = screen.getByRole('textbox');
- clickOnInput(input, 7);
- expect(getSelectedContent(input)).to.equal('DD');
+ clickOnInput(input, 'DD');
+ expect(getCleanedSelectedContent(input)).to.equal('DD');
userEvent.keyPress(input, { key: 'ArrowRight' });
- expect(getSelectedContent(input)).to.equal('YYYY');
+ expect(getCleanedSelectedContent(input)).to.equal('YYYY');
});
it('should stay on the current section when the last section is selected', () => {
render();
const input = screen.getByRole('textbox');
- clickOnInput(input, 11);
- expect(getSelectedContent(input)).to.equal('YYYY');
+ clickOnInput(input, 'YYYY');
+ expect(getCleanedSelectedContent(input)).to.equal('YYYY');
userEvent.keyPress(input, { key: 'ArrowRight' });
- expect(getSelectedContent(input)).to.equal('YYYY');
+ expect(getCleanedSelectedContent(input)).to.equal('YYYY');
});
it('should select the last section when all the sections are selected', () => {
@@ -123,10 +130,10 @@ describe(' - Selection', () => {
// Select all sections
userEvent.keyPress(input, { key: 'a', ctrlKey: true });
- expect(getSelectedContent(input)).to.equal('MM / DD / YYYY');
+ expect(getCleanedSelectedContent(input)).to.equal('MM / DD / YYYY');
userEvent.keyPress(input, { key: 'ArrowRight' });
- expect(getSelectedContent(input)).to.equal('YYYY');
+ expect(getCleanedSelectedContent(input)).to.equal('YYYY');
});
});
@@ -134,19 +141,19 @@ describe(' - Selection', () => {
it('should move selection to the previous section when one section is selected', () => {
render();
const input = screen.getByRole('textbox');
- clickOnInput(input, 7);
- expect(getSelectedContent(input)).to.equal('DD');
+ clickOnInput(input, 'DD');
+ expect(getCleanedSelectedContent(input)).to.equal('DD');
userEvent.keyPress(input, { key: 'ArrowLeft' });
- expect(getSelectedContent(input)).to.equal('MM');
+ expect(getCleanedSelectedContent(input)).to.equal('MM');
});
it('should stay on the current section when the first section is selected', () => {
render();
const input = screen.getByRole('textbox');
clickOnInput(input, 1);
- expect(getSelectedContent(input)).to.equal('MM');
+ expect(getCleanedSelectedContent(input)).to.equal('MM');
userEvent.keyPress(input, { key: 'ArrowLeft' });
- expect(getSelectedContent(input)).to.equal('MM');
+ expect(getCleanedSelectedContent(input)).to.equal('MM');
});
it('should select the first section when all the sections are selected', () => {
@@ -156,10 +163,10 @@ describe(' - Selection', () => {
// Select all sections
userEvent.keyPress(input, { key: 'a', ctrlKey: true });
- expect(getSelectedContent(input)).to.equal('MM / DD / YYYY');
+ expect(getCleanedSelectedContent(input)).to.equal('MM / DD / YYYY');
userEvent.keyPress(input, { key: 'ArrowLeft' });
- expect(getSelectedContent(input)).to.equal('MM');
+ expect(getCleanedSelectedContent(input)).to.equal('MM');
});
});
});
diff --git a/packages/x-date-pickers/src/DateTimeField/tests/describes.DateTimeField.test.tsx b/packages/x-date-pickers/src/DateTimeField/tests/describes.DateTimeField.test.tsx
index 8a950c4f5c27d..ab93850f55a19 100644
--- a/packages/x-date-pickers/src/DateTimeField/tests/describes.DateTimeField.test.tsx
+++ b/packages/x-date-pickers/src/DateTimeField/tests/describes.DateTimeField.test.tsx
@@ -43,7 +43,7 @@ describe(' - Describes', () => {
setNewValue: (value) => {
const newValue = adapterToUse.addDays(value, 1);
const input = screen.getByRole('textbox');
- clickOnInput(input, 5); // Update the day
+ clickOnInput(input, 10); // Update the day
userEvent.keyPress(input, { key: 'ArrowUp' });
return newValue;
},
diff --git a/packages/x-date-pickers/src/DesktopNextDatePicker/DesktopNextDatePicker.test.tsx b/packages/x-date-pickers/src/DesktopNextDatePicker/DesktopNextDatePicker.test.tsx
index c5adbbc0e5ab0..8678635a30213 100644
--- a/packages/x-date-pickers/src/DesktopNextDatePicker/DesktopNextDatePicker.test.tsx
+++ b/packages/x-date-pickers/src/DesktopNextDatePicker/DesktopNextDatePicker.test.tsx
@@ -6,7 +6,12 @@ import { inputBaseClasses } from '@mui/material/InputBase';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
import { fireEvent, screen, userEvent } from '@mui/monorepo/test/utils';
import { Unstable_DesktopNextDatePicker as DesktopNextDatePicker } from '@mui/x-date-pickers/DesktopNextDatePicker';
-import { createPickerRenderer, adapterToUse, openPicker } from 'test/utils/pickers-utils';
+import {
+ createPickerRenderer,
+ adapterToUse,
+ openPicker,
+ expectInputValue,
+} from 'test/utils/pickers-utils';
describe('', () => {
const { render } = createPickerRenderer({ clock: 'fake' });
@@ -65,14 +70,15 @@ describe('', () => {
const handleChange = spy();
render();
+ const input = screen.getByRole('textbox');
- fireEvent.change(screen.getByRole('textbox'), {
+ fireEvent.change(input, {
target: {
value: '10/11/2018',
},
});
- expect(screen.getByRole('textbox')).to.have.value('10 / 11 / 2018');
+ expectInputValue(input, '10 / 11 / 2018');
expect(handleChange.callCount).to.equal(1);
});
diff --git a/packages/x-date-pickers/src/DesktopNextDatePicker/tests/describes.DesktopNextDatePicker.test.tsx b/packages/x-date-pickers/src/DesktopNextDatePicker/tests/describes.DesktopNextDatePicker.test.tsx
index 3fe4fc742eed2..dd4831016a340 100644
--- a/packages/x-date-pickers/src/DesktopNextDatePicker/tests/describes.DesktopNextDatePicker.test.tsx
+++ b/packages/x-date-pickers/src/DesktopNextDatePicker/tests/describes.DesktopNextDatePicker.test.tsx
@@ -42,7 +42,7 @@ describe(' - Describes', () => {
);
} else {
const input = screen.getByRole('textbox');
- clickOnInput(input, 5); // Update the day
+ clickOnInput(input, 10); // Update the day
userEvent.keyPress(input, { key: 'ArrowUp' });
}
diff --git a/packages/x-date-pickers/src/DesktopNextDateTimePicker/tests/describes.DesktopNextDateTimePicker.test.tsx b/packages/x-date-pickers/src/DesktopNextDateTimePicker/tests/describes.DesktopNextDateTimePicker.test.tsx
index 1bb79e2cc77f6..29196c5f7f434 100644
--- a/packages/x-date-pickers/src/DesktopNextDateTimePicker/tests/describes.DesktopNextDateTimePicker.test.tsx
+++ b/packages/x-date-pickers/src/DesktopNextDateTimePicker/tests/describes.DesktopNextDateTimePicker.test.tsx
@@ -55,7 +55,7 @@ describe(' - Describes', () => {
);
} else {
const input = screen.getByRole('textbox');
- clickOnInput(input, 5); // Update the day
+ clickOnInput(input, 10); // Update the day
userEvent.keyPress(input, { key: 'ArrowUp' });
}
diff --git a/packages/x-date-pickers/src/NextDatePicker/NextDatePicker.test.tsx b/packages/x-date-pickers/src/NextDatePicker/NextDatePicker.test.tsx
index 8154fef6aef89..a571c45d59d80 100644
--- a/packages/x-date-pickers/src/NextDatePicker/NextDatePicker.test.tsx
+++ b/packages/x-date-pickers/src/NextDatePicker/NextDatePicker.test.tsx
@@ -2,7 +2,12 @@ import * as React from 'react';
import { expect } from 'chai';
import { Unstable_NextDatePicker as NextDatePicker } from '@mui/x-date-pickers/NextDatePicker';
import { fireEvent, screen } from '@mui/monorepo/test/utils/createRenderer';
-import { createPickerRenderer, openPicker, stubMatchMedia } from 'test/utils/pickers-utils';
+import {
+ createPickerRenderer,
+ openPicker,
+ stubMatchMedia,
+ expectInputValue,
+} from 'test/utils/pickers-utils';
const isJSDOM = /jsdom/.test(window.navigator.userAgent);
@@ -21,10 +26,10 @@ describe('', () => {
describe('rendering', () => {
it('should handle controlled `onChange` in desktop mode', () => {
render();
+ const input: HTMLInputElement = screen.getByRole('textbox');
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '02/22/2022' } });
-
- expect(screen.getByDisplayValue('02 / 22 / 2022')).not.to.equal(null);
+ fireEvent.change(input, { target: { value: '02/22/2022' } });
+ expectInputValue(input, '02 / 22 / 2022');
});
it('should render in mobile mode when `useMediaQuery` returns `false`', () => {
diff --git a/packages/x-date-pickers/src/TimeField/tests/editing.TimeField.test.tsx b/packages/x-date-pickers/src/TimeField/tests/editing.TimeField.test.tsx
index 442be218a28bf..ed10ecaeb0a41 100644
--- a/packages/x-date-pickers/src/TimeField/tests/editing.TimeField.test.tsx
+++ b/packages/x-date-pickers/src/TimeField/tests/editing.TimeField.test.tsx
@@ -3,10 +3,7 @@ import { expect } from 'chai';
import { spy } from 'sinon';
import { Unstable_TimeField as TimeField, TimeFieldProps } from '@mui/x-date-pickers/TimeField';
import { screen, act, userEvent, fireEvent } from '@mui/monorepo/test/utils';
-import { createPickerRenderer, adapterToUse } from 'test/utils/pickers-utils';
-
-const expectInputValue = (input: HTMLInputElement, expectedValue: string) =>
- expect(input.value.replace(//g, '')).to.equal(expectedValue);
+import { createPickerRenderer, adapterToUse, expectInputValue } from 'test/utils/pickers-utils';
describe(' - Editing', () => {
const { render, clock } = createPickerRenderer({
@@ -26,11 +23,23 @@ describe(' - Editing', () => {
key,
expectedValue,
cursorPosition = 1,
+ valueToSelect,
...props
- }: TimeFieldProps & { key: string; expectedValue: string; cursorPosition?: number }) => {
+ }: TimeFieldProps & {
+ key: string;
+ expectedValue: string;
+ cursorPosition?: number;
+ valueToSelect?: string;
+ }) => {
render();
const input = screen.getByRole('textbox');
- clickOnInput(input, cursorPosition);
+ const clickPosition = valueToSelect ? input.value.indexOf(valueToSelect) : cursorPosition;
+ if (clickPosition === -1) {
+ throw new Error(
+ `Failed to find value to select "${valueToSelect}" in input value: ${input.value}`,
+ );
+ }
+ clickOnInput(input, clickPosition);
userEvent.keyPress(input, { key });
expectInputValue(input, expectedValue);
};
@@ -103,7 +112,7 @@ describe(' - Editing', () => {
defaultValue: adapterToUse.date(new Date(2022, 5, 15, 14, 0, 32)),
key: 'ArrowDown',
expectedValue: '13:59',
- cursorPosition: 4,
+ valueToSelect: '00',
});
});
});
@@ -131,7 +140,7 @@ describe(' - Editing', () => {
format: adapterToUse.formats.fullTime12h,
key: 'ArrowDown',
expectedValue: 'hh:mm pm',
- cursorPosition: 14,
+ valueToSelect: 'aa',
});
});
@@ -141,7 +150,7 @@ describe(' - Editing', () => {
defaultValue: new Date(2022, 5, 15, 2, 25, 32),
key: 'ArrowDown',
expectedValue: '02:25 pm',
- cursorPosition: 14,
+ valueToSelect: 'am',
});
});
@@ -151,7 +160,7 @@ describe(' - Editing', () => {
defaultValue: new Date(2022, 5, 15, 14, 25, 32),
key: 'ArrowDown',
expectedValue: '02:25 am',
- cursorPosition: 14,
+ valueToSelect: 'pm',
});
});
@@ -161,7 +170,7 @@ describe(' - Editing', () => {
defaultValue: adapterToUse.date(new Date(2022, 5, 15, 0, 0, 32)),
key: 'ArrowDown',
expectedValue: '11:59 pm',
- cursorPosition: 4,
+ valueToSelect: '00',
});
});
@@ -171,7 +180,7 @@ describe(' - Editing', () => {
defaultValue: adapterToUse.date(new Date(2022, 5, 15, 12, 0, 32)),
key: 'ArrowDown',
expectedValue: '11:59 am',
- cursorPosition: 4,
+ valueToSelect: '00',
});
});
});
@@ -228,7 +237,7 @@ describe(' - Editing', () => {
defaultValue: adapterToUse.date(new Date(2022, 5, 15, 14, 59, 32)),
key: 'ArrowUp',
expectedValue: '15:00',
- cursorPosition: 4,
+ valueToSelect: '59',
});
});
});
@@ -269,7 +278,7 @@ describe(' - Editing', () => {
defaultValue: adapterToUse.date(new Date(2022, 5, 15, 11, 59, 32)),
key: 'ArrowUp',
expectedValue: '12:00 pm',
- cursorPosition: 4,
+ valueToSelect: '59',
});
});
@@ -279,7 +288,7 @@ describe(' - Editing', () => {
defaultValue: adapterToUse.date(new Date(2022, 5, 15, 23, 59, 32)),
key: 'ArrowUp',
expectedValue: '12:00 am',
- cursorPosition: 4,
+ valueToSelect: '59',
});
});
});
diff --git a/packages/x-date-pickers/src/internals/hooks/useField/index.ts b/packages/x-date-pickers/src/internals/hooks/useField/index.ts
index fe2b8a45e55cf..0e9d8009b620a 100644
--- a/packages/x-date-pickers/src/internals/hooks/useField/index.ts
+++ b/packages/x-date-pickers/src/internals/hooks/useField/index.ts
@@ -14,4 +14,5 @@ export {
splitFormatIntoSections,
addPositionPropertiesToSections,
createDateStrFromSections,
+ getSectionOrder,
} from './useField.utils';
diff --git a/packages/x-date-pickers/src/internals/hooks/useField/useField.interfaces.ts b/packages/x-date-pickers/src/internals/hooks/useField/useField.interfaces.ts
index 4339ec730f218..5e8825f6ad21c 100644
--- a/packages/x-date-pickers/src/internals/hooks/useField/useField.interfaces.ts
+++ b/packages/x-date-pickers/src/internals/hooks/useField/useField.interfaces.ts
@@ -97,8 +97,24 @@ export type UseFieldResponse = O
};
export interface FieldSection {
+ /**
+ * Start index of the section in the format
+ */
start: number;
+ /**
+ * End index of the section in the format
+ */
end: number;
+ /**
+ * Start index of the section value in the input.
+ * Takes into account invisible unicode characters such as \u2069 but does not include them
+ */
+ startInInput: number;
+ /**
+ * End index of the section value in the input.
+ * Takes into account invisible unicode characters such as \u2069 but does not include them
+ */
+ endInInput: number;
value: string;
placeholder: string;
/**
@@ -249,6 +265,21 @@ export interface FieldValueManager boolean;
+ /**
+ * Return a description of sections display order. This description is usefull in RTL mode.
+ * @template TDate
+ * @param {MuiPickersAdapter} utils The utils to manipulate the date.
+ * @param {PickersLocaleText} localeText The translation object.
+ * @param {string} format The format from which sections are computed.
+ * @param {boolean} isRTL Is the field in right-to-left orientation.
+ * @returns {SectionOrdering} The description of sections order from left to right.
+ */
+ getSectionOrder: (
+ utils: MuiPickersAdapter,
+ localeText: PickersLocaleText,
+ format: string,
+ isRTL: boolean,
+ ) => SectionOrdering;
}
export interface UseFieldState {
@@ -292,3 +323,31 @@ export type AvailableAdjustKeyCode =
| 'PageDown'
| 'Home'
| 'End';
+
+export type SectionNeighbors = {
+ [sectionIndex: number]: {
+ /**
+ * Index of the next section displayed on the left. `null` if it's the leftmost section.
+ */
+ leftIndex: number | null;
+ /**
+ * Index of the next section displayed on the right. `null` if it's the rightmost section.
+ */
+ rightIndex: number | null;
+ };
+};
+
+export type SectionOrdering = {
+ /**
+ * For each section index provide the index of the section displayed on the left and on the right.
+ */
+ neighbors: SectionNeighbors;
+ /**
+ * Index of the section displayed on the far left
+ */
+ startIndex: number;
+ /**
+ * Index of the section displayed on the far right
+ */
+ endIndex: number;
+};
diff --git a/packages/x-date-pickers/src/internals/hooks/useField/useField.ts b/packages/x-date-pickers/src/internals/hooks/useField/useField.ts
index 48d6c8ba21f18..6cdcb5d87c88b 100644
--- a/packages/x-date-pickers/src/internals/hooks/useField/useField.ts
+++ b/packages/x-date-pickers/src/internals/hooks/useField/useField.ts
@@ -16,12 +16,12 @@ import {
} from './useField.interfaces';
import {
getMonthsMatchingQuery,
- getSectionVisibleValue,
adjustDateSectionValue,
adjustInvalidDateSectionValue,
applySectionValueToDate,
cleanTrailingZeroInNumericSectionValue,
isAndroid,
+ cleanString,
} from './useField.utils';
import { useFieldState } from './useFieldState';
@@ -51,6 +51,7 @@ export const useField = <
updateSectionValue,
updateValueFromValueStr,
setTempAndroidValueStr,
+ sectionOrder,
} = useFieldState(params);
const {
@@ -68,9 +69,11 @@ export const useField = <
const focusTimeoutRef = React.useRef(undefined);
const syncSelectionFromDOM = () => {
- const nextSectionIndex = state.sections.findIndex(
- (section) => section.start > (inputRef.current!.selectionStart ?? 0),
- );
+ const browserStartIndex = inputRef.current!.selectionStart ?? 0;
+ const nextSectionIndex =
+ browserStartIndex <= state.sections[0].startInInput
+ ? 1 // Special case if browser index is in invisible cheracters at the begining.
+ : state.sections.findIndex((section) => section.startInInput > browserStartIndex);
const sectionIndex = nextSectionIndex === -1 ? state.sections.length - 1 : nextSectionIndex - 1;
setSelectedSections(sectionIndex);
};
@@ -155,7 +158,7 @@ export const useField = <
return;
}
- const valueStr = event.target.value;
+ const valueStr = cleanString(event.target.value);
// If no section is selected, we just try to parse the new value
// This line is mostly triggered by imperative code / application tests.
@@ -164,7 +167,7 @@ export const useField = <
return;
}
- const prevValueStr = fieldValueManager.getValueStrFromSections(state.sections);
+ const prevValueStr = cleanString(fieldValueManager.getValueStrFromSections(state.sections));
let startOfDiffIndex = -1;
let endOfDiffIndex = -1;
@@ -197,7 +200,7 @@ export const useField = <
valueStr.length -
prevValueStr.length +
activeSection.end -
- (activeSection.separator?.length ?? 0);
+ cleanString(activeSection.separator || '').length;
const keyPressed = valueStr.slice(activeSection.start, activeSectionEndRelativeToNewValue);
if (isAndroid() && keyPressed.length === 0) {
@@ -355,11 +358,15 @@ export const useField = <
event.preventDefault();
if (selectedSectionIndexes == null) {
- setSelectedSections(0);
+ setSelectedSections(sectionOrder.startIndex);
} else if (selectedSectionIndexes.startIndex !== selectedSectionIndexes.endIndex) {
setSelectedSections(selectedSectionIndexes.endIndex);
- } else if (selectedSectionIndexes.startIndex < state.sections.length - 1) {
- setSelectedSections(selectedSectionIndexes.startIndex + 1);
+ } else {
+ const nextSectionIndex =
+ sectionOrder.neighbors[selectedSectionIndexes.startIndex].rightIndex;
+ if (nextSectionIndex !== null) {
+ setSelectedSections(nextSectionIndex);
+ }
}
break;
}
@@ -369,11 +376,15 @@ export const useField = <
event.preventDefault();
if (selectedSectionIndexes == null) {
- setSelectedSections(state.sections.length - 1);
+ setSelectedSections(sectionOrder.endIndex);
} else if (selectedSectionIndexes.startIndex !== selectedSectionIndexes.endIndex) {
setSelectedSections(selectedSectionIndexes.startIndex);
- } else if (selectedSectionIndexes.startIndex > 0) {
- setSelectedSections(selectedSectionIndexes.startIndex - 1);
+ } else {
+ const nextSectionIndex =
+ sectionOrder.neighbors[selectedSectionIndexes.startIndex].leftIndex;
+ if (nextSectionIndex !== null) {
+ setSelectedSections(nextSectionIndex);
+ }
}
break;
}
@@ -446,8 +457,8 @@ export const useField = <
const firstSelectedSection = state.sections[selectedSectionIndexes.startIndex];
const lastSelectedSection = state.sections[selectedSectionIndexes.endIndex];
updateSelectionRangeIfChanged(
- firstSelectedSection.start,
- lastSelectedSection.start + getSectionVisibleValue(lastSelectedSection, true).length,
+ firstSelectedSection.startInInput,
+ lastSelectedSection.endInInput,
);
});
diff --git a/packages/x-date-pickers/src/internals/hooks/useField/useField.utils.ts b/packages/x-date-pickers/src/internals/hooks/useField/useField.utils.ts
index b4822ceac481d..683249266b0f6 100644
--- a/packages/x-date-pickers/src/internals/hooks/useField/useField.utils.ts
+++ b/packages/x-date-pickers/src/internals/hooks/useField/useField.utils.ts
@@ -1,4 +1,10 @@
-import { FieldSection, AvailableAdjustKeyCode, FieldBoundaries } from './useField.interfaces';
+import {
+ FieldSection,
+ AvailableAdjustKeyCode,
+ FieldBoundaries,
+ SectionNeighbors,
+ SectionOrdering,
+} from './useField.interfaces';
import { MuiPickersAdapter, MuiDateSectionName } from '../../models';
import { PickersLocaleText } from '../../../locales/utils/pickersLocaleTextApi';
@@ -239,7 +245,7 @@ export const adjustInvalidDateSectionValue = ,
+ section: Omit,
willBeRenderedInInput: boolean,
) => {
const value = section.value || section.placeholder;
@@ -249,26 +255,52 @@ export const getSectionVisibleValue = (
// Otherwise, when your input value equals `1/dd/yyyy` (format `M/DD/YYYY` on DayJs),
// If you press `1`, on the first section, the new value is also `1/dd/yyyy`,
// So the browser will not fire the input `onChange`.
+ // Adding the ltr mark is not a problem because it's only for digit (which are always ltr)
+ // The \u2068 and \u2069 are cleaned, but not the \u200e to notice that an update with same digit occures
if (willBeRenderedInInput && section.contentType === 'digit' && !section.hasTrailingZeroes) {
- return `${value}`;
+ return `\u2068${value}\u200e\u2069`;
}
+ if (willBeRenderedInInput) {
+ return `\u2068${value}\u2069`;
+ }
return value;
};
+export const cleanString = (dirtyString: string) =>
+ dirtyString.replace(/\u2066|\u2067|\u2068|\u2069/g, '');
+
export const addPositionPropertiesToSections = (
- sections: Omit[],
+ sections: Omit[],
): TSection[] => {
let position = 0;
+ let positionInInput = 1;
const newSections: TSection[] = [];
for (let i = 0; i < sections.length; i += 1) {
const section = sections[i];
- const end =
- position + getSectionVisibleValue(section, true).length + (section.separator?.length ?? 0);
+ const renderedValue = getSectionVisibleValue(section, true);
+
+ const end = position + cleanString(`${renderedValue}${section.separator || ''}`).length;
+
+ // The ...InInput values consider the unicode characters but do include them in their indexes
+ const cleanedValue = cleanString(renderedValue);
+ const startInInput = positionInInput + renderedValue.indexOf(cleanedValue[0]);
+ const endInInput = startInInput + cleanedValue.length;
- newSections.push({ ...section, start: position, end } as TSection);
+ newSections.push({
+ ...section,
+ start: position,
+ end,
+ startInInput,
+ endInInput,
+ } as TSection);
position = end;
+ // Move position to the end of string associated to the current section
+ positionInInput =
+ positionInInput +
+ getSectionVisibleValue(section, true).length +
+ (section.separator?.length ?? 0);
}
return newSections;
@@ -326,7 +358,7 @@ export const splitFormatIntoSections = (
date: TDate | null,
) => {
let currentTokenValue = '';
- const sections: Omit[] = [];
+ const sections: Omit[] = [];
const expandedFormat = utils.expandFormat(format);
const commitCurrentToken = () => {
@@ -369,6 +401,13 @@ export const splitFormatIntoSections = (
return sections.map((section) => {
if (section.separator !== '/') {
+ if (section.separator !== null && section.separator.includes(' ')) {
+ return {
+ ...section,
+ separator: `\u2069${section.separator}\u2066`,
+ parsingSeparator: section.separator,
+ };
+ }
return section;
}
return {
@@ -382,22 +421,31 @@ export const splitFormatIntoSections = (
export const createDateStrFromSections = (
sections: FieldSection[],
willBeRenderedInInput: boolean,
-) =>
- sections
- .map((section) => {
- let sectionValueStr = getSectionVisibleValue(section, willBeRenderedInInput);
+) => {
+ const formattedArray = sections.map((section) => {
+ let sectionValueStr = getSectionVisibleValue(section, willBeRenderedInInput);
- const separator = willBeRenderedInInput
- ? section.separator
- : section.parsingSeparator ?? section.separator;
+ const separator = willBeRenderedInInput
+ ? section.separator
+ : section.parsingSeparator ?? section.separator;
- if (separator != null) {
- sectionValueStr += separator;
- }
+ if (separator != null) {
+ sectionValueStr += separator;
+ }
- return sectionValueStr;
- })
- .join('');
+ return `${sectionValueStr}`;
+ });
+
+ if (willBeRenderedInInput) {
+ // \u2066: start left-to-right isolation
+ // \u2067: start right-to-left isolation
+ // \u2068: start first strong character isolation
+ // \u2069: pop isolation
+ // wrap into an isolated group such that separators can split the string in smaller ones by adding \u2069\u2068
+ return `\u2066${formattedArray.join('')}\u2069`;
+ }
+ return formattedArray.join('');
+};
export const getMonthsMatchingQuery = (
utils: MuiPickersAdapter,
@@ -582,7 +630,7 @@ export const validateSections = (
export const mergeDateIntoReferenceDate = <
TDate,
- TSection extends Omit,
+ TSection extends Omit,
>(
utils: MuiPickersAdapter,
date: TDate,
@@ -661,3 +709,55 @@ export const clampDaySection = (
};
});
};
+
+export const getSectionOrder = (
+ sections: Omit[],
+ isRTL: boolean,
+): SectionOrdering => {
+ const neighbors: SectionNeighbors = {};
+ if (!isRTL) {
+ sections.forEach((_, index) => {
+ const leftIndex = index === 0 ? null : index - 1;
+ const rightIndex = index === sections.length - 1 ? null : index + 1;
+ neighbors[index] = { leftIndex, rightIndex };
+ });
+ return { neighbors, startIndex: 0, endIndex: sections.length - 1 };
+ }
+
+ type PotisionMapping = { [from: number]: number };
+ const rtl2ltr: PotisionMapping = {};
+ const ltr2rtl: PotisionMapping = {};
+
+ let groupedSectionsStart = 0;
+ let groupedSectionsEnd = 0;
+ let RTLIndex = sections.length - 1;
+
+ while (RTLIndex >= 0) {
+ groupedSectionsEnd = sections.findIndex(
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
+ (section, index) =>
+ index >= groupedSectionsStart &&
+ (section.parsingSeparator ?? section.separator)?.includes(' '),
+ );
+ if (groupedSectionsEnd === -1) {
+ groupedSectionsEnd = sections.length - 1;
+ }
+
+ for (let i = groupedSectionsEnd; i >= groupedSectionsStart; i -= 1) {
+ ltr2rtl[i] = RTLIndex;
+ rtl2ltr[RTLIndex] = i;
+ RTLIndex -= 1;
+ }
+ groupedSectionsStart = groupedSectionsEnd + 1;
+ }
+
+ sections.forEach((_, index) => {
+ const rtlIndex = ltr2rtl[index];
+ const leftIndex = rtlIndex === 0 ? null : rtl2ltr[rtlIndex - 1];
+ const rightIndex = rtlIndex === sections.length - 1 ? null : rtl2ltr[rtlIndex + 1];
+
+ neighbors[index] = { leftIndex, rightIndex };
+ });
+
+ return { neighbors, startIndex: rtl2ltr[0], endIndex: rtl2ltr[sections.length - 1] };
+};
diff --git a/packages/x-date-pickers/src/internals/hooks/useField/useFieldState.ts b/packages/x-date-pickers/src/internals/hooks/useField/useFieldState.ts
index 5934ee5503519..1e79bab0dbc06 100644
--- a/packages/x-date-pickers/src/internals/hooks/useField/useFieldState.ts
+++ b/packages/x-date-pickers/src/internals/hooks/useField/useFieldState.ts
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTheme } from '@mui/material/styles';
import useControlled from '@mui/utils/useControlled';
import { useUtils, useLocaleText, useLocalizationContext } from '../useUtils';
import {
@@ -41,6 +42,8 @@ export const useFieldState = <
const utils = useUtils();
const localeText = useLocaleText();
const adapter = useLocalizationContext();
+ const theme = useTheme();
+ const isRTL = theme.direction === 'rtl';
const {
valueManager,
@@ -62,6 +65,13 @@ export const useFieldState = <
const valueFromTheOutside = valueProp ?? firstDefaultValue.current ?? valueManager.emptyValue;
const boundaries = React.useMemo(() => getSectionBoundaries(utils), [utils]);
+ const [sectionOrder, setSectionOrder] = React.useState(() =>
+ fieldValueManager.getSectionOrder(utils, localeText, format, isRTL),
+ );
+ React.useEffect(() => {
+ setSectionOrder(fieldValueManager.getSectionOrder(utils, localeText, format, isRTL));
+ }, [fieldValueManager, format, isRTL, localeText, utils]);
+
const [state, setState] = React.useState>(() => {
const sections = fieldValueManager.getSectionsFromValue(
utils,
@@ -321,5 +331,6 @@ export const useFieldState = <
updateSectionValue,
updateValueFromValueStr,
setTempAndroidValueStr,
+ sectionOrder,
};
};
diff --git a/packages/x-date-pickers/src/internals/index.ts b/packages/x-date-pickers/src/internals/index.ts
index 550d8d504ee6a..44531aaf5308d 100644
--- a/packages/x-date-pickers/src/internals/index.ts
+++ b/packages/x-date-pickers/src/internals/index.ts
@@ -106,6 +106,7 @@ export {
createDateStrFromSections,
addPositionPropertiesToSections,
splitFormatIntoSections,
+ getSectionOrder,
} from './hooks/useField';
export type {
UseFieldInternalProps,
diff --git a/packages/x-date-pickers/src/internals/utils/valueManagers.ts b/packages/x-date-pickers/src/internals/utils/valueManagers.ts
index 0f2096613bf77..c189a4498553c 100644
--- a/packages/x-date-pickers/src/internals/utils/valueManagers.ts
+++ b/packages/x-date-pickers/src/internals/utils/valueManagers.ts
@@ -8,6 +8,7 @@ import {
addPositionPropertiesToSections,
createDateStrFromSections,
splitFormatIntoSections,
+ getSectionOrder,
} from '../hooks/useField/useField.utils';
export type SingleItemPickerStateValueManager<
@@ -53,4 +54,6 @@ export const singleItemFieldValueManager: FieldValueManager<
parseValueStr: (valueStr, referenceValue, parseDate) =>
parseDate(valueStr.trim(), referenceValue),
hasError: (error) => error != null,
+ getSectionOrder: (utils, localeText, format, isRTL) =>
+ getSectionOrder(splitFormatIntoSections(utils, localeText, format, null), isRTL),
};
diff --git a/test/utils/pickers-utils.tsx b/test/utils/pickers-utils.tsx
index 78873f2d43cb1..88439fd0f0a4f 100644
--- a/test/utils/pickers-utils.tsx
+++ b/test/utils/pickers-utils.tsx
@@ -295,12 +295,17 @@ export const stubMatchMedia = (matches = true) =>
export const getPickerDay = (name: string, picker = 'January 2018') =>
getByRole(screen.getByText(picker)?.parentElement?.parentElement, 'gridcell', { name });
+export const cleanText = (text) => text.replace(/\u200e|\u2066|\u2067|\u2068|\u2069/g, '');
+
+export const getCleanedSelectedContent = (input: HTMLInputElement) =>
+ cleanText(input.value.slice(input.selectionStart ?? 0, input.selectionEnd ?? 0));
+
export const expectInputValue = (
input: HTMLInputElement,
expectedValue: string,
shouldRemoveDashSpaces: boolean = false,
) => {
- let value = input.value.replace(/\u200e|\u2068|\u2069/g, '');
+ let value = cleanText(input.value);
if (shouldRemoveDashSpaces) {
value = value.replace(/ \/ /g, '/');
}