Skip to content

fix: return focus to legend item on Tab when tooltip is visible#217

Open
albadra2 wants to merge 3 commits into
cloudscape-design:mainfrom
albadra2:fix/legend-tooltip-focus-return
Open

fix: return focus to legend item on Tab when tooltip is visible#217
albadra2 wants to merge 3 commits into
cloudscape-design:mainfrom
albadra2:fix/legend-tooltip-focus-return

Conversation

@albadra2
Copy link
Copy Markdown

@albadra2 albadra2 commented May 6, 2026

Description

When a chart legend tooltip is visible and the user presses Tab, focus falls to <body> instead of returning to the legend item that triggered the tooltip. This is a WCAG 2.4.3 (Focus Order) violation.

This revision implements the approach suggested in review: an unconditional dismiss button with an invisible tab-trap element to forward focus into the tooltip, rather than capturing Tab keypresses.

Changes

1. Unconditional dismiss button.

The tooltip now always renders a dismiss (X) button — both on hover and on keyboard focus. This engages the popover's built-in focus trap unconditionally, so Tab always cycles through the tooltip's interactive content. Activating the dismiss button (Enter/Space) closes the tooltip and returns focus to the legend item.

2. Invisible tab-trap <div tabIndex={0}>.

An invisible focusable element is rendered between the legend list and the tooltip. When the user Tabs past the legend items, the browser naturally lands on this element. Its onFocus handler forwards focus to the dismiss button inside the tooltip. This avoids capturing Tab keypresses and works consistently across browsers.

3. Tightened <SingleTabStopNavigationProvider> scope.

Previously, <SingleTabStopNavigationProvider> wrapped both the legend item list AND the tooltip. Because Cloudscape's Button, Link, and other interactive components consume useSingleTabStopNavigation from the context, every focusable element inside the tooltip received tabindex="-1" — making them keyboard-unreachable.

The provider now wraps only the legend item list:

<div role="toolbar">
  <SingleTabStopNavigationProvider>
    {legend items}
  </SingleTabStopNavigationProvider>
  {tab trap}
  {tooltip}
</div>

This aligns with the pattern used by Cloudscape's other navigable components — Tabs (tab-header-bar.tsx), NavigableGroup, TreeView, and Table all scope <SingleTabStopNavigationProvider> to the navigable region only.

Behavior

Action Result
Hover legend item Tooltip appears with dismiss button
Tab to legend item Tooltip appears with dismiss button; focus stays on legend item
Tab again Focus forwards to dismiss button via tab trap
Tab Cycles through tooltip's interactive content
Enter/Space on dismiss Tooltip closes, focus returns to the legend item
Escape Tooltip closes, focus returns to the legend item
Arrow keys on legend Move between legend items; tooltip follows

Implementation note

A focus-reclaim useEffect re-focuses the legend trigger after PopoverBody auto-focuses the dismiss button on mount. This relies on React's child-before-parent effect ordering. It could be removed if InternalChartTooltip supported a disableAutoFocus prop in the future.

Related links, issue #, if available: Blocking accessibility compliance reports (ACRs):

How has this been tested?

  • Manually tested in CloudWatch Console on the pages where the issue was originally reported.
  • Manually tested in chart-components's own dev pages (pages/03-core/core-legend.page.tsx) using npm start.
  • Verified: Tab on legend item → tooltip appears with dismiss button → Tab → focus forwards to dismiss button → Tab cycles through interactive content.
  • Verified: Activating dismiss button closes tooltip and returns focus to legend item.
  • Verified: Escape closes tooltip and returns focus to legend item.
  • Verified: Arrow key navigation between legend items still works.
  • Verified: Mouse hover shows tooltip with dismiss button (unconditional).
  • Verified: Tooltip content elements no longer have tabindex="-1" after provider restructure.
Review checklist

The following items are to be evaluated by the author(s) and the reviewer(s).

Correctness

  • Changes include appropriate documentation updates. N/A — no public API change.
  • Changes are backward-compatible if not indicated, see CONTRIBUTING.md. ✓ Backward-compatible. Tooltip interactive content is now keyboard-reachable — a strict accessibility improvement.
  • Changes do not include unsupported browser features, see CONTRIBUTING.md. ✓ Uses existing toolkit utilities (KeyCode, SingleTabStopNavigationProvider).
  • Changes were manually tested for accessibility, see accessibility guidelines. ✓ Tested with keyboard-only navigation in both Cloudscape's dev environment and CloudWatch Console.

Security

Testing

  • Changes are covered with new/existing unit tests? Existing tests cover the Escape path. Happy to add tests for the tab-trap focus forwarding and the provider restructure regression guard.
  • Changes are covered with new/existing integration tests? Manually tested in both CloudWatch Console and chart-components's npm start dev environment.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Copilot AI review requested due to automatic review settings May 6, 2026 15:18
@albadra2 albadra2 requested a review from a team as a code owner May 6, 2026 15:18
@albadra2 albadra2 requested review from cansuaa and removed request for a team May 6, 2026 15:18

function onKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.keyCode === KeyCode.tab && !event.shiftKey && tooltipRef.current) {
if (focusFirstInteractiveInTooltip(tooltipRef.current)) {
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.

Can we avoid capturing the tab keypress? This impl depends on the component types and might work inconsistently across browsers.

I recommend trying an alternative approach: when a legend item is focused (and also when hovered - why not? - we can generalise it for when it is highlighted and tooltip exists), we can render an invisible <div tabIndex={0} /> - to capture the Tab press and forward it to the tooltip. The tooltip content can be wrapped in a focus lock, that already support auto-focus. We can consider adding this component to the toolkit, or else an similar implementation can be used - utilising getAllFocusables to find the first focusable element in the tooltip, or maybe better just focusing the dismissButtonRef.current.focus() - provided the dismiss button is always there.

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