UNPKG

vueless

Version:

Vue Styleless UI Component Library, powered by Tailwind CSS.

289 lines (214 loc) • 8.47 kB
import { onMounted, nextTick, ref, onBeforeUnmount, toValue, watch, computed, readonly } from "vue"; import { getRawValue, getFormattedValue } from "./utilFormat.ts"; import { RAW_DECIMAL_MARK } from "./constants.ts"; import type { FormatOptions } from "./types.ts"; const digitSet = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]; const comma = ","; const minus = "-"; export default function useFormatNumber( elementId: string = "", formatOptions: (() => FormatOptions) | FormatOptions, ) { let inputElement: HTMLInputElement | null = null; const formattedValue = ref(""); const rawValue = ref(""); const prevValue = ref(""); const options = computed(() => toValue(formatOptions)); watch( () => options, () => { validateOptions(); setValue(formattedValue.value); }, { deep: true }, ); onMounted(() => { validateOptions(); inputElement = document.getElementById(elementId) as HTMLInputElement; if (inputElement) { inputElement.addEventListener("input", onInput); inputElement.addEventListener("keydown", onKeydown); } }); onBeforeUnmount(() => { if (inputElement) { inputElement.removeEventListener("input", onInput); inputElement.addEventListener("keydown", onKeydown); } }); function validateOptions() { const warnMessages = []; if (options.value.decimalSeparator.length > 1) { warnMessages.push( "[VUELESS/UInputNumber]: DecimalSeparator should not contain more than one symbol.", ); } if (options.value.thousandsSeparator.length > 1) { warnMessages.push( "[VUELESS/UInputNumber]: ThousandsSeparator should not contain more than one symbol.", ); } // eslint-disable-next-line no-console warnMessages.forEach((message) => console.warn(message)); } /** * Set the input value manually. * @param {Intl.StringNumericLiteral} value */ function setValue(value: string): void { const newFormattedValue = getFormattedValue(value, options.value); formattedValue.value = newFormattedValue; rawValue.value = getRawValue(newFormattedValue, options.value); prevValue.value = formattedValue.value; } function onKeydown(event: KeyboardEvent) { if (!event.target || !inputElement) return; const cursorStart = inputElement.selectionStart || 0; const cursorEnd = inputElement.selectionEnd || 0; const isSelection = cursorEnd !== cursorStart; if (event.key === "Backspace" && !isSelection) { const charToRemove = inputElement.value[cursorStart - 1]; const isFormatChar = [ options.value.thousandsSeparator, options.value.prefix, options.value.decimalSeparator, ].includes(charToRemove); // Skip the unremovable character and put the cursor one step back. if (isFormatChar && !inputElement.value.endsWith(options.value.decimalSeparator)) { event.preventDefault(); inputElement.setSelectionRange(cursorStart - 1, cursorEnd - 1); } return; } const endsWithDecimal = formattedValue.value.endsWith(options.value.decimalSeparator); if ((event.key === comma || event.key === RAW_DECIMAL_MARK) && endsWithDecimal) { event.preventDefault(); return; } } async function onInput(event: Event) { if (!event.target || !inputElement) return; await nextTick(); const cursorStart = inputElement.selectionStart || 0; const cursorEnd = inputElement.selectionEnd || 0; const input = event.target as HTMLInputElement; let value = input.value || ""; const prevCursorPosition = cursorEnd - 1; const eventData = (event as InputEvent).data || ""; if (value === minus) { formattedValue.value = minus; rawValue.value = ""; return; } if (!value || value.startsWith(`${options.value.decimalSeparator}0`)) { formattedValue.value = options.value.prefix; rawValue.value = ""; return; } // Replace dot with decimal separator if (eventData === RAW_DECIMAL_MARK || eventData === comma) { value = [ ...prevValue.value.slice(0, prevCursorPosition), options.value.decimalSeparator, ...prevValue.value.slice(prevCursorPosition), ].join(""); } if (value.split(options.value.decimalSeparator).length > 2) { value = value.split("").with(value.lastIndexOf(options.value.decimalSeparator), "").join(""); } const decimalSeparatorIndex = value.indexOf(options.value.decimalSeparator); const newRawValue = getRawValue(value, options.value); const isEventDataDecimal = value.endsWith(options.value.decimalSeparator) || value.endsWith(`${options.value.decimalSeparator}0`); if ( isEventDataDecimal && cursorStart > decimalSeparatorIndex && !options.value.minFractionDigits ) { formattedValue.value = value; rawValue.value = newRawValue; return; } const isNumericValue = eventData && digitSet.includes(eventData); const isMinus = cursorEnd === 1 && cursorStart === 1 && eventData === minus; const isDoubleMinus = isMinus && prevValue.value.startsWith(minus); const isMinusWithin = newRawValue.includes(minus) && !newRawValue.startsWith(minus); const isReservedSymbol = eventData !== RAW_DECIMAL_MARK && eventData !== comma; if ( (!isNumericValue && isReservedSymbol && !isMinus && eventData.length === 1) || isDoubleMinus || isMinusWithin ) { inputElement.value = formattedValue.value; await nextTick(); inputElement.setSelectionRange(cursorStart, cursorEnd); return; } const currentFraction = (newRawValue.split(RAW_DECIMAL_MARK).at(1) || "").slice( 0, options.value.maxFractionDigits, ); const actualMinFractionDigits = options.value.minFractionDigits ? options.value.minFractionDigits : currentFraction.length; const newFormattedValue = getFormattedValue(newRawValue, { ...options.value, minFractionDigits: actualMinFractionDigits, }); if (Number.isNaN(newFormattedValue) || newFormattedValue.includes("NaN")) { inputElement.value = prevValue.value; return; } formattedValue.value = newFormattedValue; rawValue.value = getRawValue(newFormattedValue, options.value); inputElement.value = formattedValue.value; await setInputCursor(newFormattedValue, inputElement, cursorStart, cursorEnd, eventData); prevValue.value = formattedValue.value; } async function setInputCursor( newValue: string, inputElement: HTMLInputElement, prevCursorStart: number, prevCursorEnd: number, eventData: string, ) { const hasValueInputValue = prevCursorStart === 1 && prevCursorEnd === 1; const currentValueOffsetLength = newValue .split("") .filter((value: string) => value === options.value.thousandsSeparator).length; const prevValueOffsetLength = prevValue.value .split("") .filter((value) => value === options.value.thousandsSeparator).length; const prefixLength = options.value.prefix.length; const offset = currentValueOffsetLength - prevValueOffsetLength; await nextTick(); if (newValue.length <= 1) return; // Move cursor after decimal mark if (newValue.length < prevValue.value.length && eventData) { const newChar = newValue[prevCursorEnd - 1]; prevCursorEnd -= newChar === options.value.decimalSeparator ? 0 : 1; prevCursorStart -= newChar === options.value.decimalSeparator ? 0 : 1; } if (offset < 0 && inputElement && eventData) { inputElement.setSelectionRange(prevCursorStart, prevCursorEnd); return; } // Move cursor step back on backspace. if (offset < 0 && inputElement && !eventData) { inputElement.setSelectionRange(prevCursorStart - 1, prevCursorEnd - 1); return; } if (newValue.length === prevCursorEnd || !prevCursorStart || !prevCursorEnd) return; let newCursorStart = prevCursorStart + offset; let newCursorEnd = prevCursorEnd + offset; if (hasValueInputValue && prefixLength) { newCursorStart += prefixLength; newCursorEnd += prefixLength; } if (inputElement) { inputElement.setSelectionRange(newCursorStart, newCursorEnd); } } return { rawValue: readonly(rawValue), formattedValue: readonly(formattedValue), setValue }; }