447 lines
18 KiB
JavaScript
447 lines
18 KiB
JavaScript
import _extends from "@babel/runtime/helpers/esm/extends";
|
|
import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
|
|
const _excluded = ["onClick", "onKeyDown", "onFocus", "onBlur", "onMouseUp", "onPaste", "error", "clearable", "onClear", "disabled"];
|
|
import * as React from 'react';
|
|
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
|
|
import useEventCallback from '@mui/utils/useEventCallback';
|
|
import useForkRef from '@mui/utils/useForkRef';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import { useValidation } from '../useValidation';
|
|
import { useUtils } from '../useUtils';
|
|
import { adjustSectionValue, isAndroid, cleanString, getSectionOrder } from './useField.utils';
|
|
import { useFieldState } from './useFieldState';
|
|
import { useFieldCharacterEditing } from './useFieldCharacterEditing';
|
|
import { getActiveElement } from '../../utils/utils';
|
|
export const useField = params => {
|
|
const utils = useUtils();
|
|
const {
|
|
state,
|
|
selectedSectionIndexes,
|
|
setSelectedSections,
|
|
clearValue,
|
|
clearActiveSection,
|
|
updateSectionValue,
|
|
updateValueFromValueStr,
|
|
setTempAndroidValueStr,
|
|
sectionsValueBoundaries,
|
|
placeholder,
|
|
timezone
|
|
} = useFieldState(params);
|
|
const {
|
|
inputRef: inputRefProp,
|
|
internalProps,
|
|
internalProps: {
|
|
readOnly = false,
|
|
unstableFieldRef,
|
|
minutesStep
|
|
},
|
|
forwardedProps: {
|
|
onClick,
|
|
onKeyDown,
|
|
onFocus,
|
|
onBlur,
|
|
onMouseUp,
|
|
onPaste,
|
|
error,
|
|
clearable,
|
|
onClear,
|
|
disabled
|
|
},
|
|
fieldValueManager,
|
|
valueManager,
|
|
validator
|
|
} = params,
|
|
otherForwardedProps = _objectWithoutPropertiesLoose(params.forwardedProps, _excluded);
|
|
const {
|
|
applyCharacterEditing,
|
|
resetCharacterQuery
|
|
} = useFieldCharacterEditing({
|
|
sections: state.sections,
|
|
updateSectionValue,
|
|
sectionsValueBoundaries,
|
|
setTempAndroidValueStr,
|
|
timezone
|
|
});
|
|
const inputRef = React.useRef(null);
|
|
const handleRef = useForkRef(inputRefProp, inputRef);
|
|
const focusTimeoutRef = React.useRef(undefined);
|
|
const theme = useTheme();
|
|
const isRTL = theme.direction === 'rtl';
|
|
const sectionOrder = React.useMemo(() => getSectionOrder(state.sections, isRTL), [state.sections, isRTL]);
|
|
const syncSelectionFromDOM = () => {
|
|
var _selectionStart;
|
|
if (readOnly) {
|
|
setSelectedSections(null);
|
|
return;
|
|
}
|
|
const browserStartIndex = (_selectionStart = inputRef.current.selectionStart) != null ? _selectionStart : 0;
|
|
let nextSectionIndex;
|
|
if (browserStartIndex <= state.sections[0].startInInput) {
|
|
// Special case if browser index is in invisible characters at the beginning
|
|
nextSectionIndex = 1;
|
|
} else if (browserStartIndex >= state.sections[state.sections.length - 1].endInInput) {
|
|
// If the click is after the last character of the input, then we want to select the 1st section.
|
|
nextSectionIndex = 1;
|
|
} else {
|
|
nextSectionIndex = state.sections.findIndex(section => section.startInInput - section.startSeparator.length > browserStartIndex);
|
|
}
|
|
const sectionIndex = nextSectionIndex === -1 ? state.sections.length - 1 : nextSectionIndex - 1;
|
|
setSelectedSections(sectionIndex);
|
|
};
|
|
const handleInputClick = useEventCallback((event, ...args) => {
|
|
// The click event on the clear button would propagate to the input, trigger this handler and result in a wrong section selection.
|
|
// We avoid this by checking if the call of `handleInputClick` is actually intended, or a side effect.
|
|
if (event.isDefaultPrevented()) {
|
|
return;
|
|
}
|
|
onClick == null || onClick(event, ...args);
|
|
syncSelectionFromDOM();
|
|
});
|
|
const handleInputMouseUp = useEventCallback(event => {
|
|
onMouseUp == null || onMouseUp(event);
|
|
|
|
// Without this, the browser will remove the selected when clicking inside an already-selected section.
|
|
event.preventDefault();
|
|
});
|
|
const handleInputFocus = useEventCallback((...args) => {
|
|
onFocus == null || onFocus(...args);
|
|
// The ref is guaranteed to be resolved at this point.
|
|
const input = inputRef.current;
|
|
window.clearTimeout(focusTimeoutRef.current);
|
|
focusTimeoutRef.current = setTimeout(() => {
|
|
// The ref changed, the component got remounted, the focus event is no longer relevant.
|
|
if (!input || input !== inputRef.current) {
|
|
return;
|
|
}
|
|
if (selectedSectionIndexes != null || readOnly) {
|
|
return;
|
|
}
|
|
if (
|
|
// avoid selecting all sections when focusing empty field without value
|
|
input.value.length && Number(input.selectionEnd) - Number(input.selectionStart) === input.value.length) {
|
|
setSelectedSections('all');
|
|
} else {
|
|
syncSelectionFromDOM();
|
|
}
|
|
});
|
|
});
|
|
const handleInputBlur = useEventCallback((...args) => {
|
|
onBlur == null || onBlur(...args);
|
|
setSelectedSections(null);
|
|
});
|
|
const handleInputPaste = useEventCallback(event => {
|
|
onPaste == null || onPaste(event);
|
|
if (readOnly) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
const pastedValue = event.clipboardData.getData('text');
|
|
if (selectedSectionIndexes && selectedSectionIndexes.startIndex === selectedSectionIndexes.endIndex) {
|
|
const activeSection = state.sections[selectedSectionIndexes.startIndex];
|
|
const lettersOnly = /^[a-zA-Z]+$/.test(pastedValue);
|
|
const digitsOnly = /^[0-9]+$/.test(pastedValue);
|
|
const digitsAndLetterOnly = /^(([a-zA-Z]+)|)([0-9]+)(([a-zA-Z]+)|)$/.test(pastedValue);
|
|
const isValidPastedValue = activeSection.contentType === 'letter' && lettersOnly || activeSection.contentType === 'digit' && digitsOnly || activeSection.contentType === 'digit-with-letter' && digitsAndLetterOnly;
|
|
if (isValidPastedValue) {
|
|
resetCharacterQuery();
|
|
updateSectionValue({
|
|
activeSection,
|
|
newSectionValue: pastedValue,
|
|
shouldGoToNextSection: true
|
|
});
|
|
// prevent default to avoid the input change handler being called
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (lettersOnly || digitsOnly) {
|
|
// The pasted value correspond to a single section but not the expected type
|
|
// skip the modification
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
event.preventDefault();
|
|
resetCharacterQuery();
|
|
updateValueFromValueStr(pastedValue);
|
|
});
|
|
const handleInputChange = useEventCallback(event => {
|
|
if (readOnly) {
|
|
return;
|
|
}
|
|
const targetValue = event.target.value;
|
|
if (targetValue === '') {
|
|
resetCharacterQuery();
|
|
clearValue();
|
|
return;
|
|
}
|
|
const eventData = event.nativeEvent.data;
|
|
// Calling `.fill(04/11/2022)` in playwright will trigger a change event with the requested content to insert in `event.nativeEvent.data`
|
|
// usual changes have only the currently typed character in the `event.nativeEvent.data`
|
|
const shouldUseEventData = eventData && eventData.length > 1;
|
|
const valueStr = shouldUseEventData ? eventData : targetValue;
|
|
const cleanValueStr = cleanString(valueStr);
|
|
|
|
// If no section is selected or eventData should be used, we just try to parse the new value
|
|
// This line is mostly triggered by imperative code / application tests.
|
|
if (selectedSectionIndexes == null || shouldUseEventData) {
|
|
updateValueFromValueStr(shouldUseEventData ? eventData : cleanValueStr);
|
|
return;
|
|
}
|
|
let keyPressed;
|
|
if (selectedSectionIndexes.startIndex === 0 && selectedSectionIndexes.endIndex === state.sections.length - 1 && cleanValueStr.length === 1) {
|
|
keyPressed = cleanValueStr;
|
|
} else {
|
|
const prevValueStr = cleanString(fieldValueManager.getValueStrFromSections(state.sections, isRTL));
|
|
let startOfDiffIndex = -1;
|
|
let endOfDiffIndex = -1;
|
|
for (let i = 0; i < prevValueStr.length; i += 1) {
|
|
if (startOfDiffIndex === -1 && prevValueStr[i] !== cleanValueStr[i]) {
|
|
startOfDiffIndex = i;
|
|
}
|
|
if (endOfDiffIndex === -1 && prevValueStr[prevValueStr.length - i - 1] !== cleanValueStr[cleanValueStr.length - i - 1]) {
|
|
endOfDiffIndex = i;
|
|
}
|
|
}
|
|
const activeSection = state.sections[selectedSectionIndexes.startIndex];
|
|
const hasDiffOutsideOfActiveSection = startOfDiffIndex < activeSection.start || prevValueStr.length - endOfDiffIndex - 1 > activeSection.end;
|
|
if (hasDiffOutsideOfActiveSection) {
|
|
// TODO: Support if the new date is valid
|
|
return;
|
|
}
|
|
|
|
// The active section being selected, the browser has replaced its value with the key pressed by the user.
|
|
const activeSectionEndRelativeToNewValue = cleanValueStr.length - prevValueStr.length + activeSection.end - cleanString(activeSection.endSeparator || '').length;
|
|
keyPressed = cleanValueStr.slice(activeSection.start + cleanString(activeSection.startSeparator || '').length, activeSectionEndRelativeToNewValue);
|
|
}
|
|
if (keyPressed.length === 0) {
|
|
if (isAndroid()) {
|
|
setTempAndroidValueStr(valueStr);
|
|
} else {
|
|
resetCharacterQuery();
|
|
clearActiveSection();
|
|
}
|
|
return;
|
|
}
|
|
applyCharacterEditing({
|
|
keyPressed,
|
|
sectionIndex: selectedSectionIndexes.startIndex
|
|
});
|
|
});
|
|
const handleInputKeyDown = useEventCallback(event => {
|
|
onKeyDown == null || onKeyDown(event);
|
|
|
|
// eslint-disable-next-line default-case
|
|
switch (true) {
|
|
// Select all
|
|
case event.key === 'a' && (event.ctrlKey || event.metaKey):
|
|
{
|
|
// prevent default to make sure that the next line "select all" while updating
|
|
// the internal state at the same time.
|
|
event.preventDefault();
|
|
setSelectedSections('all');
|
|
break;
|
|
}
|
|
|
|
// Move selection to next section
|
|
case event.key === 'ArrowRight':
|
|
{
|
|
event.preventDefault();
|
|
if (selectedSectionIndexes == null) {
|
|
setSelectedSections(sectionOrder.startIndex);
|
|
} else if (selectedSectionIndexes.startIndex !== selectedSectionIndexes.endIndex) {
|
|
setSelectedSections(selectedSectionIndexes.endIndex);
|
|
} else {
|
|
const nextSectionIndex = sectionOrder.neighbors[selectedSectionIndexes.startIndex].rightIndex;
|
|
if (nextSectionIndex !== null) {
|
|
setSelectedSections(nextSectionIndex);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Move selection to previous section
|
|
case event.key === 'ArrowLeft':
|
|
{
|
|
event.preventDefault();
|
|
if (selectedSectionIndexes == null) {
|
|
setSelectedSections(sectionOrder.endIndex);
|
|
} else if (selectedSectionIndexes.startIndex !== selectedSectionIndexes.endIndex) {
|
|
setSelectedSections(selectedSectionIndexes.startIndex);
|
|
} else {
|
|
const nextSectionIndex = sectionOrder.neighbors[selectedSectionIndexes.startIndex].leftIndex;
|
|
if (nextSectionIndex !== null) {
|
|
setSelectedSections(nextSectionIndex);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Reset the value of the selected section
|
|
case event.key === 'Delete':
|
|
{
|
|
event.preventDefault();
|
|
if (readOnly) {
|
|
break;
|
|
}
|
|
if (selectedSectionIndexes == null || selectedSectionIndexes.startIndex === 0 && selectedSectionIndexes.endIndex === state.sections.length - 1) {
|
|
clearValue();
|
|
} else {
|
|
clearActiveSection();
|
|
}
|
|
resetCharacterQuery();
|
|
break;
|
|
}
|
|
|
|
// Increment / decrement the selected section value
|
|
case ['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key):
|
|
{
|
|
event.preventDefault();
|
|
if (readOnly || selectedSectionIndexes == null) {
|
|
break;
|
|
}
|
|
const activeSection = state.sections[selectedSectionIndexes.startIndex];
|
|
const activeDateManager = fieldValueManager.getActiveDateManager(utils, state, activeSection);
|
|
const newSectionValue = adjustSectionValue(utils, timezone, activeSection, event.key, sectionsValueBoundaries, activeDateManager.date, {
|
|
minutesStep
|
|
});
|
|
updateSectionValue({
|
|
activeSection,
|
|
newSectionValue,
|
|
shouldGoToNextSection: false
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
useEnhancedEffect(() => {
|
|
if (!inputRef.current) {
|
|
return;
|
|
}
|
|
if (selectedSectionIndexes == null) {
|
|
if (inputRef.current.scrollLeft) {
|
|
// Ensure that input content is not marked as selected.
|
|
// setting selection range to 0 causes issues in Safari.
|
|
// https://bugs.webkit.org/show_bug.cgi?id=224425
|
|
inputRef.current.scrollLeft = 0;
|
|
}
|
|
return;
|
|
}
|
|
const firstSelectedSection = state.sections[selectedSectionIndexes.startIndex];
|
|
const lastSelectedSection = state.sections[selectedSectionIndexes.endIndex];
|
|
let selectionStart = firstSelectedSection.startInInput;
|
|
let selectionEnd = lastSelectedSection.endInInput;
|
|
if (selectedSectionIndexes.shouldSelectBoundarySelectors) {
|
|
selectionStart -= firstSelectedSection.startSeparator.length;
|
|
selectionEnd += lastSelectedSection.endSeparator.length;
|
|
}
|
|
if (selectionStart !== inputRef.current.selectionStart || selectionEnd !== inputRef.current.selectionEnd) {
|
|
// Fix scroll jumping on iOS browser: https://github.com/mui/mui-x/issues/8321
|
|
const currentScrollTop = inputRef.current.scrollTop;
|
|
// On multi input range pickers we want to update selection range only for the active input
|
|
// This helps to avoid the focus jumping on Safari https://github.com/mui/mui-x/issues/9003
|
|
// because WebKit implements the `setSelectionRange` based on the spec: https://bugs.webkit.org/show_bug.cgi?id=224425
|
|
if (inputRef.current === getActiveElement(document)) {
|
|
inputRef.current.setSelectionRange(selectionStart, selectionEnd);
|
|
}
|
|
// Even reading this variable seems to do the trick, but also setting it just to make use of it
|
|
inputRef.current.scrollTop = currentScrollTop;
|
|
}
|
|
});
|
|
const validationError = useValidation(_extends({}, internalProps, {
|
|
value: state.value,
|
|
timezone
|
|
}), validator, valueManager.isSameError, valueManager.defaultErrorState);
|
|
const inputError = React.useMemo(() => {
|
|
// only override when `error` is undefined.
|
|
// in case of multi input fields, the `error` value is provided externally and will always be defined.
|
|
if (error !== undefined) {
|
|
return error;
|
|
}
|
|
return valueManager.hasError(validationError);
|
|
}, [valueManager, validationError, error]);
|
|
React.useEffect(() => {
|
|
if (!inputError && !selectedSectionIndexes) {
|
|
resetCharacterQuery();
|
|
}
|
|
}, [state.referenceValue, selectedSectionIndexes, inputError]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
React.useEffect(() => {
|
|
// Select the right section when focused on mount (`autoFocus = true` on the input)
|
|
if (inputRef.current && inputRef.current === document.activeElement) {
|
|
setSelectedSections('all');
|
|
}
|
|
return () => window.clearTimeout(focusTimeoutRef.current);
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// If `state.tempValueStrAndroid` is still defined when running `useEffect`,
|
|
// Then `onChange` has only been called once, which means the user pressed `Backspace` to reset the section.
|
|
// This causes a small flickering on Android,
|
|
// But we can't use `useEnhancedEffect` which is always called before the second `onChange` call and then would cause false positives.
|
|
React.useEffect(() => {
|
|
if (state.tempValueStrAndroid != null && selectedSectionIndexes != null) {
|
|
resetCharacterQuery();
|
|
clearActiveSection();
|
|
}
|
|
}, [state.tempValueStrAndroid]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const valueStr = React.useMemo(() => {
|
|
var _state$tempValueStrAn;
|
|
return (_state$tempValueStrAn = state.tempValueStrAndroid) != null ? _state$tempValueStrAn : fieldValueManager.getValueStrFromSections(state.sections, isRTL);
|
|
}, [state.sections, fieldValueManager, state.tempValueStrAndroid, isRTL]);
|
|
const inputMode = React.useMemo(() => {
|
|
if (selectedSectionIndexes == null) {
|
|
return 'text';
|
|
}
|
|
if (state.sections[selectedSectionIndexes.startIndex].contentType === 'letter') {
|
|
return 'text';
|
|
}
|
|
return 'numeric';
|
|
}, [selectedSectionIndexes, state.sections]);
|
|
const inputHasFocus = inputRef.current && inputRef.current === getActiveElement(document);
|
|
const areAllSectionsEmpty = valueManager.areValuesEqual(utils, state.value, valueManager.emptyValue);
|
|
const shouldShowPlaceholder = !inputHasFocus && areAllSectionsEmpty;
|
|
React.useImperativeHandle(unstableFieldRef, () => ({
|
|
getSections: () => state.sections,
|
|
getActiveSectionIndex: () => {
|
|
var _selectionStart2, _selectionEnd, _inputRef$current;
|
|
const browserStartIndex = (_selectionStart2 = inputRef.current.selectionStart) != null ? _selectionStart2 : 0;
|
|
const browserEndIndex = (_selectionEnd = inputRef.current.selectionEnd) != null ? _selectionEnd : 0;
|
|
const isInputReadOnly = !!((_inputRef$current = inputRef.current) != null && _inputRef$current.readOnly);
|
|
if (browserStartIndex === 0 && browserEndIndex === 0 || isInputReadOnly) {
|
|
return null;
|
|
}
|
|
const nextSectionIndex = browserStartIndex <= state.sections[0].startInInput ? 1 // Special case if browser index is in invisible characters at the beginning.
|
|
: state.sections.findIndex(section => section.startInInput - section.startSeparator.length > browserStartIndex);
|
|
return nextSectionIndex === -1 ? state.sections.length - 1 : nextSectionIndex - 1;
|
|
},
|
|
setSelectedSections: activeSectionIndex => setSelectedSections(activeSectionIndex)
|
|
}));
|
|
const handleClearValue = useEventCallback((event, ...args) => {
|
|
var _inputRef$current2;
|
|
event.preventDefault();
|
|
onClear == null || onClear(event, ...args);
|
|
clearValue();
|
|
inputRef == null || (_inputRef$current2 = inputRef.current) == null || _inputRef$current2.focus();
|
|
setSelectedSections(0);
|
|
});
|
|
return _extends({
|
|
placeholder,
|
|
autoComplete: 'off',
|
|
disabled: Boolean(disabled)
|
|
}, otherForwardedProps, {
|
|
value: shouldShowPlaceholder ? '' : valueStr,
|
|
inputMode,
|
|
readOnly,
|
|
onClick: handleInputClick,
|
|
onFocus: handleInputFocus,
|
|
onBlur: handleInputBlur,
|
|
onPaste: handleInputPaste,
|
|
onChange: handleInputChange,
|
|
onKeyDown: handleInputKeyDown,
|
|
onMouseUp: handleInputMouseUp,
|
|
onClear: handleClearValue,
|
|
error: inputError,
|
|
ref: handleRef,
|
|
clearable: Boolean(clearable && !areAllSectionsEmpty && !readOnly && !disabled)
|
|
});
|
|
}; |