UNPKG

@mantine/hooks

Version:

A collection of 50+ hooks for state and UI management

582 lines (581 loc) 21.9 kB
"use client"; let react = require("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 = (0, react.useRef)(options); optionsRef.current = options; const inputRef = (0, react.useRef)(null); const [maskedValue, setMaskedValue] = (0, react.useState)(""); const [rawValue, setRawValue] = (0, react.useState)(""); const processedRef = (0, react.useRef)(""); const displayValueRef = (0, react.useRef)(""); const rawValueRef = (0, react.useRef)(""); const wasCompleteRef = (0, react.useRef)(false); const isFocusedRef = (0, react.useRef)(false); const undoStackRef = (0, react.useRef)([]); const redoStackRef = (0, react.useRef)([]); const getOptions = (0, react.useCallback)(() => { const opts = optionsRef.current; return getResolvedOptions(opts, rawValue); }, [rawValue]); const updateValue = (0, react.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 = (0, react.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 = (0, react.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 = (0, react.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 = (0, react.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 = (0, react.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 = (0, react.useCallback)(() => { const input = inputRef.current; if (!input || input !== document.activeElement) return; clampCursorToProcessed(input); }, [clampCursorToProcessed]); const handleMouseDown = (0, react.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 = (0, react.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 = (0, react.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 = (0, react.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 = (0, react.useCallback)((input) => { if (optionsRef.current.invalid) input.setAttribute("aria-invalid", "true"); else input.removeAttribute("aria-invalid"); }, []); const refCallback = (0, react.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 ]); (0, react.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: (0, react.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 exports.formatMask = formatMask; exports.generatePattern = generatePattern; exports.isMaskComplete = isMaskComplete; exports.unformatMask = unformatMask; exports.useMask = useMask; //# sourceMappingURL=use-mask.cjs.map