WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit a80bc27

Browse files
committed
feat: implement sortable columns with visual indicators and sorting logic
1 parent 65bcf57 commit a80bc27

File tree

1 file changed

+106
-6
lines changed

1 file changed

+106
-6
lines changed

adminforth/spa/src/afcl/Table.vue

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,28 @@
44
<table class="afcl-table w-full text-sm text-left rtl:text-right text-lightTableText dark:text-darkTableText overflow-x-auto">
55
<thead class="afcl-table-thread text-xs text-lightTableHeadingText uppercase bg-lightTableHeadingBackground dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText">
66
<tr>
7-
<th scope="col" class="px-6 py-3" ref="headerRefs" :key="`header-${column.fieldName}`"
7+
<th
8+
scope="col"
9+
class="px-6 py-3"
10+
ref="headerRefs"
11+
:key="`header-${column.fieldName}`"
812
v-for="column in columns"
13+
:aria-sort="getAriaSort(column)"
14+
:class="{ 'cursor-pointer select-none afcl-table-header-sortable': isColumnSortable(column) }"
15+
@click="onHeaderClick(column)"
916
>
1017
<slot v-if="$slots[`header:${column.fieldName}`]" :name="`header:${column.fieldName}`" :column="column" />
11-
12-
<span v-else>
18+
19+
<span v-else class="inline-flex items-center">
1320
{{ column.label }}
21+
<span v-if="isColumnSortable(column)" class="text-lightTableHeadingText dark:text-darkTableHeadingText">
22+
<!-- Unsorted indicator -->
23+
<svg v-if="!isSorted(column)" class="w-3 h-3 ms-1.5 opacity-30" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z"></path></svg>
24+
<!-- Sorted ascending indicator -->
25+
<svg v-else-if="currentSortDirection === 'asc'" class="w-3 h-3 ms-1.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 0z"></path></svg>
26+
<!-- Sorted descending indicator (rotated) -->
27+
<svg v-else class="w-3 h-3 ms-1.5 rotate-180" fill="currentColor" viewBox="0 0 24 24"><path d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 0z"></path></svg>
28+
</span>
1429
</span>
1530
</th>
1631
</tr>
@@ -141,16 +156,21 @@
141156
columns: {
142157
label: string,
143158
fieldName: string,
159+
sortable?: boolean,
144160
}[],
145161
data: {
146162
[key: string]: any,
147-
}[] | ((params: { offset: number, limit: number }) => Promise<{data: {[key: string]: any}[], total: number}>),
163+
}[] | ((params: { offset: number, limit: number, sortField?: string, sortDirection?: 'asc' | 'desc' }) => Promise<{data: {[key: string]: any}[], total: number}>),
148164
evenHighlights?: boolean,
149165
pageSize?: number,
150166
isLoading?: boolean,
167+
sortable?: boolean, // enable/disable sorting globally
168+
defaultSortField?: string,
169+
defaultSortDirection?: 'asc' | 'desc',
151170
}>(), {
152171
evenHighlights: true,
153172
pageSize: 5,
173+
sortable: true,
154174
}
155175
);
156176
@@ -163,6 +183,8 @@
163183
const isLoading = ref(false);
164184
const dataResult = ref<{data: {[key: string]: any}[], total: number}>({data: [], total: 0});
165185
const isAtLeastOneLoading = ref<boolean[]>([false]);
186+
const currentSortField = ref<string | undefined>(props.defaultSortField);
187+
const currentSortDirection = ref<'asc' | 'desc'>(props.defaultSortDirection ?? 'asc');
166188
167189
onMounted(() => {
168190
refresh();
@@ -181,6 +203,15 @@
181203
emit('update:tableLoading', isLoading.value || props.isLoading);
182204
});
183205
206+
watch([() => currentSortField.value, () => currentSortDirection.value], () => {
207+
// reset to first page on sort change
208+
if (currentPage.value !== 1) currentPage.value = 1;
209+
refresh();
210+
emit('update:sortField', currentSortField.value);
211+
emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined as any);
212+
emit('sort-change', { field: currentSortField.value, direction: currentSortDirection.value });
213+
});
214+
184215
const totalPages = computed(() => {
185216
return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1;
186217
});
@@ -196,6 +227,9 @@
196227
197228
const emit = defineEmits([
198229
'update:tableLoading',
230+
'update:sortField',
231+
'update:sortDirection',
232+
'sort-change',
199233
]);
200234
201235
function onPageInput(event: any) {
@@ -231,7 +265,12 @@
231265
isLoading.value = true;
232266
const currentLoadingIndex = currentPage.value;
233267
isAtLeastOneLoading.value[currentLoadingIndex] = true;
234-
const result = await props.data({ offset: (currentLoadingIndex - 1) * props.pageSize, limit: props.pageSize });
268+
const result = await props.data({
269+
offset: (currentLoadingIndex - 1) * props.pageSize,
270+
limit: props.pageSize,
271+
sortField: currentSortField.value,
272+
sortDirection: currentSortDirection.value,
273+
});
235274
isAtLeastOneLoading.value[currentLoadingIndex] = false;
236275
if (isAtLeastOneLoading.value.every(v => v === false)) {
237276
isLoading.value = false;
@@ -240,7 +279,9 @@
240279
} else if (typeof props.data === 'object' && Array.isArray(props.data)) {
241280
const start = (currentPage.value - 1) * props.pageSize;
242281
const end = start + props.pageSize;
243-
dataResult.value = { data: props.data.slice(start, end), total: props.data.length };
282+
const total = props.data.length;
283+
const sorted = sortArrayData(props.data, currentSortField.value, currentSortDirection.value);
284+
dataResult.value = { data: sorted.slice(start, end), total };
244285
}
245286
}
246287
@@ -252,4 +293,63 @@
252293
}
253294
}
254295
296+
function isColumnSortable(column: { fieldName: string; sortable?: boolean }) {
297+
return !!props.sortable && column.sortable !== false;
298+
}
299+
300+
function isSorted(column: { fieldName: string }) {
301+
return currentSortField.value === column.fieldName;
302+
}
303+
304+
function getAriaSort(column: { fieldName: string; sortable?: boolean }) {
305+
if (!isColumnSortable(column)) return undefined;
306+
if (!isSorted(column)) return 'none';
307+
return currentSortDirection.value === 'asc' ? 'ascending' : 'descending';
308+
}
309+
310+
function onHeaderClick(column: { fieldName: string; sortable?: boolean }) {
311+
if (!isColumnSortable(column)) return;
312+
if (currentSortField.value !== column.fieldName) {
313+
currentSortField.value = column.fieldName;
314+
currentSortDirection.value = props.defaultSortDirection ?? 'asc';
315+
} else {
316+
if (currentSortDirection.value === 'asc') {
317+
currentSortDirection.value = 'desc';
318+
} else if (currentSortDirection.value === 'desc') {
319+
currentSortField.value = undefined;
320+
currentSortDirection.value = props.defaultSortDirection ?? 'asc';
321+
} else {
322+
currentSortDirection.value = 'asc';
323+
}
324+
}
325+
}
326+
327+
function getValueByPath(obj: any, path: string | undefined) {
328+
if (!path) return undefined;
329+
return path.split('.').reduce((acc: any, key: string) => (acc == null ? acc : acc[key]), obj);
330+
}
331+
332+
function compareValues(a: any, b: any) {
333+
if (a == null && b == null) return 0;
334+
if (a == null) return 1;
335+
if (b == null) return -1;
336+
if (typeof a === 'number' && typeof b === 'number') return a - b;
337+
const aDate = a instanceof Date ? a : undefined;
338+
const bDate = b instanceof Date ? b : undefined;
339+
if (aDate && bDate) return aDate.getTime() - bDate.getTime();
340+
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
341+
}
342+
343+
function sortArrayData(data: { [key: string]: any }[], sortField?: string, sortDirection: 'asc' | 'desc' = 'asc') {
344+
if (!props.sortable || !sortField) return data;
345+
const copy = data.slice();
346+
copy.sort((rowA, rowB) => {
347+
const aVal = getValueByPath(rowA, sortField);
348+
const bVal = getValueByPath(rowB, sortField);
349+
const cmp = compareValues(aVal, bVal);
350+
return sortDirection === 'asc' ? cmp : -cmp;
351+
});
352+
return copy;
353+
}
354+
255355
</script>

0 commit comments

Comments
 (0)