Skip to content

Keep columnheader role on sortable cell table headers - #20

Merged
gtritchie merged 3 commits into
rstudio/montauk-daisyfrom
bugfix/sortable-header-columnheader
Oct 17, 2026
Merged

gtritchie merged 3 commits into
rstudio/montauk-daisyfrom
bugfix/sortable-header-columnheader

Conversation

@gtritchie

@gtritchie gtritchie commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

DefaultHeaderOrFooterBuilder put role="button" and tabindex="-1" on each sortable <th>. That replaced the cell's columnheader role, so screen readers got no column header for any sortable column, and the sort direction couldn't be exposed with aria-sort, which belongs on the column header. Axe's empty-table-header rule also skipped these cells (it selects th:not([role])), so empty sortable headers went unreported. Addresses rstudio/rstudio#19170.

Changes

  • A sortable <th> keeps its columnheader role. The sorted column's <th> gets aria-sort="ascending" or "descending"; other columns get no aria-sort.
  • role="button" and tabindex="-1" move to a <div> inside the <th> that wraps the header content (and the sort icon, when sorted). It's a <div> rather than a <button> because the table sorts on Enter keydown, and the click a <button> synthesizes from Enter would sort a second time.
  • The sort icon's alt text ("Ascending sort"/"Descending sort") is now empty. aria-sort reports the direction, and the alt text was being read as part of the header's name (e.g. "Descending sort Name").
  • New protected final AbstractHeaderOrFooterBuilder.enableHeaderHandlers(builder, header), the header counterpart of enableColumnHandlers(): it puts the header marker attribute on an element, so events on it reach the header. renderHeader() now uses it.
  • An unsorted header's <th> is marked with it. Unsorted headers used to render straight into the <th>, so this keeps events on the <th>'s padding reaching the header (including onPreviewColumnSortEvent). A sorted header's <th> is not marked. Sorted headers never passed sort icon clicks to the header, and marking the <th> would hand the whole icon wrapper to the header's cell, so an interactive cell such as ActionCell would treat an icon click as its own.

Not changed: the sort control still isn't in the tab order and Space doesn't sort, as before. Clicks on a sorted header's icon or padding still sort without calling onPreviewColumnSortEvent, as in upstream GWT.

Testing

Three new tests in AbstractCellTableTestBase, so they run for both CellTable and DataGrid:

  • testSortableHeaderAria: no role on the <th>, the inner button's role and tabindex, aria-sort following the sort direction on the sorted column only, and an empty alt on the sort icon.
  • testSortButtonActivation: a click or Enter on the inner button sorts once.
  • testSortableHeaderEventsReachHeader: a click on an unsorted header's <th> reaches the header's cell and preview, and the preview can veto the sort; on a sorted header, a click on the text reaches the cell but a click on the sort icon doesn't.

Each failed before the change it covers. Results:

  • CellTableTest and DataGridTest pass in HtmlUnit (86 tests).
  • checkstyle and apicheck pass, and ant clean dist-dev builds.
  • With RStudio built against this SDK, a new e2e test confirms the Files pane headers are column headers and aria-sort follows clicks on the Name sort button. It fails against 2.13.1-montauk-daisy-1.
  • In a browser: the Files pane, Git changelist, Modify Keyboard Shortcuts and Browse Addins dialogs sort once per click, and the Files pane's column resize handles stay on the column borders. Axe now evaluates these headers and they pass empty-table-header.

DefaultHeaderOrFooterBuilder put role="button" and tabindex=-1 on
sortable <th> elements, replacing their columnheader role, so screen
readers got no column header and aria-sort had nowhere to go.

The th now keeps its role and carries aria-sort ("ascending" or
"descending") on the sorted column. The role="button" and tabindex=-1
move to a div wrapping the header content. It's a div rather than a
<button> because the table sorts on Enter keydown, and the click a
<button> synthesizes from Enter would sort again.

The sort icon's alt text ("Ascending sort"/"Descending sort") is now
empty, since aria-sort reports the direction and the alt text was
being read as part of the header's name.

Addresses rstudio/rstudio#19170.
Moving the sortable header content into a button div moved the
__gwt_header marker off the th. Clicks on the th's padding, or on a
sorted header's button itself rather than its text, then found no
header: Header.onBrowserEvent and onPreviewColumnSortEvent didn't run,
so a header couldn't veto those sorts, and headers that sort through
their own cell (like a ClickableTextCell) didn't respond.

Add enableHeaderHandlers(), the header counterpart of
enableColumnHandlers(), and mark sortable th elements with it, as
unsorted ones were before.

Addresses rstudio/rstudio#19170.
Marking a sorted header's th sent clicks on its sort icon to the
header's cell with the th as the parent, so an interactive header cell
such as an ActionCell treated an icon click as a click on itself.
Before the sort button existed, sorted headers never passed icon
clicks to the cell, while unsorted headers rendered straight into the
th, so events on its padding reached the header. Mark the th only when
the column is unsorted, which keeps both behaviors.

Addresses rstudio/rstudio#19170.

@gtritchie gtritchie left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Code review findings: one regression in RStudio (custom header builders), plus smaller items.

* @return the rendered HTML
*/
private SafeHtml getSortIcon(boolean isAscending) {
// The icon is decorative: the header builder reports the sort direction with

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Custom header builders lose the sort direction. The alt text is removed here in shared code, but only DefaultHeaderOrFooterBuilder adds aria-sort. RStudio's EnvironmentObjectGrid.GridHeaderBuilder (Environment pane, grid view) extends AbstractHeaderOrFooterBuilder, calls renderSortableHeader(cell, ..., i == host_.getSortColumn(), host_.getAscendingSort()), and never sets aria-sort. Before this change, screen readers read "Ascending sort"/"Descending sort" from the icon there. After it, the icon has alt="" and the th has no aria-sort, so the sort direction is no longer exposed.

One option: a protected helper on AbstractHeaderOrFooterBuilder that sets aria-sort on the th, called by DefaultHeaderOrFooterBuilder and by RStudio's GridHeaderBuilder. Alternatively, update GridHeaderBuilder in RStudio alongside the SDK bump. The comment here ("the header builder reports the sort direction") should also say that a custom builder has to do this itself.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not changing this in the fork. Agreed that a custom builder has to set aria-sort itself. The RStudio branch for rstudio/rstudio#19170 (not pushed yet) already does that in EnvironmentObjectGrid.GridHeaderBuilder: it sets aria-sort on the sorted column's th, and also wraps each header in the same role="button" div. In a dev server against this SDK, the Environment grid's th gets aria-sort ascending/descending as you sort. That RStudio branch can't ship without this SDK, since it also calls enableHeaderHandlers(), so the alt text can't go away there without aria-sort arriving. No other RStudio header builder renders sortable headers (rstudio-pro has none either).

// rather than a <button> because the table sorts on Enter keydown, and the click
// a <button> synthesizes from Enter would sort a second time.
if (isSorted) {
th.attribute("aria-sort", isSortAscending ? "ascending" : "descending");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

aria-sort can be wrong on a header that spans several columns. This predates the PR, but aria-sort now inherits it. In buildHeaderOrFooterImpl, each column under a shared header overwrites isSorted = (column == sortedColumn), so for colspan > 1 it reflects only the last column. If the header covers sortable columns A and B and the table is sorted by A, the th gets no aria-sort (and no sorted style or icon). It's an unusual setup, so this is low priority.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not changing this. No RStudio table shares one header across several columns: I checked every addColumn call for a header object passed more than once, and the Environment grid's custom builder renders one th per column. As you note, the sorted style and icon already had the same limitation before this PR.

// reached the header. Keep that. Sorted ones never passed sort icon events to the
// header, and marking their th would make an interactive header cell (such as an
// ActionCell) treat an icon click as its own.
enableHeaderHandlers(th, header);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The header cell gets different parent elements depending on where the click lands (minor). With both the unsorted th and the inner button div marked as the header, Header.onBrowserEvent gets the th for padding clicks and the div for content clicks. Under the th, parent.getFirstChildElement() is now the button wrapper, not the cell's content. ActionCell and ButtonCellBase check the event target, so they're fine. A cell that rewrites parent on click, such as EditTextCell, would run setValue(context, th, ...) on a padding click, replacing the th's innerHTML and dropping the role=button div until the headers redraw. This is a rare configuration; mentioning it in case it's worth a note in the comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not changing this. RStudio's sortable headers use TextCell (string and TextHeader headers), SafeHtml headers, and ResizableHeader's cell, which consumes no events. So Header.onBrowserEvent never runs for them, and RStudio has no EditTextCell or other parent-rewriting cell in a header. Unsorted headers also got the th as parent before this PR, for every click. The difference now is that getFirstChildElement() is the wrapper rather than the content.

Element iconWrapper = getHeaderElement(table, 0).getFirstChildElement().getFirstChildElement();
iconWrapper.getFirstChildElement().dispatchEvent(click);
iconWrapper.getLastChild().<Element>cast().dispatchEvent(click);
assertEquals(Arrays.asList("th", "div"), cellClicks);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Test gap: after push(column), the test checks cellClicks and previews but not sorts. The PR description says an icon click on a sorted header still sorts (without a preview) and the text click is vetoed by the preview. Asserting sorts here (e.g. assertEquals(Arrays.asList(false), sorts), assuming the icon click toggles to descending) would cover that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not adding this: it doesn't change behavior in RStudio. For the record, the icon click on the sorted header does sort without a preview (the first version of this test asserted no sort there and failed for that reason), and the text click is vetoed. Happy to add the assertion if you'd like it pinned.

AbstractImagePrototype proto =
AbstractImagePrototype.create(table.getResources().sortAscending());
sortAscIconHtml = addAltText(SafeHtmlUtils.fromTrustedString(proto.getHTML()), "Ascending sort");
sortAscIconHtml = addAltText(SafeHtmlUtils.fromTrustedString(proto.getHTML()), "");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nit: both callers now pass "", so addAltText's altText parameter and the htmlEscape call no longer do anything. A helper that just inserts alt="" would be clearer.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed that the parameter and the escaping are now dead, but leaving it as is: no user-visible effect, and it keeps the diff to the alt values. Happy to simplify it if you prefer.

@gtritchie
gtritchie merged commit 051156e into rstudio/montauk-daisy Oct 17, 2026
1 check passed
gtritchie added a commit to rstudio/rstudio that referenced this pull request Oct 18, 2026
* Give the Files pane file-type column header readable text

The icon column's header was an empty <span aria-label="File Type">,
where aria-label is not permitted, so the header had no name. It now
holds visually hidden, localized "File type" text.

Addresses #19170.

* Report sort state on Environment grid column headers

The grid's custom header builder now sets aria-sort on the sorted
column's <th> and wraps each header in a role="button" div, matching
GWT's DefaultHeaderOrFooterBuilder. The wrapper's text gets its own
ellipsis, since text-overflow on the th doesn't reach it.

Addresses #19170.

* Retarget changelist sort icon padding past the header button

GWT now wraps sortable header content in a role="button" div, so
".cellTableSortableHeader > div" would pad every sortable header's
button instead of only the sorted header's icon container.

Addresses #19170.

* Add NEWS entry for sortable column header semantics

Addresses #19170.

* Mark the sortable column header NEWS entry as accessibility

Addresses #19170.

* Route clicks anywhere in an Environment grid header to its cell

The grid sorts through each header's ClickableTextCell. For the sorted
column, GWT marks only the text div inside the sort icon wrapper as
the header, so a click on the new sort button itself (such as
assistive technology activating it) found no header and didn't sort.
Marking the th with enableHeaderHandlers() routes those clicks too.

Addresses #19170.

* Add an e2e test for Files pane column header semantics

Checks that the Files pane's headers (including the icon column's
"File type") are column headers, and that clicking the Name sort
button puts aria-sort on that header alone and reverses it on each
click. Fails against GWT 2.13.1-montauk-daisy-1, where the sortable
th has role="button" and so isn't a column header.

Addresses #19170.

* Update GWT to 2.13.1-montauk-daisy-2

The new fork build keeps the columnheader role on sortable cell table
headers, adds aria-sort to the sorted column, and moves the sort
control into a role="button" div inside the th (rstudio/gwt#20). The
Environment grid change on this branch depends on its new
enableHeaderHandlers().

Addresses #19170.

* Share sort state between the Environment header class and aria-sort

Addresses #19170

* Build the file-type header span with SafeHtmlUtil.createOpenTag

Addresses #19170

* Ellipsize sorted Environment grid headers

Addresses #19170

* Redraw sortable headers after sorting an empty table

GWT tables redraw their headers only when rows are pushed, and sorting
an empty list in place pushes none, so the sort icon and aria-sort kept
showing the previous sort.

Addresses #19170

* Simplify the Files pane column header e2e test

Addresses #19170

* Redraw Environment grid headers after sorting

Sorting an empty environment pushes no rows, so the grid's sort icon
and aria-sort kept showing the previous sort. The new e2e test also
covers the grid's hand-built header markup.

Addresses #19170

* Update the Files sort test's comment on header markup

Addresses #19170

* Restore the Environment sort after the grid header test

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant