Last active
June 9, 2025 16:35
-
-
Save ThomasR/065c082fdfe92ea323a76149627e8fc9 to your computer and use it in GitHub Desktop.
A no-bullshit dependency-free currency converter for your browser. See it live at https://thomas-rosenau.de/currencyConversion.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright 2025 Thomas Rosenau | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
* | |
*/ | |
@keyframes rotate { | |
from { | |
transform: rotate(0deg); | |
} | |
to { | |
transform: rotate(360deg); | |
} | |
} | |
*, *::before, *::after { | |
box-sizing: border-box; | |
} | |
html { | |
font-family: "Twemoji Country Flags", "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
font-size: 16px; | |
line-height: 1.25; | |
margin: 0; | |
padding: 0; | |
-webkit-text-size-adjust: 100%; | |
-moz-text-size-adjust: 100%; | |
text-size-adjust: 100%; | |
} | |
body { | |
margin: 0; | |
padding: 0; | |
&.loading main { | |
display: none; | |
} | |
&.loading::before { | |
display: block; | |
width: 2rem; | |
height: 2rem; | |
margin: auto; | |
content: ""; | |
animation: rotate 2s linear infinite; | |
border: 4px solid #c0c0c0; | |
border-right-color: transparent; | |
border-left-color: transparent; | |
border-radius: 1rem; | |
} | |
} | |
@media screen { | |
body { | |
min-height: 100vh; | |
padding: 1rem; | |
} | |
} | |
h1 { | |
font-size: 2rem; | |
} | |
h2 { | |
font-size: 1.5rem; | |
} | |
h3 { | |
font-size: 1.2rem; | |
} | |
h4 { | |
font-size: 1.1rem; | |
} | |
h5 { | |
font-size: 1.1rem; | |
} | |
h6 { | |
font-size: .9rem; | |
} | |
h1, h2, h3, h4, h5, h6 { | |
line-height: 1.1; | |
margin: 0.67em 0; | |
text-wrap: balance; | |
} | |
pre, code, kbd, samp { | |
font-size: inherit; | |
} | |
small { | |
font-size: 80%; | |
} | |
details { | |
display: block; | |
} | |
summary { | |
display: list-item; | |
} | |
table { | |
border-spacing: 0; | |
border-collapse: collapse; | |
--table-border-color: hsla(330, 4%, 78%, 0.3); | |
--table-stripe-color: hsla(330, 4%, 78%, 0.15); | |
} | |
th, td { | |
padding: .25rem .5rem; | |
text-align: left; | |
border-right: 1px solid var(--table-border-color); | |
&:last-of-type { | |
border-right: none; | |
} | |
} | |
thead { | |
border-bottom: 1px solid var(--table-border-color); | |
} | |
tfoot { | |
border-top: 1px solid var(--table-border-color); | |
} | |
thead { | |
font-size: 120%; | |
} | |
thead th, | |
tr:nth-child(even) td, | |
table:has(tbody tr:nth-child(odd):last-of-type) tfoot th { | |
background-color: var(--table-stripe-color); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"/> | |
<title>Currency Conversion</title> | |
<link rel="icon" | |
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💱</text></svg>"> | |
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> | |
<link rel="stylesheet" href="base.css"/> | |
<link rel="stylesheet" href="form.css"/> | |
<style> | |
:root { | |
--form-theme-color: hsl(180, 100%, 30%); | |
} | |
html { | |
font-size: max(1.7vh, 16px); | |
} | |
main { | |
display: flex; | |
flex-direction: column; | |
justify-content: space-between; | |
gap: 2rem; | |
} | |
table { | |
margin: auto; | |
--table-stripe-color: var(--form-background-color); | |
} | |
thead { | |
font-size: 120%; | |
} | |
tbody tr { | |
font-family: "JetBrains Mono", monospace; | |
} | |
th, td { | |
padding: 0 .25em 0 .75em; | |
text-align: right; | |
border: none; | |
} | |
th > span { | |
display: inline-block; | |
text-align: center; | |
} | |
th .flag { | |
display: block; | |
margin: 0; | |
} | |
:is(td, th):nth-of-type(even):not(:last-of-type) { | |
padding-right: 1rem; | |
border-right: 2px solid #909192; | |
} | |
tfoot th { | |
font-size: 80%; | |
font-weight: normal; | |
padding: .5em; | |
text-align: center; | |
background: transparent; | |
} | |
small { | |
font-size: 66%; | |
} | |
label small { | |
margin-top: .4em; | |
} | |
small.thousands { | |
margin-left: .15em; | |
opacity: .5; | |
&::before { | |
content: "000"; | |
} | |
body:has([name="k-style"][value="000"]:checked).millions &::after { | |
content: "000"; | |
margin-left: .15em; | |
} | |
body:has([name="k-style"][value=".000"]:checked) & { | |
font-size: unset; | |
margin: 0; | |
&::before { | |
content: ".000"; | |
} | |
} | |
body:has([name="k-style"][value=".000"]:checked).millions & { | |
margin: 0; | |
&::before { | |
content: ".000.000"; | |
} | |
} | |
body:has([name="k-style"][value="K"]:checked) &::before { | |
content: "K"; | |
} | |
body:has([name="k-style"][value="K"]:checked).millions &::before { | |
content: "M"; | |
} | |
} | |
form { | |
margin: auto; | |
} | |
form.k-style { | |
display: none; | |
flex-direction: row; | |
justify-content: space-around; | |
.show-k & { | |
display: flex; | |
} | |
} | |
label { | |
justify-content: space-between; | |
} | |
.suffix-input:has(input[name="foreignAmount"]), input[name="homeAmount"] { | |
width: unset; | |
min-width: 3em; | |
} | |
label:has([name="k"]) { | |
display: none; | |
.show-k & { | |
display: initial; | |
} | |
} | |
label:has([name="rate"]) output { | |
flex: 1; | |
} | |
label:has(.suffix-input) { | |
flex: 1; | |
} | |
.conversion { | |
display: grid; | |
font-size: 1.5rem; | |
align-items: center; | |
text-align: center; | |
row-gap: .5rem; | |
column-gap: .5rem; | |
grid-template-columns: max-content 1fr max-content; | |
& > * { | |
display: unset; | |
} | |
.equals { | |
grid-column: span 2; | |
} | |
label:has([name="foreignAmount"]) { | |
grid-column: span 2; | |
.show-k & { | |
grid-column: unset; | |
} | |
} | |
[name="homeAmount"] { | |
grid-column: span 2; | |
} | |
.currency { | |
justify-self: end; | |
} | |
} | |
[name="homeAmount"] { | |
text-align: right; | |
} | |
.flag { | |
margin-left: .3em; | |
} | |
@media screen { | |
html { | |
background: #f5f7f5; | |
} | |
table, form { | |
width: min(25rem, 100%); | |
} | |
table { | |
overflow: hidden; | |
border-radius: 0.5rem; | |
} | |
table + table { | |
display: none; | |
} | |
thead th { | |
padding-top: .5rem; | |
} | |
tfoot th { | |
line-height: 2; | |
} | |
:is(th, td):last-child:not([colspan]) { | |
padding-right: .75em; | |
} | |
table, form { | |
box-shadow: 0 0 5px #cccc; | |
} | |
.flag { | |
text-shadow: 0 0 2px #9999; | |
} | |
} | |
@media screen and (min-width: 700px) { | |
.currency-select, | |
.conversion { | |
width: calc(700px - 2rem); | |
} | |
.conversion { | |
grid-template-columns: max-content 1fr max-content 3rem 1fr max-content; | |
& .equals, | |
& [name="homeAmount"] { | |
grid-column: unset; | |
} | |
} | |
} | |
@media print { | |
@page { | |
size: landscape; | |
margin: 10mm; | |
} | |
html { | |
font-size: 13px; | |
} | |
main { | |
flex-direction: row; | |
height: calc(210mm - 2 * 15mm); | |
} | |
form { | |
display: none !important; | |
} | |
small.thousands { | |
opacity: 1; | |
color: #666; | |
} | |
table { | |
--table-stripe-color: hsl(210, 2%, 80%); | |
} | |
table, thead { | |
position: relative; | |
} | |
table::before, thead::before, table::after, thead::after { | |
--cutmark-size: .6cm; | |
display: block; | |
content: ""; | |
position: absolute; | |
inset: 0; | |
width: var(--cutmark-size); | |
height: var(--cutmark-size); | |
border: 1px dashed red; | |
} | |
thead::before { | |
top: calc(-1 * var(--cutmark-size)); | |
left: calc((100% - 6.8cm) / 2 - var(--cutmark-size) - 1px); | |
border-top: none; | |
border-left: none; | |
} | |
thead::after { | |
top: calc(-1 * var(--cutmark-size)); | |
left: calc((100% + 6.8cm) / 2 - 1px); | |
border-top: none; | |
border-right: none; | |
} | |
table::before { | |
top: calc(10.05cm - 1px); | |
left: calc((100% - 6.8cm) / 2 - var(--cutmark-size) - 1px); | |
border-bottom: none; | |
border-left: none; | |
} | |
table::after { | |
top: calc(10.05cm - 1px); | |
left: calc((100% + 6.8cm) / 2 - 1px); | |
border-bottom: none; | |
border-right: none; | |
} | |
} | |
</style> | |
<script type="text/template" id="table-head"> | |
<thead> | |
<tr> | |
<th><span><span class="flag">{{foreignFlag}}</span> <span class="currency">{{foreignCurrency}}</span></span></th> | |
<th><span><span class="flag">{{homeFlag}}</span> <span class="currency">{{homeCurrency}}</span></span></th> | |
<th><span><span class="flag">{{foreignFlag}}</span> <span class="currency">{{foreignCurrency}}</span></span></th> | |
<th><span><span class="flag">{{homeFlag}}</span> <span class="currency">{{homeCurrency}}</span></span></th> | |
</tr> | |
</thead> | |
</script> | |
<script type="text/template" id="table-row"> | |
<tr> | |
<td>{{foreignAmount1}}</td> | |
<td>{{homeAmount1}}</td> | |
<td>{{foreignAmount2}}</td> | |
<td>{{homeAmount2}}</td> | |
</tr> | |
</script> | |
<script type="text/template" id="table-foot"> | |
<tfoot> | |
<tr> | |
<th colspan="4"> | |
{{updateDate}} | |
</th> | |
</tr> | |
</tfoot> | |
</script> | |
<script type="module" defer> | |
import { polyfillCountryFlagEmojis } from 'https://cdn.skypack.dev/country-flag-emoji-polyfill'; | |
polyfillCountryFlagEmojis(); | |
</script> | |
</head> | |
<body class="loading"> | |
<main> | |
<form class="currency-select"> | |
<label>Currency: <select name="currency"></select></label> | |
<label>Exchange rate: <input type="hidden" name="rate"> | |
<output></output> | |
</label> | |
</form> | |
<form class="conversion"> | |
<label><input type="checkbox" class="toggle-button" name="k" disabled>× 1.000</label> | |
<label><span class="suffix-input"><input type="text" name="foreignAmount" | |
value="0" onfocus="this.value=''"></span></label> | |
<span class="currency foreignCurrency"></span> | |
<span class="equals"> = </span> | |
<input type="text" name="homeAmount" value="1" onfocus="this.value=''"/><span class="currency homeCurrency"></span> | |
</form> | |
<table> | |
<tbody> | |
</tbody> | |
</table> | |
<table aria-hidden="true"> | |
<tbody> | |
</tbody> | |
</table> | |
<table aria-hidden="true"> | |
<tbody> | |
</tbody> | |
</table> | |
<form class="k-style"> | |
<label><input type="radio" | |
name="k-style" | |
value=".000">.000</label> | |
<label><input type="radio" name="k-style" value="000" checked><small>000</small></label> | |
<label><input type="radio" name="k-style" value="K"><small>K</small></label> | |
</form> | |
<form> | |
<input type="button" onclick="window.print()" value="🖨️ Print"> | |
</form> | |
</main> | |
</body> | |
<script type="module"> | |
import { renderTemplate } from 'templateEngine.mjs'; | |
const amounts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80]; | |
const defaultLocale = 'de-DE'; | |
const homeCurrency = 'EUR'; | |
const preselectedForeignCurrency = 'USD'; | |
const currencyField = document.querySelector('select[name="currency"]'); | |
const rateField = document.querySelector('input[name="rate"]'); | |
const rateLabel = document.querySelector('label:has(input[name="rate"]) output'); | |
const foreignAmountField = document.querySelector('input[name="foreignAmount"]'); | |
const homeAmountField = document.querySelector('input[name="homeAmount"]'); | |
const thousandsButton = document.querySelector('input[name="k"]'); | |
const format = ({ | |
amount, | |
currency, | |
withDecimals = true, | |
withSymbol = false | |
}) => { | |
let formatter = new Intl.NumberFormat(defaultLocale, { | |
style: 'currency', | |
currency, | |
maximumFractionDigits: withDecimals ? 2 : 0 | |
}); | |
let result = formatter.format(amount); | |
if (!withSymbol) { | |
result = result.replace(/[^\d,. ].*$/, '').trim(); | |
} | |
return result; | |
}; | |
const getCurrencySymbol = (currency, locale = defaultLocale) => | |
new Intl.NumberFormat(locale, { style: 'currency', currency }) | |
.formatToParts(1) | |
.find(x => x.type === 'currency') | |
.value; | |
const getFlagEmoji = countryCode => { | |
const codePoints = countryCode | |
.toUpperCase() | |
.split('') | |
.map(char => 127397 + char.charCodeAt()); | |
return String.fromCodePoint(...codePoints); | |
}; | |
const getCountryFromCurrency = (currency, countries) => { | |
let countryCode; | |
let country; | |
switch (currency) { | |
case 'CHF': | |
case 'GBP': | |
case 'NOK': | |
case 'NZD': | |
case 'USD': | |
countryCode = currency.substring(0, 2).toLowerCase(); | |
break; | |
case 'EUR': | |
return { | |
countryCode: 'eu', | |
country_name: 'european union' | |
}; | |
case 'XAF': | |
return { | |
countryCode: 'cm', | |
country_name: 'african financial community' | |
}; | |
case 'XCD': | |
return { | |
countryCode: 'lc', | |
country_name: 'organisation of eastern caribbean states' | |
}; | |
case 'XOF': | |
return { | |
countryCode: 'ci', | |
country_name: 'west african economic and monetary union' | |
}; | |
default: | |
country = countries.find(c => c[1].currency_code === currency.toLowerCase()); | |
countryCode = country?.[0]; | |
break; | |
} | |
if (!country) { | |
country = countries.find(c => c[0] === countryCode); | |
} | |
if (!country) { | |
return null; | |
} | |
return { | |
...country[1], | |
countryCode | |
}; | |
}; | |
const targetTables = [...document.querySelectorAll('table')]; | |
let currencies = {}; | |
const loadFromCDN = async ({ | |
apiVersion = 'v1', | |
date = 'latest', | |
endpoint | |
}) => { | |
try { | |
return await fetch(`https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@${date}/${apiVersion}/${endpoint}`); | |
} catch { | |
return await fetch(`https://${date}.currency-api.pages.dev/${apiVersion}/${endpoint}`); | |
} | |
}; | |
const loadRates = async () => { | |
let ratesRequest = await loadFromCDN({ endpoint: `currencies/${homeCurrency.toLowerCase()}.min.json` }); | |
let rates = await ratesRequest.json(); | |
let date = Date.parse(rates.date); | |
let updateDate = new Intl.DateTimeFormat(defaultLocale).format(date); | |
targetTables.forEach(target => { | |
renderTemplate({ | |
templateId: 'table-foot', | |
data: { updateDate }, | |
target | |
}); | |
}); | |
let exchangeRates = rates[homeCurrency.toLowerCase()]; | |
let countriesRequest = await loadFromCDN({ endpoint: 'country.json' }); | |
let countries = Object.entries(await countriesRequest.json()); | |
let namesRequest = await loadFromCDN({ endpoint: 'currencies.min.json' }); | |
let currencyNames = await namesRequest.json(); | |
let nativelySupported = Intl.supportedValuesOf('currency'); | |
for (let currency in exchangeRates) { | |
currency = currency.toUpperCase(); | |
let isSupported = nativelySupported.includes(currency); | |
let country = getCountryFromCurrency(currency, countries); | |
isSupported = isSupported && Boolean(country); | |
if (!isSupported) { continue; } | |
let flag = getFlagEmoji(country.countryCode); | |
isSupported = isSupported && Boolean(flag); | |
if (!isSupported) { continue; } | |
currencies[currency] = { | |
rate: exchangeRates[currency.toLowerCase()], | |
name: currencyNames[currency.toLowerCase()], | |
country: { | |
...country, | |
flag | |
} | |
}; | |
} | |
let select = document.querySelector('[name="currency"]'); | |
Object.keys(currencies).forEach(currency => { | |
if (currency === homeCurrency) { return; } | |
let option = document.createElement('option'); | |
option.value = currency; | |
option.selected = option.value === preselectedForeignCurrency; | |
let country = currencies[currency].country; | |
let countryName = country.country_name; | |
countryName = countryName.replace(/\(.*/, '').trim(); | |
countryName = countryName.replace('cura\u{FFFD}ao', 'curaçao'); | |
countryName = countryName.replace('c\u{FFFD}te', 'côte'); | |
countryName = countryName.replaceAll(/(^| )([^ ]+)/g, function (match, space, word) { | |
if (word === 'and' || word === 'of') { | |
return match; | |
} | |
if (word === 'd\'ivoire') { | |
return ' d\'Ivoire'; | |
} | |
return `${space}${word[0].toUpperCase()}${word.slice(1)}`; | |
}); | |
// console.debug(currency, currencies[currency].rate); | |
option.textContent = `${country.flag} ${option.value} - ${currencies[currency].name} (${countryName})`; | |
select.appendChild(option); | |
}); | |
// window.currencies = currencies; | |
let homeSymbol = getCurrencySymbol(homeCurrency); | |
let homeFlag = currencies[homeCurrency].country.flag; | |
document.querySelector('.homeCurrency').innerHTML = `${homeSymbol} <span class="flag">${homeFlag}</span>`; | |
}; | |
const getMultiplier = () => { | |
const rate = Number(rateField.value); | |
let multiplier = 1 / 100; | |
while (multiplier / rate < .175) { | |
multiplier *= 10; | |
} | |
return multiplier; | |
}; | |
const renderTable = () => { | |
document.querySelectorAll('thead, tbody tr').forEach(row => { | |
row.remove(); | |
}); | |
const currency = currencyField.value; | |
const rate = Number(rateField.value); | |
const multiplier = getMultiplier(); | |
targetTables.forEach(target => { | |
renderTemplate({ | |
templateId: 'table-head', | |
data: { | |
foreignFlag: currencies[currency].country.flag, | |
foreignCurrency: currency, | |
homeCurrency, | |
homeFlag: currencies[homeCurrency].country.flag | |
}, | |
target, | |
prepend: true | |
}); | |
let rowCount = Math.ceil(amounts.length / 2); | |
for (let i = 0; i < rowCount; i++) { | |
renderTemplate({ | |
templateId: 'table-row', | |
data: { | |
foreignAmount1: format({ amount: amounts[i] * multiplier, currency, withDecimals: multiplier < 1 }), | |
homeAmount1: format({ amount: amounts[i] * multiplier / rate, currency: homeCurrency }), | |
foreignAmount2: format({ | |
amount: amounts[i + rowCount] * multiplier, | |
currency, | |
withDecimals: multiplier < 1 | |
}), | |
homeAmount2: format({ | |
amount: amounts[i + rowCount] * multiplier / rate, | |
currency: homeCurrency | |
}) | |
}, | |
target: target.querySelector('tbody') | |
}); | |
} | |
if (multiplier >= 1_000_000) { | |
target.querySelectorAll('tr td:nth-child(odd)').forEach(cell => { | |
cell.innerHTML = cell.innerHTML.replace(/([.,\s]?000){2}$/, '<small class="thousands"></small>'); | |
}); | |
} else if (multiplier >= 1_000) { | |
target.querySelectorAll('tr td:nth-child(odd)').forEach(cell => { | |
cell.innerHTML = cell.innerHTML.replace(/[.,\s]?000$/, '<small class="thousands"></small>'); | |
}); | |
} | |
}); | |
}; | |
const parseAmount = (value) => { | |
value = value.replace(/k$/i, '000'); | |
if (/\.\d{3,}/.test(value) || /\..+,/.test(value)) { | |
value = value.replaceAll('.', ''); | |
} else if (/,\d{3,}/.test(value)) { | |
value = value.replaceAll(',', ''); | |
} | |
if (/,\d{1,2}/.test(value)) { | |
value = value.replace(',', '.'); | |
} | |
let result = Number(value); | |
if (isFinite(result)) { | |
return result; | |
} | |
return null; | |
}; | |
const handleCurrencyChange = () => { | |
saveToStorage(); | |
let currency = currencyField.value; | |
let currencySymbol = getCurrencySymbol(currency); | |
document.querySelector('.foreignCurrency').innerHTML = `${currencySymbol} <span class="flag">${currencies[currency].country.flag}</span>`; | |
let { rate } = currencies[currency]; | |
rateField.value = rate; | |
rateLabel.textContent = new Intl.NumberFormat(defaultLocale, { maximumFractionDigits: 100 }).format(rate); | |
const multiplier = getMultiplier(); | |
if (multiplier >= 1_000_000) { | |
document.body.classList.add('show-k'); | |
document.body.classList.add('millions'); | |
thousandsButton.checked = true; | |
} else if (multiplier >= 1_000) { | |
document.body.classList.add('show-k'); | |
document.body.classList.remove('millions'); | |
thousandsButton.checked = true; | |
} else { | |
document.body.classList.remove('show-k'); | |
document.body.classList.remove('millions'); | |
thousandsButton.checked = false; | |
} | |
updateForeignAmountField(); | |
renderTable(); | |
let amount = parseAmount(homeAmountField.value); | |
if (!amount) { | |
homeAmountField.value = format({ amount: 1, currency: homeCurrency }); | |
} | |
handleAmountInput(homeAmountField); | |
}; | |
const handleAmountInput = (field) => { | |
const rate = Number(rateField.value); | |
const kClicked = field === foreignAmountField && thousandsButton.checked; | |
let { value } = field; | |
if (kClicked && field.value.toLowerCase().endsWith('k')) { | |
value = value.replace(/k$/i, ''); | |
field.value = value; | |
} | |
let amount = parseAmount(value); | |
if (typeof amount !== 'number') { | |
return; | |
} | |
if (kClicked) { | |
amount *= 1_000; | |
} | |
if (value.toLowerCase().endsWith('k')) { | |
console.log(amount); | |
field.value = format({ | |
amount: kClicked ? amount / 1_000 : amount, | |
currency: field === homeAmountField ? homeCurrency : currency, withDecimals: false | |
}); | |
} | |
if (field === homeAmountField) { | |
foreignAmountField.value = format({ | |
amount: amount * rate, | |
currency: currencyField.value, | |
withDecimals: rate < 20 | |
}); | |
foreignAmountField.closest('.suffix-input').dataset.after = ''; | |
} else { | |
if (kClicked) { | |
foreignAmountField.closest('.suffix-input').dataset.after = '.000'; | |
} | |
homeAmountField.value = format({ amount: amount / rate, currency: homeCurrency }); | |
} | |
}; | |
const currencyStorageKey = location.pathname + '#currency'; | |
const kStyleStorageKey = location.pathname + '#kStyle'; | |
const loadFromStorage = () => { | |
let stored = localStorage.getItem(currencyStorageKey); | |
if (stored) { | |
currencyField.value = stored; | |
} | |
stored = localStorage.getItem(kStyleStorageKey); | |
if (stored) { | |
document.querySelectorAll('[name="k-style"]').forEach(input => input.checked = input.value === stored); | |
} | |
}; | |
const saveToStorage = () => { | |
localStorage[currencyStorageKey] = currencyField.value; | |
localStorage[kStyleStorageKey] = document.querySelector('[name="k-style"]:checked').value; | |
}; | |
const updateForeignAmountField = () => { | |
let clicked = thousandsButton.checked; | |
let span = foreignAmountField.closest('.suffix-input'); | |
if (clicked) { | |
span.dataset.after = '.000'; | |
} else { | |
span.dataset.after = ''; | |
} | |
}; | |
const handleKClick = () => { | |
updateForeignAmountField(); | |
handleAmountInput(foreignAmountField); | |
}; | |
const handleAmountFocusChange = (hasFocus) => { | |
thousandsButton.disabled = !hasFocus; | |
if (hasFocus) { | |
updateForeignAmountField(); | |
} | |
}; | |
currencyField.addEventListener('input', handleCurrencyChange); | |
foreignAmountField.addEventListener('input', handleAmountInput.bind(null, foreignAmountField)); | |
homeAmountField.addEventListener('input', handleAmountInput.bind(null, homeAmountField)); | |
foreignAmountField.addEventListener('focus', handleAmountFocusChange.bind(null, true)); | |
homeAmountField.addEventListener('input', handleAmountFocusChange.bind(null, false)); | |
currencyField.addEventListener('focus', handleAmountFocusChange.bind(null, false)); | |
thousandsButton.addEventListener('change', handleKClick); | |
document.querySelectorAll('[name="k-style"]').forEach(input => input.addEventListener('change', saveToStorage)); | |
document.querySelectorAll('form').forEach((form) => { | |
form.addEventListener('submit', (e) => { | |
e.preventDefault(); | |
}); | |
}); | |
await loadRates(); | |
document.body.classList.remove('loading'); | |
let urlParam = new URLSearchParams(location.search).get('currency'); | |
if (urlParam) { | |
currencyField.value = urlParam; | |
history.replaceState({}, '', location.pathname); | |
} else { | |
loadFromStorage(); | |
} | |
let currency = currencyField.value; | |
if (!currencies[currency]) { | |
currencyField.value = 'USD'; | |
} | |
handleCurrencyChange(); | |
</script> | |
</html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright 2025 Thomas Rosenau | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
* | |
*/ | |
form, form * { | |
&, &::before, &::after { | |
box-sizing: border-box; | |
} | |
} | |
:root { | |
--button-hover-background: color-mix(in srgb, var(--form-theme-color) 90%, black); | |
--button-padding-active: calc(.5rem + 1px) .5rem calc(.5rem - 1px) .5rem; | |
--button-shadow-active: inset 0 0 .4em #0005, 0 0 0 transparent, 0 0 0 transparent; | |
--button-shadow-idle: inset 0 0 transparent, 1px 1px 4px #0003, -1px -1px 4px white; | |
--form-background-color: #edefee; | |
--form-border-style: 1px solid #0006; | |
--form-contrast-color: white; | |
--form-disabled-color: color-mix(in srgb, var(--form-theme-color) 50%, gray); | |
--form-field-radius: .35rem; | |
--form-focus-color: color-mix(in srgb, var(--form-theme-color) 80%, black); | |
--form-theme-color: #1b69ee; | |
--form-transition: border-color ease-in-out .2s, opacity ease-in-out .3s, box-shadow ease-in-out .1s, padding linear .1s; | |
} | |
/* layout */ | |
form { | |
line-height: 1.25; | |
padding: 1rem; | |
border-radius: .5rem; | |
background: var(--form-background-color); | |
} | |
form, fieldset { | |
display: flex; | |
flex-direction: column; | |
justify-content: stretch; | |
gap: 1.5em; | |
p { | |
margin: 0; | |
} | |
} | |
fieldset { | |
padding: 1rem; | |
border: 1px solid #0002; | |
border-radius: .25rem; | |
legend { | |
font-weight: bold; | |
color: #747474; | |
} | |
} | |
label:not(:has(input, select, textarea, button, output)) { | |
display: block; | |
&:not(:last-child) { | |
margin-bottom: .5em; | |
:is(form, fieldset) > & { | |
margin-bottom: -1em; | |
} | |
} | |
} | |
label:has(input, select, textarea) { | |
display: inline-flex; | |
align-items: center; | |
flex-wrap: wrap; | |
vertical-align: middle; | |
gap: .5em; | |
} | |
input:not([type]), | |
input:is( | |
[type="search"], [type="submit"], [type="button"], [type="reset"], [type="range"], | |
[type="text"], [type="email"], [type="password"], [type="number"], [type="tel"], [type="url"], [type="file"], | |
[type="date"], [type="time"], [type|="datetime"], [type="month"], [type="week"] | |
), select, textarea, button, .suffix-input, .prefix-input, label:has(.toggle-button) { | |
flex: 1; | |
width: 100%; | |
min-width: 100%; | |
max-width: 100%; | |
padding: 0.5rem; | |
} | |
/* appearance */ | |
input, select, button, textarea, .prefix-input, .suffix-input { | |
font-family: inherit; | |
font-size: inherit; | |
line-height: inherit; | |
margin: 0; | |
} | |
input:not([type]), | |
input:is( | |
[type="search"], [type="submit"], [type="button"], [type="reset"], [type="range"], | |
[type="text"], [type="email"], [type="password"], [type="number"], [type="tel"], [type="url"], [type="file"], | |
[type="date"], [type="time"], [type|="datetime"], [type="month"], [type="week"] | |
), select, textarea, button, .suffix-input, .prefix-input, .toggle-button { | |
appearance: none; | |
border: var(--form-border-style); | |
border-radius: var(--form-field-radius); | |
background: Field; | |
transition: var(--form-transition); | |
} | |
button, input:is([type="submit"], [type="button"], [type="reset"], .toggle-button) { | |
--inner-shadow: var(--button-shadow-idle); | |
appearance: none; | |
color: var(--form-contrast-color); | |
border: var(--form-border-style); | |
background: var(--form-theme-color); | |
box-shadow: var(--inner-shadow); | |
&:disabled { | |
background: var(--form-disabled-color); | |
} | |
&:enabled:active { | |
--inner-shadow: var(--button-shadow-active); | |
padding: var(--button-padding-active); | |
} | |
&:focus-visible { | |
outline-offset: 2px; | |
box-shadow: var(--focus-shadow), var(--inner-shadow); | |
} | |
} | |
select:not([multiple]) { | |
padding-right: 2rem; | |
background: Field url("data:image/svg+xml;utf8,<svg height='12' viewBox='0 -1 20 20' width='12' xmlns='http://www.w3.org/2000/svg'><path fill='FieldText' d='M5.516 7.548l4.484 4.484 4.484-4.484-1.06-1.06L10 9.912 6.576 6.488z'/></svg>") no-repeat right .75rem center; | |
background-size: 1rem; | |
} | |
/* disabled */ | |
input, select, button, textarea, .prefix-input, .suffix-input { | |
&:disabled { | |
opacity: 0.5; | |
} | |
} | |
/* hover */ | |
label:has(input:is([type="checkbox"], [type="radio"]):enabled) { | |
cursor: pointer; | |
} | |
select, button, input:is([type="file"], [type="checkbox"], [type="radio"], [type="submit"], [type="image"], [type="reset"], [type="button"]) { | |
&:enabled { | |
cursor: pointer; | |
} | |
&:disabled { | |
cursor: default; | |
} | |
} | |
input:not([type]), | |
input:is( | |
[type="search"], | |
[type="text"], [type="email"], [type="password"], [type="number"], [type="tel"], [type="url"], | |
[type="date"], [type="time"], [type|="datetime"], [type="month"], [type="week"] | |
), | |
select, textarea, .suffix-input, .prefix-input { | |
&:hover:not(:disabled, :has(:disabled)) { | |
border-color: var(--form-theme-color); | |
} | |
} | |
input:is([type="button"], [type="submit"], [type="reset"]), button, .toggle-button { | |
&:hover:enabled { | |
background-color: var(--button-hover-background); | |
} | |
} | |
/* focus */ | |
:is(input, button, textarea, select, .toggle-button):focus-visible, | |
:is(.suffix-input, .prefix-input):has(:focus-visible) { | |
--focus-shadow: 0 0 1px 3px #fff6; | |
outline: 2px solid var(--form-focus-color); | |
outline-offset: 0; | |
box-shadow: var(--focus-shadow); | |
} | |
input:is([type="image"], [type="file"], [type="checkbox"]:not(.toggle-button), [type="range"], [type="radio"]):focus-visible { | |
outline-offset: 2px; | |
box-shadow: none; | |
} | |
/* radio */ | |
input[type="radio"] { | |
--size: 1.2em; | |
appearance: none; | |
position: relative; | |
width: var(--size); | |
height: var(--size); | |
margin: 0; | |
border: var(--form-border-style); | |
border-radius: 50%; | |
background: Field; | |
transition: var(--form-transition); | |
&:hover:enabled { | |
border-color: var(--form-theme-color); | |
} | |
&:checked::before { | |
content: ""; | |
position: absolute; | |
inset: calc(var(--size) / 6); | |
border-radius: 50%; | |
background: var(--form-theme-color); | |
} | |
&:disabled { | |
opacity: .5; | |
&:checked::before { | |
background-color: var(--form-disabled-color); | |
} | |
} | |
} | |
/* checkbox */ | |
input[type="checkbox"]:not(.toggle):not(.toggle-button) { | |
--size: 1.2em; | |
appearance: none; | |
display: inline-block; | |
position: relative; | |
width: var(--size); | |
height: var(--size); | |
margin: 0; | |
border: var(--form-border-style); | |
border-radius: 16%; | |
background: Field; | |
transition: var(--form-transition); | |
&:disabled { | |
opacity: .5; | |
} | |
&:hover:enabled { | |
border-color: var(--form-theme-color); | |
} | |
&:checked { | |
border-color: var(--form-theme-color); | |
background: var(--form-theme-color); | |
&:disabled { | |
border-color: var(--form-disabled-color); | |
background: var(--form-disabled-color); | |
} | |
&::after { | |
content: ""; | |
position: absolute; | |
top: calc(var(--size) / 10); | |
left: calc(var(--size) * 2 / 7); | |
width: calc(var(--size) / 3); | |
height: calc(var(--size) * 7 / 12); | |
transform: rotate(45deg); | |
border: solid var(--form-contrast-color); | |
border-width: 0 calc(var(--size) / 7) calc(var(--size) / 7) 0; | |
} | |
} | |
} | |
/* custom input: toggle */ | |
input[type="checkbox"].toggle { | |
--size: 1.2em; | |
appearance: none; | |
position: relative; | |
width: calc(25 / 14 * var(--size)); | |
height: var(--size); | |
margin: 0; | |
border-radius: var(--size); | |
background: hsl(0, 0%, 65%); | |
transition: background 0.3s ease; | |
&::before { | |
content: ""; | |
position: absolute; | |
top: 0; | |
bottom: 0; | |
left: 0; | |
width: calc(10 / 14 * var(--size)); | |
margin: calc(2 / 14 * var(--size)); | |
border-radius: 50%; | |
background: var(--form-contrast-color); | |
transition: left 0.3s; | |
} | |
&:checked { | |
background: var(--form-theme-color); | |
&:disabled { | |
background: var(--form-disabled-color); | |
} | |
&::before { | |
left: calc(11 / 14 * var(--size)); | |
} | |
} | |
&:active:enabled { | |
&::before { | |
left: calc(11 / 14 / 2 * var(--size)); | |
} | |
} | |
} | |
/* range slider */ | |
input[type="range"] { | |
--border-width: calc((var(--size) - var(--thumb-size)) / 2); | |
--size: 1.2em; | |
--thumb-size: calc(.6 * var(--size)); | |
-webkit-appearance: none; | |
overflow: hidden; | |
height: var(--size); | |
padding: 0; | |
border-radius: calc(var(--size) / 2); | |
background: var(--form-theme-color); | |
border-color: var(--form-theme-color); | |
/* do not combine ::-webkit-slider-thumb and ::-moz-range-thumb, this will break Chrome */ | |
&::-webkit-slider-thumb { | |
-webkit-appearance: none; | |
box-sizing: content-box; | |
width: var(--thumb-size); | |
height: var(--thumb-size); | |
border: var(--border-width) solid var(--form-theme-color); | |
border-radius: 50%; | |
background: var(--form-contrast-color); | |
box-shadow: 100vw 0 0 calc(100vw - var(--size) / 2) Field; | |
} | |
&::-moz-range-thumb { | |
appearance: none; | |
box-sizing: content-box; | |
width: var(--thumb-size); | |
height: var(--thumb-size); | |
border: var(--border-width) solid var(--form-theme-color); | |
border-radius: 50%; | |
background: var(--form-contrast-color); | |
box-shadow: 100vw 0 0 calc(100vw - var(--size) / 2) Field; | |
} | |
&:focus-visible { | |
outline-offset: 2px; | |
} | |
&:hover:enabled { | |
border-color: var(--form-theme-color); | |
} | |
&:enabled { | |
&::-webkit-slider-thumb { | |
cursor: pointer; | |
} | |
&::-moz-range-thumb { | |
cursor: pointer; | |
} | |
} | |
&:disabled { | |
opacity: .5; | |
} | |
} | |
/* custom inputs: prefix-input and suffix-input */ | |
.suffix-input, .prefix-input { | |
display: flex; | |
align-items: center; | |
justify-content: flex-start; | |
padding: 0; | |
&:not(:has(:disabled)) { | |
cursor: text; | |
} | |
&:has(:disabled) { | |
opacity: 0.5; | |
} | |
input { | |
flex: 1; | |
min-width: 0; | |
padding: .5rem; | |
opacity: unset; | |
border: none; | |
border-radius: 0; | |
background: none; | |
&:focus-visible { | |
outline: none; | |
box-shadow: none; | |
} | |
} | |
} | |
.suffix-input { | |
padding-right: .5rem; | |
input { | |
padding-right: 0; | |
text-align: right; | |
} | |
&:after { | |
content: attr(data-after); | |
opacity: .5; | |
} | |
} | |
.prefix-input { | |
padding-left: .5rem; | |
input { | |
padding-left: 0; | |
} | |
&:before { | |
content: attr(data-before); | |
opacity: .5; | |
} | |
} | |
/* custom input: toggle-button */ | |
label:has(input.toggle-button) { | |
position: relative; | |
justify-content: center; | |
user-select: none; | |
color: inherit; | |
border: 1px solid transparent; | |
transition: var(--form-transition), color .3s ease; | |
isolation: isolate; | |
&:has(:checked, :active:enabled) { | |
color: var(--form-contrast-color); | |
} | |
&:has(:is(:checked, :active:enabled)) { | |
padding: var(--button-padding-active); | |
} | |
&:has(:disabled) { | |
opacity: .5; | |
} | |
} | |
input.toggle-button { | |
z-index: -1; | |
position: absolute; | |
inset: -1px; | |
width: auto; | |
min-width: 0; | |
height: auto; | |
min-height: 0; | |
margin: 0; | |
background: transparent; | |
transition: var(--form-transition), background 0.3s ease; | |
&:is(:active, :checked):enabled { | |
--inner-shadow: var(--button-shadow-active); | |
background: var(--form-theme-color); | |
} | |
&:not(:disabled, :checked, :active):hover { | |
background: color-mix(in lab, var(--form-theme-color) 10%, transparent); | |
background: #0001; | |
} | |
&:checked:enabled:hover:not(:active) { | |
background: var(--button-hover-background); | |
} | |
&:disabled { | |
opacity: unset; | |
background: var(--form-disabled-color); | |
&:not(:checked) { | |
background: transparent; | |
} | |
} | |
} | |
@media screen { | |
form { | |
box-shadow: 0 0 5px #cccc; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright 2025 Thomas Rosenau | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
* | |
*/ | |
const escapeHTML = (str) => String(str).replaceAll( | |
/[&<>"']/g, | |
(char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }[char]) | |
); | |
const parseHTMLFragment = (html) => { | |
let contextTag = html.match(/<([a-z]+)/i)?.[1] || 'div'; | |
if (['thead', 'tfoot', 'tbody', 'tr', 'td', 'th'].includes(contextTag)) { | |
const table = document.createElement('table'); | |
table.innerHTML = html; | |
return table.querySelector(contextTag); | |
} | |
return document.createRange().createContextualFragment(html); | |
}; | |
export const renderTemplate = ({ templateId, data, target, prepend = false }) => { | |
let template = document.querySelector(`script[type="text/template"]#${templateId}`).innerHTML; | |
const conditionMatcher = /\{\{\?(\w+)}}(.*?)\{\{\/\1}}/sg; | |
const negativeConditionMatcher = /\{\{!(\w+)}}(.*?)\{\{\/\1}}/sg; | |
while (conditionMatcher.test(template) || negativeConditionMatcher.test(template)) { | |
template = template.replaceAll(conditionMatcher, | |
(_, key, content) => [false, null, undefined].includes(data[key]) ? '' : content | |
); | |
template = template.replaceAll(negativeConditionMatcher, | |
(_, key, content) => [false, null, undefined].includes(data[key]) ? content : '' | |
); | |
} | |
let rendered = template.replaceAll(/\{\{(\w+)}}/g, (_, key) => escapeHTML(data[key] ?? '')); | |
if (typeof target === 'string') { | |
target = document.querySelector(target); | |
} | |
let fragment = parseHTMLFragment(rendered); | |
if (prepend) { | |
target.insertBefore(fragment, target.firstChild); | |
} else { | |
target.appendChild(fragment); | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment