// MoneyInput Phoenix LiveView hooks.
//
// Exports three hooks:
//
// NumberInput — locale-aware live formatting for plain numbers
// MoneyInput — same, with currency-aware precision
// CurrencyPicker — searchable currency picker (recents, sheet,
// keyboard nav, flag glyphs)
//
// AutoNumeric (https://autonumeric.org/) is a *peer* dependency.
// Install it in the host app:
//
// npm install autonumeric
//
// And expose it on window before importing these hooks, or pass it
// in via `MoneyInputHooks.configure({ AutoNumeric })` before
// constructing your LiveSocket. When AutoNumeric isn't available
// the hooks degrade to the Path A baseline: the input still works,
// the server-side parser still accepts whatever the user typed,
// but live formatting and cursor preservation are off.
let AutoNumericCtor =
(typeof window !== "undefined" && window.AutoNumeric) || null;
/** Inject a specific AutoNumeric constructor (for bundlers that
* don't put it on window). Call this before `new LiveSocket`. */
export function configure({ AutoNumeric }) {
AutoNumericCtor = AutoNumeric || AutoNumericCtor;
}
// ── Number / money hooks ──────────────────────────────────────
function readData(el) {
const d = el.dataset;
const num = (v) => (v == null || v === "" ? null : Number(v));
return {
locale: d.locale || "en",
decimal: d.decimal || ".",
group: d.group || ",",
minus: d.minus || "-",
numberSystem: d.numberSystem || "latn",
integer: d.integer === "true",
decimals: num(d.decimals),
isoDigits: num(d.isoDigits),
min: d.min || null,
max: d.max || null,
currency: d.currency || null,
symbolPosition: d.symbolPosition || "prefix",
};
}
function buildAutoNumericOptions(data, kind) {
const decimals =
kind === "money"
? data.isoDigits ?? 2
: data.decimals ?? (data.integer ? 0 : 6);
const opts = {
decimalCharacter: data.decimal,
digitGroupSeparator: data.group,
decimalCharacterAlternative: data.decimal === "." ? "," : ".",
negativeSignCharacter: data.minus,
decimalPlaces: decimals,
decimalPlacesShownOnFocus: decimals,
decimalPlacesShownOnBlur: decimals,
allowDecimalPadding: false,
currencySymbol: "",
selectOnFocus: false,
modifyValueOnWheel: false,
minimumValue: data.min ?? "-10000000000000",
maximumValue: data.max ?? "10000000000000",
onInvalidPaste: "clamp",
digitalGroupSpacing: data.locale && data.locale.startsWith("en-IN") ? "2s" : "3",
};
return opts;
}
function cssEscape(value) {
if (window.CSS && CSS.escape) return CSS.escape(value);
return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
}
function mountInput(hook, kind) {
hook.input = hook.el.querySelector("input.money-input-field");
if (!hook.input) return;
const data = readData(hook.el);
hook.data = data;
if (!AutoNumericCtor) {
// No-JS fallback. The form still submits; the server-side
// parser still accepts the locale-typed string. We only lose
// the live formatting + cursor preservation.
hook.input.addEventListener("paste", (event) =>
paste_sanitize(event, data),
);
return;
}
hook.an = new AutoNumericCtor(
hook.input,
buildAutoNumericOptions(data, kind),
);
// Deliberately *no* submit-time canonicalisation. The form
// value is the user's locale-formatted string as they see it
// on screen. The server-side cast uses the locale option to
// parse it. This keeps the wire format identical whether or
// not AutoNumeric is loaded — no canonical-vs-locale-formatted
// ambiguity for the server to puzzle out.
if (kind === "money") {
// Listen for currency changes from a sibling picker so the
// input's precision can adapt mid-input.
hook.onCurrencyChange = (event) => {
const newCurrency = event.detail && event.detail.currency;
const newDigits = event.detail && event.detail.isoDigits;
if (newCurrency) hook.el.dataset.currency = newCurrency;
if (typeof newDigits === "number") {
hook.an.update({
decimalPlaces: newDigits,
decimalPlacesShownOnFocus: newDigits,
decimalPlacesShownOnBlur: newDigits,
});
}
};
hook.el.addEventListener("money-input:currency-change", hook.onCurrencyChange);
}
}
function destroyInput(hook) {
if (hook.an) hook.an.remove();
if (hook.onCurrencyChange)
hook.el.removeEventListener(
"money-input:currency-change",
hook.onCurrencyChange,
);
}
function paste_sanitize(event, data) {
// Pre-AutoNumeric paste handler used only in the fallback path.
// Strip obvious decorations so the server-side parser doesn't
// have to.
const text = (event.clipboardData || window.clipboardData).getData("text");
if (!text) return;
event.preventDefault();
const cleaned = text
.replace(/[ ]/g, " ")
.replace(/[−–—]/g, "-")
.replace(/^\((.*)\)$/, "-$1")
.trim();
const input = event.target;
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
input.value = input.value.slice(0, start) + cleaned + input.value.slice(end);
const cursor = start + cleaned.length;
input.setSelectionRange(cursor, cursor);
}
export const MoneyInput = {
mounted() {
mountInput(this, "money");
},
destroyed() {
destroyInput(this);
},
};
// ── Currency picker ───────────────────────────────────────────
const RECENTS_KEY = "money_input:recents";
const SHEET_BREAKPOINT_PX = 640;
function loadRecents() {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY) || "[]");
} catch (_) {
return [];
}
}
function saveRecents(list) {
try {
localStorage.setItem(RECENTS_KEY, JSON.stringify(list));
} catch (_) {
/* private mode etc — silently ignore */
}
}
function pushRecent(code, limit) {
const list = loadRecents().filter((c) => c !== code);
list.unshift(code);
saveRecents(list.slice(0, limit));
return list;
}
export const CurrencyPicker = {
mounted() {
this.trigger = this.el.querySelector("[data-currency-picker-trigger]");
this.overlay = this.el.querySelector("[data-currency-picker-overlay]");
this.search = this.el.querySelector("[data-currency-picker-search]");
this.list = this.el.querySelector("[data-currency-picker-list]");
this.closeBtn = this.el.querySelector("[data-currency-picker-close]");
this.valueInput = this.el.querySelector("[data-currency-picker-value]");
this.empty = this.el.querySelector("[data-currency-picker-empty]");
this.recentsLimit = parseInt(this.el.dataset.recentsLimit || "5", 10);
this.variant = this.el.dataset.variant || "auto";
this.onTriggerClick = (e) => {
e.preventDefault();
this.open();
};
this.onCloseClick = () => this.close();
this.onSearchInput = () => this.filter();
this.onListClick = (e) => {
const row = e.target.closest("[data-currency-picker-row]");
if (!row) return;
// Prevent the click from bubbling to the document-level
// outside-click handler attached in `open()`. If that
// handler still runs, it calls `close()` with the default
// `refocus: true`, which yanks focus back to the trigger
// immediately after `selectCode` placed it on the paired
// input. Browsers disagree on whether a listener removed
// mid-dispatch fires for the same event, so be defensive.
e.stopPropagation();
e.preventDefault();
this.selectCode(row.dataset.code);
};
this.onKeydown = (e) => this.handleKeydown(e);
this.onDocClick = (e) => {
if (!this.el.contains(e.target)) this.close();
};
this.trigger.addEventListener("click", this.onTriggerClick);
this.closeBtn.addEventListener("click", this.onCloseClick);
this.search.addEventListener("input", this.onSearchInput);
this.list.addEventListener("click", this.onListClick);
this.el.addEventListener("keydown", this.onKeydown);
this.applyRecents();
this.applySheetVariant();
},
destroyed() {
this.trigger.removeEventListener("click", this.onTriggerClick);
this.closeBtn.removeEventListener("click", this.onCloseClick);
this.search.removeEventListener("input", this.onSearchInput);
this.list.removeEventListener("click", this.onListClick);
this.el.removeEventListener("keydown", this.onKeydown);
document.removeEventListener("click", this.onDocClick);
},
applyRecents() {
const recents = loadRecents().slice(0, this.recentsLimit);
if (recents.length === 0) return;
// Build (or refresh) a "Recents" section at the top of the
// list. Implemented in JS so the server never sees user
// localStorage state.
let section = this.list.querySelector(".currency-picker-section-recents");
if (!section) {
section = document.createElement("li");
section.className =
"currency-picker-section currency-picker-section-recents";
section.setAttribute("role", "presentation");
section.textContent = "Recents";
this.list.insertBefore(section, this.list.firstChild);
}
// Remove previous recent rows and reinsert clones in order.
this.list
.querySelectorAll("[data-currency-picker-row].is-recent")
.forEach((node) => node.remove());
let anchor = section.nextSibling;
recents.forEach((code) => {
const source = this.list.querySelector(
`[data-currency-picker-row][data-code="${cssEscape(code)}"]`,
);
if (!source) return;
const clone = source.cloneNode(true);
clone.classList.add("is-recent");
this.list.insertBefore(clone, anchor);
});
},
applySheetVariant() {
const useSheet =
this.variant === "sheet" ||
(this.variant === "auto" &&
window.matchMedia(`(max-width: ${SHEET_BREAKPOINT_PX}px)`).matches);
this.el.classList.toggle("is-sheet", useSheet);
},
open() {
this.overlay.hidden = false;
this.trigger.setAttribute("aria-expanded", "true");
this.applySheetVariant();
// The picker is rendered inside the money-input-wrapper,
// which has `overflow: hidden` for its rounded corners. Float
// the overlay with `position: fixed` so it escapes the clip
// region. The sheet variant already uses position:fixed via
// CSS and covers the whole viewport.
if (!this.el.classList.contains("is-sheet")) {
this.positionOverlay();
this.repositionHandler = () => this.positionOverlay();
window.addEventListener("resize", this.repositionHandler);
window.addEventListener("scroll", this.repositionHandler, true);
}
setTimeout(() => {
this.search.value = "";
this.filter();
this.search.focus();
document.addEventListener("click", this.onDocClick);
}, 0);
},
close({ refocus = true } = {}) {
this.overlay.hidden = true;
this.trigger.setAttribute("aria-expanded", "false");
document.removeEventListener("click", this.onDocClick);
if (this.repositionHandler) {
window.removeEventListener("resize", this.repositionHandler);
window.removeEventListener("scroll", this.repositionHandler, true);
this.repositionHandler = null;
}
this.overlay.style.position = "";
this.overlay.style.top = "";
this.overlay.style.left = "";
this.overlay.style.width = "";
if (refocus) this.trigger.focus();
},
positionOverlay() {
const rect = this.trigger.getBoundingClientRect();
const overlayWidth = Math.max(rect.width, 320);
const maxLeft = Math.max(8, window.innerWidth - overlayWidth - 8);
this.overlay.style.position = "fixed";
this.overlay.style.top = `${rect.bottom + 4}px`;
this.overlay.style.left = `${Math.min(rect.left, maxLeft)}px`;
this.overlay.style.width = `${overlayWidth}px`;
},
filter() {
const term = this.search.value.trim().toLowerCase();
const rows = this.list.querySelectorAll("[data-currency-picker-row]");
let visible = 0;
rows.forEach((row) => {
const hay = `${row.dataset.code} ${row.dataset.name} ${row.dataset.country} ${row.dataset.symbol}`.toLowerCase();
const match = !term || hay.includes(term);
row.hidden = !match;
if (match) visible++;
});
if (this.empty) this.empty.hidden = visible > 0;
},
selectCode(code) {
if (!code) return;
this.el.dataset.current = code;
const codeNode = this.trigger.querySelector(".currency-picker-code");
if (codeNode) codeNode.textContent = code;
const flagNode = this.trigger.querySelector(".currency-picker-flag");
const row = this.list.querySelector(
`[data-currency-picker-row][data-code="${cssEscape(code)}"]`,
);
if (row && flagNode) {
const newFlag = row.querySelector(".currency-picker-flag");
if (newFlag) flagNode.textContent = newFlag.textContent;
}
if (this.valueInput) {
this.valueInput.value = code;
this.valueInput.dispatchEvent(new Event("change", { bubbles: true }));
}
// Walk up to the surrounding money-input wrapper (if any) and
// notify it so the precision adapts immediately. The server
// is also notified via the hidden input's change event so
// `on_currency_change` events fire as expected.
const wrapper = this.el.closest("[data-money-input]");
if (wrapper) {
const isoDigits = row && parseInt(row.dataset.isoDigits || "2", 10);
wrapper.dispatchEvent(
new CustomEvent("money-input:currency-change", {
detail: { currency: code, isoDigits },
bubbles: true,
}),
);
}
pushRecent(code, this.recentsLimit);
// Hand focus back to the paired money input rather than the
// trigger — picking a currency is almost always followed by
// typing an amount, so dropping the user back into the
// amount field is the right next action. Falls back to the
// trigger when no paired input is found (the picker is being
// used standalone, e.g. as a "show prices in" widget).
const pairedInput = wrapper && wrapper.querySelector("input.money-input-field");
if (pairedInput) {
this.close({ refocus: false });
pairedInput.focus();
// Place the caret at the end so the next keystroke appends
// rather than overwriting a partial number.
const length = pairedInput.value.length;
try {
pairedInput.setSelectionRange(length, length);
} catch (_) {
// Not all input types support setSelectionRange; ignore.
}
} else {
this.close();
}
},
handleKeydown(e) {
if (e.key === "Escape") {
e.preventDefault();
this.close();
return;
}
if (this.overlay.hidden) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const rows = Array.from(
this.list.querySelectorAll("[data-currency-picker-row]"),
).filter((row) => !row.hidden);
if (rows.length === 0) return;
const focused = document.activeElement;
let idx = rows.indexOf(focused);
idx = (idx + (e.key === "ArrowDown" ? 1 : -1) + rows.length) % rows.length;
rows[idx].focus();
} else if (e.key === "Enter") {
const focused = document.activeElement;
if (focused && focused.dataset && focused.dataset.code) {
e.preventDefault();
this.selectCode(focused.dataset.code);
}
}
},
};
export default { MoneyInput, CurrencyPicker, configure };