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. +
+``` + +Events you can listen to: + +```html +
+``` + ### 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 @@
- @@ -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
- - + + {{ column.label }} + + + + + + + + + +