187 lines
7.9 KiB
JavaScript
187 lines
7.9 KiB
JavaScript
import { useReducer, useRef, useLayoutEffect, useEffect } from 'react';
|
|
|
|
const useRifm = props => {
|
|
const [, refresh] = useReducer(c => c + 1, 0);
|
|
const valueRef = useRef(null);
|
|
const {
|
|
replace,
|
|
append
|
|
} = props;
|
|
const userValue = replace ? replace(props.format(props.value)) : props.format(props.value); // state of delete button see comments below about inputType support
|
|
|
|
const isDeleleteButtonDownRef = useRef(false);
|
|
|
|
const onChange = evt => {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
if (evt.target.type === 'number') {
|
|
console.error('Rifm does not support input type=number, use type=tel instead.');
|
|
return;
|
|
}
|
|
|
|
if (evt.target.type === 'date') {
|
|
console.error('Rifm does not support input type=date.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const eventValue = evt.target.value;
|
|
valueRef.current = [eventValue, // eventValue
|
|
evt.target, // input
|
|
eventValue.length > userValue.length, // isSizeIncreaseOperation
|
|
isDeleleteButtonDownRef.current, // isDeleleteButtonDown
|
|
userValue === props.format(eventValue) // isNoOperation
|
|
];
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
const formattedEventValue = props.format(eventValue);
|
|
|
|
if (eventValue !== formattedEventValue && eventValue.toLowerCase() === formattedEventValue.toLowerCase()) {
|
|
console.warn('Case enforcement does not work with format. Please use replace={value => value.toLowerCase()} instead');
|
|
}
|
|
} // The main trick is to update underlying input with non formatted value (= eventValue)
|
|
// that allows us to calculate right cursor position after formatting (see getCursorPosition)
|
|
// then we format new value and call props.onChange with masked/formatted value
|
|
// and finally we are able to set cursor position into right place
|
|
|
|
|
|
refresh();
|
|
}; // React prints warn on server in non production mode about useLayoutEffect usage
|
|
// in both cases it's noop
|
|
|
|
|
|
if (process.env.NODE_ENV === 'production' || typeof window !== 'undefined') {
|
|
useLayoutEffect(() => {
|
|
if (valueRef.current == null) return;
|
|
let [eventValue, input, isSizeIncreaseOperation, isDeleleteButtonDown, // No operation means that value itself hasn't been changed, BTW cursor, selection etc can be changed
|
|
isNoOperation] = valueRef.current;
|
|
valueRef.current = null; // this usually occurs on deleting special symbols like ' here 123'123.00
|
|
// in case of isDeleleteButtonDown cursor should move differently vs backspace
|
|
|
|
const deleteWasNoOp = isDeleleteButtonDown && isNoOperation;
|
|
const valueAfterSelectionStart = eventValue.slice(input.selectionStart);
|
|
const acceptedCharIndexAfterDelete = valueAfterSelectionStart.search(props.accept || /\d/g);
|
|
const charsToSkipAfterDelete = acceptedCharIndexAfterDelete !== -1 ? acceptedCharIndexAfterDelete : 0; // Create string from only accepted symbols
|
|
|
|
const clean = str => (str.match(props.accept || /\d/g) || []).join('');
|
|
|
|
const valueBeforeSelectionStart = clean(eventValue.substr(0, input.selectionStart)); // trying to find cursor position in formatted value having knowledge about valueBeforeSelectionStart
|
|
// This works because we assume that format doesn't change the order of accepted symbols.
|
|
// Imagine we have formatter which adds ' symbol between numbers, and by default we refuse all non numeric symbols
|
|
// for example we had input = 1'2|'4 (| means cursor position) then user entered '3' symbol
|
|
// inputValue = 1'23'|4 so valueBeforeSelectionStart = 123 and formatted value = 1'2'3'4
|
|
// calling getCursorPosition("1'2'3'4") will give us position after 3, 1'2'3|'4
|
|
// so for formatting just this function to determine cursor position after formatting is enough
|
|
// with masking we need to do some additional checks see `mask` below
|
|
|
|
const getCursorPosition = val => {
|
|
let start = 0;
|
|
let cleanPos = 0;
|
|
|
|
for (let i = 0; i !== valueBeforeSelectionStart.length; ++i) {
|
|
let newPos = val.indexOf(valueBeforeSelectionStart[i], start) + 1;
|
|
let newCleanPos = clean(val).indexOf(valueBeforeSelectionStart[i], cleanPos) + 1; // this skips position change if accepted symbols order was broken
|
|
// For example fixes edge case with fixed point numbers:
|
|
// You have '0|.00', then press 1, it becomes 01|.00 and after format 1.00, this breaks our assumption
|
|
// that order of accepted symbols is not changed after format,
|
|
// so here we don't update start position if other accepted symbols was inbetween current and new position
|
|
|
|
if (newCleanPos - cleanPos > 1) {
|
|
newPos = start;
|
|
newCleanPos = cleanPos;
|
|
}
|
|
|
|
cleanPos = Math.max(newCleanPos, cleanPos);
|
|
start = Math.max(start, newPos);
|
|
}
|
|
|
|
return start;
|
|
}; // Masking part, for masks if size of mask is above some value
|
|
// we need to replace symbols instead of do nothing as like in format
|
|
|
|
|
|
if (props.mask === true && isSizeIncreaseOperation && !isNoOperation) {
|
|
let start = getCursorPosition(eventValue);
|
|
const c = clean(eventValue.substr(start))[0];
|
|
start = eventValue.indexOf(c, start);
|
|
eventValue = `${eventValue.substr(0, start)}${eventValue.substr(start + 1)}`;
|
|
}
|
|
|
|
let formattedValue = props.format(eventValue);
|
|
|
|
if (append != null && // cursor at the end
|
|
input.selectionStart === eventValue.length && !isNoOperation) {
|
|
if (isSizeIncreaseOperation) {
|
|
formattedValue = append(formattedValue);
|
|
} else {
|
|
// If after delete last char is special character and we use append
|
|
// delete it too
|
|
// was: "12-3|" backspace pressed, then should be "12|"
|
|
if (clean(formattedValue.slice(-1)) === '') {
|
|
formattedValue = formattedValue.slice(0, -1);
|
|
}
|
|
}
|
|
}
|
|
|
|
const replacedValue = replace ? replace(formattedValue) : formattedValue;
|
|
|
|
if (userValue === replacedValue) {
|
|
// if nothing changed for formatted value, just refresh so userValue will be used at render
|
|
refresh();
|
|
} else {
|
|
props.onChange(replacedValue);
|
|
}
|
|
|
|
return () => {
|
|
let start = getCursorPosition(formattedValue); // Visually improves working with masked values,
|
|
// like cursor jumping over refused symbols
|
|
// as an example date mask: was "5|1-24-3" then user pressed "6"
|
|
// it becomes "56-|12-43" with this code, and "56|-12-43" without
|
|
|
|
if (props.mask != null && (isSizeIncreaseOperation || isDeleleteButtonDown && !deleteWasNoOp)) {
|
|
while (formattedValue[start] && clean(formattedValue[start]) === '') {
|
|
start += 1;
|
|
}
|
|
}
|
|
|
|
input.selectionStart = input.selectionEnd = start + (deleteWasNoOp ? 1 + charsToSkipAfterDelete : 0);
|
|
};
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
// until https://developer.mozilla.org/en-US/docs/Web/API/InputEvent/inputType will be supported
|
|
// by all major browsers (now supported by: +chrome, +safari, ?edge, !firefox)
|
|
// there is no way I found to distinguish in onChange
|
|
// backspace or delete was called in some situations
|
|
// firefox track https://bugzilla.mozilla.org/show_bug.cgi?id=1447239
|
|
const handleKeyDown = evt => {
|
|
if (evt.code === 'Delete') {
|
|
isDeleleteButtonDownRef.current = true;
|
|
}
|
|
};
|
|
|
|
const handleKeyUp = evt => {
|
|
if (evt.code === 'Delete') {
|
|
isDeleleteButtonDownRef.current = false;
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
document.addEventListener('keyup', handleKeyUp);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
document.removeEventListener('keyup', handleKeyUp);
|
|
};
|
|
}, []);
|
|
return {
|
|
value: valueRef.current != null ? valueRef.current[0] : userValue,
|
|
onChange
|
|
};
|
|
};
|
|
const Rifm = props => {
|
|
const renderProps = useRifm(props);
|
|
return props.children(renderProps);
|
|
};
|
|
|
|
export { Rifm, useRifm };
|