Files
Iliyan Angelov 306b20e24a Frontend start
2025-09-14 00:54:48 +03:00

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 };