Skip to content

feat(#3344): add multi-column sorting to Table and TableSortHeader#3409

Merged
chrisolsen merged 2 commits intodevfrom
tom/table-fixes
Mar 7, 2026
Merged

feat(#3344): add multi-column sorting to Table and TableSortHeader#3409
chrisolsen merged 2 commits intodevfrom
tom/table-fixes

Conversation

@twjeffery
Copy link
Collaborator

@twjeffery twjeffery commented Feb 10, 2026

Summary

Adds built-in multi-column sorting to Table and TableSortHeader components.

  • sortMode prop: "single" (default) or "multi" (up to 2 columns)
  • initialSort prop: Pre-configure sort state
  • onSortChange callback: Receives array of current sorts
  • sortOrder prop on TableSortHeader: Shows "1", "2" priority badges
  • Table manages sort state internally, headers update automatically
  • New icons: chevron-expand (unsorted), arrow-up/arrow-down (sorted)
  • V2 focus ring on icon only

Multi sort

image

Single sort

image

Test plan

  • Visit React playground at /features/3344
  • Test single-column sort (Test 1) - click headers, verify only one sorts at a time
  • Test multi-column sort (Test 2) - click multiple headers, verify "1" and "2" badges appear
  • Test initial sort (Test 3) - verify Department starts sorted
  • Test V2 experimental wrappers (Test 4) - verify focus ring on icon only
  • Visit Angular playground at /features/3344 and repeat tests

Closes #3344

@twjeffery twjeffery force-pushed the tom/table-fixes branch 3 times, most recently from c1514d7 to 7097784 Compare February 10, 2026 01:49
Copy link
Collaborator

@bdfranck bdfranck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sorting interactions are looking good! I've left some comments that can be grouped into three categories:

  1. Try to re-use and expand on existing variables and functions.
  2. Avoid using JSON because it can lead to increased errors. Use Types instead.
  3. Make sure that you're giving devs the data they need for their sort functions.

@netlify
Copy link

netlify bot commented Feb 24, 2026

Deploy Preview for goa-design-2 ready!

Name Link
🔨 Latest commit 0543166
🔍 Latest deploy log https://app.netlify.com/projects/goa-design-2/deploys/69ac4732e9b981000842b79e
😎 Deploy Preview https://deploy-preview-3409--goa-design-2.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@vanessatran-ddi vanessatran-ddi force-pushed the tom/table-fixes branch 3 times, most recently from 91d0a01 to fa361f7 Compare February 27, 2026 01:05
@vanessatran-ddi vanessatran-ddi marked this pull request as ready for review February 27, 2026 01:06
Copy link
Collaborator

@willcodeforcoffee willcodeforcoffee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Looks good!

Copy link
Collaborator

@bdfranck bdfranck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at the latest changes...

  • ✅ I see the JSON was removed
  • ✅ The PR playground examples work as expected in Angular and React
  • ✅ All tests pass

Looks good to me! 👍

/** @internal Design system version for styling. */
export let version: "1" | "2" = "1";
/** Sort order number for multi-column sort display ("1", "2", etc). */
export let sortOrder: number = 0;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be any number? Or just 0 | 1 | 2?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice just 0, 1, or 2. _maxSorts is 2, and the Table sets this directly on headers. 0 means not actively sorted (no sort showing). It's using a number since it's internally managed, not something a dev sets by hand.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If if it can only be a 0, 1 or 2, then the type should reflect that.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chrisolsen I updated sortOrder type to 0 | 1 | 2 in the Svelte component (internal). Angular and React wrappers still need to pass 1|2.


if (sortBy && sortDir !== 0) {
dispatch(heading, { sortBy, sortDir });
function initializeSorts() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason this function name was changed? This new name is rather vague to what it does.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at what initializeSorts was actually doing:

  1. Reading initial direction/sortOrder from registered headers
  2. Attaching click handlers to headers
  3. Updating header attributes
  4. Dispatching the initial sort event

That seems like a lot for one function, and what I understand is that mixing handler attachment with state building meant listeners could stack if headers registered at different times.

It's now split into: attachClickHandlers, buildInitialSortState, with a bindAndInitialize orchestrator. Listener attachment is guarded so it only runs once even if headers register at different times. Also split handleSortClick into handleSingleSort/handleMultiSort per your earlier comment.

Comment on lines 49 to 56

dispatch<GoATableSortHeaderProps>(
_rootEl,
"table-sort-header:mounted",
{ el: host, name, direction, sortOrder },
{ bubbles: true, timeout: 10 },
);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for adding the dispatch call? Since all the props are reflected all the values are accessible via the attributes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was added in Vanessa's refactor. I think it's a registration pattern so the Table doesn't need to query the DOM for headers, but @vanessatran-ddi can probably speak to the reasoning better than I can.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me remove it, at first I think query all attributes will not work, but I try local and it seems working well, I will update the code.


// Warn when multi-mode headers have initial direction but missing sortOrder
if (sortMode === "multi" && entries.length > 1) {
const missing = entries.filter((e) => !e.order);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple things

  1. The var is called missing, which implies that the order may be null, but it's defined as a number with a default value of 0, so it's not missing, but rather just not set. (This is minor, but it is a little confusing)
  2. I am confused why a having a header with a sort-order value of 0 is bad.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two changes here:

  1. Renamed missing to withoutSortOrder and changed the check to e.order === 0 to be explicit.

  2. sort-order="0" isn't bad on its own. The warning only fires when multiple headers have initial direction set in multi mode but no sort-order to define priority. Without it the table falls back to DOM order, which might not match what was intended. Reworded the warning to try to explain that better.

{
sortBy: firstSort?.column ?? "",
sortDir: firstSort ? (firstSort.direction === "asc" ? 1 : -1) : 0,
sorts: _sorts,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be cleaner if there was different events for the different sort types.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _sort event already exists on dev with { sortBy, sortDir }. This extends it with an optional sorts array for multi-mode. Single-sort consumers get the same detail they always did.

Splitting into separate events would mean separate wrapper props (onSort + onMultiSort), and if a team moves from single to multi sort they'd need to swap out their event handler too. Keeping it as one event means existing implementations don't break.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But in the way things are done now every event has some null values and some not. So it isn't clear to them, without reading the docs, what is what.

Keeping it as one event means existing implementations don't break.

That doesn't make sense. You would keep the initial event handler and add a new one, named appropriately.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @chrisolsen , the web component now dispatches _sort({sortBy, sortDir}) and _multisort({sort:[]}). Each event has a clean and dedicated detail type with no nullable fields. Both angular and react wrappers expose onSort like now and onMultiSort accordingly.

@vanessatran-ddi vanessatran-ddi force-pushed the tom/table-fixes branch 2 times, most recently from e56b58f to fd2d258 Compare March 6, 2026 20:32
Copy link
Collaborator

@chrisolsen chrisolsen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one more small thing, then I am good merging it. @bdfranck @willcodeforcoffee do you guys need to re-review?

let _isTableRoot: boolean = false;
let _sorts: SortEntry[] = [];
let _headings: NodeListOf<Element> | undefined;
const _maxSorts = 2;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity, since this is a const value this should be renamed to MAX_SORTS

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I rename this.

@vanessatran-ddi vanessatran-ddi force-pushed the tom/table-fixes branch 2 times, most recently from d94f6fd to 4fb6b81 Compare March 6, 2026 23:06
Copy link
Collaborator

@willcodeforcoffee willcodeforcoffee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing works, looks good!
Still! 😉 😆

@chrisolsen chrisolsen merged commit 6a3d871 into dev Mar 7, 2026
8 checks passed
@chrisolsen chrisolsen deleted the tom/table-fixes branch March 7, 2026 16:06
@chrisolsen
Copy link
Collaborator

🎉 This PR is included in version 1.41.0-dev.15 🎉

The release is available on:

Your semantic-release bot 📦🚀

@chrisolsen
Copy link
Collaborator

🎉 This PR is included in version 1.11.0-dev.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

@chrisolsen
Copy link
Collaborator

🎉 This PR is included in version 6.11.0-dev.11 🎉

The release is available on:

Your semantic-release bot 📦🚀

@chrisolsen
Copy link
Collaborator

🎉 This PR is included in version 4.11.0-dev.11 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add optional multi-column sorting (primary + secondary) to the table component

5 participants