Skip to content

[core] Add aria-describedby to Tooltip target#8100

Open
dokson wants to merge 2 commits into
palantir:developfrom
dokson:ac/tooltip-aria-describedby
Open

[core] Add aria-describedby to Tooltip target#8100
dokson wants to merge 2 commits into
palantir:developfrom
dokson:ac/tooltip-aria-describedby

Conversation

@dokson
Copy link
Copy Markdown

@dokson dokson commented May 4, 2026

Summary

Fixes #7672.

Tooltip targets had no aria-describedby referencing the tooltip content, so screen readers had no way to announce the tooltip text to users when the target was focused.

This PR implements the standard WAI-ARIA tooltip pattern by:

  1. Adding a new Popover.popoverContentProps API (mirrors the existing targetProps): HTMLProps<HTMLDivElement> spread onto the .bp6-popover-content element. Useful for attaching ARIA attributes (or any HTML props) to the floating content without extra DOM nodes.
  2. Using it from Tooltip to set id={uniqueId} and role="tooltip" directly on the existing content div, and passing aria-describedby={tooltipId} on the target via targetProps.

Why a new API instead of a wrapper div

An earlier iteration of this PR wrapped content in <div role="tooltip" id={tooltipId}>{content}</div>. Reviewer feedback pointed out that introducing a new pattern just for Tooltip would be a regression in API consistency, since Popover already exposes targetProps for the symmetric concern on the target side. The new popoverContentProps is the natural counterpart: same shape, same behavior, no extra DOM, and immediately usable by all consumers that forward popoverProps (Select, MultiSelect, Suggest, ContextMenu, DateInput, etc.) if they ever need to attach attributes to their popover content.

API specifics

// new in PopoverSharedProps:
popoverContentProps?: React.HTMLProps<HTMLDivElement>;

Spread on the existing <div className={Classes.POPOVER_CONTENT}> wrapper, with className merged so consumer-supplied classes don't clobber Blueprint's. Other consumers can opt-in via <Popover popoverProps={{ popoverContentProps: { ... } }}> — no proactive change is required for them; they continue to render as before.

Edge cases handled in Tooltip

  • Empty/whitespace content: detected before assembling props so Popover's existing POPOVER_WARN_EMPTY_CONTENT warn path still fires; aria-describedby is also omitted to avoid pointing at a missing element.
  • Disabled tooltip: aria-describedby is omitted (the tooltip never opens, so the id never exists in the DOM).
  • User-supplied targetProps: their props are spread after our default, so a consumer-supplied aria-describedby still wins.
  • User-supplied popoverContentProps: their props are spread after our defaults, so a consumer can override id/role if needed (e.g. for a custom ARIA pattern). They cannot accidentally drop the bp6-popover-content class — that's merged in popover.tsx.
  • Lazy mount (Tooltip's default): the tooltip element only exists in the DOM while open. Screen readers handle a missing reference gracefully when the tooltip is closed and announce content correctly when it opens. Matches MUI Tooltip and Reach UI Tooltip behavior.

Why no other components need to change

  • ContextMenu already sets role="menu" on its inner <Menu> element; no popoverContentProps needed.
  • Select / MultiSelect / Suggest apply role="listbox" on the QueryList; no change needed.
  • DateInput / DateRangeInput render calendar widgets with their own ARIA semantics inside.
  • Popover-next has its own structure and does not extend PopoverSharedProps for content rendering.

The new API is a general-purpose escape hatch; no consumer is forced to use it.

Test plan

  • pnpm --filter @blueprintjs/core run compile (esm, cjs, esnext, css)
  • pnpm --filter @blueprintjs/core run test:typeCheck
  • pnpm --filter @blueprintjs/core run test:vitest:run — 1250 passed (including existing Popover empty-content warn assertions)
  • npx prettier --check and eslint clean on changed files
  • Manual smoke check with a screen reader (deferred to reviewer)

Files changed

  • packages/core/src/components/popover/popoverSharedProps.ts — new popoverContentProps prop with TSDoc
  • packages/core/src/components/popover/popover.tsx — spread popoverContentProps on the .bp6-popover-content div, merging className
  • packages/core/src/components/tooltip/tooltip.tsx — generate stable tooltipId, set id/role via popoverContentProps, set aria-describedby via targetProps, with the edge cases above

Tooltip targets had no aria-describedby pointing at the tooltip content,
making the tooltip text unavailable to assistive tech. Wrap the content in
a div with role="tooltip" and a unique id, and pass aria-describedby on
the target via Popover's targetProps.

When content is empty/disabled, aria-describedby is omitted so the target
doesn't reference a non-existent element. User-supplied targetProps still
override.

Fixes palantir#7672.
@changelog-app
Copy link
Copy Markdown

changelog-app Bot commented May 4, 2026

Generate changelog in packages/core/changelog/@unreleased

Type (Select exactly one)

  • Feature (Adding new functionality)
  • Improvement (Improving existing functionality)
  • Fix (Fixing an issue with existing functionality)
  • Break (Creating a new major version by breaking public APIs)
  • Deprecation (Removing functionality in a non-breaking way)
  • Migration (Automatically moving data/functionality to a new system)

Description

[core] Add aria-describedby to Tooltip target

Check the box to generate changelog(s)

  • Generate changelog entry

@dokson
Copy link
Copy Markdown
Author

dokson commented May 4, 2026

@vnkommu — this addresses the missing aria-describedby you reported in #7672. Implementation summary: Tooltip now sets id + role="tooltip" on the .bp6-popover-content element via a new general-purpose Popover.popoverContentProps API, and aria-describedby on the target via targetProps. No extra DOM is added.

A couple of behavioral notes worth confirming with your use case:

  • Lazy mount: the tooltip element is only in the DOM while open (Blueprint's default), so when the tooltip is closed aria-describedby points to a non-existent id. Screen readers handle this gracefully — they announce the content the moment the tooltip opens. This matches MUI Tooltip and Reach UI behavior.
  • Disabled / empty content: aria-describedby is omitted entirely, so the target doesn't reference a missing element when the tooltip will never appear.
  • User-supplied props: if you pass your own targetProps={{ "aria-describedby": ... }} or popoverContentProps={{ id, role }}, your values win.

Feedback welcome on whether this covers what you needed.

Add Popover.popoverContentProps (HTMLProps<HTMLDivElement>) symmetrical
to targetProps, spread onto the .bp6-popover-content element. Tooltip now
uses it to set id and role="tooltip" directly on the existing content
div, avoiding an extra wrapper.
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.

Tooltip doesn't give target aria-describedby

1 participant