diff --git a/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md
index e2b77bcb..40e58ef0 100644
--- a/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md
+++ b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md
@@ -22,6 +22,57 @@ import { Button } from '@/afcl'
```
```html
+
+### Sorting
+
+Table supports column sorting out of the box.
+
+- Sorting is enabled globally by default. You can disable it entirely with `:sortable="false"`.
+- Per column, sorting can be disabled with `sortable: false` inside the column definition.
+- 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):
+
+```html
+
+```
+
+You can also predefine a default sort:
+
+```html
+
+```
+
+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.
+
Your button text
@@ -973,6 +1024,59 @@ async function loadPageData(data) {
```
> 👆 The page size is used as the limit for pagination.
+### 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:
+
+```ts
+type LoaderArgs = {
+ offset: number;
+ limit: number;
+ sortField?: string; // undefined when unsorted
+ sortDirection?: 'asc' | 'desc'; // only when sortField is set
+}
+```
+
+Example using `fetch`:
+
+```ts
+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 res = await fetch(url.toString(), { credentials: 'include' });
+ const json = await res.json();
+ return { data: json.data, total: json.total };
+}
+
+
+```
+
+Events you can listen to:
+
+```html
+ 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.
diff --git a/adminforth/spa/src/afcl/Table.vue b/adminforth/spa/src/afcl/Table.vue
index 93e057f1..8d0f0654 100644
--- a/adminforth/spa/src/afcl/Table.vue
+++ b/adminforth/spa/src/afcl/Table.vue
@@ -4,13 +4,30 @@
-
-
-
+
+
{{ column.label }}
+
+
+
+
+
+
+
+
+
+
@@ -141,16 +158,21 @@
columns: {
label: string,
fieldName: string,
+ sortable?: boolean,
}[],
data: {
[key: string]: any,
- }[] | ((params: { offset: number, limit: number }) => Promise<{data: {[key: string]: any}[], total: number}>),
+ }[] | ((params: { offset: number, limit: number, sortField?: string, sortDirection?: 'asc' | 'desc' }) => Promise<{data: {[key: string]: any}[], total: number}>),
evenHighlights?: boolean,
pageSize?: number,
isLoading?: boolean,
+ sortable?: boolean, // enable/disable sorting globally
+ defaultSortField?: string,
+ defaultSortDirection?: 'asc' | 'desc',
}>(), {
evenHighlights: true,
pageSize: 5,
+ sortable: false,
}
);
@@ -163,6 +185,8 @@
const isLoading = ref(false);
const dataResult = ref<{data: {[key: string]: any}[], total: number}>({data: [], total: 0});
const isAtLeastOneLoading = ref([false]);
+ const currentSortField = ref(props.defaultSortField);
+ const currentSortDirection = ref<'asc' | 'desc'>(props.defaultSortDirection ?? 'asc');
onMounted(() => {
refresh();
@@ -181,6 +205,15 @@
emit('update:tableLoading', isLoading.value || props.isLoading);
});
+ watch([() => currentSortField.value, () => currentSortDirection.value], () => {
+ if (!props.sortable) return;
+ if (currentPage.value !== 1) currentPage.value = 1;
+ refresh();
+ emit('update:sortField', currentSortField.value);
+ emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined);
+ emit('sort-change', { field: currentSortField.value, direction: currentSortDirection.value });
+ });
+
const totalPages = computed(() => {
return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1;
});
@@ -196,6 +229,9 @@
const emit = defineEmits([
'update:tableLoading',
+ 'update:sortField',
+ 'update:sortDirection',
+ 'sort-change',
]);
function onPageInput(event: any) {
@@ -231,7 +267,12 @@
isLoading.value = true;
const currentLoadingIndex = currentPage.value;
isAtLeastOneLoading.value[currentLoadingIndex] = true;
- const result = await props.data({ offset: (currentLoadingIndex - 1) * props.pageSize, limit: props.pageSize });
+ const result = await props.data({
+ offset: (currentLoadingIndex - 1) * props.pageSize,
+ limit: props.pageSize,
+ sortField: currentSortField.value,
+ ...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}),
+ });
isAtLeastOneLoading.value[currentLoadingIndex] = false;
if (isAtLeastOneLoading.value.every(v => v === false)) {
isLoading.value = false;
@@ -240,7 +281,9 @@
} else if (typeof props.data === 'object' && Array.isArray(props.data)) {
const start = (currentPage.value - 1) * props.pageSize;
const end = start + props.pageSize;
- dataResult.value = { data: props.data.slice(start, end), total: props.data.length };
+ const total = props.data.length;
+ const sorted = sortArrayData(props.data, currentSortField.value, currentSortDirection.value);
+ dataResult.value = { data: sorted.slice(start, end), total };
}
}
@@ -252,4 +295,47 @@
}
}
+function isColumnSortable(col:{fieldName:string; sortable?:boolean}) {
+ return props.sortable === true && col.sortable === true;
+}
+
+function onHeaderClick(col:{fieldName:string; sortable?:boolean}) {
+ if (!isColumnSortable(col)) return;
+ if (currentSortField.value !== col.fieldName) {
+ currentSortField.value = col.fieldName;
+ currentSortDirection.value = props.defaultSortDirection ?? 'asc';
+ } else {
+ currentSortDirection.value =
+ currentSortDirection.value === 'asc' ? 'desc' :
+ currentSortField.value ? (currentSortField.value = undefined, props.defaultSortDirection ?? 'asc') :
+ 'asc';
+ }
+}
+
+function getAriaSort(col:{fieldName:string; sortable?:boolean}) {
+ if (!isColumnSortable(col)) return undefined;
+ if (currentSortField.value !== col.fieldName) return 'none';
+ return currentSortDirection.value === 'asc' ? 'ascending' : 'descending';
+}
+
+const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
+
+function sortArrayData(data:any[], sortField?:string, dir:'asc'|'desc'='asc') {
+ if (!props.sortable || !sortField) return data;
+ // Helper function to get nested properties by path
+ const getByPath = (o:any, p:string) => p.split('.').reduce((a:any,k)=>a?.[k], o);
+ return [...data].sort((a,b) => {
+ let av = getByPath(a, sortField), bv = getByPath(b, sortField);
+ // Handle null/undefined values
+ if (av == null && bv == null) return 0;
+ // Handle null/undefined values
+ if (av == null) return 1; if (bv == null) return -1;
+ // Data types
+ if (av instanceof Date && bv instanceof Date) return dir === 'asc' ? av.getTime() - bv.getTime() : bv.getTime() - av.getTime();
+ // Strings and numbers
+ if (typeof av === 'number' && typeof bv === 'number') return dir === 'asc' ? av - bv : bv - av;
+ const cmp = collator.compare(String(av), String(bv));
+ return dir === 'asc' ? cmp : -cmp;
+ });
+}
\ No newline at end of file