Account List Page

Updated:

How to build an Account List page in Tecton: a summary header and a searchable, client-filtered account list.

An Account List page is a dashboard-style entry point that shows all of a user's bank accounts at a glance. The account items are written as static markup. JavaScript handles the interactive layer: filtering items by toggling their hidden attribute, keeping the summary header in sync with the visible results, and showing a no-results empty state when nothing matches.

Template

Quick Transfer
Family Checking
Money Market
Emergency Savings
Business Checking
Home Equity Line
<q2-card type="non-clickable">
  <q2-detail stacked size="medium" label="Total Assets" id="total-assets-detail">
    <q2-currency id="total-assets-currency" amount="71550.54"></q2-currency>
  </q2-detail>
  <q2-action-group>
    <q2-btn intent="workflow-primary">
      <span>Quick Transfer</span>
      <q2-icon type="transfer"></q2-icon>
    </q2-btn>
  </q2-action-group>
</q2-card>
<q2-input label="Search accounts" hide-label type="search" placeholder="Search accounts" id="accounts-search"></q2-input>
<q2-list label="Primary Accounts" bordered>
  <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>
    <div slot="footer">
      <q2-detail label="Account Balance" size="small" stacked>
        <q2-currency amount="3415.89"></q2-currency>
      </q2-detail>
    </div>
  </q2-item>
  <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>
    <div slot="footer">
      <q2-detail label="Account Balance" size="small" stacked>
        <q2-currency amount="17845.90"></q2-currency>
      </q2-detail>
    </div>
  </q2-item>
  <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>
    <div slot="footer">
      <q2-detail label="Account Balance" size="small" stacked>
        <q2-currency amount="12953.25"></q2-currency>
      </q2-detail>
    </div>
  </q2-item>
  <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>
    <div slot="footer">
      <q2-detail label="Account Balance" size="small" stacked>
        <q2-currency amount="24095.98"></q2-currency>
      </q2-detail>
    </div>
  </q2-item>
  <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>
    <div slot="footer">
      <q2-detail label="Account Balance" size="small" stacked>
        <q2-currency amount="13239.52"></q2-currency>
      </q2-detail>
    </div>
  </q2-item>
  <q2-item id="no-results-item" hidden>
    <div slot="header">No accounts found</div>
    <q2-detail description="Try a different search term." slot="body"></q2-detail>
  </q2-item>
</q2-list>
<q2-action-group>
  <q2-link label="See Full View" variant="standalone" icon-type="list" href="#"></q2-link>
  <q2-link label="Deposit Check" variant="standalone" icon-type="check-add" href="#"></q2-link>
</q2-action-group>

<script>
// Build a searchable index from the static item markup once on load so we
// don't re-query the DOM on every keystroke.
const items = Array.from(document.querySelectorAll('q2-list q2-item:not(#no-results-item)'));

const index = items.map(item => ({
    el: item,
    // Join the header name and body detail descriptions into a single
    // lowercase string for case-insensitive multi-field matching.
    text: [
        item.querySelector('[slot="header"]')?.textContent ?? '',
        ...Array.from(item.querySelectorAll('q2-detail[slot="body"]'))
            .map(d => d.getAttribute('description') ?? ''),
    ].join(' ').toLowerCase(),
    amount: parseFloat(item.querySelector('q2-currency')?.getAttribute('amount') ?? '0'),
}));

// Element references.
const totalAssetsCurrency = document.querySelector('#total-assets-currency');
const searchElement = document.querySelector('#accounts-search');
const noResultsItem = document.querySelector('#no-results-item');

// View state.
const state = { search: '' };

// Show or hide each item and recompute the summary from the visible subset.
function updateList() {
    const visible = index.filter(({ el, text }) => {
        const show = !state.search || text.includes(state.search);
        el.hidden = !show;
        return show;
    });

    // Show the empty state only when no real items are visible.
    noResultsItem.hidden = visible.length > 0;

    // Keep Total Assets in sync with the filtered results.
    totalAssetsCurrency.amount = visible.reduce((sum, { amount }) => sum + amount, 0);
}

// Event handlers.

searchElement.addEventListener('tctInput', event => {
    state.search = (event?.detail?.value ?? '').toLowerCase();
    updateList();
});

searchElement.addEventListener('tctClear', () => {
    state.search = '';
    updateList();
});

// Initial render - computes summary values from the DOM rather than relying
// on the hardcoded markup values.
updateList();
</script>

How it works

The account items are written as static HTML. JavaScript does not build or rebuild the list - it filters it. On load, the script reads each q2-item once to build a lightweight index (an array of { el, text, amount } objects). From that point on, every filter pass works entirely against the index: each item is shown or hidden by toggling its hidden attribute, the summary header is recalculated from the visible subset, and a no-results item appears when the visible set is empty.

const index = items.map(item => ({
    el: item,
    text: [...].join(' ').toLowerCase(),
    amount: parseFloat(item.querySelector('q2-currency')?.getAttribute('amount') ?? '0'),
}));

Reading amount from the q2-currency attribute at index-build time - rather than on every filter pass - avoids touching the DOM repeatedly. The index is built once; filtering is pure array work.

Account list structure

Each account is a q2-item inside a labeled q2-list. The item uses four named slots:

  • decorator - a q2-avatar with an icon representing the account type (people-group, linechart-trend, piggy-bank, etc.)
  • header - the account name as plain text
  • body - two q2-detail components: one for the masked account number and one for the account type. The description attribute provides the value; label provides the accessible field name.
  • footer - a stacked q2-detail containing a q2-currency element for the balance
<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>
  <div slot="footer">
    <q2-detail label="Account Balance" size="small" stacked>
      <q2-currency amount="12953.25"></q2-currency>
    </q2-detail>
  </div>
</q2-item>

The label="Primary Accounts" attribute on q2-list renders a visible section heading above the items. The bordered attribute adds a border around the list and a divider between each item.

Search and filtering

The index builds a searchable text string for each item by joining the header name and the description attributes from both body q2-detail elements:

text: [
    item.querySelector('[slot="header"]')?.textContent ?? '',
    ...Array.from(item.querySelectorAll('q2-detail[slot="body"]'))
        .map(d => d.getAttribute('description') ?? ''),
].join(' ').toLowerCase(),

This lets a user type "checking", "xxxx-xxx-9538", "savings", or "investment" and find matching accounts. All matching runs against a single lowercased string - no per-field conditionals needed.

Inside updateList(), the filter sets el.hidden directly and returns only the visible items:

const visible = index.filter(({ el, text }) => {
    const show = !state.search || text.includes(state.search);
    el.hidden = !show;
    return show;
});

Setting hidden on a q2-item applies the browser's built-in display: none rule via the [hidden] attribute selector. The q2-list slot renders light-DOM children, so items hidden this way disappear immediately without needing to re-render the component.

Empty state

When the visible set is empty, a dedicated q2-item with id="no-results-item" appears in its place. It is excluded from the index so it never contributes to the summary totals.

// Show the empty state only when no real items are visible.
noResultsItem.hidden = visible.length > 0;

The tctClear event fires when the user clicks the built-in clear button on the search input. Without a handler for this event, the field would visually empty while state.search still held the old value, leaving the list filtered and the empty state potentially visible.

searchElement.addEventListener('tctClear', () => {
    state.search = '';
    updateList();
});

Summary header

The summary card shows Total Assets, which updates whenever the search filter changes. updateList() runs on every tctInput and tctClear event, and also on initial load:

// Keep Total Assets in sync with the filtered results.
totalAssetsCurrency.amount = visible.reduce((sum, { amount }) => sum + amount, 0);

The q2-currency amount property accepts a number directly and re-renders the formatted value. It is set from the same visible array that drives the item filter, so the total is always consistent with what is on screen.

The hardcoded amount value in the summary markup serves as a fallback before JavaScript runs. updateList() overwrites it on initial render, so the displayed total is always computed from the actual item data.

Related