Clicking a column header is a single-column-sort shortcut, distinct from the Sort dropdown (which remains the tool for building a deliberate multi-column sort with explicit priority/direction control):
- Plain click sorts by that column alone, discarding every other active non-group sort —
replaceSort(sorts, key, defaultDir?, groupBy?)(core; named to read as distinct fromtoggleSort/appendOrToggleSortrather than a near-synonym, since all three sound alike despite very different effects on the rest of the multi-sort): ifkeyis already the sole active non-group sort, cycles its direction the same waytoggleSortdoes (asc → desc → none); otherwise replaces the non-group portion ofsortsoutright with a fresh{ key, dir: 'asc' }, regardless of what was sorted before. The optional 4thgroupByparam (default[]) exempts any entry whose key is currently grouped from that discard — see "Auto-syncing group order with sort" under Grouped columns: a grouped column has no header of its own to reclick, so without this a plain click on some unrelated column would silently wipe out every grouped column's sort order with no way to see why. Each adapter'ssort.replaceaction passes its currentgroupBythrough. - Shift-click adds the column to the existing multi-sort (ascending) if it isn't already part of it, or flips its direction in place if it is —
appendOrToggleSort(sorts, key)(core). It deliberately never removes an entry: cycling through "none" would both surprise someone who only meant to flip direction, and — because the next shift-click would then re-add the column at the end of the priority stack — silently lose its original position. Removing a column from the multi-sort stays a dedicated action: the active-bar chip's×or the Sort dropdown's own remove button. - The Sort dropdown's own "add sort" entries keep using the original
toggleSort(append, cycling to removal) — unaffected by either of the above, since those entries only ever fire on a column that isn't already active (already-active columns render as reorderable "active sort" rows with their own direction toggle/remove controls instead), so the removal branch is unreachable from there in practice. - An unsorted column's ↕ is hidden until the pointer is over its header or its label has keyboard focus; a sorted column always shows ↑/↓, and a
sortable: falsecolumn has no arrow at all. Ten muted arrows read as noise, and the label button already signals it can be clicked. - The header's own sort-icon index number (
1↑,2↓, …) is only rendered once more than one currently-visible header is sorted — with just one, a bare↑/↓is enough and a lone "1" would be noise. "Visible" matters because agroupBycolumn keeps its ownsortsentry (sortWithinGroupsuses it to order the groups themselves) but has no<th>of its own once grouped (see Grouped columns); counting it toward the total, or numbering off its raw position insorts, could show a stray "2" on the only visible sorted header with nothing numbered "1" to match. Each adapter filterssortsdown to entries whose key is still inactiveColumnsbefore computing both the threshold and the index itself, local to the header-row render (React: an inlineheaderSortscomputed via an IIFE in theactiveColumns.map; Vue: aheaderSortscomputed plusisHeaderSorted/headerSortLabelhelpers; Solid: aheaderSortsmemo inTableBody.tsx) — this doesn't touch the sort dropdown's or active-bar chip's own numbering, which still reflects every entry insortsregardless of header visibility. - Every sorted visible header carries
aria-sort(ascending/descending), from the sameheaderSortslist; unsorted headers omit it. All sorted headers get it, not only the primary one, so a multi-sort stays audible. table.sort.replace/table.sort.appendOrToggleare exposed alongside the pre-existingtable.sort.toggle; Solid'sTableBody.tsxwires both directly into the header<th>'sonClick, readinge.shiftKeyto choose between them (vanilla gets this for free — see Vanilla package).ColumnDefBase.sortable?: boolean(defaulttrue) excludes a column from header-click/shift-click sorting entirely and from the Sort dropdown's own "add a sort" list — the samesortable !== false-style opt-out conventionfilterablealready uses (groupableis the opposite, opt-in). Each adapter enforces this itself (c.sortable !== falseguarding both the addable-columns list and the header click handler's early-return) since core'stoggleSort/replaceSort/appendOrToggleSorthave nocolumnsargument to check it against. An already-active sort entry on asortable: falsecolumn (set programmatically viasetViewState, or from before the flag was added) stays fully intact and removable via its active-bar chip or the Sort dropdown's own remove button — only adding a new header-driven sort is blocked.ColumnDefBase.defaultSortDir?: SortDir(default'asc') picks which direction a fresh sort on that column starts at — e.g.'desc'for a "last modified" date column or a score/count column, where descending is the more useful first click. It's opt-in per column only; there's no type-based inference (atype: 'date'column doesn't default to'desc'on its own), to avoid a surprising behavior change for existing columns.toggleSort/replaceSort/appendOrToggleSort(core) all take it as an optional 3rddefaultDirparam and cyclenone → defaultDir → opposite(defaultDir) → noneinstead of the fixednone → asc → desc → none— so a'desc'-default column's first click sorts descending, second click flips to ascending, third clears it. Only where a new sort entry starts (and which direction its cycle removes from) changes; an already-active entry'stoggleSortDirflip (chip click, Sort dropdown row) is unaffected, since it just alternates whatever direction is already set regardless of where the cycle began. Each adapter looks upcolumns.find(c => c.key === key)?.defaultSortDir ?? 'asc'at the call site (React/Vue'suseTableStateand Solid'screateTableStateactions) and passes it through — no new state, no persistence changes (TableViewState.sortsalready stores each entry's resolveddirexplicitly).
ColumnDefBase.compare?: (a: unknown, b: unknown, dir: SortDir) => number (see GitHub issue #15) is a plain Array.prototype.sort-style comparator over two already-resolved column values — not full rows (sortWithinGroups' group-order pass has no single owning row for a bucket, so a row-aware signature couldn't be reused there anyway) — for a column whose natural order is neither numeric nor alphabetical (an enum/tier column: Bronze < Silver < Gold, not alphabetical). It overrides the default numeric-or-lexicographic comparison (factored out as defaultCompare in logic.ts) everywhere a column's values are ordered: processData's row sort and sortWithinGroups' per-bucket row sort (both via the shared sortRows), sortWithinGroups' group-order pass (comparing keyParts[idx] directly instead of through comparableFromKeyPart's type coercion), computeStringValues' default checklist order, and sortFilterValues' explicit alpha-mode sort toggle including count-mode's tie-break (replacing localeCompare in both). compare takes priority over type: 'number'/'date' coercion when both are set on a column — sortRows reads the raw value via getColumnValue instead of the type-coercing getComparableValue whenever col.compare is present. Doesn't affect groupValue/groupFormat bucketing, which solves the equivalent problem for grouping instead.
The 3rd dir argument is the active ascending/descending direction at that sort site (a fixed 'asc' at the two checklist-ordering sites, which have no direction of their own — computeStringValues's default order, and sortFilterValues's count-mode tie-break, which — like computeStringValueCounts's existing "tie-break alphabetically" behavior — is always ascending regardless of the checklist's own sort.dir, itself only ever governing the count comparison above it). An ordinary comparator should ignore it: every call site already flips the return value's sign for a descending sort (dir === 'asc' ? cmp : -cmp) the same way the default comparison does, so a direction-naive (a, b) => … behaves correctly for free. dir exists only for the rarer case of a value that must stay pinned to one end regardless of which direction is active (e.g. a missing value sorting last whether ascending or descending) — genuinely impossible to express as a plain (a, b) => number return, since that return's sign gets flipped for desc right along with everything else, flipping "always after" to "always before" the moment the direction is toggled.
compareMissingLast(compare?, isMissing?) (core, re-exported from all three adapter packages alongside bucketNumericRange/bucketDatePart) is a ready-made comparator built on this: isMissing (default (v) => v == null || v === '', covering both a real null/undefined raw value — row-level sort, where getColumnValue/getComparableValue can return either — and the empty string a missing scalar stringifies to wherever grouping/the filter checklist need a string instead, multiValues' String(value ?? '')) decides which values get pinned; compare (default: this module's own numeric-or-lexicographic fallback) orders any two non-pinned values, direction-naive same as any other comparator. Two pinned values compare as equal (return 0), falling through to the next sort key the same way a tied compare result would; a pinned value against a normal one returns dir === 'asc' ? rel : -rel for a fixed rel — pre-cancelling the call site's own upcoming flip so the net result stays "pinned always after" in both directions. Combine it with a custom order by passing that order's own comparator as compareMissingLast's first argument, e.g. compareMissingLast((a, b) => TIER_ORDER.indexOf(a) - TIER_ORDER.indexOf(b)) ranks by tier with an empty tier always last.
No adapter-level wiring was needed beyond passing filterDetailCol.compare through to sortFilterValues's existing 4th param (React's filterDetailValues, Vue's FilterPane.vue values, Solid's FilterDropdown.tsx) — compare lives on ColumnDefBase and every other call site is internal to logic.ts.