Skip to content

Latest commit

 

History

History
25 lines (17 loc) · 10.5 KB

File metadata and controls

25 lines (17 loc) · 10.5 KB

Sorting

Header click sorting

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 from toggleSort/appendOrToggleSort rather than a near-synonym, since all three sound alike despite very different effects on the rest of the multi-sort): if key is already the sole active non-group sort, cycles its direction the same way toggleSort does (asc → desc → none); otherwise replaces the non-group portion of sorts outright with a fresh { key, dir: 'asc' }, regardless of what was sorted before. The optional 4th groupBy param (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's sort.replace action passes its current groupBy through.
  • 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: false column 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 a groupBy column keeps its own sorts entry (sortWithinGroups uses 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 in sorts, could show a stray "2" on the only visible sorted header with nothing numbered "1" to match. Each adapter filters sorts down to entries whose key is still in activeColumns before computing both the threshold and the index itself, local to the header-row render (React: an inline headerSorts computed via an IIFE in the activeColumns.map; Vue: a headerSorts computed plus isHeaderSorted/headerSortLabel helpers; Solid: a headerSorts memo in TableBody.tsx) — this doesn't touch the sort dropdown's or active-bar chip's own numbering, which still reflects every entry in sorts regardless of header visibility.
  • Every sorted visible header carries aria-sort (ascending/descending), from the same headerSorts list; 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.appendOrToggle are exposed alongside the pre-existing table.sort.toggle; Solid's TableBody.tsx wires both directly into the header <th>'s onClick, reading e.shiftKey to choose between them (vanilla gets this for free — see Vanilla package).
  • ColumnDefBase.sortable?: boolean (default true) excludes a column from header-click/shift-click sorting entirely and from the Sort dropdown's own "add a sort" list — the same sortable !== false-style opt-out convention filterable already uses (groupable is the opposite, opt-in). Each adapter enforces this itself (c.sortable !== false guarding both the addable-columns list and the header click handler's early-return) since core's toggleSort/replaceSort/appendOrToggleSort have no columns argument to check it against. An already-active sort entry on a sortable: false column (set programmatically via setViewState, 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 (a type: '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 3rd defaultDir param and cycle none → defaultDir → opposite(defaultDir) → none instead of the fixed none → 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's toggleSortDir flip (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 up columns.find(c => c.key === key)?.defaultSortDir ?? 'asc' at the call site (React/Vue's useTableState and Solid's createTableState actions) and passes it through — no new state, no persistence changes (TableViewState.sorts already stores each entry's resolved dir explicitly).

Custom sort order (compare)

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.