Skip to content

[v10] Add option to make tables sortable#1654

Open
colinrotherham wants to merge 61 commits into
support/10.xfrom
sortable-table
Open

[v10] Add option to make tables sortable#1654
colinrotherham wants to merge 61 commits into
support/10.xfrom
sortable-table

Conversation

@colinrotherham

@colinrotherham colinrotherham commented Oct 30, 2025

Copy link
Copy Markdown
Contributor

Description

This expands the existing Table component to enable a new option to make some or all of the columns sortable by clicking the column header.

This is partly inspired by sortable table component from MOJ Frontend.

The table sorting feature is done using JavaScript as a progressive enhancement. If javascript is unavailable or not supported, then the table remains in its initial state, and no sort buttons are added.

It works by adding aria-sort attributes to the headers of columns that you want to be sortable.

The aria-sort attribute should be set to ascending or descending on the 1 column which is initially sorted, and none on all other columns that you want to be sortable:

{{ table({
  caption: "People",
  head: [
    { text: "Name", attributes: { "aria-sort": "ascending" },
    { text: "Age", attributes: { "aria-sort": "none" }
  ],
  rows: […]
}) }}

By default, the JavaScript will sort the column numerically if all cells contain a number, or will otherwise sort them alphabetically (or reverse-alphabetical).

If you need to specify an alternative sort order, you can do this by setting data-sort-value attributes on individual cells:

{{ table({
  caption: "Appointments",
  head: [
    { text: "Title", attributes: { "aria-sort": "none" },
    { text: "Date", attributes: { "aria-sort": "ascending" }
  ],
  rows: [
    [{ text: "Vaccination"}, {text: "4 January 2026", attributes: { "data-sort-value: "2026-01-04"}],
    [{ text: "Health check"}, {text: "15 February 2026", attributes: { "data-sort-value: "2026-02-15"}],
  ]
}) }}

Screenshots

Screenshot 2026-01-12 at 16 19 22 Screenshot 2026-01-12 at 16 19 37

Checklist

@paulrobertlloyd

paulrobertlloyd commented Oct 30, 2025

Copy link
Copy Markdown
Contributor

Question of icons…

  • Is it useful having the double pointed arrow for unsorted columns (as opposed to no icon)
  • Should the arrows be more chevron shaped, to be consistent with iconography used elsewhere?

On the later point, when I was using sortable tables in my service, I used the following SVG:

<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 27 27">
  <path fill="#212b32" d="m13 18.6-7-7.2c-.6-.5-.6-1.4 0-2 .5-.5 1.3-.5 2 0l6 6.2 6-6.2c.7-.5 1.5-.5 2 0 .6.6.6 1.5 0 2l-7 7.2c-.3.3-.6.4-1 .4s-.7-.1-1-.4Z"/>
</svg>

@colinrotherham colinrotherham temporarily deployed to nhsuk-frontend-pr-1654 November 20, 2025 14:00 Inactive
@colinrotherham colinrotherham added table javascript Pull requests that update Javascript code labels Nov 20, 2025
@anandamaryon1 anandamaryon1 temporarily deployed to nhsuk-frontend-pr-1654 November 25, 2025 16:55 Inactive
@anandamaryon1

anandamaryon1 commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

Pushed some initial styling for the sortable tables heading buttons and icons, based on designs worked on in the sortable tables working group.

For discussion.

Some notes:

  • I've kept the icon to the left of the heading for right-aligned header cells, to avoid icons being placed right beside each other.
  • I've reduced the current sorted underline from 4px to 2px, to make it play nicer with the focus style, I'm open to re-exploring this. (it looked odd since the sorted underline indicator runs from bottom up, and the focus style runs from top down.)
  • Playing with it I notice that the first click makes the column ascending - showing smallest first, which makes sense with A-Z. But for some reason, I expect biggest first, with numbers when I click for an initial sort.

To do:

  • Give non-button headings matching padding as buttons. (currently only buttons have 2px padding on the outer edge)
  • Consider icon and spacing - ascending and descending arrows have inbuilt padding (in the SVG) which affects spacing between the icon and heading text. But, SVGs ideally should all be the same size to avoid content jumping around when swapping them out. Open to ideas.

Screenshot:

image image image
Preview it:

https://nhsuk-frontend-pr-1654.herokuapp.com/nhsuk-frontend/components/tables/with-numeric-data-sortable/

@colinrotherham

Copy link
Copy Markdown
Contributor Author

Can we take any inspiration from tabs where only the middle bit is focused?

Tab with focus

Comment thread packages/nhsuk-frontend/src/nhsuk/components/tables/table.mjs Outdated
@anandamaryon1

Copy link
Copy Markdown
Contributor

Can we take any inspiration from tabs where only the middle bit is focused?

Tab with focus

That could make things a lot less hacky with styling the buttons. I'm fine with the focus being on the text only, but we do want the increased target area, and the hover should match, to show where you can click to activate it. Would that rely on JS click events for the table header?

@colinrotherham

Copy link
Copy Markdown
Contributor Author

Don't let the small target area from tabs put you off, both could be bigger

Expanders are similar where target area focus styles are removed, with (fake) focus styles on the text only:

Expander focus

…Would that rely on JS click events for the table header?

I'm assuming yes, but we'd need to research why click events on child elements weren't used

The current single-listener event bubbling approach could be for either compatibility or performance

@frankieroberto

Copy link
Copy Markdown
Contributor

Expanders are similar where target area focus styles are removed, with (fake) focus styles on the text only

Task list does the same. On the other hand the combined logo + service name link in the header has the focus style cover the whole clickable area, including the logo.

@paulrobertlloyd

Copy link
Copy Markdown
Contributor

Probably need an audit of focus styles… part of me wants to suggest making focus styles big and obvious, now that Firefox doesn’t show them on click (or can at least be avoided)

@anandamaryon1 anandamaryon1 temporarily deployed to nhsuk-frontend-pr-1654 December 4, 2025 14:46 Inactive
@anandamaryon1

Copy link
Copy Markdown
Contributor

Seems all the other components expand the click area by placing an absolutely positioned :after element to cover the parent container. This is tricky with the table header buttons because of display: table-cell (hence my earlier hacky CSS to grow the buttons).

I've now removed that so that the focus is placed only on the natural sized buttons, and added a hover state to the header cells to illustrate how I'd like them to be, but of course this doesn't trigger the buttons.

Can someone help me add a click event to the table heads? Or maybe there's a better way?

I also notice a possible slight bug where the aria-sort="none" is added to none sortable columns when one is sorted, but maybe this is on purpose to identify the state of the columns?

@colinrotherham

Copy link
Copy Markdown
Contributor Author

Can someone help me add a click event to the table heads? Or maybe there's a better way?

There's an event listener on the <head> element you can use

this.$head.addEventListener('click', this.onSortButtonClick.bind(this))

But later on you'll see it ignores all clicks unless it bubbled up from a button element

onSortButtonClick(event) {
  const $target = /** @type {HTMLElement} */ (event.target)
  const $button = $target.closest('button')

  if (!$button?.parentElement) {
    return
  }
  
  // …
 }

In theory you could change this code to allow clicks from non-clickable things

But we shouldn't do really

Is it a bad thing that only the <button> is clickable, like MOJ do?

@colinrotherham

Copy link
Copy Markdown
Contributor Author

Rebased with main to fix the conflict

Table examples will now be in table/macro-options.mjs table/fixtures.mjs

@frankieroberto

Copy link
Copy Markdown
Contributor

Can someone help me add a click event to the table heads? Or maybe there's a better way?

Is it a bad thing that only the <button> is clickable, like MOJ do?

The MOJ one doesn't have any hover state. We’ve added a hover state (background colour change to grey) for the whole <thead>. The clickable area should be match the hover state area, I think – like on the Task list rows. And in general, the bigger the hit area the better?

Would one option be to make button size expand to fill the entirety of the <thead>, and then move the background hover colour to the <button>?

I also just spotted that the task list rows are using color.adjust(nhsuk-colour("grey-5"), $lightness: -6%); whereas the sortable table headers are using nhsuk-colour("grey-4") – should they be consistent? The task list one has more of a noticeable blue tint to me, and isn’t quite as dark.

Comment thread packages/nhsuk-frontend/src/nhsuk/components/tables/table.mjs
@anandamaryon1

Copy link
Copy Markdown
Contributor

Can someone help me add a click event to the table heads? Or maybe there's a better way?

Is it a bad thing that only the <button> is clickable, like MOJ do?
Would one option be to make button size expand to fill the entirety of the <thead>, and then move the background hover colour to the <button>?

This is what I had first, which kinda worked but relied on some hacky CSS to get the button to grow to fill the <thead>. And meant the focus state applied to the whole area (screens here).

I'm not sure on the best approach from a frontend dev perspective but what I want to achieve is the whole <thead> clickable, with a hover state that matches the clickable area. And a focus state that applies only to the text if possible, to better match other components, eg. tabs.

I also just spotted that the task list rows are using color.adjust(nhsuk-colour("grey-5"), $lightness: -6%); whereas the sortable table headers are using nhsuk-colour("grey-4") – should they be consistent? The task list one has more of a noticeable blue tint to me, and isn’t quite as dark.

Ohh, well spotted, will need to take a look at this. I think I was using pagination as my reference.

@frankieroberto

Copy link
Copy Markdown
Contributor

@anandamaryon1 the task list trick is to use this to generate a bigger click area, but then the focus background colour only applies to the smaller <a> element:

.nhsuk-task-list__link::after {
  content: "";
  display: block;
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}

Bit of a hack but it seems to work?! There might be a better way though.

@colinrotherham

Copy link
Copy Markdown
Contributor Author

Sorry for the rebase, but I've updated this branch to pick up the new status check names

Comment thread packages/nhsuk-frontend/src/nhsuk/components/tables/template.njk Outdated
Based on discussion, we've decided to switch to a boolean `initialSortColumn` to set the initial sort column on page load, in combination with `sortFirstDirection` for the initial sort direction for that column.
As this represents the current status of the table, past tense should be clearer.
@frankieroberto

Copy link
Copy Markdown
Contributor

@anandamaryon1 @colinrotherham @edwardhorsford ok I’ve updated the param name in 7279943 from sorted to initialSortColumn based on our discussion in the comments above.

Now instead of setting sorted: 'ascending' to indicate the initial sort column on page load, you'd set initialSortColumn: true and sortFirstDirection: 'ascending'.

Hopefully this will be clearer! (Avoids having 2 params which both accept ascending/descending as options).

Comment on lines +141 to +147
if (
!$heading ||
!$sortButton ||
!(sortDirection === 'ascending' || sortDirection === 'descending')
) {
return
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Have you had a look at the Sonar check?

There isn't any code coverage for this bit

const sortDirection = $heading.getAttribute('aria-sort')
const sortFirstDirection = $heading.dataset.sortFirstDirection

const columnNumber = Number.parseInt($button.dataset.index ?? '0', 10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Similarly no coverage for this bit

Is it correct for us to assume '0' when no index is in the HTML?

E.g. Let's say if one of the NHS.UK frontend ports omit it (or allow it to be overridden)

Comment on lines +218 to +225
if (
!(direction === 'ascending' || direction === 'descending') ||
!$button.parentElement ||
!config.statusMessage ||
!$status
) {
return
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We haven't got code coverage here for when the direction isn't ascending/descending

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've refactored this a bit to use Typescript annotations for the directions, so that bit of the guard clause is no longer necessary.

Also renamed the function a bit to make it clearer.

Other ideas for refactoring welcome!

And add a comment so we remember what we've done.
Previously the arrow icons were added as SVG images within the DOM (either server-side or client-side).

This switches to using CSS to add the images as background-images. It uses CSS 'masks', where supported, to set the colour of the icons to be the `currentcolor` so that the colour will change in forced-colours mode.
This avoids some of the need for the guard clause.
Update it to accept the $heading element rather than the $button, and then rename it to `updateColumnState`.

This makes the intent clearer and removes some of the need for the guard clause.
@sonarqubecloud

Copy link
Copy Markdown

@anandamaryon1

anandamaryon1 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Couple of bugs I found just now:

  1. Icons being clipped when table squeezed (see 6-in-1):
image

Fixed by: 1bc4fab (adding flex-shrink:0)


  1. No icon when sortFirstDirection is omitted:
image

Not sure on the fix for this? Was this expected with the rejig of Nunjucks options? @frankieroberto


And one new thought:
3. Should we make the icons (slightly) smaller when the font-size goes smaller, on mobile?
They currently look a bit large to my eye:
image

Eg. 1.5em to 1.25em:
image

If the `sortFirstDirection` is missing on a column with `initialSortColumn: true` then it was showing a blue box.

It could default to a particular direction, but I think it's better to treat it as a required attribute, and if it's missing fall back to no inital sort direction on that column?
@frankieroberto

Copy link
Copy Markdown
Contributor

@anandamaryon1 fixed the second bug in b4cfc28.

There are 2 options if initialSortColumn: true is set, but sortFirstDirection is missing:

  1. default to a particular initial sort direction (ascending or descending)
  2. don't sort the column at all initially (ie ignore initialSortColumn: true)

I think the second option is safer, so I've gone with that.

@anandamaryon1

Copy link
Copy Markdown
Contributor

@anandamaryon1 fixed the second bug in b4cfc28.

There are 2 options if initialSortColumn: true is set, but sortFirstDirection is missing:

  1. default to a particular initial sort direction (ascending or descending)
  2. don't sort the column at all initially (ie ignore initialSortColumn: true)

I think the second option is safer, so I've gone with that.

Thanks!

I'd have maybe gone with option 1. Since columns already kind of have a default of sorting ascending. But not too precious.

Any thoughts on Q3, icon sizing?

// NHS pages have a grey background, so we need a slightly darker colour for the hover
// This produces 1.1:1 contrast, the same as GOV.UK’s
// This is also used by the Task list component
$sortable-table-hover-colour: color.adjust(nhsuk-colour("grey-5"), $lightness: -6%);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just to flag that this is identical to the task list hover colour

We should wrap it in nhsuk-colour-compatible() and maybe share it as an applied colour?

Suggested change
$sortable-table-hover-colour: color.adjust(nhsuk-colour("grey-5"), $lightness: -6%);
$nhsuk-task-list-hover-colour: nhsuk-colour-compatible(color.adjust(nhsuk-colour("grey-5"), $lightness: -6%));

{% if item.href %}
<a href="{{ item.href }}">{{ item.html | safe | trim | indent(8) if item.html else item.text }}</a>
{% else %}
{{ item.html | safe | trim | indent(8) if item.html else item.text }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Whilst indent(8) is correct, this first line will be indented with 10 spaces

If you can check the HTML output in the code details (on the review app)

Comment thread packages/nhsuk-frontend/src/nhsuk/components/tables/template.njk Outdated
{
"name": "nhsuk-frontend",
"version": "10.4.2",
"version": "11.0.0",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just a reminder to remove this before we merge

We probably did it for the branch preview

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@colinrotherham shall I change it to 10.5.0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We've got 60 commits in this PR so ideally we rebase and drop the one that changed it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Conflicts need resolving anyway

frankieroberto and others added 2 commits June 23, 2026 13:38
Co-authored-by: Colin Rotherham <work@colinr.com>
@frankieroberto

Copy link
Copy Markdown
Contributor

@anandamaryon1 @colinrotherham dropped the icon size down to 20px on mobile: d687d90. About right?

@anandamaryon1

Copy link
Copy Markdown
Contributor

@anandamaryon1 @colinrotherham dropped the icon size down to 20px on mobile: d687d90. About right?

Yep looks good to me, thank you

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

Labels

javascript Pull requests that update Javascript code table

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants