Skip to content

CORE-2871: Make the TooltipGroup trigger a button that does something - #161

Draft
OpenStaxClaude wants to merge 4 commits into
mainfrom
core-2871-tooltip-name-role-state
Draft

OpenStaxClaude wants to merge 4 commits into
mainfrom
core-2871-tooltip-name-role-state

Conversation

@OpenStaxClaude

@OpenStaxClaude OpenStaxClaude commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Jira: https://openstax.atlassian.net/browse/CORE-2871

What the evaluation found

A Level Access manual evaluation flagged the info-icon tooltip trigger (the "Multiple attempts" one on the assignment settings screen) as a WCAG 4.1.2 name/role/value failure: "It is functioning as a tooltip but incorrectly given as button."

Their recommendation was aria-describedby on the trigger plus role="tooltip" on the tooltip element. I rendered TooltipGroup against the installed react-aria-components@1.10.1 and read the DOM before changing anything: both of those were already correct, and the screenshot attached to the ticket confirms it (Description reads "UNLIMITED ATTEMPTS If unlimite…", aria-describedby is on the button). So the recommendation as written had nothing to fix.

What was actually broken

The line in "Details of the issue" is the real defect. useTooltipTrigger binds both onPointerDown and onKeyDown to onPressStart, which closes the tooltip:

  • Keyboard: Tab to the trigger → tooltip opens. Press Enter or Space — the one interaction role="button" promises — and it is dismissed. Nothing is ever activated.
  • Touch: useHover ignores touch, and the tap closes on pointerdown, so the content is simply unreachable on a touch device.

That is a role advertising an action the control does not have.

The change

TooltipGroup now owns the trigger state and a press toggles it, so the button role describes real behaviour and the content is reachable by keyboard and by touch. Those library handlers run before onPress, so the press records the state at onPressStart rather than reading one react-aria has already flipped — there is a comment on the component saying so, because it looks removable and is not.

Because the trigger now holds persistent state, it exposes aria-expanded. aria-describedby supplies the description once the tooltip is already open; it says nothing about a control that can be opened and closed, which is the "value" half of 4.1.2. No aria-controls — react-aria keeps the tooltip id internal and screen-reader support for it is thin.

onOpenChange reports each transition once. react-aria's close-on-pointerdown/keydown runs through the same callback before onPress completes the toggle, so setOpen drops a call that would not change the value — the rule useControlledState already applies internally. Both of those came out of review.

Also fixed, found along the way: isOpen was spread into Tooltip instead of handed to TooltipTrigger. react-aria's Tooltip does state = props.isOpen != null || props.defaultOpen != null || !contextState ? localState : contextState, so passing it to the tooltip element builds a second state the trigger knows nothing about — the trigger's state stays closed, aria-describedby is never emitted at all, and hover and Escape-to-dismiss stop working. isOpen keeps its public meaning and now drives the trigger; defaultOpen and onOpenChange are accepted alongside it. Our own snapshot test went through that path, which is why the committed snapshot showed a trigger with no aria-describedby — the one-line snapshot change in this PR is that attribute appearing.

ariaLabel is unchanged but now documented in the story. The More information default repeats across every instance on a screen; naming the thing the tooltip is about is the caller's job. No assignments call site passes it today — that is the companion PR.

Tests

New name, role and state block in Tooltip.spec.tsx: aria-describedby matches the tooltip's id when open (including via the isOpen prop, as a regression test), Enter and Space toggle rather than only dismissing, Escape closes, aria-expanded tracks the state both uncontrolled and via isOpen, onOpenChange reports each transition exactly once, and the accessible name defaults and overrides.

Touch is covered too, in a nested touch block. My first pass called it untestable because jsdom pinned the tooltip open; the real cause is that jsdom has no PointerEvent, and without one react-aria falls back to branches that cannot represent touch — useHover binds onMouseEnter with a hardcoded 'mouse' pointer type, so a simulated tap opened the tooltip by hover and would have passed for the wrong reason. A minimal PointerEvent polyfill (the checks run at render, not module load) puts useHover and usePress on the same branches a real browser takes, with triggerHoverStart ignoring touch as it does on a device. One test drives the tap sequence and asserts open → toggle shut with aria-describedby, aria-expanded and a single onOpenChange each way; a companion asserts a touch pointerenter does not open it, which is what keeps the first test meaningful. The first fails without the onPress toggle.

defaultOpen has its own block, since this PR introduced it: it starts open with aria-describedby/aria-expanded set, false starts closed, and — the behaviour that separates it from isOpen — it hands control to the user after the initial render, paired with a test that isOpen pins the state when the caller ignores the change. Both halves of that pair are load-bearing: dropping the useState seed (assuming react-aria consumes the prop, which is the mistake this PR fixed for isOpen) fails two of them, and treating defaultOpen as controlling fails a third.

Full suite green (788 tests), lint and typecheck clean.

Notes for review

  • Version: left at 1.24.2, which is bumped but not yet tagged, so this entry rides in [Unreleased]. A behaviour change to a public component may deserve 1.25.0 instead — your call, happy to bump.
  • Touch toggle: now covered by tests (see above) rather than reasoned, but a polyfilled jsdom faithfully models react-aria's branches without being a touchscreen. Still worth a real-device check before release.
  • No Playwright: the touch finding offered a browser-level test as an alternative. @playwright/test is in devDependencies but has no config, no specs, and CI runs only lint + test — standing up a browser harness is infrastructure, not a test, and shouldn't land inside an accessibility fix. Happy to file it separately if the team wants it.
  • Left in draft per @roy-johnson-openstax's note on the ticket.

Follow-up

The flagged content is two headed sections of prose, and role="tooltip" flattens that into a single unnavigable description string. The right answer for long-form content is a disclosure/popover rather than a tooltip; that is being tracked as a separate ticket rather than widened into this PR.

🤖 Generated with Claude Code

An accessibility evaluation flagged the info-icon trigger as a name/role/value
failure: it is exposed as role=button, but pressing it did nothing useful.
react-aria's useTooltipTrigger binds onPointerDown and onKeyDown to close the
tooltip, so tabbing in opened it and Enter/Space then dismissed it, and on touch
-- where hover never fires -- the tap closed it on pointerdown and the content was
unreachable.

TooltipGroup now owns the trigger state and presses toggle it. Because those
library handlers run before onPress, the press records state at onPressStart
rather than reading one react-aria has already flipped.

Also fixes isOpen being spread into Tooltip instead of TooltipTrigger. react-aria's
Tooltip builds its own state when passed isOpen, detached from the trigger's, so in
controlled mode aria-describedby was never emitted at all and hover and
Escape-to-dismiss stopped working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

This comment was marked as resolved.

RoyEJohnson

This comment was marked as resolved.

Two things the review caught on the press-to-toggle change.

aria-expanded: the trigger now controls persistent content, and
aria-describedby only supplies the description once the tooltip is
already open — it says nothing about a control that can be opened and
closed. The button reports its state.

Duplicate onOpenChange: react-aria's useTooltipTrigger closes the
tooltip from its own pointerdown/keydown handler, which runs through
the same onOpenChange we hand TooltipTrigger, before our onPress
finishes the toggle. A press on an open trigger therefore reported the
close twice. setOpen now drops a call that would not change the value,
which is the rule useControlledState already applies internally and
also covers a press that reaches onPress without the library's
handlers having fired first.

Both are covered by tests that fail without the corresponding change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This comment was marked as resolved.

RoyEJohnson

This comment was marked as resolved.

The touch behaviour was the part of this change I had only reasoned
about, on the grounds that jsdom pinned the tooltip open. That was the
wrong conclusion. The cause is that jsdom has no PointerEvent, and
without one react-aria takes fallback branches that cannot represent
touch at all: useHover binds onMouseEnter with a hardcoded 'mouse'
pointer type, so a simulated tap opened the tooltip by "hover" and any
passing test would have passed for the wrong reason.

Installing a minimal PointerEvent polyfill puts useHover and usePress
on the same branches a real browser takes, and triggerHoverStart then
bails on pointerType 'touch' as it does on a device. The tap sequence
is driven directly so the assertions are about the press and nothing
else.

Two tests: a tap opens the tooltip and the next one closes it, with
aria-describedby, aria-expanded and a single onOpenChange call each
way; and a companion asserting a touch pointerenter does not open it,
which is what makes the first test meaningful rather than a restatement
of the hover behaviour.

The first fails without the onPress toggle, so it pins the fix.

Pointer sizes are non-zero deliberately — isVirtualPointerEvent reads a
zero-sized pointer as a screen reader and would route the press through
usePress's virtual-click path instead of the touch one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Add regression coverage for the new defaultOpen behavior.

Review effort: Lite
Findings: None

Resolved since last review (1)

@RoyEJohnson RoyEJohnson left a comment

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.

Address Copilot's latest comment.

defaultOpen was added by this change and had no tests. Four, in a
nested block:

- it starts open, with aria-describedby and aria-expanded following
- false starts closed
- it hands control to the user after the initial render, which is the
  behaviour that distinguishes it from isOpen
- and the counterpart that gives that one its meaning: isOpen pins the
  state, so a dismiss the caller ignores must not close the tooltip

Checked against the two ways this can plausibly break. Dropping the
useState seed — the mistake of assuming defaultOpen is consumed by the
library, which is exactly what went wrong for isOpen — fails the first
and third. Treating defaultOpen as controlling rather than seeding
fails the third. Neither is caught by the existing tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OpenStaxClaude

Copy link
Copy Markdown
Contributor Author

Right call — defaultOpen was added by this PR and had no tests at all. Now covered, in a nested defaultOpen block.

Four tests, and the interesting part is the pairing rather than the count:

  • starts open and describes the trigger — open on first render, with aria-describedby pointing at the tooltip and aria-expanded true.
  • starts closed when false.
  • hands control to the user after the initial render — Escape closes it and onOpenChange reports once. This is the behaviour that distinguishes defaultOpen from isOpen: it seeds the state, it does not pin it.
  • isOpen pins the state instead, when the caller ignores the change — the counterpart that gives the previous test its meaning. A controlled caller owns the state, so a dismiss it ignores must leave the tooltip open. Without this one, "uncontrolled" is asserted with nothing to contrast it against.

I checked these against the two ways the prop can plausibly break rather than just watching them go green:

Regression Result
Drop the useState seed (useState(false)) — i.e. assume defaultOpen is consumed by react-aria starts open and hands control both fail
Treat defaultOpen as controlling (isOpen ?? defaultOpen ?? uncontrolledOpen) hands control fails

The first is worth calling out because it is precisely the mistake this PR already fixed for isOpen: handing the prop to the Tooltip element instead of the TooltipTrigger builds a detached state, the trigger stays closed, and aria-describedby is never emitted. That failed silently once in this component and the existing tests would not have caught it happening again to defaultOpen.

Full suite 788 tests, lint and typecheck clean. Still in draft per your note on the ticket — say the word and I will flip it.

Copilot AI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Prevent duplicate onOpenChange notifications for controlled triggers.

Review effort: Lite
Findings: None

This branch was successfully deployed

1 active deployment
refs/heads/core-2871-tooltip-name-role-state — 148a8bdb Deployed Sep 29, 2026 by github-actions[bot]
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.

3 participants