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, '/'); }