Skip to content

fix(a11y): point pinned scrollbar aria-controls at the scroll container's actual id - #1394

Open
askalf wants to merge 7 commits into
jbetancur:masterfrom
sprayberry-code:fix/pinned-scrollbar-aria-controls
Open

askalf wants to merge 7 commits into
jbetancur:masterfrom
sprayberry-code:fix/pinned-scrollbar-aria-controls

Conversation

@askalf

@askalf askalf commented Sep 14, 2026

Copy link
Copy Markdown

Summary

  • PinnedScrollbar used this mount's React.useId() for aria-controls, but assigned that id to the scroll container only when el.id was empty.
  • A pre-existing container id—whether supplied by the host, retained through a remount, or reached after a scrollRef swap—therefore left the thumb pointing at a nonexistent id.
  • The fix stores the actual el.id after the existing stamp-or-preserve branch and renders aria-controls={controlsId}. It changes 10 source lines and no public API.
$ npx vitest run src/__tests__/pinning.test.tsx     # fixed source
 ✓ src/__tests__/pinning.test.tsx (24 tests) 888ms
 Test Files  1 passed (1)
      Tests  24 passed (24)
   Duration  3.86s

$ git checkout master -- src/components/PinnedScrollbar.tsx
$ npx vitest run src/__tests__/pinning.test.tsx     # base source; identical 24-test file
 × pinned scrollbar aria-controls still resolves after a column is unpinned and re-pinned
 × aria-controls points at a host-supplied container id instead of a fresh one
 × aria-controls survives a remount onto the same container
 × aria-controls re-points when the scroll container is swapped for another one
 × aria-controls follows an id the host assigns after the first mount stamped one
 ✓ aria-controls resolves to the scroll container it labelled (control)
 ✓ two concurrent scrollbars each control their own container (control)
 ✓ emits no thumb and no aria-controls when the scroll ref is empty (control)
 Test Files  1 failed (1)
      Tests  5 failed | 19 passed (24)
   Duration  3.29s

Decisions

PinnedScrollbar now keeps a controlsId state initialized from scrollContainerId; in the existing synchronization effect, setControlsId(el.id) is called immediately after the existing if (!el.id) { el.id = scrollContainerId; } stamp-or-preserve branch, and aria-controls={controlsId} is rendered instead of the raw scrollContainerId. That way the element's actual id is used whether it was freshly stamped or already present.

Rejected alternatives: mutating a ref directly wouldn't trigger a re-render of the thumb; overwriting el.id unconditionally would clobber host-owned DOM attributes; adding a MutationObserver to track id changes during an already-active mount is a different, unrelated concern and is out of scope for this fix.

Full package suite (npm test, 23 files / 726 tests) and npx tsc --noEmit both pass with the fix; not run: any real-browser/layout-specific check, since the defect and its tests are DOM id/reference resolution, fully exercised under jsdom.

AI assistance: this bug was found and the fix and tests were drafted with AI tooling in my workflow; the tests and checks above were executed as pasted. I'm responsible for the change and will handle review feedback.

The thumb's aria-controls was hardcoded to this mount's useId value, but
the sync effect only stamps that id onto the scroll container when the
container has none. When the host app supplies its own id, or when the
component remounts onto a container a previous mount already labelled,
aria-controls names an element that does not exist and assistive tech
cannot resolve the scrollbar's target.

Track the id actually present on the element instead.
The unit test drives the remount directly against PinnedScrollbar. Exercise
the same path through DataTable's public API, where unpinning every column
drops hasPinnedColumns and re-pinning mounts a fresh scrollbar over the
wrapper the previous mount already labelled.
The sync effect returns at `if (!el)` before it can stamp an id or set
controlsId, so no thumb is rendered and no control reference is emitted.
Green on both source arms - it pins the early-return row of the boundary
ledger, which had no coverage of its own.
@netlify

netlify Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for react-data-table-component ready!

Name Link
🔨 Latest commit 92f7524
🔍 Latest deploy log https://app.netlify.com/projects/react-data-table-component/deploys/6ab320c092002a0008f9e137
😎 Deploy Preview https://deploy-preview-1394--react-data-table-component.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

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

@jbetancur jbetancur left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@askalf Thank you for identifying this bug! Just a few comments to address. Also please update the CHANGELOG.md. One generate nit is to avoid those em dashes if you can. Not a blocker and I will add this to the claude.md in the future

Comment thread src/__tests__/pinning.test.tsx Outdated
});

// Control: two scrollbars on the page at once, each on its own container.
// Green before and after — the id each thumb points at is per-instance state,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I know this is what the AI generated but can we clean this comment up. Like Green before and after. just indiciate what the tests accomplishes

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 9913338: the comments now say what each test checks, nothing about how it was written.

Comment thread src/__tests__/pinning.test.tsx Outdated
});

// Control: the container has no id of its own, so the effect stamps it with
// this mount's useId and aria-controls matches either way. Green before and

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I know this is what the AI generated but can we clean this comment up. Like Green before and after. just indiciate what the tests accomplishes

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 9913338: the comments now say what each test checks, nothing about how it was written.

Comment thread src/components/PinnedScrollbar.tsx Outdated
rightInset,
}: PinnedScrollbarProps): JSX.Element | null {
const scrollContainerId = React.useId();
// The id the thumb points at. Only equals scrollContainerId when this

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is kind of a nit and won't block the PR - and I should put it in the claude.md but try to remove em dashes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 9913338, no em dashes left in the diff. CHANGELOG entry is in the same push.

Review feedback: the two control tests' comments now say only what
each test pins and that it is green before and after the fix; every em
dash this change introduced is gone; and the fix has a CHANGELOG entry
under 8.10.0 / Bug fixes.
@askalf

askalf commented Sep 18, 2026

Copy link
Copy Markdown
Author

Thanks for the review. All three addressed in the latest push:

  • The two control-test comments now just say what each test pins and that it is green before and after the fix.
  • Every em dash this change introduced is gone, tests and component both. Happy to keep that convention for anything else here.
  • CHANGELOG entry added under 8.10.0 / Bug fixes.

pinning.test.tsx passes locally on the new head, 24/24.

Containers the PinnedScrollbar tests append to document.body are now
removed in afterEach rather than at the end of each test, so a failed
assertion cannot leave them behind for the next test. The comments say
what each case pins; the empty-ref case is synchronous; the changelog
bullet sits under its heading with the blank line where it was.
Comment thread CHANGELOG.md

### Bug fixes

- **Pinned scrollbar** now sets `aria-controls` to the scroll container's real id. When the container already carried an id, or was labelled again after an unpin and re-pin, the thumb kept pointing at a generated id no element had, so assistive technology could not resolve what the scrollbar controlled.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Sorry I should have been more clear. This needs to land in a new entry for 8.10.1 since 8.10.0 was already released

The repo's curly rule requires braces on every for-of, and CI lint
failed on the one-line cleanup loop. attach now returns the element
type it was given, so the swap harness gets an HTMLDivElement back and
tsc --noEmit is green alongside lint.
@askalf

askalf commented Sep 23, 2026

Copy link
Copy Markdown
Author

Updated the test-only cleanup after the CI report: the for-of cleanup now has braces to satisfy the repository curly rule, and attach preserves its concrete element type so the swap harness remains type-safe. The full CI chain is clean locally: ESLint, tsc --noEmit, Prettier, the focused 24-test Vitest file, and the production build. The current upstream run is awaiting first-contributor approval.

This branch has not been deployed

No deployments
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.

2 participants