@mantine/hooks
Version:
A collection of 50+ hooks for state and UI management
578 lines (577 loc) • 21.6 kB
JavaScript
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
//#region packages/@mantine/hooks/src/use-mask/use-mask.ts
const DEFAULT_TOKENS = {
"9": /[0-9]/,
a: /[A-Za-z]/,
A: /[A-Z]/,
"*": /[A-Za-z0-9]/,
"#": /[-+0-9]/
};
const MAX_UNDO_HISTORY = 100;
function parseMask(mask, tokens) {
if (Array.isArray(mask)) return mask.map((item) => {
if (item instanceof RegExp) return {
type: "token",
char: "_",
pattern: item
};
return {
type: "literal",
char: item
};
});
const slots = [];
let optional = false;
for (let i = 0; i < mask.length; i++) {
const char = mask[i];
if (char === "\\" && i + 1 < mask.length) {
i++;
slots.push({
type: "literal",
char: mask[i]
});
continue;
}
if (char === "?") {
optional = true;
continue;
}
if (tokens[char]) slots.push({
type: "token",
char,
pattern: tokens[char],
optional
});
else slots.push({
type: "literal",
char,
optional
});
}
return slots;
}
function getSlotChar(slotCharOption, index) {
if (slotCharOption === null || slotCharOption === "" || slotCharOption === void 0) return "";
if (slotCharOption.length > 1) return slotCharOption[index] || "_";
return slotCharOption;
}
function applyMaskToRaw(raw, slots, _slotCharOption, transform) {
let result = "";
let rawIndex = 0;
let slotIndex = 0;
for (slotIndex = 0; slotIndex < slots.length; slotIndex++) {
const slot = slots[slotIndex];
if (slot.type === "literal") result += slot.char;
else if (rawIndex < raw.length) {
const ch = transform ? transform(raw[rawIndex]) : raw[rawIndex];
if (slot.pattern && slot.pattern.test(ch)) {
result += ch;
rawIndex++;
} else {
rawIndex++;
slotIndex--;
}
} else break;
}
return result;
}
function buildDisplayValue(value, slots, slotCharOption, showSlots) {
if (!showSlots) return value;
let display = value;
for (let i = value.length; i < slots.length; i++) {
const slot = slots[i];
if (slot.type === "literal") display += slot.char;
else {
const sc = getSlotChar(slotCharOption, i);
if (!sc) break;
display += sc;
}
}
return display;
}
function extractRaw(masked, slots) {
let raw = "";
for (let i = 0; i < masked.length && i < slots.length; i++) if (slots[i].type === "token") raw += masked[i];
return raw;
}
function checkComplete(masked, slots) {
for (let i = 0; i < slots.length; i++) if (slots[i].type === "token" && !slots[i].optional) {
if (i >= masked.length) return false;
if (!slots[i].pattern.test(masked[i])) return false;
}
return true;
}
function findNextTokenIndex(slots, from) {
for (let i = from; i < slots.length; i++) if (slots[i].type === "token") return i;
return slots.length;
}
function findPrevTokenIndex(slots, from) {
for (let i = from; i >= 0; i--) if (slots[i].type === "token") return i;
return -1;
}
function processInput(inputValue, slots, _slotCharOption) {
let result = "";
let inputIndex = 0;
for (let slotIndex = 0; slotIndex < slots.length && inputIndex <= inputValue.length; slotIndex++) {
const slot = slots[slotIndex];
if (slot.type === "literal") {
result += slot.char;
if (inputIndex < inputValue.length && inputValue[inputIndex] === slot.char) inputIndex++;
continue;
}
if (inputIndex >= inputValue.length) break;
while (inputIndex < inputValue.length) {
const ch = inputValue[inputIndex];
inputIndex++;
if (slot.pattern.test(ch)) {
result += ch;
break;
}
}
if (result.length <= slotIndex) break;
}
return result;
}
function getResolvedOptions(options, rawValue) {
const tokens = {
...DEFAULT_TOKENS,
...options.tokens
};
let mask = options.mask;
let slotChar = options.slotChar === void 0 ? "_" : options.slotChar;
let separate = options.separate ?? false;
if (options.modify) {
const overrides = options.modify(rawValue);
if (overrides) {
if (overrides.mask !== void 0) mask = overrides.mask;
if (overrides.tokens !== void 0) Object.assign(tokens, overrides.tokens);
if (overrides.slotChar !== void 0) slotChar = overrides.slotChar;
if (overrides.separate !== void 0) separate = overrides.separate;
}
}
return {
slots: parseMask(mask, tokens),
slotChar,
separate,
tokens,
transform: options.transform
};
}
function formatMask(raw, options) {
const { slots, slotChar, transform } = getResolvedOptions(options, raw);
return applyMaskToRaw(raw, slots, slotChar, transform);
}
function unformatMask(masked, options) {
const { slots } = getResolvedOptions(options, "");
return extractRaw(masked, slots);
}
function isMaskComplete(masked, options) {
const { slots } = getResolvedOptions(options, "");
return checkComplete(masked, slots);
}
function generatePattern(mode, options) {
const { slots } = getResolvedOptions(options, "");
let pattern = "";
for (const slot of slots) if (slot.type === "literal") pattern += slot.char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
else {
const src = slot.pattern.source;
if (mode === "full-inexact") pattern += slot.optional ? `${src}?` : src;
else pattern += slot.optional ? `(${src})?` : `(${src})`;
}
return pattern;
}
function useMask(options) {
const optionsRef = useRef(options);
optionsRef.current = options;
const inputRef = useRef(null);
const [maskedValue, setMaskedValue] = useState("");
const [rawValue, setRawValue] = useState("");
const processedRef = useRef("");
const displayValueRef = useRef("");
const rawValueRef = useRef("");
const wasCompleteRef = useRef(false);
const isFocusedRef = useRef(false);
const undoStackRef = useRef([]);
const redoStackRef = useRef([]);
const getOptions = useCallback(() => {
const opts = optionsRef.current;
return getResolvedOptions(opts, rawValue);
}, [rawValue]);
const updateValue = useCallback((newMasked, cursorPos) => {
const opts = optionsRef.current;
const { slots } = getResolvedOptions(opts, extractRaw(newMasked, getResolvedOptions(opts, "").slots));
const { slots: resolvedSlots, slotChar } = getResolvedOptions(opts, extractRaw(newMasked, slots));
const reprocessed = processInput(newMasked, resolvedSlots, slotChar);
const newRaw = extractRaw(reprocessed, resolvedSlots);
const showSlots = opts.alwaysShowMask || isFocusedRef.current;
const showOnFocus = opts.showMaskOnFocus !== false;
const displayValue = buildDisplayValue(reprocessed, resolvedSlots, slotChar, showSlots && (showOnFocus || reprocessed.length > 0));
processedRef.current = reprocessed;
displayValueRef.current = displayValue;
rawValueRef.current = newRaw;
setMaskedValue(displayValue);
setRawValue(newRaw);
if (inputRef.current) {
inputRef.current.value = displayValue;
if (cursorPos !== void 0 && document.activeElement === inputRef.current) {
const pos = Math.min(cursorPos, reprocessed.length);
inputRef.current.setSelectionRange(pos, pos);
}
}
if (opts.onChangeRaw) opts.onChangeRaw(newRaw, displayValue);
const complete = checkComplete(reprocessed, resolvedSlots);
if (complete && !wasCompleteRef.current && opts.onComplete) opts.onComplete(displayValue, newRaw);
wasCompleteRef.current = complete;
return {
displayValue,
newRaw,
reprocessed,
resolvedSlots
};
}, [getOptions]);
const pushUndoState = useCallback(() => {
const selectionStart = inputRef.current?.selectionStart ?? rawValueRef.current.length;
const state = {
rawValue: rawValueRef.current,
selectionStart
};
const stack = undoStackRef.current;
const top = stack[stack.length - 1];
if (top && top.rawValue === state.rawValue && top.selectionStart === state.selectionStart) return;
stack.push(state);
if (stack.length > MAX_UNDO_HISTORY) stack.shift();
redoStackRef.current = [];
}, []);
const applyHistoryState = useCallback((target) => {
const opts = optionsRef.current;
const { slots, slotChar, transform } = getResolvedOptions(opts, target.rawValue);
updateValue(applyMaskToRaw(target.rawValue, slots, slotChar, transform), target.selectionStart);
}, [updateValue]);
const handleInput = useCallback((e) => {
const input = e.target;
const opts = optionsRef.current;
const { slots: resolvedSlots, slotChar, transform } = getResolvedOptions(opts, "");
const prev = displayValueRef.current;
const curr = input.value;
let prefixLen = 0;
const maxPrefix = Math.min(prev.length, curr.length);
while (prefixLen < maxPrefix && prev[prefixLen] === curr[prefixLen]) prefixLen++;
let suffixLen = 0;
const maxSuffix = Math.min(prev.length - prefixLen, curr.length - prefixLen);
while (suffixLen < maxSuffix && prev[prev.length - 1 - suffixLen] === curr[curr.length - 1 - suffixLen]) suffixLen++;
const insertedText = curr.slice(prefixLen, curr.length - suffixLen);
const removedEnd = prev.length - suffixLen;
const beforeRaw = extractRaw(prev.slice(0, prefixLen), resolvedSlots.slice(0, prefixLen));
const afterRaw = extractRaw(prev.slice(removedEnd), resolvedSlots.slice(removedEnd));
const reformatted = applyMaskToRaw(beforeRaw + insertedText + afterRaw, resolvedSlots, slotChar, transform);
const maskedPrefix = applyMaskToRaw(beforeRaw + insertedText, resolvedSlots, slotChar, transform);
if (reformatted !== prev) pushUndoState();
updateValue(reformatted, maskedPrefix.length);
}, [pushUndoState, updateValue]);
const clampCursorToProcessed = useCallback((input) => {
const start = input.selectionStart ?? 0;
if (start !== (input.selectionEnd ?? 0)) return;
const opts = optionsRef.current;
const { slots } = getResolvedOptions(opts, "");
const processed = processedRef.current;
const endPos = processed.length > 0 ? findNextEditablePosition(processed.length, slots, processed) : findNextTokenIndex(slots, 0);
const startPos = findNextTokenIndex(slots, 0);
if (start > endPos || start < startPos) input.setSelectionRange(endPos, endPos);
}, []);
const handleFocus = useCallback(() => {
isFocusedRef.current = true;
const opts = optionsRef.current;
const input = inputRef.current;
if (!input) return;
const { slots, slotChar } = getResolvedOptions(opts, "");
const showOnFocus = opts.showMaskOnFocus !== false;
const processed = processedRef.current;
if (showOnFocus || opts.alwaysShowMask) {
const display = buildDisplayValue(processed, slots, slotChar, true);
input.value = display;
displayValueRef.current = display;
setMaskedValue(display);
}
requestAnimationFrame(() => {
if (input === document.activeElement) clampCursorToProcessed(input);
});
}, [clampCursorToProcessed]);
const handleMouseUp = useCallback(() => {
const input = inputRef.current;
if (!input || input !== document.activeElement) return;
clampCursorToProcessed(input);
}, [clampCursorToProcessed]);
const handleMouseDown = useCallback(() => {
const input = inputRef.current;
if (!input) return;
requestAnimationFrame(() => {
if (input !== document.activeElement) return;
const start = input.selectionStart ?? 0;
if (start !== (input.selectionEnd ?? 0)) return;
const opts = optionsRef.current;
const { slots } = getResolvedOptions(opts, "");
const processed = processedRef.current;
const endPos = processed.length > 0 ? findNextEditablePosition(processed.length, slots, processed) : findNextTokenIndex(slots, 0);
if (start > endPos) input.setSelectionRange(endPos, endPos);
});
}, []);
const handleBlur = useCallback(() => {
isFocusedRef.current = false;
const opts = optionsRef.current;
const input = inputRef.current;
if (!input) return;
const { slots, slotChar } = getResolvedOptions(opts, rawValue);
const expectedFocusDisplay = buildDisplayValue(processedRef.current, slots, slotChar, true);
const processed = input.value === expectedFocusDisplay ? processedRef.current : processInput(input.value, slots, slotChar);
const complete = checkComplete(processed, slots);
if (opts.autoClear && !complete && processed.length > 0) {
input.value = "";
processedRef.current = "";
displayValueRef.current = "";
rawValueRef.current = "";
setMaskedValue("");
setRawValue("");
wasCompleteRef.current = false;
if (opts.onChangeRaw) opts.onChangeRaw("", "");
if (opts.alwaysShowMask) {
const emptyDisplay = buildDisplayValue("", slots, slotChar, true);
input.value = emptyDisplay;
displayValueRef.current = emptyDisplay;
setMaskedValue(emptyDisplay);
}
return;
}
if (!opts.alwaysShowMask && !complete) {
if (extractRaw(processed, slots).length === 0) {
input.value = "";
processedRef.current = "";
displayValueRef.current = "";
rawValueRef.current = "";
setMaskedValue("");
setRawValue("");
wasCompleteRef.current = false;
if (opts.onChangeRaw) opts.onChangeRaw("", "");
return;
}
const display = buildDisplayValue(processed, slots, slotChar, false);
input.value = display;
displayValueRef.current = display;
setMaskedValue(display);
}
}, [rawValue]);
const handleKeyDown = useCallback((e) => {
const input = e.target;
const opts = optionsRef.current;
const { slots, slotChar, transform } = getResolvedOptions(opts, rawValue);
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const processed = processedRef.current;
const modifier = e.metaKey || e.ctrlKey && !e.altKey;
const key = e.key.toLowerCase();
if (modifier && key === "z" && !e.shiftKey) {
e.preventDefault();
const prev = undoStackRef.current.pop();
if (!prev) return;
redoStackRef.current.push({
rawValue: rawValueRef.current,
selectionStart: input.selectionStart ?? 0
});
applyHistoryState(prev);
return;
}
if (modifier && (key === "z" && e.shiftKey || key === "y" && !e.shiftKey)) {
e.preventDefault();
const next = redoStackRef.current.pop();
if (!next) return;
undoStackRef.current.push({
rawValue: rawValueRef.current,
selectionStart: input.selectionStart ?? 0
});
applyHistoryState(next);
return;
}
if (e.key === "Backspace") {
e.preventDefault();
if (e.metaKey || e.ctrlKey && !e.altKey) {
const clampedStart = Math.min(start, processed.length);
const newValue = applyMaskToRaw(extractRaw(processed.slice(clampedStart), slots.slice(clampedStart)), slots, slotChar, transform);
pushUndoState();
updateValue(newValue, 0);
return;
}
if (start !== end) {
const clampedEnd = Math.min(end, processed.length);
const before = processed.slice(0, start);
const afterRaw = extractRaw(processed.slice(clampedEnd), slots.slice(clampedEnd));
const newValue = applyMaskToRaw(extractRaw(before, slots) + afterRaw, slots, slotChar, transform);
pushUndoState();
updateValue(newValue, start);
return;
}
if (start === 0) return;
let deletePos = start - 1;
while (deletePos >= 0 && slots[deletePos] && slots[deletePos].type === "literal") deletePos--;
if (deletePos < 0) return;
const newValue = applyMaskToRaw(extractRaw(processed.slice(0, deletePos), slots.slice(0, deletePos)) + extractRaw(processed.slice(deletePos + 1), slots.slice(deletePos + 1)), slots, slotChar, transform);
pushUndoState();
updateValue(newValue, deletePos);
} else if (e.key === "Delete") {
e.preventDefault();
if (start !== end) {
const clampedEnd = Math.min(end, processed.length);
const before = processed.slice(0, start);
const afterRaw = extractRaw(processed.slice(clampedEnd), slots.slice(clampedEnd));
const newValue = applyMaskToRaw(extractRaw(before, slots) + afterRaw, slots, slotChar, transform);
pushUndoState();
updateValue(newValue, start);
return;
}
let deletePos = start;
while (deletePos < slots.length && slots[deletePos] && slots[deletePos].type === "literal") deletePos++;
if (deletePos >= processed.length) return;
const newValue = applyMaskToRaw(extractRaw(processed.slice(0, start), slots.slice(0, start)) + extractRaw(processed.slice(deletePos + 1), slots.slice(deletePos + 1)), slots, slotChar, transform);
pushUndoState();
updateValue(newValue, start);
} else if (e.key === "ArrowRight" && !e.shiftKey) {
const nextPos = findNextEditablePosition(start + 1, slots, input.value);
if (nextPos !== start + 1) {
e.preventDefault();
input.setSelectionRange(nextPos, nextPos);
}
} else if (e.key === "ArrowLeft" && !e.shiftKey) {
if (start > 0) {
const prevToken = findPrevTokenIndex(slots, start - 1);
if (prevToken >= 0 && prevToken !== start - 1) {
e.preventDefault();
input.setSelectionRange(prevToken + 1, prevToken + 1);
}
}
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
let insertPos = Math.min(start, processed.length);
while (insertPos < slots.length && slots[insertPos] && slots[insertPos].type === "literal") insertPos++;
if (insertPos >= slots.length) return;
const slot = slots[insertPos];
const ch = transform ? transform(e.key) : e.key;
if (!slot.pattern.test(ch)) return;
const beforeRaw = extractRaw(processed.slice(0, insertPos), slots.slice(0, insertPos));
const afterRaw = start < end ? extractRaw(processed.slice(Math.min(end, processed.length)), slots.slice(Math.min(end, processed.length))) : extractRaw(processed.slice(insertPos), slots.slice(insertPos));
const newValue = applyMaskToRaw(beforeRaw + ch + afterRaw, slots, slotChar, transform);
const newCursorPos = findNextEditablePosition(insertPos + 1, slots, newValue);
pushUndoState();
updateValue(newValue, newCursorPos);
}
}, [
applyHistoryState,
pushUndoState,
rawValue,
updateValue
]);
const handlePaste = useCallback((e) => {
e.preventDefault();
const input = e.target;
const opts = optionsRef.current;
const pastedText = e.clipboardData?.getData("text") ?? "";
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const processed = processedRef.current;
const { slots, slotChar, transform } = getResolvedOptions(opts, "");
const clampedStart = Math.min(start, processed.length);
const clampedEnd = Math.min(end, processed.length);
const beforeRaw = extractRaw(processed.slice(0, clampedStart), slots.slice(0, clampedStart));
const afterRaw = extractRaw(processed.slice(clampedEnd), slots.slice(clampedEnd));
const newValue = applyMaskToRaw(beforeRaw + pastedText + afterRaw, slots, slotChar, transform);
pushUndoState();
updateValue(newValue);
const maskedPrefix = applyMaskToRaw(beforeRaw + pastedText, slots, slotChar, transform);
const pasteEndPos = Math.min(maskedPrefix.length, slots.length);
if (input === document.activeElement) input.setSelectionRange(pasteEndPos, pasteEndPos);
}, [pushUndoState, updateValue]);
const setAriaAttributes = useCallback((input) => {
if (optionsRef.current.invalid) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
}, []);
const refCallback = useCallback((node) => {
const prevInput = inputRef.current;
if (prevInput) {
prevInput.removeEventListener("input", handleInput);
prevInput.removeEventListener("focus", handleFocus);
prevInput.removeEventListener("blur", handleBlur);
prevInput.removeEventListener("mousedown", handleMouseDown);
prevInput.removeEventListener("mouseup", handleMouseUp);
prevInput.removeEventListener("keydown", handleKeyDown);
prevInput.removeEventListener("paste", handlePaste);
}
inputRef.current = node;
if (node) {
node.addEventListener("input", handleInput);
node.addEventListener("focus", handleFocus);
node.addEventListener("blur", handleBlur);
node.addEventListener("mousedown", handleMouseDown);
node.addEventListener("mouseup", handleMouseUp);
node.addEventListener("keydown", handleKeyDown);
node.addEventListener("paste", handlePaste);
setAriaAttributes(node);
if (options.alwaysShowMask && !node.value) {
const { slots, slotChar } = getResolvedOptions(options, "");
const display = buildDisplayValue("", slots, slotChar, true);
node.value = display;
displayValueRef.current = display;
setMaskedValue(display);
}
}
}, [
handleInput,
handleFocus,
handleBlur,
handleMouseDown,
handleMouseUp,
handleKeyDown,
handlePaste,
setAriaAttributes,
options
]);
useEffect(() => {
const input = inputRef.current;
if (!input) return;
setAriaAttributes(input);
}, [options.invalid, setAriaAttributes]);
return {
ref: refCallback,
value: maskedValue,
rawValue,
isComplete: (() => {
const { slots } = getOptions();
return checkComplete(processedRef.current, slots);
})(),
reset: useCallback(() => {
const opts = optionsRef.current;
const input = inputRef.current;
processedRef.current = "";
displayValueRef.current = "";
rawValueRef.current = "";
undoStackRef.current = [];
redoStackRef.current = [];
setMaskedValue("");
setRawValue("");
wasCompleteRef.current = false;
if (input) if (opts.alwaysShowMask) {
const { slots, slotChar } = getResolvedOptions(opts, "");
const display = buildDisplayValue("", slots, slotChar, true);
input.value = display;
displayValueRef.current = display;
setMaskedValue(display);
} else input.value = "";
if (opts.onChangeRaw) opts.onChangeRaw("", "");
}, [])
};
}
function findNextEditablePosition(from, slots, value) {
let pos = from;
while (pos < slots.length && pos < value.length && slots[pos] && slots[pos].type === "literal") pos++;
return pos;
}
//#endregion
export { formatMask, generatePattern, isMaskComplete, unformatMask, useMask };
//# sourceMappingURL=use-mask.mjs.map