diff --git a/addons/web/static/src/core/datetime/datetime_hook.js b/addons/web/static/src/core/datetime/datetime_hook.js index 55ed7e7d86e..0eec201353c 100644 --- a/addons/web/static/src/core/datetime/datetime_hook.js +++ b/addons/web/static/src/core/datetime/datetime_hook.js @@ -88,24 +88,27 @@ export const useDateTimePicker = (hookParams) => { }; /** - * Ensures the given input has the focus and applies the class names on all - * inputs accordingly. - * - * @param {HTMLInputElement} inputEl + * Ensures the current focused input (indicated by `pickerProps.focusedDateIndex`) + * is actually focused. */ - const focusInput = (inputEl) => { + const focusActiveInput = () => { + const inputEl = getInput(pickerProps.focusedDateIndex); + if (!inputEl) { + shouldFocusOnPatched = true; + return; + } + const { activeElement } = inputEl.ownerDocument; if (activeElement !== inputEl) { inputEl.focus(); } - inputEl.selectionStart = 0; - inputEl.selectionEnd = inputEl.value.length; - setFocusClass(inputEl); + + setInputFocus(inputEl); }; /** * @param {number} valueIndex - * @returns + * @returns {HTMLInputElement | null} */ const getInput = (valueIndex) => { const el = inputRefs[valueIndex]?.el; @@ -174,17 +177,7 @@ export const useDateTimePicker = (hookParams) => { * @param {PointerEvent} ev */ const onInputClick = ({ target }) => { - pickerProps.focusedDateIndex = target === getInput(1) ? 1 : 0; - - if (!popover.isOpen) { - if (target && env.isSmall) { - target.scrollIntoView(true); - } - - popover.open(getPopoverTarget(), { pickerProps }); - } - - focusInput(target); + openPicker(target === getInput(1) ? 1 : 0); }; /** @@ -192,7 +185,7 @@ export const useDateTimePicker = (hookParams) => { */ const onInputFocus = ({ target }) => { pickerProps.focusedDateIndex = target === getInput(1) ? 1 : 0; - focusInput(target); + setInputFocus(target); }; /** @@ -202,7 +195,6 @@ export const useDateTimePicker = (hookParams) => { switch (ev.key) { case "Enter": case "Escape": { - ev.preventDefault(); return saveAndClose(); } case "Tab": { @@ -213,6 +205,23 @@ export const useDateTimePicker = (hookParams) => { } }; + /** + * @param {number} inputIndex Input from which to open the picker + */ + const openPicker = (inputIndex) => { + pickerProps.focusedDateIndex = inputIndex; + + if (!popover.isOpen) { + const popoverTarget = getPopoverTarget(); + if (env.isSmall) { + popoverTarget.scrollIntoView(true); + } + popover.open(popoverTarget, { pickerProps }); + } + + focusActiveInput(); + }; + /** * @template {"format" | "parse"} T * @param {T} operation @@ -262,6 +271,20 @@ export const useDateTimePicker = (hookParams) => { } }; + /** + * Applies class names to all inputs according to whether they are focused or not. + * + * @param {HTMLInputElement} inputEl + */ + const setInputFocus = (inputEl) => { + inputEl.selectionStart = 0; + inputEl.selectionEnd = inputEl.value.length; + + setFocusClass(inputEl); + + shouldFocusOnPatched = false; + }; + /** * Synchronizes the given input with the given value. * @@ -277,26 +300,26 @@ export const useDateTimePicker = (hookParams) => { * @param {DateTimePickerProps["value"]} value */ const updateValue = (value) => { + const previousValue = pickerProps.value; + pickerProps.value = value; + + if (areDatesEqual(previousValue, pickerProps.value)) { + return; + } + if (pickerProps.range) { // When in range: compare each individual value - const [currentStart, currentEnd] = ensureArray(pickerProps.value); - const [nextStart, nextEnd] = ensureArray(value); - const areStartDatesEqual = areDatesEqual(currentStart, nextStart); - const areEndDatesEqual = areDatesEqual(currentEnd, nextEnd); + const [prevStart, prevEnd] = ensureArray(previousValue); + const [nextStart, nextEnd] = ensureArray(pickerProps.value); if ( - (pickerProps.focusedDateIndex === 0 && areEndDatesEqual) || - (pickerProps.focusedDateIndex === 1 && areStartDatesEqual) + (pickerProps.focusedDateIndex === 0 && areDatesEqual(prevEnd, nextEnd)) || + (pickerProps.focusedDateIndex === 1 && areDatesEqual(prevStart, nextStart)) ) { pickerProps.focusedDateIndex = pickerProps.focusedDateIndex === 1 ? 0 : 1; } } - const previousValue = pickerProps.value; - pickerProps.value = value; - - if (!areDatesEqual(previousValue, pickerProps.value)) { - onChange?.(pickerProps.value); - } + onChange?.(pickerProps.value); }; const updateValueFromInputs = () => { @@ -322,8 +345,8 @@ export const useDateTimePicker = (hookParams) => { // Hook variables const { format, onApply, onChange, target } = hookParams; - /** @type {ReturnType>[]} */ const targetRef = typeof target === "string" ? useRef(target) : target; + /** @type {ReturnType>[]} */ const inputRefs = [useRef("start-date"), useRef("end-date")]; const env = useEnv(); const popover = usePopover(DateTimePickerPopover, { @@ -367,33 +390,20 @@ export const useDateTimePicker = (hookParams) => { } } - // Will focus input on next patch - shouldFocus = true; - setFocusClass(getInput(pickerProps.focusedDateIndex)); + shouldFocusOnPatched = true; }); /** Decides whether the popover 'onClose' callback can be called */ let allowOnClose = true; /** @type {boolean[]} */ let inputsChanged = []; - let lastIsRange = pickerProps.range; /** @type {DateTimePickerProps | null} */ let lastInitialProps = null; - let shouldFocus = false; + let lastIsRange = pickerProps.range; + let shouldFocusOnPatched = false; onWillRender(computeBasePickerProps); - onPatched(() => { - if (!shouldFocus || !popover.isOpen) { - return; - } - const input = getInput(pickerProps.focusedDateIndex); - if (input) { - shouldFocus = false; - focusInput(input); - } - }); - const cleanups = []; useEffect(() => { let editableInputs = 0; @@ -418,5 +428,13 @@ export const useDateTimePicker = (hookParams) => { }; }); - return pickerProps; + // Note: this `onPatched` callback must be called after the `useEffect` since + // the effect may change input values that will be selected by the patch callback. + onPatched(() => { + if (popover.isOpen && shouldFocusOnPatched) { + focusActiveInput(); + } + }); + + return { state: pickerProps, open: openPicker }; }; diff --git a/addons/web/static/src/core/utils/arrays.js b/addons/web/static/src/core/utils/arrays.js index 009007a51f9..98a1dd9ecb4 100644 --- a/addons/web/static/src/core/utils/arrays.js +++ b/addons/web/static/src/core/utils/arrays.js @@ -57,7 +57,8 @@ function _getExtractorFrom(criterion) { /** * @template T - * @param {T | T[]} value + * @param {T | Iterable} value + * @returns {T[]} */ export function ensureArray(value) { return value && typeof value === "object" && value[Symbol.iterator] ? [...value] : [value]; diff --git a/addons/web/static/src/views/fields/datetime/datetime_field.js b/addons/web/static/src/views/fields/datetime/datetime_field.js index 6719d3adf64..d0b8c24ece7 100644 --- a/addons/web/static/src/views/fields/datetime/datetime_field.js +++ b/addons/web/static/src/views/fields/datetime/datetime_field.js @@ -98,7 +98,7 @@ export class DateTimeField extends Component { return pickerProps; }; - const pickerState = useDateTimePicker({ + const dateTimePicker = useDateTimePicker({ target: "root", get pickerProps() { return getPickerProps(); @@ -125,9 +125,9 @@ export class DateTimeField extends Component { } }, }); - // Subscribes to changes made on the picker state - this.state = useState(pickerState); + this.state = useState(dateTimePicker.state); + this.openPicker = dateTimePicker.open; onWillRender(() => this.triggerIsDirty()); } @@ -146,6 +146,8 @@ export class DateTimeField extends Component { this.state.focusedDateIndex = valueIndex; this.state.value = values; this.state.range = true; + + this.openPicker(valueIndex); } /** diff --git a/addons/web/static/tests/core/datetime/datetime_hook_tests.js b/addons/web/static/tests/core/datetime/datetime_hook_tests.js index d3908ecda47..60ff01fa51c 100644 --- a/addons/web/static/tests/core/datetime/datetime_hook_tests.js +++ b/addons/web/static/tests/core/datetime/datetime_hook_tests.js @@ -115,7 +115,7 @@ QUnit.module("Components", ({ beforeEach }) => { }; const input = await mountInput(() => { - pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }); + pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state; }); assert.strictEqual(input.value, ""); @@ -135,7 +135,7 @@ QUnit.module("Components", ({ beforeEach }) => { }; const input = await mountInput(() => { - pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }); + pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state; }); assert.strictEqual(input.value, ""); diff --git a/addons/web/static/tests/views/fields/daterange_field_tests.js b/addons/web/static/tests/views/fields/daterange_field_tests.js index 0a9a257d918..f18157e2ac8 100644 --- a/addons/web/static/tests/views/fields/daterange_field_tests.js +++ b/addons/web/static/tests/views/fields/daterange_field_tests.js @@ -648,6 +648,7 @@ QUnit.module("Fields", (hooks) => { await nextTick(); assert.notOk(isHiddenByCSS(target.querySelector(".o_add_date"))); + assert.containsNone(target, ".o_datetime_picker"); assert.strictEqual( target.querySelector(".o_add_date").innerText.trim().toLowerCase(), "add end date" @@ -657,6 +658,7 @@ QUnit.module("Fields", (hooks) => { await click(target.querySelector(".o_add_date")); const [startInput, endInput] = target.querySelectorAll(".o_field_daterange input"); + assert.containsOnce(target, ".o_datetime_picker"); assert.strictEqual( startInput.value, endInput.value,