Funds Transfer Page

Updated:

How to build a Funds Transfer form in Tecton: a single-column transfer with rich account options, a recurrence schedule, and client-side validation.

A Funds Transfer page lets a user move money between accounts, optionally on a recurring schedule. This example is a single-column form that covers three things: composing rich account options, revealing the recurrence fields only when they apply, and validating the form before submit. All state is held in memory, and the example stops at validation. A real application would submit the transfer to an API once validation passes.

Template

Family Checking
Money Market
Emergency Savings
Business Checking
Home Equity Line
Family Checking
Money Market
Emergency Savings
Business Checking
Home Equity Line
One time Daily Weekly Monthly Annually
Submit Transfer Reset
<q2-form>
<q2-select id="from-account" label="From Account" multiline-options>
  <q2-option value="1" display="Family Checking">
    <q2-item>
      <q2-avatar slot="decorator" icon="people-group"></q2-avatar>
      <div slot="header">Family Checking</div>
      <q2-detail label="Account Number" description="xxxx-xxx-9538" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Checking" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
  <q2-option value="2" display="Money Market">
    <q2-item>
      <q2-avatar slot="decorator" icon="linechart-trend"></q2-avatar>
      <div slot="header">Money Market</div>
      <q2-detail label="Account Number" description="xxxx-xxx-9424" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Investment" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
  <q2-option value="3" display="Emergency Savings">
    <q2-item>
      <q2-avatar slot="decorator" icon="piggy-bank"></q2-avatar>
      <div slot="header">Emergency Savings</div>
      <q2-detail label="Account Number" description="xxxx-xxx-0745" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Savings" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
  <q2-option value="4" display="Business Checking">
    <q2-item>
      <q2-avatar slot="decorator" icon="buildings"></q2-avatar>
      <div slot="header">Business Checking</div>
      <q2-detail label="Account Number" description="xxxx-xxx-9402" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Checking" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
  <q2-option value="5" display="Home Equity Line">
    <q2-item>
      <q2-avatar slot="decorator" icon="home-chimney"></q2-avatar>
      <div slot="header">Home Equity Line</div>
      <q2-detail label="Account Number" description="xxxx-xxx-1094" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Loan" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
</q2-select>
<q2-select id="to-account" label="To Account" multiline-options>
  <q2-option value="1" display="Family Checking">
    <q2-item>
      <q2-avatar slot="decorator" icon="people-group"></q2-avatar>
      <div slot="header">Family Checking</div>
      <q2-detail label="Account Number" description="xxxx-xxx-9538" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Checking" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
  <q2-option value="2" display="Money Market">
    <q2-item>
      <q2-avatar slot="decorator" icon="linechart-trend"></q2-avatar>
      <div slot="header">Money Market</div>
      <q2-detail label="Account Number" description="xxxx-xxx-9424" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Investment" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
  <q2-option value="3" display="Emergency Savings">
    <q2-item>
      <q2-avatar slot="decorator" icon="piggy-bank"></q2-avatar>
      <div slot="header">Emergency Savings</div>
      <q2-detail label="Account Number" description="xxxx-xxx-0745" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Savings" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
  <q2-option value="4" display="Business Checking">
    <q2-item>
      <q2-avatar slot="decorator" icon="buildings"></q2-avatar>
      <div slot="header">Business Checking</div>
      <q2-detail label="Account Number" description="xxxx-xxx-9402" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Checking" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
  <q2-option value="5" display="Home Equity Line">
    <q2-item>
      <q2-avatar slot="decorator" icon="home-chimney"></q2-avatar>
      <div slot="header">Home Equity Line</div>
      <q2-detail label="Account Number" description="xxxx-xxx-1094" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Loan" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
</q2-select>
<q2-input id="transfer-amount" label="Amount" type="currency"></q2-input>
<q2-input id="transfer-date" label="Start Date" type="date" optional></q2-input>
<q2-select id="frequency" label="Frequency" value="once">
  <q2-option value="once">One time</q2-option>
  <q2-option value="daily">Daily</q2-option>
  <q2-option value="weekly">Weekly</q2-option>
  <q2-option value="monthly">Monthly</q2-option>
  <q2-option value="annually">Annually</q2-option>
</q2-select>
<q2-select id="day-of-week" label="Repeats on" value="Monday" hidden>
  <q2-option value="Sunday">Sunday</q2-option>
  <q2-option value="Monday">Monday</q2-option>
  <q2-option value="Tuesday">Tuesday</q2-option>
  <q2-option value="Wednesday">Wednesday</q2-option>
  <q2-option value="Thursday">Thursday</q2-option>
  <q2-option value="Friday">Friday</q2-option>
  <q2-option value="Saturday">Saturday</q2-option>
</q2-select>
<q2-select id="day-of-month" label="Repeats on day" hidden></q2-select>
</q2-form>
<q2-action-group>
  <q2-btn id="transfer-btn" intent="workflow-primary">
    <span>Submit Transfer</span>
    <q2-icon type="transfer-send"></q2-icon>
  </q2-btn>
  <q2-btn id="reset-btn" intent="workflow-secondary">
    <span>Reset</span>
    <q2-icon type="refresh"></q2-icon>
  </q2-btn>
</q2-action-group>

<script>
// Accounts keyed by the value of each q2-option. Balances drive the
// insufficient-funds check. Nothing here talks to a server.
const accounts = {
    '1': { balance: 4820.0 },
    '2': { balance: 12340.5 },
    '3': { balance: 7500.0 },
    '4': { balance: 24095.98 },
    '5': { balance: 13239.52 },
};

// Returns "1st", "2nd", "3rd", and so on for the day-of-month options.
const ordinal = n => {
    const num = Number(n);
    const tens = num % 100;
    const suffix = tens >= 11 && tens <= 13 ? 'th' : ['th', 'st', 'nd', 'rd'][num % 10] ?? 'th';
    return `${num}${suffix}`;
};

// Element references.
const fromSelect = document.querySelector('#from-account');
const toSelect = document.querySelector('#to-account');
const amountInput = document.querySelector('#transfer-amount');
const dateInput = document.querySelector('#transfer-date');
const frequencySelect = document.querySelector('#frequency');
const dayOfWeekField = document.querySelector('#day-of-week');
const dayOfMonthField = document.querySelector('#day-of-month');
const submitBtn = document.querySelector('#transfer-btn');
const resetBtn = document.querySelector('#reset-btn');

// Default field values, reused by Reset.
const DEFAULTS = { from: '', to: '', amount: '', date: '', frequency: 'once', dayOfWeek: 'Monday', dayOfMonth: '1' };
const state = { ...DEFAULTS };

// Build the day-of-month options (1 through 31, plus "Last day"). q2-select
// observes its child q2-option elements, so options appended here are picked up.
for (let day = 1; day <= 31; day++) {
    const option = document.createElement('q2-option');
    option.value = String(day);
    option.textContent = ordinal(day);
    dayOfMonthField.appendChild(option);
}
const lastDayOption = document.createElement('q2-option');
lastDayOption.value = 'last';
lastDayOption.textContent = 'Last day';
dayOfMonthField.appendChild(lastDayOption);
dayOfMonthField.value = state.dayOfMonth;

// Shows the day-of-week picker for Weekly and the day-of-month picker for Monthly.
function updateRecurrenceFields() {
    dayOfWeekField.hidden = state.frequency !== 'weekly';
    dayOfMonthField.hidden = state.frequency !== 'monthly';
}

// Validates the form, sets field errors, and returns true when every check passes.
function validate() {
    let valid = true;

    if (!state.from) {
        fromSelect.errors = ['Please select a from account.'];
        valid = false;
    }

    if (!state.to) {
        toSelect.errors = ['Please select a to account.'];
        valid = false;
    }

    if (state.from && state.to && state.from === state.to) {
        toSelect.errors = ['From and To accounts must be different.'];
        valid = false;
    }

    const amount = parseFloat(state.amount);
    if (!state.amount || amount <= 0) {
        amountInput.errors = ['Please enter a valid amount.'];
        valid = false;
    } else if (state.from && amount > accounts[state.from].balance) {
        amountInput.errors = ['Amount exceeds the available balance.'];
        valid = false;
    }

    return valid;
}

// Each field writes to state and clears its own error as the user corrects it.
fromSelect.addEventListener('tctChange', event => {
    // Ignore the empty-value tctChange q2-select emits on close or blur, which
    // would otherwise wipe the chosen account and leave the field invalid.
    const value = event?.detail?.value;
    if (!value) return;
    state.from = value;
    if (fromSelect.errors?.length) fromSelect.errors = [];
    // Only clear To's cross-field "must be different" error, which can only exist
    // once To has a value. Leave its "please select" error alone while To is empty.
    if (toSelect.errors?.length && state.to && state.from !== state.to) toSelect.errors = [];
});

toSelect.addEventListener('tctChange', event => {
    const value = event?.detail?.value;
    if (!value) return;
    state.to = value;
    if (toSelect.errors?.length) toSelect.errors = [];
});

amountInput.addEventListener('tctInput', event => {
    state.amount = event?.detail?.value ?? '';
    if (amountInput.errors?.length) amountInput.errors = [];
});

dateInput.addEventListener('tctInput', event => {
    state.date = event?.detail?.value ?? '';
});

frequencySelect.addEventListener('tctChange', event => {
    // q2-select emits a tctChange with an empty value when the field closes or
    // blurs. Ignore those so leaving the field never resets the schedule; only a
    // real selection should drive the recurrence fields.
    const value = event?.detail?.value;
    if (!value) return;
    state.frequency = value;
    updateRecurrenceFields();
});

dayOfWeekField.addEventListener('tctChange', event => {
    const value = event?.detail?.value;
    if (!value) return;
    state.dayOfWeek = value;
});

dayOfMonthField.addEventListener('tctChange', event => {
    const value = event?.detail?.value;
    if (!value) return;
    state.dayOfMonth = value;
});

// Submit validates only. A real application would send the transfer here.
submitBtn.addEventListener('tctClick', () => {
    if (!validate()) return;
});

// Reset returns every field to its default and collapses the day pickers.
resetBtn.addEventListener('tctClick', () => {
    Object.assign(state, DEFAULTS);
    fromSelect.value = '';
    toSelect.value = '';
    amountInput.value = '';
    dateInput.value = '';
    frequencySelect.value = 'once';
    dayOfWeekField.value = 'Monday';
    dayOfMonthField.value = '1';
    fromSelect.errors = [];
    toSelect.errors = [];
    amountInput.errors = [];
    updateRecurrenceFields();
});

// Initial render: hide the day pickers until a recurring frequency is chosen.
updateRecurrenceFields();
</script>

How it works

The form holds its values in a single state object. Each field writes to state on change, and the Submit button reads state to validate. There are no network calls and no success or error screens. The example stops once the input is valid, which is the point at which a real application would send the transfer to an API.

const DEFAULTS = { from: '', to: '', amount: '', date: '', frequency: 'once', dayOfWeek: 'Monday', dayOfMonth: '1' };
const state = { ...DEFAULTS };

Holding the defaults in their own object lets Reset restore every field in one call with Object.assign(state, DEFAULTS).

Field spacing

The fields are wrapped in a q2-form, which applies one consistent vertical rhythm to every control instead of relying on per-field margins. See Designing Forms for the full rationale.

Rich account options

The From and To selects use multiline-options, so each q2-option renders a full q2-item instead of a single line of text. Each account shows an avatar, the masked account number, and the account type, which makes the right account easy to identify. The display attribute provides the short label the select shows once an option is chosen.

<q2-select id="from-account" label="From Account" multiline-options>
  <q2-option value="1" display="Family Checking">
    <q2-item>
      <q2-avatar slot="decorator" icon="people-group"></q2-avatar>
      <div slot="header">Family Checking</div>
      <q2-detail label="Account Number" description="xxxx-xxx-9538" slot="body"></q2-detail>
      <q2-detail label="Account Type" description="Checking" slot="body"></q2-detail>
    </q2-item>
  </q2-option>
</q2-select>

Recurrence: reveal only the field that applies

The Frequency select drives the schedule. Most frequencies need no extra input, so the day pickers stay hidden until they are relevant. Weekly reveals the day-of-week select, and Monthly reveals the day-of-month select. One time, Daily, and Annually show no extra field.

function updateRecurrenceFields() {
    dayOfWeekField.hidden = state.frequency !== 'weekly';
    dayOfMonthField.hidden = state.frequency !== 'monthly';
}

updateRecurrenceFields() runs whenever the Frequency selection changes. One subtlety matters here: q2-select can emit a tctChange with an empty value when the field closes or loses focus. The handler ignores any change that carries no value, so only a real selection updates the schedule. Without that guard, simply leaving the Frequency field would reset state.frequency and hide the pickers that were just revealed.

frequencySelect.addEventListener('tctChange', event => {
    const value = event?.detail?.value;
    if (!value) return; // ignore the empty close/blur change
    state.frequency = value;
    updateRecurrenceFields();
});

The day-of-month options (1 through 31, plus "Last day") are generated in JavaScript rather than hand-written. q2-select observes its child q2-option elements with a mutation observer, so options appended after render are picked up automatically.

for (let day = 1; day <= 31; day++) {
    const option = document.createElement('q2-option');
    option.value = String(day);
    option.textContent = ordinal(day); // "1st", "2nd", "3rd", ...
    dayOfMonthField.appendChild(option);
}

Validation

Validation runs once, when the user clicks Submit. validate() checks every required field, enforces that From and To are different, and confirms the amount is positive and within the source account's balance.

const amount = parseFloat(state.amount);
if (!state.amount || amount <= 0) {
    amountInput.errors = ['Please enter a valid amount.'];
    valid = false;
} else if (state.from && amount > accounts[state.from].balance) {
    amountInput.errors = ['Amount exceeds the available balance.'];
    valid = false;
}

Setting errors to a non-empty array activates a field's error state. Setting it back to an empty array clears it. Each field clears its own error as soon as the user changes it, so the feedback that a problem is fixed is immediate. The From and To selects clear their error on tctChange using the same empty-value guard as the Frequency select, so closing or blurring the field does not discard the account you just chose. The Start Date is optional and is never validated.

Submitting and resetting

Submit validates and stops. When validation passes there is nothing more to do in the example, so the handler returns. Reset restores every field to its default, clears any validation errors, and re-runs updateRecurrenceFields() so the day pickers collapse back out of view.

// Submit validates only. A real application would send the transfer here.
submitBtn.addEventListener('tctClick', () => {
    if (!validate()) return;
});

Related