Skip to content

Conversation

@beaesguerra
Copy link
Member

@beaesguerra beaesguerra commented Nov 13, 2025

Summary:

These changes address focus management issues when Tabs are used within a Popover. The issues were:

  • The tabpanel element in a Popover is focusable even when there are interactive elements in the tab panel.
  • All the elements with role="tab" in a Popover could be tabbed to
    • Expected behaviour: A user should not be able to press "Tab" to navigate to inactive tabs (switching between tabs uses arrow key navigation)

See PR comment annotations for more details on how they were addressed

Issue: WB-2151

Test plan:

Navigate to ?path=/story/packages-tabs-tabs-testing-tabs-playtesting--popover-with-tabs and confirm focus behaviour works as expected:

  • Tab panel is only focusable when there are no interactive elements in the tab panel
  • Tabs can only be focused on via left/right arrow keys (not by pressing tabs)
    Note: There remains an issue where inactive tabs can still be focusable if Tabs are wrapped with another component in a Popover (see related Slack thread for more details). The Popover focus logic will be revisited soon with the floating-ui work happening in parallel, so I've left that logic as is (TLDR of the issue: FocusManager in Popover doesn't have an up to date list of focusable elements when Tabs are wrapped)
  • Popover and Tabs components on its own should continue to work as expected in terms of keyboard navigation and tab order
Screen.Recording.2025-11-14.at.11.46.18.AM.mov

@changeset-bot
Copy link

changeset-bot bot commented Nov 13, 2025

🦋 Changeset detected

Latest commit: a28c480

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@khanacademy/wonder-blocks-tabs Patch
@khanacademy/wonder-blocks-popover Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Nov 13, 2025

Size Change: +11 B (+0.01%)

Total Size: 109 kB

Filename Size Change
packages/wonder-blocks-popover/dist/es/index.js 4.3 kB +2 B (+0.05%)
packages/wonder-blocks-tabs/dist/es/index.js 3.72 kB +9 B (+0.24%)
ℹ️ View Unchanged
Filename Size
packages/wonder-blocks-accordion/dist/es/index.js 3 kB
packages/wonder-blocks-announcer/dist/es/index.js 1.74 kB
packages/wonder-blocks-badge/dist/es/index.js 2.02 kB
packages/wonder-blocks-banner/dist/es/index.js 2.01 kB
packages/wonder-blocks-birthday-picker/dist/es/index.js 1.92 kB
packages/wonder-blocks-breadcrumbs/dist/es/index.js 755 B
packages/wonder-blocks-button/dist/es/index.js 4.25 kB
packages/wonder-blocks-card/dist/es/index.js 1.01 kB
packages/wonder-blocks-cell/dist/es/index.js 2.19 kB
packages/wonder-blocks-clickable/dist/es/index.js 2.66 kB
packages/wonder-blocks-core/dist/es/index.js 2.48 kB
packages/wonder-blocks-data/dist/es/index.js 5.48 kB
packages/wonder-blocks-dropdown/dist/es/index.js 19.4 kB
packages/wonder-blocks-form/dist/es/index.js 6.2 kB
packages/wonder-blocks-grid/dist/es/index.js 1.24 kB
packages/wonder-blocks-icon-button/dist/es/index.js 3.16 kB
packages/wonder-blocks-icon/dist/es/index.js 1.91 kB
packages/wonder-blocks-labeled-field/dist/es/index.js 3.48 kB
packages/wonder-blocks-layout/dist/es/index.js 1.63 kB
packages/wonder-blocks-link/dist/es/index.js 1.52 kB
packages/wonder-blocks-modal/dist/es/index.js 7.04 kB
packages/wonder-blocks-pill/dist/es/index.js 1.31 kB
packages/wonder-blocks-progress-spinner/dist/es/index.js 1.48 kB
packages/wonder-blocks-search-field/dist/es/index.js 1.1 kB
packages/wonder-blocks-styles/dist/es/index.js 464 B
packages/wonder-blocks-switch/dist/es/index.js 1.55 kB
packages/wonder-blocks-testing-core/dist/es/index.js 3.25 kB
packages/wonder-blocks-testing/dist/es/index.js 978 B
packages/wonder-blocks-theming/dist/es/index.js 384 B
packages/wonder-blocks-timing/dist/es/index.js 1.37 kB
packages/wonder-blocks-tokens/dist/es/index.js 5.01 kB
packages/wonder-blocks-toolbar/dist/es/index.js 906 B
packages/wonder-blocks-tooltip/dist/es/index.js 6.4 kB
packages/wonder-blocks-typography/dist/es/index.js 1.57 kB

compressed-size-action

@github-actions
Copy link
Contributor

github-actions bot commented Nov 13, 2025

npm Snapshot: Published

🎉 Good news!! We've packaged up the latest commit from this PR (f1976fd) and published all packages with changesets to npm.

You can install the packages in frontend by running:

./dev/tools/deploy_wonder_blocks.js --tag="PR2863"

Packages can also be installed manually by running:

pnpm add @khanacademy/wonder-blocks-<package-name>@PR2863

@github-actions
Copy link
Contributor

github-actions bot commented Nov 13, 2025

A new build was pushed to Chromatic! 🚀

https://5e1bf4b385e3fb0020b7073c-cxbhcqrqoj.chromatic.com/

Chromatic results:

Metric Total
Captured snapshots 398
Tests with visual changes 2
Total stories 742
Inherited (not captured) snapshots [TurboSnap] 242
Tests on the build 441

…e determined there's no focusable element. Previously, we were initializing hasFocusableElement to false, which would set tabindex=0. Because of this, Popover with detect the tabpanel as focusable when the FocusManager finds focusable elements inside the popover
…set with tabIndex=0 before it has determined if it has focusable elements within it (related to a focus management bug when Tabs are used inside a Popover)
…to second tab, tabbing to content, shift tabbing (was going to tabpanel because FocusManager was getting focusable elements when the tab panel hasn't rendered it's children yet so it had tabIndex set to 0. FocusManager should update when child elements change
@codecov
Copy link

codecov bot commented Nov 14, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 0.00%. Comparing base (0bb9648) to head (1b2f733).

Additional details and impacted files

Impacted file tree graph

@@     Coverage Diff      @@
##   main   #2863   +/-   ##
============================
============================

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 0bb9648...1b2f733. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

*/
const FOCUSABLE_ELEMENTS =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
'button:not([tabindex="-1"]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
Copy link
Member Author

Choose a reason for hiding this comment

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

This makes it so that the tabs (which are button elements with role=tab) with tabindex="-1" aren't included in FocusManager's logic for determining focusable nodes

This prevents the issue where all the tabs are focusable by default

tabPanel: {
// Apply flex so that panel supports rtl
display: "flex",
...focusStyles.focus,
Copy link
Member Author

Choose a reason for hiding this comment

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

Adding the WB focus styles to tab panel so that it is compatible with dark backgrounds later on. The tab panel becomes focusable if there are no interactive elements within it

// If the tab panel doesn't have focusable elements after being
// determined, it should be focusable so that it is included in the
// tab sequence of the page
tabIndex={hasFocusableElement === false ? 0 : undefined}
Copy link
Member Author

Choose a reason for hiding this comment

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

hasFocusableElement is initialized to null now, so we only set tabIndex=0 only once it has been determined that are no focusable elements in the tab panel

Previously, hasFocusableElement was defaulting to false before the useEffect has run. This would cause the initial render of the tab panel to have tabIndex=0. Because of this, the FocusManager in the Popover component would include all tabpanels as focusable elements once it was mounted. The FocusManager should update it's list of focusable elements whenever it was updated, however, when the Tabs component is wrapped by another component and placed in a Popover, the componentDidUpdate lifecycle in FocusManager is not triggered.

TLDR: By making sure we don't initially set the tabindex=0 on the tabpanel element, the tab panels won't get included in the FocusManager logic!

Copy link
Member Author

Choose a reason for hiding this comment

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

Note: There are some edge cases still with focus management in popovers + tabs. The long term fix would be to update FocusManager so it can keep track of whenever any descendant element becomes focusable/non-focusable. The focus management in Popover is going to be re-visited as part of the work to switch to floating-ui, which can hopefully address these issues! (related Slack thread)

For now, this PR addresses the main issues!

// focusable element
if (ref.current) {
// focusable element (only if the tab panel has children)
if (ref.current && children) {
Copy link
Member Author

Choose a reason for hiding this comment

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

If children is falsey, it means that the tab panel content hasn't been rendered yet since the Tabs component renders the panel content conditionally

<TabPanel
key={tab.id}
id={getTabPanelId(tab.id)}
aria-labelledby={getTabId(tab.id)}
active={selectedTabId === tab.id}
testId={tab.testId && getTabPanelId(tab.testId)}
style={stylesProp?.tabPanel}
>
{/* Tab panel contents are rendered if the tab has
been previously visited or if mountAllPanels is enabled.
If mountAllPanels is off, it prevents unnecessary
re-mounting of tab panel contents when switching tabs.
Note that TabPanel will only display the contents if it
is the active panel. */}
{(mountAllPanels ||
visitedTabsRef.current.has(tab.id)) &&
tab.panel}
</TabPanel>

This additional check makes it so the tab index on the tabpanel is not initialized until the children prop is provided with content. This prevents the FocusManager in Popover from including inactive tabpanels in it's list of focusable elements

(related to https://github.com/Khan/wonder-blocks/pull/2863/files#r2528858860)

@beaesguerra beaesguerra marked this pull request as ready for review November 14, 2025 21:09
@khan-actions-bot khan-actions-bot requested a review from a team November 14, 2025 21:09
@khan-actions-bot
Copy link
Contributor

Gerald

Required Reviewers
  • @Khan/wonder-blocks for changes to .changeset/serious-comics-mate.md, .changeset/thirty-dolphins-battle.md, __docs__/wonder-blocks-tabs/tabs-testing-playtesting.stories.tsx, packages/wonder-blocks-popover/src/util/util.ts, packages/wonder-blocks-tabs/src/components/tab-panel.tsx, packages/wonder-blocks-popover/src/util/__tests__/util.test.tsx

Don't want to be involved in this pull request? Comment #removeme and we won't notify you of further changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants