import { getCoreRowModel, getSortedRowModel, sortingFns, type ColumnDef, type Row, type SortingFn, type SortingState, useVueTable, } from '@tanstack/vue-table'; 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. */ function sortEmptyLast(getDirection: () => SortDirection): SortingFn { return (rowA: Row, rowB: Row, columnId: string) => { const a = rowA.getValue(columnId); const b = rowB.getValue(columnId); if (a === undefined && b === undefined) { return 0; } if (a === undefined || b === undefined) { const emptyLast = a === undefined ? 1 : -1; return getDirection() === 'desc' ? -emptyLast : emptyLast; } return typeof a === 'string' || typeof b === 'string' ? sortingFns.alphanumeric(rowA, rowB, columnId) : sortingFns.basic(rowA, rowB, columnId); }; } /** * 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. */ export type SortableColumnDef = ColumnDef & { id: TColumn; }; export function useSortableTable(options: { data: () => TData[]; columns: () => SortableColumnDef[]; sortColumn: () => TColumn; sortDirection: () => SortDirection; tieBreakColumn?: TColumn; }): { sortedRows: ComputedRef; descFirstColumns: ComputedRef>; nextDirection: (column: TColumn) => SortDirection; } { const sorting = computed(() => [ { id: options.sortColumn(), desc: options.sortDirection() === 'desc', }, ...(options.tieBreakColumn && options.sortColumn() !== options.tieBreakColumn ? [{ id: options.tieBreakColumn, desc: false }] : []), ]); const resolvedColumns = computed[]>(() => options.columns().map((column) => column.sortingFn ? column : { ...column, sortUndefined: false as const, sortingFn: sortEmptyLast(options.sortDirection), } ) ); const descFirstColumns = computed>( () => new Set( options .columns() .filter((column) => column.sortDescFirst) .map((column) => column.id) ) ); function nextDirection(column: TColumn): SortDirection { if (options.sortColumn() === column) { return options.sortDirection() === 'asc' ? 'desc' : 'asc'; } return descFirstColumns.value.has(column) ? 'desc' : 'asc'; } const table = useVueTable({ get data() { return options.data(); }, get columns() { return resolvedColumns.value; }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), state: { get sorting() { return sorting.value; }, }, manualSorting: false, }); const sortedRows = computed(() => table.getRowModel().rows.map((row) => row.original)); return { sortedRows, descFirstColumns, nextDirection }; }