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'; /** * 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(getSorting: () => SortingState): SortingFn { return (rowA: Row, rowB: Row, columnId: string) => { const a = rowA.getValue(columnId); const b = rowB.getValue(columnId); if (a == null && b == null) { return 0; } 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) : sortingFns.basic(rowA, rowB, columnId); }; } /** * 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 = ColumnDef & { id: TColumn; sortingFn?: never; }; 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, sortUndefined: false as const, sortingFn: sortEmptyLast(() => sorting.value), })) ); 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 }; }