-
Notifications
You must be signed in to change notification settings - Fork 21
feat: implement sortable columns with visual indicators and sorting l… #414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a80bc27
91ef36b
b963cde
3a66c2c
a8f54b6
df06a7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,13 +4,30 @@ | |||||||||||||||||||||||||
| <table class="afcl-table w-full text-sm text-left rtl:text-right text-lightTableText dark:text-darkTableText overflow-x-auto"> | ||||||||||||||||||||||||||
| <thead class="afcl-table-thread text-xs text-lightTableHeadingText uppercase bg-lightTableHeadingBackground dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText"> | ||||||||||||||||||||||||||
| <tr> | ||||||||||||||||||||||||||
| <th scope="col" class="px-6 py-3" ref="headerRefs" :key="`header-${column.fieldName}`" | ||||||||||||||||||||||||||
| <th | ||||||||||||||||||||||||||
| scope="col" | ||||||||||||||||||||||||||
| class="px-6 py-3" | ||||||||||||||||||||||||||
| ref="headerRefs" | ||||||||||||||||||||||||||
| :key="`header-${column.fieldName}`" | ||||||||||||||||||||||||||
| v-for="column in columns" | ||||||||||||||||||||||||||
| :aria-sort="getAriaSort(column)" | ||||||||||||||||||||||||||
| :class="{ 'cursor-pointer select-none afcl-table-header-sortable': isColumnSortable(column) }" | ||||||||||||||||||||||||||
| @click="onHeaderClick(column)" | ||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||
| <slot v-if="$slots[`header:${column.fieldName}`]" :name="`header:${column.fieldName}`" :column="column" /> | ||||||||||||||||||||||||||
| <span v-else> | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| <span v-else class="inline-flex items-center"> | ||||||||||||||||||||||||||
| {{ column.label }} | ||||||||||||||||||||||||||
| <span v-if="isColumnSortable(column)" class="text-lightTableHeadingText dark:text-darkTableHeadingText"> | ||||||||||||||||||||||||||
| <!-- Unsorted --> | ||||||||||||||||||||||||||
| <svg v-if="currentSortField !== column.fieldName" 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"/></svg> | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| <!-- Sorted ascending --> | ||||||||||||||||||||||||||
| <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"/></svg> | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| <!-- Sorted descending --> | ||||||||||||||||||||||||||
| <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"/></svg> | ||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||
| </th> | ||||||||||||||||||||||||||
| </tr> | ||||||||||||||||||||||||||
|
|
@@ -141,13 +158,16 @@ | |||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||
| defaultSortField?: string, | ||||||||||||||||||||||||||
| defaultSortDirection?: 'asc' | 'desc', | ||||||||||||||||||||||||||
| }>(), { | ||||||||||||||||||||||||||
| evenHighlights: true, | ||||||||||||||||||||||||||
| pageSize: 5, | ||||||||||||||||||||||||||
|
|
@@ -163,8 +183,17 @@ | |||||||||||||||||||||||||
| const isLoading = ref(false); | ||||||||||||||||||||||||||
| const dataResult = ref<{data: {[key: string]: any}[], total: number}>({data: [], total: 0}); | ||||||||||||||||||||||||||
| const isAtLeastOneLoading = ref<boolean[]>([false]); | ||||||||||||||||||||||||||
| const currentSortField = ref<string | undefined>(props.defaultSortField); | ||||||||||||||||||||||||||
| const currentSortDirection = ref<'asc' | 'desc'>(props.defaultSortDirection ?? 'asc'); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| onMounted(() => { | ||||||||||||||||||||||||||
| // If defaultSortField points to a non-sortable column, ignore it | ||||||||||||||||||||||||||
| if (currentSortField.value) { | ||||||||||||||||||||||||||
| const col = props.columns?.find(c => c.fieldName === currentSortField.value); | ||||||||||||||||||||||||||
| if (!col || !isColumnSortable(col)) { | ||||||||||||||||||||||||||
| currentSortField.value = undefined; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| refresh(); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -181,6 +210,14 @@ | |||||||||||||||||||||||||
| emit('update:tableLoading', isLoading.value || props.isLoading); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| watch([() => currentSortField.value, () => currentSortDirection.value], () => { | ||||||||||||||||||||||||||
| 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 }); | ||||||||||||||||||||||||||
| }, { immediate: false }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const totalPages = computed(() => { | ||||||||||||||||||||||||||
| return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1; | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
@@ -196,6 +233,9 @@ | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const emit = defineEmits([ | ||||||||||||||||||||||||||
| 'update:tableLoading', | ||||||||||||||||||||||||||
| 'update:sortField', | ||||||||||||||||||||||||||
| 'update:sortDirection', | ||||||||||||||||||||||||||
| 'sort-change', | ||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function onPageInput(event: any) { | ||||||||||||||||||||||||||
|
|
@@ -231,7 +271,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 +285,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 +299,48 @@ | |||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function isColumnSortable(col:{fieldName:string; sortable?:boolean}) { | ||||||||||||||||||||||||||
| // Sorting is controlled per column; default is NOT sortable. Enable with `sortable: true`. | ||||||||||||||||||||||||||
| return 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'; | ||||||||||||||||||||||||||
|
Comment on lines
+313
to
+316
|
||||||||||||||||||||||||||
| currentSortDirection.value = | |
| currentSortDirection.value === 'asc' ? 'desc' : | |
| currentSortField.value ? (currentSortField.value = undefined, props.defaultSortDirection ?? 'asc') : | |
| 'asc'; | |
| if (currentSortDirection.value === 'asc') { | |
| currentSortDirection.value = 'desc'; | |
| } else if (currentSortField.value) { | |
| currentSortField.value = undefined; | |
| currentSortDirection.value = props.defaultSortDirection ?? 'asc'; | |
| } else { | |
| currentSortDirection.value = 'asc'; | |
| } |
Copilot
AI
Nov 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate comment 'Handle null/undefined values' appears on consecutive lines. Remove the duplicate on line 336 to improve code clarity.
| // Handle null/undefined values |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The SVG path for the descending sort icon is incomplete. It ends with
1.847 0zbut should have a complete path. This matches the same truncated path as line 26, resulting in an incorrect/incomplete icon rendering.