TransferForm
A transfer between your accounts or to a payee: from, to, amount, date and memo, checked, then a review with the fee and the balance after.
Demo
Nothing sent yet.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/transfer_form.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/transfer_form.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/transfer_form.qwe
let transfer_form_demo_accounts = [ { id: "chk", name: "Everyday", number: "0012 0048 21", available: 2310.18m }, { id: "sav", name: "Rainy day", number: "0012 0077 10", available: 420m },]let transfer_form_demo_payees = [ { id: "p-ana", name: "Ana Okafor", bank: "Harbor Bank", number: "88120345" }, { id: "p-rent", name: "Linden Court Lettings", bank: "Fernway Credit Union", number: "40029917" },]component TransferFormDemoLive() { state sent = "" <div.demo-stack> <TransferForm id="transfer-form-demo" accounts={transfer_form_demo_accounts} payees={transfer_form_demo_payees} today="2026-10-29" fee={1.5m} memo="October rent" on_confirm={(t) => { sent = `${t.from} to ${t.to}: ${t.amount} + ${t.fee} on ${t.date}, "${t.memo}"` }}/> <p.demo-note>{sent == "" ? "Nothing sent yet." : `on_confirm: ${sent}`}</p> </div>}view TransferFormDemo() = <TransferFormDemoLive/>Source
ui/transfer_form.qwe
// TransferForm: money moved between your own accounts or to a saved payee, in two// steps. First from, to, amount, date and memo; Continue checks them (an amount// above zero, a different account, enough available with the fee, a date not in// the past) and shows a review: each party, the amount, the fee, the total, and// the balance left after it. Back changes it; Confirm sends it. The fields have// names (from, to, amount, date, memo), so with `action` Confirm posts a plain form.//// <TransferForm action="/transfers" today="2026-10-29" fee={1.5m}// accounts={[{ id: "chk", name: "Everyday", number: "0012004821", available: 2310.18m },// { id: "sav", name: "Rainy day", number: "0012007710", available: 420m }]}// payees={[{ id: "p-ana", name: "Ana Okafor", bank: "Harbor Bank", number: "88120345" }]}/>// <TransferForm accounts={accounts} payees={payees} on_confirm={(t) => { last = t }}/>//// accounts: your accounts, a list of { id, name, number?, available, currency? }.// payees: who else can be paid, a list of { id, name, bank?, number? }; ids are// unique across both lists, and `to` is sent as one of them. from, to: the ids// chosen to start with (from: the first account). amount, when (the date, an// ISO date; today's by default) and memo: what the fields start with. today: an// ISO date, the earliest a transfer can be dated (null: no limit). fee: what a// transfer to a payee costs (0); own_fee: one between your own accounts (0).// currency ("USD") and locale: how amounts and dates are written. action: where Confirm posts the form// (method post); without it, the page's own address. on_confirm(transfer): inside// one of your components, called instead of posting with { from, to, amount, fee,// total, date, memo } (amounts as decimals), and the form then says it is done.// title: the heading ("Transfer money"); id: the element's id.import ui/theme.qwe use ui_clslet transfer_form_css = css`.ui-xfer { display: grid; gap: 16px; min-width: 0; max-width: 480px; padding: 20px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-xfer-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }.ui-xfer-title { margin: 0; font-size: 17px; font-weight: 600; }.ui-xfer-step { color: var(--ui-muted); font-size: 12px; font-weight: 500; }.ui-xfer-fields { display: grid; gap: 14px; }.ui-xfer-field { display: grid; gap: 6px; min-width: 0; }.ui-xfer-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.ui-xfer-label { font-size: 13px; font-weight: 500; }.ui-xfer-opt { color: var(--ui-muted); font-weight: 400; }.ui-xfer-field select, .ui-xfer-field input { width: 100%; height: 38px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); outline: none; transition: border-color .15s, box-shadow .15s; }.ui-xfer-field select:hover, .ui-xfer-field input:hover { border-color: var(--ui-strong); }.ui-xfer-field select:focus, .ui-xfer-field input:focus { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-xfer-money { position: relative; }.ui-xfer-money span { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--ui-muted); font-weight: 500; pointer-events: none; }.ui-xfer-field .ui-xfer-money input { padding-left: 28px; font-variant-numeric: tabular-nums; }.ui-xfer-field [aria-invalid="true"] { border-color: var(--ui-danger); }.ui-xfer-hint, .ui-xfer-error { margin: 0; color: var(--ui-muted); font-size: 12px; }.ui-xfer-error { color: var(--ui-danger); font-weight: 500; }.ui-xfer-review { display: grid; grid-template-columns: auto 1fr; gap: 8px 18px; margin: 0; padding: 14px 16px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-xfer-review dt { color: var(--ui-muted); }.ui-xfer-review dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }.ui-xfer-sub { display: block; color: var(--ui-muted); font-size: 12px; }.ui-xfer-total, .ui-xfer-total + dd { padding-top: 8px; border-top: 1px solid var(--ui-border); font-weight: 650; color: var(--ui-fg); }.ui-xfer-after + dd { font-weight: 600; }.ui-xfer-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }.ui-xfer-btn { display: inline-flex; align-items: center; justify-content: center; height: 38px; padding: 0 16px; border: 1px solid transparent; border-radius: var(--ui-radius-sm); font: 500 14px var(--ui-font); cursor: pointer; transition: background .15s, border-color .15s; }.ui-xfer-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-xfer-primary { background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-xfer-primary:hover { background: color-mix(in srgb, var(--ui-accent) 88%, #000); }.ui-xfer-secondary { border-color: var(--ui-border); background: var(--ui-surface); color: var(--ui-fg); }.ui-xfer-secondary:hover { border-color: var(--ui-strong); }.ui-xfer-done { display: grid; justify-items: center; gap: 6px; padding: 12px 0; text-align: center; }.ui-xfer-done svg { width: 40px; height: 40px; color: var(--ui-success); }.ui-xfer-done p { margin: 0; }.ui-xfer-done-amount { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }@media (max-width: 480px) { .ui-xfer-row { grid-template-columns: 1fr; } }@media (prefers-reduced-motion: reduce) { .ui-xfer-field select, .ui-xfer-field input, .ui-xfer-btn { transition: none; } }`// An amount as text in its currency.fn transfer_form_money(amount, currency: str, locale: str?) -> str { let m = money.of(dec(amount), currency, "half_up") if is_err(m) { return str(amount) } locale != null ? money.format(m, locale) : money.format(m)}// An account or payee as one line: "Everyday •••• 4821".fn transfer_form_line(p) -> str { let last4 = slice(re.replace(str(p.number ?? ""), "[^0-9A-Za-z]", ""), -4) last4 == "" ? p.name : `${p.name} •••• ${last4}`}// What was typed as an amount: a decimal, or null when it is not one ("1,250.5" is 1250.5m).fn transfer_form_amount(text: str) { let clean = re.replace(trim(text), "[,\\s]", "") if clean == "" || !re.test(clean, "^[0-9]*\\.?[0-9]*$") { return null } let d = dec(clean) type_of(d) == "error" ? null : d}component TransferForm(accounts: list = [], payees: list = [], from: str | num | null = null, to: str | num | null = null, amount: num | str | null = null, when: str? = null, memo: str = "", today: str? = null, fee = 0, own_fee = 0, currency: str = "USD", locale: str? = null, action: str? = null, on_confirm = null, title: str = "Transfer money", id: str? = null, class: str? = null) { style global transfer_form_css state step = "edit" state tried = false state from_id = str(from ?? (len(accounts) > 0 ? accounts[0].id : "")) state to_id = to != null ? str(to) : "" state amount_text = amount != null ? str(amount) : "" state day = when ?? today ?? "" state memo_text = memo let code = upper(currency) let base = id ?? "transfer-form" let src = find(accounts, (a) => str(a.id) == from_id) let own_to = find(accounts, (a) => str(a.id) == to_id) let payee_to = find(payees, (p) => str(p.id) == to_id) let dest = own_to ?? payee_to let amt = transfer_form_amount(amount_text) let charge = dec(own_to != null ? own_fee : fee) let places = money.minor_units(code) let total = amt != null ? amt + charge : null let left = src != null && total != null ? dec(src.available ?? 0) - total : null let amount_error = amt == null ? "Enter an amount, such as 125.00." : amt <= 0 ? "Enter an amount above zero." : round(amt, places, "down") != amt ? `Use at most ${places} decimals.` : left != null && left < 0 ? `${src.name} has ${transfer_form_money(src.available ?? 0, code, locale)} available; this needs ${transfer_form_money(total, code, locale)}${charge > 0 ? " with the fee" : ""}.` : null let to_error = to_id == "" ? "Choose who to send it to." : to_id == from_id ? "Choose an account other than the one it comes from." : null let date_error = day == "" || !date.valid(day, "date") ? "Choose a date." : today != null && day < today ? "Choose today or a later date." : null let ok = src != null && amount_error == null && to_error == null && date_error == null let field_error = (key: str, message) => tried && message != null ? <p.ui-xfer-error id={`${base}-${key}-error`}>{message}</p> : null let transfer = () => ({ from: from_id, to: to_id, amount: amt, fee: charge, total, date: day, memo: trim(memo_text) }) <form class={ui_cls(["ui-xfer", class])} id={id} method="post" action={action} aria-labelledby={`${base}-title`} novalidate> <div.ui-xfer-head> <h3.ui-xfer-title id={`${base}-title`}>{step == "done" ? "Transfer sent" : title}</h3> {step != "done" && <span.ui-xfer-step>{step == "edit" ? "Step 1 of 2" : "Step 2 of 2: review"}</span>} </div> <p.ui-sr role="status">{step == "review" ? "Review the transfer, then confirm." : step == "done" ? "Transfer sent." : ""}</p> {match step { "edit" => <div.ui-xfer-fields> <div.ui-xfer-field> <label.ui-xfer-label for={`${base}-from`}>From</label> <select id={`${base}-from`} name="from" aria-describedby={`${base}-from-hint`} @change={(e) => { from_id = e.value }}> {for a in accounts => <option key={str(a.id)} value={str(a.id)} selected={str(a.id) == from_id}>{transfer_form_line(a)}</option>} </select> <p.ui-xfer-hint id={`${base}-from-hint`}>{src != null ? `Available: ${transfer_form_money(src.available ?? 0, code, locale)}` : ""}</p> </div> <div.ui-xfer-field> <label.ui-xfer-label for={`${base}-to`}>To</label> <select id={`${base}-to`} name="to" aria-invalid={tried && to_error != null ? "true" : null} aria-describedby={tried && to_error != null ? `${base}-to-error` : null} @change={(e) => { to_id = e.value }}> <option value="" selected={to_id == ""}>Choose an account or payee</option> {len(accounts) > 1 && <optgroup label="Your accounts"> {for a in accounts => <option key={str(a.id)} value={str(a.id)} selected={str(a.id) == to_id}>{transfer_form_line(a)}</option>} </optgroup>} {len(payees) > 0 && <optgroup label="Payees"> {for p in payees => <option key={str(p.id)} value={str(p.id)} selected={str(p.id) == to_id}>{transfer_form_line(p)}{p.bank != null ? `, ${p.bank}` : ""}</option>} </optgroup>} </select> {field_error("to", to_error)} </div> <div.ui-xfer-row> <div.ui-xfer-field> <label.ui-xfer-label for={`${base}-amount`}>Amount ({code})</label> <div.ui-xfer-money> <input id={`${base}-amount`} name="amount" type="text" inputmode="decimal" autocomplete="off" placeholder="0.00" bind:value={amount_text} aria-invalid={tried && amount_error != null ? "true" : null} aria-describedby={tried && amount_error != null ? `${base}-amount-error` : null}/> </div> {field_error("amount", amount_error)} </div> <div.ui-xfer-field> <label.ui-xfer-label for={`${base}-date`}>Date</label> <input id={`${base}-date`} name="date" type="date" min={today} bind:value={day} aria-invalid={tried && date_error != null ? "true" : null} aria-describedby={tried && date_error != null ? `${base}-date-error` : null}/> {field_error("date", date_error)} </div> </div> <div.ui-xfer-field> <label.ui-xfer-label for={`${base}-memo`}>Memo <span.ui-xfer-opt>(optional)</span></label> <input id={`${base}-memo`} name="memo" type="text" maxlength="140" autocomplete="off" bind:value={memo_text} placeholder="What it is for"/> </div> <div.ui-xfer-actions> <button.ui-xfer-btn.ui-xfer-primary type="button" @click={() => { tried = true if ok { step = "review" } }}>Continue</button> </div> </div> "review" => <div.ui-xfer-fields> <dl.ui-xfer-review> <dt>From</dt><dd>{transfer_form_line(src)}</dd> <dt>To</dt><dd>{transfer_form_line(dest)}{payee_to != null && payee_to.bank != null && <span.ui-xfer-sub>{payee_to.bank}</span>}</dd> <dt>Date</dt><dd><time datetime={day}>{date.format(day, { locale: locale ?? "en-US", style: "long" })}</time>{today != null && day == today && <span.ui-xfer-sub>Today</span>}</dd> {trim(memo_text) != "" && [<dt>Memo</dt>, <dd>{trim(memo_text)}</dd>]} <dt>Amount</dt><dd.ui-xfer-amount>{transfer_form_money(amt, code, locale)}</dd> <dt>Fee</dt><dd.ui-xfer-fee>{charge > 0 ? transfer_form_money(charge, code, locale) : "Free"}</dd> <dt.ui-xfer-total>Total</dt><dd>{transfer_form_money(total, code, locale)}</dd> <dt.ui-xfer-after>{src.name} after</dt><dd.ui-xfer-left>{transfer_form_money(left, code, locale)}</dd> {own_to != null && [<dt>{own_to.name} after</dt>, <dd.ui-xfer-to-left>{transfer_form_money(dec(own_to.available ?? 0) + amt, code, locale)}</dd>]} </dl> <input type="hidden" name="from" value={from_id}/> <input type="hidden" name="to" value={to_id}/> <input type="hidden" name="amount" value={str(amt)}/> <input type="hidden" name="date" value={day}/> <input type="hidden" name="memo" value={trim(memo_text)}/> <div.ui-xfer-actions> <button.ui-xfer-btn.ui-xfer-secondary type="button" @click={() => { step = "edit" }}>Back</button> {on_confirm != null ? <button.ui-xfer-btn.ui-xfer-primary type="button" @click={() => { on_confirm(transfer()) step = "done" }}>Confirm transfer</button> : <button.ui-xfer-btn.ui-xfer-primary type="submit">Confirm transfer</button>} </div> </div> _ => <div.ui-xfer-done> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="m7.5 12.5 3 3 6-6.5"/></svg> <p.ui-xfer-done-amount>{transfer_form_money(total ?? 0, code, locale)}</p> <p>to {dest != null ? dest.name : ""}, {today != null && day == today ? "today" : `on ${date.format(day, { locale: locale ?? "en-US", style: "long" })}`}.</p> <button.ui-xfer-btn.ui-xfer-secondary type="button" @click={() => { amount_text = "" memo_text = "" tried = false step = "edit" }}>Make another transfer</button> </div> }} </form>}Related
AccountPickerChoose the account to pay from as radio cards with each one's masked number and available balance, those short of the amount marked.7 views · 7 downloadsCardControlsA card's controls beside its masked face: freeze it, set daily spending and cash limits, and allow online, ATM, contactless and abroad use.7 views · 6 downloadsMembershipApplicationA co-op membership application in steps: personal details, ID, beneficiaries adding up to 100%, the share pledge, and a review.7 views · 6 downloadsRemittanceQuoteSending money abroad: you send, the fee, the rate against the mid-market rate, what they get and when it arrives, worked out as the amount changes.8 views · 5 downloadsAccountCardA bank account at a glance: type, nickname, masked number, current and available balance, what is on hold, and quick actions.8 views · 7 downloadsAccountDetailsThe details to share to get paid: account name and number, IBAN in groups of four (checked), BIC, sort code or routing number, each with a copy button.6 views · 6 downloads
Thread
Thread 0 posts
On TransferForm: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.