Skip to main content

Data Display and Loading

Tables and other views of data, and indicators of loading and progress. Import the components from @/afcl, see AdminForth Components Library.

Table​

import { Table } from '@/afcl'
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="[
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'UK' },
{ name: 'Colin', age: 40, country: 'AU' },
]"
></Table>

No even highlights​

import { Table } from '@/afcl'
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="[
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'UK' },
{ name: 'Colin', age: 40, country: 'AU' },
]"
:evenHighlights="false"
></Table>

Custom cell​

import { Table } from '@/afcl'
const isoFlagToEmoji = (iso) => iso.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397))
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="[
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'BR' },
{ name: 'Colin', age: 40, country: 'AU' },
]"
>
<template #cell:country="{item}">
{{ isoFlagToEmoji(item.country) }}
</template>
</Table>

Custom header​

import { Table } from '@/afcl'
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="[
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'BR' },
{ name: 'Colin', age: 40, country: 'AU' },
]"
>
<template #header:country>
🌍 Geo
</template>
</Table>

Pagination​

Table provides front-end side pagination. You can set pageSize (default is 5) to set how many rows to show per page. If there is less then pageSize rows, pagination will not be shown.

import { Table } from '@/afcl'
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="[
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'BR' },
{ name: 'Colin', age: 40, country: 'AU' },
]"
:pageSize="3"
>
</Table>

Server-side pagination​

To load pages dynamically, simply pass async callback to data:

import { Table } from '@/afcl'
async function loadPageData(data) {
const { offset, limit } = data;
// in real app do await callAdminForthApi or await fetch to get date, use offset and limit value to slice data
return {
data: [
{ name: 'John', age: offset, country: 'US' },
{ name: 'Rick', age: offset+1, country: 'CA' },
{ name: 'Alice', age: offset+2, country: 'BR' },
],
total: 9 // should return total amount of records in database
}
}
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="loadPageData"
:pageSize="3">
</Table>

👆 The page size is used as the limit for pagination.

Sorting​

Table supports column sorting out of the box.

  • By default, columns are NOT sortable. Enable sorting per column with sortable: true.
  • Clicking a sortable header cycles sorting in a tri-state order:
    • none → ascending → descending → none
    • When it returns to "none", the sorting is cleared.

Basic example (client-side sorting when data is an array):

import { Table } from '@/afcl'
<Table
:columns="[
{ label: 'Name', fieldName: 'name', sortable: true },
{ label: 'Age', fieldName: 'age', sortable: true },
// disable sort for a specific column
{ label: 'Country', fieldName: 'country', sortable: false },
]"
:data="[
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'BR' },
{ name: 'Colin', age: 40, country: 'AU' },
]"
:pageSize="3"
/>

You can also predefine a default sort:

import { Table } from '@/afcl'
const rows = [
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'BR' },
];
<Table
:columns="[
{ label: 'Name', fieldName: 'name', sortable: true },
{ label: 'Age', fieldName: 'age', sortable: true },
{ label: 'Country', fieldName: 'country' },
]"
:data="rows"
defaultSortField="age"
defaultSortDirection="desc"
/>

Notes:

  • Client-side sorting supports nested field paths using dot-notation, e.g. user.name.
  • When a column is not currently sorted, a subtle double-arrow icon is shown; arrows switch up/down for ascending/descending.

Nested field path sorting​

You can sort by nested properties of objects in rows using dot-notation in fieldName.

Example:

import { Table } from '@/afcl'
<Table
:columns="[
{ label: 'User Name', fieldName: 'user.name', sortable: true },
{ label: 'User Email', fieldName: 'user.email', sortable: true },
{ label: 'City', fieldName: 'user.address.city', sortable: true },
{ label: 'Country', fieldName: 'user.address.country' },
]"
:data="[
{ user: { name: 'Alice', email: 'alice@example.com', address: { city: 'Berlin', country: 'DE' } } },
{ user: { name: 'Bob', email: 'bob@example.com', address: { city: 'Paris', country: 'FR' } } },
{ user: { name: 'Carlos', email: 'carlos@example.com', address: { city: 'Madrid', country: 'ES' } } },
{ user: { name: 'Dana', email: 'dana@example.com', address: { city: 'Rome', country: 'IT' } } },
{ user: { name: 'Eve', email: 'eve@example.com', address: { city: null, country: 'US' } } },
]"
:pageSize="3"
/>

Behavior details:

  • The path is split on dots and resolved step by step: user.address.city.
  • Missing or null nested values always go to the bottom, for ascending and descending order alike.
  • Works the same for client-side sorting and for server-side loaders: when using an async loader the same sortField (e.g. user.address.city) is passed so you can implement equivalent ordering on the backend.
  • Date objects at nested paths are detected and compared chronologically.
  • Numeric comparison is stable for mixed numeric strings via Intl.Collator with numeric option.

Edge cases to consider in your own data:

  • Deeply missing branches like user.profile.settings.locale simply result in undefined and will follow the null ordering logic above.
  • Arrays are not traversed; if you need array-specific sorting you should pre-normalize data into scalar fields before passing to the table.

Server-side sorting​

When you provide an async function to data, the table will pass the current sort along with pagination params.

Signature of the loader receives:

type LoaderArgs = {
offset: number;
limit: number;
sortField?: string; // undefined when unsorted
sortDirection?: 'asc' | 'desc'; // only when sortField is set
}

Example using fetch:

import { Table } from '@/afcl'

async function loadPageData({ offset, limit, sortField, sortDirection }) {
const url = new URL('/api/products', window.location.origin);
url.searchParams.set('offset', String(offset));
url.searchParams.set('limit', String(limit));
if (sortField) url.searchParams.set('sortField', sortField);
if (sortField && sortDirection) url.searchParams.set('sortDirection', sortDirection);

const { data, total } = await (await fetch(url)).json();
return { data, total };
}

<Table
:columns="[
{ label: 'ID', fieldName: 'id' },
{ label: 'Title', fieldName: 'title' },
{ label: 'Price', fieldName: 'price' },
]"
:data="loadPageData"
:pageSize="10"
/>

Events you can listen to:

<Table
:columns="columns"
:data="loadPageData"
@update:sortField="(f) => currentSortField = f"
@update:sortDirection="(d) => currentSortDirection = d"
@sort-change="({ field, direction }) => console.log('sort changed', field, direction)"
/>

Table loading states​

For tables where you load data externally and pass them to data prop as array (including case with front-end pagination) you might want to show skeleton loaders in table externaly using isLoading props.

For tables with server-side paganation which use async function in data prop you can listen for @update:tableLoading to get table internal loading state, in other words this event will let you know when server side function started and finished execution, so you might use it e.g. to disable some external filter buttons and so on.

import { ref } from 'vue'
import { Table, Button } from '@/afcl'

const isTableLoading = ref(false);
const tableState = ref('loading');

async function loadPageData({ offset }) {
await new Promise((resolve) => setTimeout(resolve, 1000)); // simulate network delay
return {
data: [
{ name: 'John', age: offset, country: 'US' },
{ name: 'Rick', age: offset + 1, country: 'CA' },
{ name: 'Alice', age: offset + 2, country: 'BR' },
],
total: 9,
};
}
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="loadPageData"
:isLoading="isTableLoading"
:pageSize="3"
@update:tableLoading="(loading) => tableState = loading ? 'loading' : 'loaded'"
>
</Table>

<p class="my-3">Table state: {{ tableState }}</p>

<Button @click="isTableLoading = !isTableLoading">Toggle loading state</Button>

Making header and pagination sticky​

If you want to make table header or pagination sticky, you can add makeHeaderSticky, makePaginationSticky

import { Table } from '@/afcl'
<Table
class="min-h-[200px] max-h-[250px]"
makeHeaderSticky
makePaginationSticky
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="[
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'UK' },
{ name: 'Colin', age: 40, country: 'AU' },
{ name: 'Dana', age: 28, country: 'IT' },
{ name: 'Eve', age: 33, country: 'DE' },
]"
></Table>

Don't block pagination on loading​

Sometimes you might want to allow user switch between pages, even if old request wasn't finished. For these porpuses you can use blockPaginationOnLoading and abortSignal in data callback:

import { Table } from '@/afcl'
async function loadPageData(data, abortSignal) {
const { offset, limit } = data;
// in real app do await callAdminForthApi or await fetch to get date, use offset and limit value to slice data
await new Promise(resolve => setTimeout(resolve, 500)) // simulate network delay
if (abortSignal.aborted) return; // since result won't be displayed, we stop computing

return {
data: [
{ name: 'John', age: offset, country: 'US' },
{ name: 'Rick', age: offset+1, country: 'CA' },
{ name: 'Alice', age: offset+2, country: 'BR' },
],
total: 30 // should return total amount of records in database
}
}
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ label: 'Country', fieldName: 'country' },
]"
:data="loadPageData"
:blockPaginationOnLoading="false"
:pageSize="3">
</Table>

Json Viewer​

Basic​

import { JsonViewer } from '@/afcl'
<JsonViewer
:value="[
{
id: 1,
name: 'Alice',
meta: {
age: 30,
hobbies: ['reading', 'biking'],
}
},
{
id: 2,
name: 'Bob',
}
]"
:expandDepth="2"
/>

Country Flag​

The Country Flag component displays national flags using ISO 3166-1 alpha-2 country codes. A flag has a hairline edge, so white stripes stay visible on a white page. Only the SVG of a shown flag is downloaded.

import { CountryFlag } from '@/afcl'
<CountryFlag countryCode="ua" />

size is md (default, 32×24) or sm (16×12) for menus, selects and lists:

import { CountryFlag } from '@/afcl'
<div class="flex items-center gap-4">
<span class="flex items-center gap-2 text-sm"><CountryFlag countryCode="jp" size="sm" />日本語</span>
<CountryFlag countryCode="jp" />
</div>

ProgressBar​

import { ProgressBar } from '@/afcl'
<ProgressBar
:currentValue="2600"
:minValue="0"
:maxValue="5000"
/>

Custom labels​

Custom labels in the ProgressBar component allow you to customize the text displayed on the left and right sides of the progress bar. You can also customize the format of the value and the progress text.

import { ProgressBar } from '@/afcl'
<ProgressBar
:currentValue="1070"
:minValue="0"
:maxValue="5000"
:leftLabel="'Level 2'"
:rightLabel="'Level 3'"
:formatter="(value: number) => `${value} points`"
:progressFormatter="(value: number, percentage: number) => `${value} done`"
/>

Show progress animation​

import { ProgressBar } from '@/afcl'
<ProgressBar
:currentValue="2600"
:minValue="0"
:maxValue="5000"
showAnimation
/>

Change height of the progress bar​

import { ProgressBar } from '@/afcl'
<ProgressBar
:currentValue="2600"
:minValue="0"
:maxValue="5000"
:height="6"
/>

Skeleton​

Skeleton component is used to display a loading state for a component. You can use prop type to set the type of the skeleton.

import { Skeleton } from '@/afcl'
<div class="flex flex-col gap-2">
<Skeleton class="w-full h-4" />
<Skeleton class="w-full h-2" />
<Skeleton class="w-full h-2" />
<Skeleton class="w-full h-2" />
<Skeleton class="w-full h-2" />
</div>

Skeleton image​

import { Skeleton } from '@/afcl'
<Skeleton type="image" class="w-full h-40" />

Skeleton input​

import { Skeleton } from '@/afcl'
<div class="flex flex-col gap-2">
<Skeleton type="input" class="w-full h-4" />
<Skeleton type="input" class="w-full h-2" />
<Skeleton type="input" class="w-full h-2" />
<Skeleton type="input" class="w-full h-2" />
<Skeleton type="input" class="w-full h-2" />
</div>

Skeleton video​

import { Skeleton } from '@/afcl'
<Skeleton type="video" class="w-full h-40" />

Skeleton avatar​

import { Skeleton } from '@/afcl'
<Skeleton type="avatar" class="w-20 h-20" />

Spinner​

Spinner component is used to display a loading state for a component.

import { Spinner } from '@/afcl'
<Spinner class="w-10 h-10" />