aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2025-08-20 13:19:03 -0700
committerAdam Soltys <[email protected]>2025-08-20 13:19:03 -0700
commite129c615b1b46ea307a00994c84cb2ed086d8c51 (patch)
tree0316d3615e3b4834dbdc41751a09fcd083e2a962
parent41642852b636d2e07b9d72482b9a40328fc76903 (diff)
downloadcoinos-ui-e129c615b1b46ea307a00994c84cb2ed086d8c51.tar.gz
coinos-ui-e129c615b1b46ea307a00994c84cb2ed086d8c51.zip
numpad upgrade
-rw-r--r--src/components/Numpad.svelte399
1 files changed, 220 insertions, 179 deletions
diff --git a/src/components/Numpad.svelte b/src/components/Numpad.svelte
index d80f0400..00547e6a 100644
--- a/src/components/Numpad.svelte
+++ b/src/components/Numpad.svelte
@@ -1,23 +1,29 @@
<script>
import { onMount } from "svelte";
- import { fiat } from "$lib/store";
import Left from "$comp/Left.svelte";
- import Icon from "$comp/Icon.svelte";
import { f, s, focus, warning, sat, sats } from "$lib/utils";
import { t } from "$lib/translations";
- export let amount = 0,
+ // ---- Props (Svelte 5 runes) ----
+ let {
+ amount = $bindable(0),
currency = "USD",
+ fiat = $bindable(true),
element,
rate,
locale,
- submit = undefined;
+ submit = undefined,
+ amountFiat = 0,
+ } = $props();
- export let amountFiat = 0;
+ // ---- Local state ----
+ let fiatDigits = $state("0"); // cents buffer in fiat mode
+ let html = $state("0");
- let pasted = async () => {
- html = (await navigator.clipboard.readText()).replace(/,/g, "");
- };
+ // currency info as reactive state
+ let decimalChar = $state(".");
+ let symbol = $state("");
+ let position = $state("before");
function getCurrencyInfo(locale = "en-US", currency) {
const formatter = new Intl.NumberFormat(locale, {
@@ -27,239 +33,274 @@
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
-
const parts = formatter.formatToParts(123456.78);
- const { value: decimal } = parts.find((part) => part.type === "decimal");
- const currencyPart = parts.find((part) => part.type === "currency");
- const symbol = currencyPart ? currencyPart.value : "";
-
- const firstNumberIndex = parts.findIndex((part) => part.type === "integer");
- const currencyIndex = parts.findIndex((part) => part.type === "currency");
- const position = currencyIndex < firstNumberIndex ? "before" : "after";
-
- return { symbol, position, decimal };
+ const { value: decimal } = parts.find((p) => p.type === "decimal");
+ const currencyPart = parts.find((p) => p.type === "currency");
+ const sym = currencyPart ? currencyPart.value : "";
+ const firstNumberIndex = parts.findIndex((p) => p.type === "integer");
+ const currencyIndex = parts.findIndex((p) => p.type === "currency");
+ const pos = currencyIndex < firstNumberIndex ? "before" : "after";
+ return { symbol: sym, position: pos, decimal };
}
- function strip(input) {
- const regex = new RegExp(`[^0-9\.]`, "g");
- return input.replace(decimal, ".").replace(regex, "");
- }
-
- let { decimal, symbol, position } = getCurrencyInfo(locale, currency);
+ // reflect external prop/locale changes
+ $effect(() => {
+ const info = getCurrencyInfo(locale, currency);
+ decimalChar = info.decimal;
+ symbol = info.symbol;
+ position = info.position;
+ });
- let arrow = "<";
+ function formatFiatFromDigits(digits, dec = ".") {
+ let d = digits.replace(/\D+/g, "");
+ if (!d) d = "0";
+ if (d.length > 15) d = d.slice(0, 15);
+ if (d.length === 1) d = "0" + d;
+ if (d.length === 2) d = "0" + d;
+ const whole = d.slice(0, -2).replace(/^0+(?=\d)/, "") || "0";
+ const cents = d.slice(-2);
+ return `${whole}${dec}${cents}`;
+ }
- $: update(amount, amountFiat);
- let update = (a, f) => {
- if ($fiat) {
- amount = f ? Math.round(strip(f) / (rate / sats)) : 0;
+ const updateByMode = () => {
+ if (fiat) {
+ const numericFiat =
+ parseFloat((fiatDigits || "0").replace(/\D/g, "")) / 100;
+ amountFiat = isFinite(numericFiat) ? numericFiat.toFixed(2) : "0.00";
+ amount = numericFiat ? Math.round((numericFiat * sats) / rate) : 0;
} else {
- amountFiat = a ? ((a * rate) / sats).toFixed(2) : 0;
+ // CHANGED: sats side is integer-only
+ const a = parseInt((html || "0").replace(/[^\d]/g, "")) || 0;
+ amount = a || 0;
+ const fval = amount ? (amount * rate) / sats : 0;
+ amountFiat = fval ? fval.toFixed(2) : "0.00";
}
};
- let loading = false;
-
- const numPad = [
- "1",
- "2",
- "3",
- "4",
- "5",
- "6",
- "7",
- "8",
- "9",
- decimal,
- "0",
- "<",
- ];
-
- const handleInput = (value) => {
- if (value === "<") html = html.substr(0, html.length - 1);
- else html += value;
+ const pasted = async () => {
+ const txt = (await navigator.clipboard.readText()) ?? "";
+ if (fiat) {
+ const m = txt.replace(/[^\d.,]/g, "");
+ const normalized = m.replace(new RegExp(`[${decimalChar}]`, "g"), ".");
+ const val = parseFloat(normalized);
+ const cents = isFinite(val) ? Math.round(val * 100) : 0;
+ fiatDigits = Math.max(0, cents).toString();
+ html = formatFiatFromDigits(fiatDigits, decimalChar); // CHANGED: keep html in sync immediately
+ } else {
+ const clean = txt.replace(/[^\d]/g, ""); // CHANGED: integer-only for sats
+ html = clean || "0";
+ }
input(null, true);
};
- $: html = $fiat ? amountFiat : (amount || 0).toString();
-
- let prev = "";
+ let prevHtml = $state("");
- let input = (e, numpad = false) => {
- let d = decimal.replace(".", "\\.");
-
- if (!$fiat && html.includes(decimal)) {
- html = html.replace(new RegExp(d), "");
- }
-
- if (prev === "0" && html !== `0${decimal}`) {
- prev = "";
- html = html.replace("0", "");
- }
-
- let sel = getSelection();
- let i = sel.focusOffset;
-
- if (html[0] === "0") i = html.length;
-
- if (html.split(decimal)[0][0] === "0" && html.length > 1) {
- html = html.replace("0", "");
- i++;
- }
-
- if (html[0] === decimal) {
- html = `0${html}`;
- i = html.length;
- }
+ // (kept for completeness; no longer used in sats path)
+ function trimTrailingZeros(str) {
+ if (!str.includes(".")) return str;
+ let out = str.replace(/(\.\d*?[1-9])0+$/, "$1");
+ out = out.replace(/\.0*$/, "");
+ return out;
+ }
- if (html.includes(decimal) && html.split(decimal)[1].length > 2) {
- html = html.slice(0, html.indexOf(decimal) + 3);
- i = html.length;
+ const input = (e, fromPad = false) => {
+ if (fiat) {
+ if (!fromPad) {
+ const raw = (element?.textContent ?? "").replace(/[^\d]/g, "");
+ fiatDigits = raw || "0";
+ }
+ const cents = parseInt(fiatDigits || "0") || 0;
+ const numericFiat = cents / 100;
+ const satsEstimate = Math.round((numericFiat * sats) / rate);
+ if (satsEstimate > sats && html.length > prevHtml.length) {
+ fiatDigits = fiatDigits.slice(0, -1) || "0";
+ warning($t("user.receive.lessThan1BTCWarning"));
+ }
+ html = formatFiatFromDigits(fiatDigits, decimalChar);
+ } else {
+ // CHANGED: sats input only digits (no decimals)
+ let clean = (element?.textContent ?? "").replace(/[^\d]/g, "") || "0";
+ const maybe = parseInt(clean) || 0;
+ if (maybe > sats && clean.length > prevHtml.length) {
+ clean = clean.slice(0, -1) || "0";
+ warning($t("user.receive.lessThan1BTCWarning"));
+ }
+ html = clean;
}
- let clean = html
- .substr(0, 15)
- .replace(new RegExp(`[^0-9${d}]+`, "g"), "")
- .replace(new RegExp(d), "X")
- .replace(new RegExp(d, "g"), "")
- .replace("X", decimal);
+ updateByMode();
- if (clean !== html) {
- html = clean;
- i = html.length;
- }
- if (amount > sats && html.length > prev.length) {
- html = html.substr(0, html.length - 1);
- i = html.length;
- warning($t("user.receive.lessThan1BTCWarning"));
+ if (!fromPad) {
+ queueMicrotask(() => {
+ const node = element?.childNodes?.[0];
+ if (!node) return;
+ const range = document.createRange();
+ const sel = getSelection();
+ const L = node.length ?? node.textContent?.length ?? 0;
+ range.setStart(node, L);
+ range.setEnd(node, L);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ });
}
+ prevHtml = html.toString();
+ };
- if ($fiat) {
- amountFiat = html;
+ const handleInput = (value) => {
+ if (fiat) {
+ if (value === arrow) {
+ fiatDigits = fiatDigits.length > 1 ? fiatDigits.slice(0, -1) : "0";
+ } else if (value === "00") {
+ fiatDigits = (fiatDigits === "0" ? "" : fiatDigits) + "00";
+ if (!fiatDigits) fiatDigits = "0";
+ } else {
+ fiatDigits = (fiatDigits === "0" ? "" : fiatDigits) + value;
+ }
+ html = formatFiatFromDigits(fiatDigits, decimalChar);
} else {
- amount = parseInt(html);
- }
-
- if (!amount) amount = null;
-
- if (!numpad) {
- setTimeout(() => {
- let node = element.childNodes[0];
- let range = document.createRange();
-
- if (node) {
- if (i > node.length) i = node.length;
- range.setStart(node, i);
- range.setEnd(node, i);
-
- let sel = getSelection();
- sel.removeAllRanges();
- sel.addRange(range);
- node.selectionStart = node.selectionEnd;
- }
-
- prev = html.toString();
- }, 0);
+ if (value === arrow) {
+ html = html.length > 1 ? html.slice(0, -1) : "0";
+ } else if (value === "00") {
+ html = (html === "0" ? "" : html) + "00";
+ if (!html) html = "0";
+ } else {
+ html = (html === "0" ? "" : html) + value;
+ }
+ // CHANGED: no decimals in sats, so no trimming needed
+ element && (element.innerHTML = html);
}
+ input(null, true);
};
- let select = (e) => {
- let range = document.createRange();
+ const select = (e) => {
+ const range = document.createRange();
range.selectNodeContents(e.target);
- let sel = getSelection();
+ const sel = getSelection();
sel.removeAllRanges();
sel.addRange(range);
};
- let blur = () => {
- if (!html) html = "0";
+ const blur = () => {
+ if (!html) html = fiat ? formatFiatFromDigits("0", decimalChar) : "0";
};
- let keydown = (e) =>
- e.key === "Enter" && (e.preventDefault() || submit.click());
+ const keydown = (e) =>
+ e.key === "Enter" && (e.preventDefault() || submit?.click?.());
- let swap = () => {
- if ($fiat) {
- amount = parseInt((amountFiat / (rate / sats)).toFixed(0));
+ // CHANGED: make swap set html for the new mode immediately and keep DOM in sync
+ const swap = () => {
+ if (fiat) {
+ // going -> sats
+ fiat = false;
+ html = (amount || 0).toString();
+ element && (element.innerHTML = html);
} else {
- amountFiat =
- (amount * (rate / sats)).toFixed(2) > 0.0
- ? (amount * (rate / sats)).toFixed(2)
- : 0;
+ // going -> fiat
+ const cents = Math.round((parseFloat(amountFiat || "0") || 0) * 100);
+ fiatDigits = Math.max(0, cents).toString();
+ fiat = true;
+ html = formatFiatFromDigits(fiatDigits, decimalChar);
+ element && (element.innerHTML = html);
}
- $fiat = !$fiat;
+ updateByMode();
+ prevHtml = html.toString();
+
+ // Move caret to end after swap
+ queueMicrotask(() => {
+ const node = element?.childNodes?.[0];
+ if (!node) return;
+ const range = document.createRange();
+ const sel = getSelection();
+ const L = node.length ?? node.textContent?.length ?? 0;
+ range.setStart(node, L);
+ range.setEnd(node, L);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ });
};
+
+ const arrow = "<";
+ const numPad = ["1","2","3","4","5","6","7","8","9","00","0",arrow];
+
+ onMount(() => {
+ if (fiat) {
+ const cents = Math.round((parseFloat(amountFiat || "0") || 0) * 100);
+ fiatDigits = Math.max(0, cents).toString();
+ html = formatFiatFromDigits(fiatDigits, decimalChar); // CHANGED: ensure html matches fiat display on mount
+ } else {
+ html = (amount || 0).toString();
+ }
+ updateByMode();
+ });
</script>
<div class="flex justify-center items-center">
<div class="space-y-5 w-full">
<div class="text-center">
- <div
- class="text-5xl md:text-6xl font-semibold tracking-widest flex justify-center"
- >
- <div class="my-auto" class:text-5xl={!$fiat}>
- {#if $fiat}
- {#if position === "before"}
- {symbol}
- {/if}
+ <div class="text-5xl md:text-6xl font-semibold tracking-widest flex justify-center">
+ <div class="my-auto" class:text-5xl={!fiat}>
+ {#if fiat}
+ {#if position === "before"}{symbol}{/if}
{:else}
- <iconify-icon
- noobserver
- icon="ph:lightning-fill"
- class="text-yellow-300"
- ></iconify-icon>
+ <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300"></iconify-icon>
{/if}
</div>
+
+ <!-- contenteditable input -->
<div
use:focus
+ role="textbox"
+ tabindex="0"
+ aria-label="Amount input"
+ aria-multiline="false"
contenteditable
bind:innerHTML={html}
- on:paste={pasted}
- on:focus={select}
- on:blur={blur}
- on:input={input}
- on:keydown={keydown}
+ onpaste={pasted}
+ onfocus={select}
+ onblur={blur}
+ oninput={input}
+ onkeydown={keydown}
class="outline-none my-auto"
bind:this={element}
- />
- {#if $fiat && position === "after"}
- {symbol}
- {/if}
+ ></div>
+
+ {#if fiat && position === "after"}{symbol}{/if}
</div>
- <div
- class="flex items-center justify-center text-2xl cursor-pointer"
- on:click|preventDefault|stopPropagation={swap}
+
+ <!-- swap button -->
+ <button
+ type="button"
+ class="flex items-center justify-center text-2xl cursor-pointer mx-auto select-none"
+ aria-label="Swap currency display"
+ onclick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ swap();
+ }}
>
- {#if $fiat}
- <iconify-icon
- noobserver
- icon="ph:lightning-fill"
- class="text-yellow-300"
- ></iconify-icon>
+ {#if fiat}
+ <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300"></iconify-icon>
{s(amount, locale)}
{:else}
{f(amountFiat, currency, locale)}
{/if}
- </div>
+ </button>
</div>
<div class="grid grid-cols-3 gap-2 w-full mx-auto">
{#each numPad as value}
{#if value === arrow}
- <button
- type="button"
- class="btn"
- on:click|preventDefault|stopPropagation={() => handleInput(value)}
- >
+ <button type="button" class="btn" aria-label="Backspace" onclick={(e) => {
+ e.preventDefault(); e.stopPropagation(); handleInput(value);
+ }}>
<Left />
</button>
{:else}
- <button
- type="button"
- class="btn"
- on:click|preventDefault|stopPropagation={() => handleInput(value)}
- >{value}</button
- >
+ <button type="button" class="btn" aria-label={`Key ${value}`} onclick={(e) => {
+ e.preventDefault(); e.stopPropagation(); handleInput(value);
+ }}>
+ {value}
+ </button>
{/if}
{/each}
</div>