diff options
| author | Adam Soltys <[email protected]> | 2025-08-20 13:19:03 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2025-08-20 13:19:03 -0700 |
| commit | e129c615b1b46ea307a00994c84cb2ed086d8c51 (patch) | |
| tree | 0316d3615e3b4834dbdc41751a09fcd083e2a962 /src/components | |
| parent | 41642852b636d2e07b9d72482b9a40328fc76903 (diff) | |
| download | coinos-ui-e129c615b1b46ea307a00994c84cb2ed086d8c51.tar.gz coinos-ui-e129c615b1b46ea307a00994c84cb2ed086d8c51.zip | |
numpad upgrade
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/Numpad.svelte | 399 |
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> |
