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
nullnested 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.localesimply result inundefinedand 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" />