alpinejs-number
Version:
Enhanced Numeric Input for Alpine.js: Simplifying Number Formatting and Currency Handling.
367 lines (301 loc) • 9.85 kB
JavaScript
export default function (Alpine) {
Alpine.directive(
"number",
(el, { value, modifiers, expression }, { cleanup }) => {
const parts = (expression ?? "").split("|");
/** @type {string} */
const prefix = parts?.[0] ?? "";
/** @type {string} */
const suffix = parts?.[3] ?? value ?? "";
/** @type {string} */
const separator = parts?.[1] ?? ",";
/** @type {string} */
const decimalChar = parts?.[2] ?? ".";
/** @type {number} */
const precision = parseInt(
modifiers.find((m) => !isNaN(parseInt(m))) ?? -1,
);
/** @type {boolean} */
const unsigned = modifiers.find((m) => m === "unsigned") !== undefined;
/**
* @typedef {{ prefix: string, suffix: string, separator: string, decimalChar: string, precision: number, unsigned: boolean }} Params
*/
/** @type {Params} **/
const params = {
prefix,
suffix,
separator,
decimalChar,
precision,
unsigned,
};
update({ target: el });
// Ensure cursor is between the prefix and suffix
const clickHandler = () => {
const value = el.value;
const cursorPosition = el.selectionStart;
if (cursorPosition < prefix.length) {
el.setSelectionRange(prefix.length, prefix.length);
}
if (cursorPosition > value.length - suffix.length) {
el.setSelectionRange(
value.length - suffix.length,
value.length - suffix.length,
);
}
};
el.addEventListener("click", clickHandler);
// Keydown handler for toggling negative sign
const keydownMinusHandler = (e) => {
if (e.key !== "-") {
return;
}
let value = el.value;
// Remove selected text if any
const selectionStart = el.selectionStart;
const selectionEnd = el.selectionEnd;
value =
value.substring(0, selectionStart) + value.substring(selectionEnd);
if (value === "") {
return;
}
// Remove -
if (el.value.startsWith(prefix + "-")) {
el.value = el.value.replace("-", "");
el.setSelectionRange(selectionStart - 1, selectionStart - 1);
el.dispatchEvent(new Event("input", { bubbles: true }));
e.preventDefault();
return;
}
// Prepend -
el.value = el.value.replace(prefix, prefix + "-");
el.setSelectionRange(selectionStart + 1, selectionStart + 1);
el.dispatchEvent(new Event("input", { bubbles: true }));
e.preventDefault();
};
if (!unsigned) {
// Toggle negative by pressing - anywhere in input
el.addEventListener("keydown", keydownMinusHandler);
}
// Handler for keeping cursor between prefix and suffix
const cursorBoundsHandler = (e) => {
if (
e.key !== "ArrowLeft" &&
e.key !== "ArrowRight" &&
e.key !== "ArrowUp" &&
e.key !== "ArrowDown"
) {
return;
}
const value = el.value;
let cursorPosition = el.selectionStart;
if (e.key === "ArrowLeft") {
cursorPosition--;
}
if (e.key === "ArrowRight") {
cursorPosition++;
}
if (e.key === "ArrowUp") {
cursorPosition = 0;
}
if (e.key === "ArrowDown") {
cursorPosition = value.length;
}
if (cursorPosition < prefix.length) {
el.setSelectionRange(prefix.length, prefix.length);
e.preventDefault();
}
if (cursorPosition > value.length - suffix.length) {
el.setSelectionRange(
value.length - suffix.length,
value.length - suffix.length,
);
e.preventDefault();
}
};
// Ensure cursor is between the prefix and suffix
el.addEventListener("keydown", cursorBoundsHandler);
el.addEventListener("input", update);
function update(event) {
const { value, cursorPosition } = modify(
event.target.value,
event.target.selectionStart,
event?.data === decimalChar,
params,
);
if (event.target.value !== value) {
event.target.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
}
event.target.setSelectionRange(cursorPosition, cursorPosition);
}
cleanup(() => {
el.removeEventListener("input", update);
el.removeEventListener("click", clickHandler);
el.removeEventListener("keydown", cursorBoundsHandler);
if (!unsigned) {
el.removeEventListener("keydown", keydownMinusHandler);
}
});
},
);
// Returns a number from a number string
Alpine.magic("toNumber", () => (value, decimalChar = ".", precision = -1) => {
return Number(
toNumber(String(value), 0, false, {
decimalChar,
precision,
unsigned: false,
}).value.replace(decimalChar, "."),
);
});
// Returns a formatted number from a number
Alpine.magic(
"toFormatted",
() =>
(
value,
prefix = "",
separator = ",",
decimalChar = ".",
suffix = "",
precision = -1,
unsigned = false,
) => {
return modify(value, 0, false, {
prefix,
suffix,
separator,
decimalChar,
precision,
unsigned,
}).value;
},
);
/**
* Returns a number string from an input string, as well as the cursor position.
* @param {string} value
* @param {number} cursorPosition
* @param {boolean} decimalTyped
* @param {Params} Params
* @returns {{ value: string, cursorPosition: number }}
*/
function toNumber(
value,
cursorPosition,
decimalTyped,
{ decimalChar, precision, unsigned },
) {
// An array of char positions to remove from the string
let positionsToRemove = findNonNumericPositions(value, !unsigned);
const decimalPosition = decimalTyped
? cursorPosition - 1
: value.indexOf(decimalChar);
// Remove decimal point from the positionsToRemove array
if ((precision > 0 || precision === -1) && decimalPosition !== -1) {
positionsToRemove = positionsToRemove.filter(
(pos) => decimalPosition !== pos,
);
}
// Remove extraneous numbers after the decimal point to the positions to remove
if (precision > 0 && decimalPosition !== -1) {
let decimalCount = 0;
for (let i = decimalPosition + 1; i < value.length; i++) {
if (positionsToRemove.includes(i)) {
continue;
}
decimalCount++;
if (decimalCount > precision) {
positionsToRemove.push(i);
}
}
}
value = removeCharsAtPositions(value, positionsToRemove);
const charsRemovedBeforeCursor = positionsToRemove.reduce((acc, pos) => {
return acc + (pos < cursorPosition ? 1 : 0);
}, 0);
cursorPosition = cursorPosition - charsRemovedBeforeCursor;
return { value, cursorPosition };
}
/**
* Modifies the input value and cursor position
* @param {string} value
* @param {number} cursorPosition
* @param {boolean} decimalTyped
* @param {Params} Params
* @returns {{ value: string, cursorPosition: number }}
*/
function modify(value, cursorPosition, decimalTyped, params) {
// Convert to number
({ value, cursorPosition } = toNumber(
value,
cursorPosition,
decimalTyped,
params,
));
const { prefix, suffix, separator, decimalChar } = params;
// Temporary remove the minus sign
const isNegative = value.startsWith("-");
cursorPosition = cursorPosition - (isNegative ? 1 : 0);
value = value.replace("-", "");
// Split into integer and decimal parts
const parts = value.split(decimalChar);
const commasBeforeCursor = Math.max(
0,
Math.floor((Math.min(cursorPosition, parts[0].length) - 1) / 3),
);
// Add commas
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
// Add prefix, suffix, minus sign and decimal point.
value = prefix + (isNegative ? "-" : "") + parts.join(decimalChar) + suffix;
// Update cursor position
cursorPosition =
cursorPosition +
prefix.length +
(isNegative ? 1 : 0) +
commasBeforeCursor;
if (value === prefix + suffix) {
value = "";
cursorPosition = 0;
}
return { value, cursorPosition };
}
/**
* Returns an array of non-numeric char positions in a string.
* @param {string} str
* @param {boolean} keepNegativeSign True to not include negative sign
* @returns {Array}
*/
function findNonNumericPositions(str, keepNegativeSign) {
const nonNumericPositions = [];
let numberFound = false;
for (let i = 0; i < str.length; i++) {
if (isNaN(parseInt(str[i]))) {
if (!numberFound && str[i] === "-" && keepNegativeSign) {
numberFound = true;
continue;
}
nonNumericPositions.push(i);
} else {
numberFound = true;
}
}
return nonNumericPositions;
}
/**
* Remove chars from a string at set positions.
* @param {string} str
* @param {Array<number>} positions
* @returns {string} String with characters removed
*/
function removeCharsAtPositions(str, positions) {
// Convert positions array to a Set for faster lookup
const positionsSet = new Set(positions);
// Filter out characters at specified positions
const result = str
.split("")
.filter((_, index) => !positionsSet.has(index))
.join("");
return result;
}
}