fix empty-value sorting edge cases and simplify sort components

This commit is contained in:
Gregor Vostrak
2026-08-26 18:11:24 +02:00
parent c9330e6cb8
commit 1edb940557
9 changed files with 67 additions and 126 deletions

View File

@@ -13,26 +13,23 @@ import { computed, type ComputedRef } from 'vue';
export type SortDirection = 'asc' | 'desc';
/**
* The comparator every sortable column gets: a row whose accessor returns `undefined`
* sorts to the bottom in both directions, and two such rows compare as equal so the
* tie-break decides their order.
*
* TanStack cannot express that combination. Its `sortUndefined: 'last'` keeps empty rows
* at the bottom but returns a non-zero result even when both rows are empty, which
* swallows the tie-break and leaves those rows in API (created_at) order; its default of
* `1` returns zero for that case but flips empty rows to the top when descending.
* Comparator for every sortable column: empty values (`null`/`undefined`) sort last in
* both directions and compare as equal, so the tie-break orders them. TanStack's own
* `sortUndefined` cannot do this: `'last'` bypasses the tie-break and the default flips
* empty rows to the top when descending.
*/
function sortEmptyLast<TData>(getDirection: () => SortDirection): SortingFn<TData> {
function sortEmptyLast<TData>(getSorting: () => SortingState): SortingFn<TData> {
return (rowA: Row<TData>, rowB: Row<TData>, columnId: string) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);
if (a === undefined && b === undefined) {
if (a == null && b == null) {
return 0;
}
if (a === undefined || b === undefined) {
const emptyLast = a === undefined ? 1 : -1;
return getDirection() === 'desc' ? -emptyLast : emptyLast;
if (a == null || b == null) {
const emptyLast = a == null ? 1 : -1;
const desc = getSorting().find((entry) => entry.id === columnId)?.desc ?? false;
return desc ? -emptyLast : emptyLast;
}
return typeof a === 'string' || typeof b === 'string'
? sortingFns.alphanumeric(rowA, rowB, columnId)
@@ -41,13 +38,13 @@ function sortEmptyLast<TData>(getDirection: () => SortDirection): SortingFn<TDat
}
/**
* A column definition whose `id` has to be one of the table's sortable columns, so a
* renamed or mistyped id is a compile error rather than a column that silently stops
* sorting: TanStack drops sort entries for ids it cannot resolve, leaving the rows in
* their original order with no chevron and no error.
* Requires `id` to be one of the table's sortable columns, so a mistyped id is a compile
* error instead of a column that silently stops sorting. `sortingFn` is forbidden
* because the composable always installs its own comparator.
*/
export type SortableColumnDef<TData, TColumn extends string> = ColumnDef<TData, unknown> & {
id: TColumn;
sortingFn?: never;
};
export function useSortableTable<TData, TColumn extends string>(options: {
@@ -72,15 +69,11 @@ export function useSortableTable<TData, TColumn extends string>(options: {
]);
const resolvedColumns = computed<ColumnDef<TData, unknown>[]>(() =>
options.columns().map((column) =>
column.sortingFn
? column
: {
...column,
sortUndefined: false as const,
sortingFn: sortEmptyLast<TData>(options.sortDirection),
}
)
options.columns().map((column) => ({
...column,
sortUndefined: false as const,
sortingFn: sortEmptyLast<TData>(() => sorting.value),
}))
);
const descFirstColumns = computed<ReadonlySet<TColumn>>(