How to build an Account Details page in Tecton: a sortable, filterable transaction list with client-side search, column sorting, and pagination.
An Account Details page shows an account summary at the top and a transaction list below. This example holds all data in memory. It covers three things: composing the account header from layout and display components, filtering the transaction list with a search input, and keeping sort and pagination applied to the same filtered result. There are no network calls and no loading states.
<q2-grid columns="1" md-columns="2">
<q2-grid-area>
<q2-item>
<q2-avatar src="/examples/logo.svg" slot="decorator">
</q2-avatar>
<div slot="header">
<span class="heading-2">Checking Account</span>
</div>
<div slot="body">Last Updated: April 15, 2024</div>
</q2-item>
</q2-grid-area>
<q2-grid-area justify="end">
<q2-grid columns="1" md-columns="2">
<q2-grid-area justify="end">
<q2-detail stacked size="small" alignment="right" label="Current balance">
<q2-currency amount="1480.13">
</q2-currency>
</q2-detail>
</q2-grid-area>
<q2-grid-area justify="end">
<q2-detail stacked size="small" alignment="right" label="Available balance">
<q2-currency amount="1373.49">
</q2-currency>
</q2-detail>
</q2-grid-area>
</q2-grid>
</q2-grid-area>
</q2-grid>
<q2-input label="Search" hide-label type="search" placeholder="Search transactions" id="accounts-search"></q2-input>
<q2-data-table bordered id="accounts-table"></q2-data-table>
<q2-pagination record-type="Transactions" total="15" page="1" per-page="5" id="accounts-pagination"></q2-pagination>
<script>
// Formatters
const dateFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
const formatDate = isoString => dateFormatter.format(new Date(isoString));
const formatCurrency = amount => currencyFormatter.format(amount);
// Data - raw values so sorts and filters work on numbers and ISO date strings,
// not on already-formatted display strings.
const transactions = [
{ id: 1, date: '2024-04-18T14:20:16Z', description: 'Zoom Video Comm', amount: 125.00, type: 'debit' },
{ id: 2, date: '2024-04-15T09:15:55Z', description: 'Southwest Airlines', amount: 312.90, type: 'debit' },
{ id: 3, date: '2024-04-12T23:30:00Z', description: 'Amazon Web Services', amount: 50.75, type: 'debit' },
{ id: 4, date: '2024-04-10T17:45:00Z', description: 'Best Buy', amount: 89.99, type: 'debit' },
{ id: 5, date: '2024-04-08T08:00:10Z', description: 'Refund from Blue Apron', amount: 250.00, type: 'credit' },
{ id: 6, date: '2024-04-05T12:00:45Z', description: 'Spotify Premium', amount: 19.99, type: 'debit' },
{ id: 7, date: '2024-03-28T15:30:22Z', description: 'Dell Technologies', amount: 475.00, type: 'debit' },
{ id: 8, date: '2024-03-25T10:25:17Z', description: 'Office Depot', amount: 79.20, type: 'debit' },
{ id: 9, date: '2024-03-20T13:50:31Z', description: 'Staples', amount: 142.55, type: 'debit' },
{ id: 10, date: '2024-03-15T08:30:16Z', description: 'Rent Payment', amount: 600.00, type: 'debit' },
{ id: 11, date: '2024-03-14T17:30:22Z', description: 'Adobe Creative Cloud', amount: 120.48, type: 'debit' },
{ id: 12, date: '2024-03-13T12:15:48Z', description: 'Apple Store', amount: 350.00, type: 'debit' },
{ id: 13, date: '2024-03-12T08:12:30Z', description: 'Local Coffee Shop', amount: 20.00, type: 'debit' },
{ id: 14, date: '2024-03-10T11:23:50Z', description: 'Conference Fee', amount: 450.00, type: 'debit' },
{ id: 15, date: '2024-03-08T19:20:16Z', description: 'Netflix', amount: 15.99, type: 'debit' },
];
const tableHeaders = [
{ title: 'Date', key: 'date', width: '15%', sortable: 'manual' },
{ title: 'Description', key: 'description', sortable: 'manual' },
{ title: 'Type', key: 'type', width: '10%', sortable: 'manual' },
{ title: 'Amount', key: 'amount', align: 'end', sortable: 'manual' },
];
// Element references
const accountsTable = document.querySelector('#accounts-table');
const searchElement = document.querySelector('#accounts-search');
const paginationElement = document.querySelector('#accounts-pagination');
// View state - every event handler updates one or more fields on `state`
// and then calls `updatePage()` to push the result to the components.
const state = {
search: '', // current search text, lowercased
sort: null, // { key, direction } or null when unsorted
page: 1, // 1-based page number
perPage: 5, // rows per page
};
// Helpers
// Builds the {id, cells} shape that q2-data-table expects from a transaction.
const toRow = transaction => ({
id: transaction.id,
cells: {
date: formatDate(transaction.date),
description: transaction.description,
type: transaction.type.charAt(0).toUpperCase() + transaction.type.slice(1),
amount: formatCurrency(transaction.amount),
},
});
// Comparator for Array.prototype.sort, parameterized by sort key + direction.
const compareTransactions = (a, b, key, direction) => {
const cmp = a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0;
return direction === 'ASC' ? cmp : -cmp;
};
// Update
function updatePage() {
// 1. Filter against the current search text. We search formatted display
// values so users can type "$125" or "Apr 18" and find what they expect.
const filtered = transactions.filter(t => {
if (!state.search) return true;
return [t.description, formatCurrency(t.amount), formatDate(t.date)]
.join(' ').toLowerCase().includes(state.search);
});
// 2. Sort if a sort is active. Copy first; Array#sort mutates in place.
const sorted = state.sort
? [...filtered].sort((a, b) =>
compareTransactions(a, b, state.sort.key, state.sort.direction)
)
: filtered;
// 3. Paginate the result and write to the table.
const start = (state.page - 1) * state.perPage;
accountsTable.rows = sorted.slice(start, start + state.perPage).map(toRow);
// Mirror the active sort onto the matching header so the table renders
// the sort indicator. Other headers get `sorted: undefined`.
accountsTable.headers = tableHeaders.map(header => ({
...header,
sorted: state.sort?.key === header.key ? state.sort.direction : undefined,
}));
// Keep the pager in sync with the filtered total so the page count is
// accurate when search reduces the result set.
paginationElement.total = sorted.length;
paginationElement.page = state.page;
}
// Event handlers
paginationElement.addEventListener('tctChange', event => {
state.page = event?.detail?.page ?? 1;
updatePage();
});
searchElement.addEventListener('tctInput', event => {
state.search = (event?.detail?.value ?? '').toLowerCase();
state.page = 1; // jump back to page 1 so the user sees results immediately
updatePage();
});
accountsTable.addEventListener('tctSort', event => {
// Calling preventDefault() tells q2-data-table not to sort itself.
// We own the sort because pagination must apply to the sorted result.
event.preventDefault();
const { direction, header } = event.detail;
state.sort = { key: header.key, direction };
state.page = 1; // jump back to page 1 so the new "first" rows are visible
updatePage();
});
// Initial render.
updatePage();
</script>
The page uses three Tecton components together: q2-input for search, q2-data-table for the transaction list, and q2-pagination to page through the results. None of them know about each other. A single state object and a single updatePage() function own the connection between them. Each event handler updates state, then calls updatePage(). The order inside updatePage() always goes filter, then sort, then paginate.
// Single source of truth for what the user is currently looking at.
const state = {
search: '', // current search text, lowercased
sort: null, // { key, direction } or null when unsorted
page: 1, // current page number
perPage: 5, // rows per page
};
function updatePage() {
// 1. Filter against the current search text.
const filtered = transactions.filter(t => {
if (!state.search) return true;
return [t.description, formatCurrency(t.amount), formatDate(t.date)]
.join(' ').toLowerCase().includes(state.search);
});
// 2. Sort if a sort is active. Copy first; Array#sort mutates in place.
const sorted = state.sort
? [...filtered].sort((a, b) => compareTransactions(a, b, state.sort.key, state.sort.direction))
: filtered;
// 3. Paginate the result and write to the table.
const start = (state.page - 1) * state.perPage;
accountsTable.rows = sorted.slice(start, start + state.perPage).map(toRow);
accountsTable.headers = tableHeaders.map(header => ({
...header,
sorted: state.sort?.key === header.key ? state.sort.direction : undefined,
}));
paginationElement.total = sorted.length;
paginationElement.page = state.page;
}Filtering before sorting means you never waste work sorting rows that the filter will discard. Sorting before paginating means each page reflects the correct position in the sorted result.
q2-data-table expects. The toRow mapper builds { id, cells } from the raw transaction at render time. This lets you sort and filter on real values (numbers, ISO date strings) rather than pre-formatted display strings, which sort lexically and produce surprises like "$1,480.13" landing before "$250.00".The header uses q2-grid to place the account name and balance details side by side on wider screens, stacking into a single column on mobile. The left column holds a q2-item with an avatar and the account name. The right column uses a nested q2-grid to show the current and available balances end-aligned.
<q2-grid columns="1" md-columns="2">
<q2-grid-area>
<q2-item>
<q2-avatar src="/examples/logo.svg" slot="decorator"></q2-avatar>
<div slot="header">
<span class="heading-2">Checking Account</span>
</div>
<div slot="body">Last Updated: April 15, 2024</div>
</q2-item>
</q2-grid-area>
<q2-grid-area justify="end">
<q2-grid columns="1" md-columns="2">
<q2-grid-area justify="end">
<q2-detail stacked size="small" alignment="right" label="Current balance">
<q2-currency amount="1480.13"></q2-currency>
</q2-detail>
</q2-grid-area>
<q2-grid-area justify="end">
<q2-detail stacked size="small" alignment="right" label="Available balance">
<q2-currency amount="1373.49"></q2-currency>
</q2-detail>
</q2-grid-area>
</q2-grid>
</q2-grid-area>
</q2-grid>Every transaction has a type of 'debit' or 'credit'. Including a text Type column means debit vs. credit is never communicated by color alone, which matters for accessibility and for users who print or export the table.
const tableHeaders = [
{ title: 'Date', key: 'date', width: '15%', sortable: 'manual' },
{ title: 'Description', key: 'description', sortable: 'manual' },
{ title: 'Type', key: 'type', width: '10%', sortable: 'manual' },
{ title: 'Amount', key: 'amount', align: 'end', sortable: 'manual' },
];
// Builds the {id, cells} shape that q2-data-table expects from a transaction.
const toRow = transaction => ({
id: transaction.id,
cells: {
date: formatDate(transaction.date),
description: transaction.description,
type: transaction.type.charAt(0).toUpperCase() + transaction.type.slice(1),
amount: formatCurrency(transaction.amount),
},
});The raw type value ('debit' or 'credit') is capitalized in toRow for display. The raw value is what the sort comparator receives, which is correct because both values are plain strings of the same length and sort alphabetically.
The Input emits a tctInput event whose detail.value is the current search string. The handler lowercases it, stores it on state.search, resets state.page to 1 so the user lands on the first page of results, and calls updatePage().
// Builds on the `state` + `updatePage()` pattern shown above.
searchElement.addEventListener('tctInput', event => {
state.search = (event?.detail?.value ?? '').toLowerCase();
state.page = 1; // jump back to page 1 so the user sees results immediately
updatePage();
});The match runs against the formatted display values so users can type "$125" or "Apr 18" and find what they expect:
const filtered = transactions.filter(t => {
if (!state.search) return true;
return [t.description, formatCurrency(t.amount), formatDate(t.date)]
.join(' ').toLowerCase().includes(state.search);
});updatePage() always recomputes paginationElement.total from the filtered result, so the pager automatically reflects the search results. No separate code path needed.The Data Table emits a tctSort event when the user clicks a sortable header. Calling event.preventDefault() tells the table not to sort itself. The handler stores the requested key and direction on state.sort, resets to page 1, and calls updatePage().
// Builds on the `state` + `updatePage()` pattern shown above.
accountsTable.addEventListener('tctSort', event => {
// Calling preventDefault() tells q2-data-table not to sort itself.
// We own the sort because pagination must apply to the sorted result.
event.preventDefault();
const { direction, header } = event.detail;
state.sort = { key: header.key, direction };
state.page = 1; // jump back to page 1 so the new "first" rows are visible
updatePage();
});The actual sort runs inside updatePage() on the raw transaction objects, not the formatted cells:
const compareTransactions = (a, b, key, direction) => {
const cmp = a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0;
return direction === 'ASC' ? cmp : -cmp;
};
const sorted = state.sort
? [...filtered].sort((a, b) => compareTransactions(a, b, state.sort.key, state.sort.direction))
: filtered;Two details worth noting:
[...filtered].sort(...) copies before sorting. Array.prototype.sort mutates in place, and you do not want to scramble your source data on every interaction.amount (a number) and raw date (an ISO string, which sorts chronologically). Sorting on cells.amount would compare "$1,480.13" lexically, which produces wrong results.The header update inside updatePage() sets a sorted property on the matching header so the Data Table renders the sort indicator in the correct column and direction.
The Pagination component emits a tctChange event whose detail.page is the page the user moved to. The handler updates state.page and calls updatePage(). The slicing happens inside updatePage() against the already-filtered, already-sorted array.
// Builds on the `state` + `updatePage()` pattern shown above.
paginationElement.addEventListener('tctChange', event => {
state.page = event?.detail?.page ?? 1;
updatePage();
});Inside updatePage(), the page total is recomputed from the filtered result on every render:
paginationElement.total = sorted.length;This keeps the page count accurate when the user filters the list down to fewer rows.