Skip to content

Fix SortableList accessibility semantics for tree consumers #4602 - #4603

Merged
ashklianko merged 2 commits into
masterfrom
issue-4602
Jul 29, 2026
Merged

Fix SortableList accessibility semantics for tree consumers #4602#4603
ashklianko merged 2 commits into
masterfrom
issue-4602

Conversation

@skjulestad

Copy link
Copy Markdown
Collaborator

Allowed consumers to provide container and row accessibility properties. Made drag focus restoration configurable with existing behavior as default. Disabled sortable behavior when the list is disabled.

#####NOTE#####
This issue is dependant on npm-enonic-ui issue:
enonic/npm-enonic-ui#505

@ashklianko ashklianko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

1. A tree built with containerProps fails an accessibility check

What happens. dnd-kit renders an invisible announcement element (the thing that says "Picked up item, moved to position 2" to a screen reader). By default it renders it right where the list is, so it ends up inside your container — the same element that now gets role="tree". A tree is only allowed to contain tree items, so the list becomes invalid. Running axe (the standard accessibility checker) on the rendered output:

aria-required-children  [critical]
  Element has children which are not allowed: [role=status]

Move that element to document.body and the same markup passes with zero problems. Nothing looks broken on screen, which is why this is easy to miss — the element is invisible either way.

Fix (SortableList.tsx:477):

accessibility={{restoreFocus, container: document.body}}

The rows keep working: the aria-describedby link between a row and dnd-kit's instructions text still resolves once the element lives elsewhere in the page.

2. A disabled tree row does not sound disabled

What happens. When the list is disabled, an ordinary row correctly gets aria-disabled="true", so a screen reader says "dimmed" and the user knows not to try dragging it. A row with a custom role gets nothing:

default row:   role="button"    aria-disabled="true"
tree row:      role="treeitem"  aria-disabled is missing

That happens because the code drops aria-disabled whenever the consumer sets their own role, and SortableListItemProps has no field to put it back. So a read-only tree sounds exactly like an editable one.

Dropping the other two attributes in that branch is correct: aria-pressed only means something on a button, and dnd-kit's aria-roledescription="sortable" would replace the words "tree item" in the announcement. But aria-disabled works on any role, so it should not be tied to the role at all.

Fix (SortableList.tsx:286-288): apply aria-disabled regardless of the role, and add 'aria-disabled' and 'aria-roledescription' to SortableListItemProps so consumers can supply their own.

3. A disabled list is still full of Tab stops

What happens. dnd-kit always reports tabIndex: 0 for a row, whether or not dragging is disabled — the disabled flag only affects its key handlers. So with enabled={false} every row still receives keyboard focus, announces itself as a button, and does nothing when activated. A read-only form with twenty occurrences means twenty pointless Tab presses.

This is the only item that affects existing consumers right away. Before this PR the rows at least did something when focused (they dragged, incorrectly); now the action is gone but the focus stop remains.

Fix (SortableList.tsx:285):

tabIndex={resolvedItemProps?.tabIndex ?? (isMovable && enabled ? attributes.tabIndex : undefined)}

4. The role check reacts to the role's name

What happens. usesDefaultRole compares the resolved role against dnd-kit's default, and that default is 'button'. So passing a role explicitly does not necessarily take control of the ARIA — it depends on which role you pass:

getItemProps -> {role: 'button'}:    dnd-kit's aria-roledescription, aria-disabled, aria-describedby all come back
getItemProps -> {role: 'treeitem'}:  none of them are applied

Someone who deliberately keeps role="button" (a flat list that only wants a roving Tab stop, say) will be surprised. The intent is "did the consumer override the role", so check exactly that.

Fix (SortableList.tsx:255):

const hasCustomRole = resolvedItemProps?.role != null;

Allowed consumers to provide container and row accessibility properties.
Made drag focus restoration configurable with existing behavior as default.
Disabled sortable behavior when the list is disabled.
Kept dnd-kit's drag-disabled state off rows that carry a consumer-provided role.
Documented role and tab stop behavior of `getItemProps`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ashklianko
ashklianko merged commit 523d50e into master Jul 29, 2026
4 checks passed
@ashklianko
ashklianko deleted the issue-4602 branch July 29, 2026 14:00
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.

Fix SortableList accessibility semantics for tree consumers

3 participants