Skip to content

fix: Overflow Tabs (3558) included in collection system#3790

Open
williamjstanton wants to merge 2 commits intoWorkday:masterfrom
williamjstanton:william-3558-overflow-tabs-fixes
Open

fix: Overflow Tabs (3558) included in collection system#3790
williamjstanton wants to merge 2 commits intoWorkday:masterfrom
williamjstanton:william-3558-overflow-tabs-fixes

Conversation

@williamjstanton
Copy link
Collaborator

Summary

Fixes: #3558

This PR improves the Tabs overflow pattern for accessibility and keyboard navigation. The "More" overflow button is now a first-class member of the tablist: it uses role="tab", participates in the collection's roving tabindex, and is reached with Left/Right arrow keys (same as other tabs) instead of the Tab key. When there are no hidden items, the overflow button is not rendered so it does not appear in the tab count or focus order.

Motivation: After receiving usability feedback about our Tabs overflow pattern, we revisited the implementation:

  1. Keyboard discoverability: Users were expected to use the Tab key to focus the "More" overflow button, even though it is visually styled like the other tab elements. This was inconsistent with how tabs are normally navigated (arrow keys within a tablist).

  2. ARIA semantics: The overflow control was a standard button element and did not use the role="tab" required for children of a role="tablist" container, which could confuse assistive technologies and automated checks.

Release Category

Components

Release Note

Optional release note message. Changelog and release summaries will contain a pull request title. This section will add additional notes under that title. This section is not a summary, but something extra to point out in release notes. An example might be calling out breaking changes in a labs component or minor visual changes that need visual regression updates. Remove this section if no additional release notes are required.

The Tabs "More" overflow button is now part of the tablist for accessibility: it has role="tab", is included in the roving tabindex, and is reached with Left/Right arrow keys instead of Tab. Automation or tests that relied on the overflow control being a button or focused only via Tab may need to be updated.

BREAKING CHANGES

Optional breaking changes message. If your PR includes breaking changes. It is extremely rare to put breaking changes outside a prerelease/major branch. Anything in this section will show up in release notes. Remove this section if no breaking changes are present.

  • The "More" overflow tab is included in the collection system and is now accessed by using Left and Right arrow keys (not the Tab key).
  • The overflow tab now has role="tab" (not role="button").

Automation or accessibility tests that target the overflow control by role (button) or by Tab-key focus order may need to be updated to use arrow-key navigation and role="tab" (e.g. within the tablist).


Checklist

For the Reviewer

  • PR title is short and descriptive
  • PR summary describes the change (Fixes/Resolves linked correctly)
  • PR Release Notes describes additional information useful to call out in a release message or removed if not applicable
  • Breaking Changes provides useful information to upgrade to this code or removed if not applicable

Where Should the Reviewer Start?

  • modules/react/tabs/lib/useTabsModel.tsx — Synthetic overflow item and non-interactive handling.
  • modules/react/tabs/lib/TabsOverflowButton.tsx — Overflow button as a tab (role, roving focus, conditional render).
  • modules/react/tabs/lib/TabsList.tsx — Filtering the synthetic item in the render prop and cursor reset on blur.

Areas for Feedback? (optional)

  • Code
  • Documentation
  • Testing
  • Codemods

Testing Manually

  1. Open the TabsOverflow story (e.g. OverflowTabs or the overflow example in the Tabs docs).
  2. Resize the container (e.g. use the "Change Tabs container size" control) so that some tabs are hidden and the "More" button appears.
  3. Keyboard: Focus the tablist (e.g. click the first tab or Tab into it). Use Right Arrow to move through visible tabs; the last focusable element before the overflow menu should be the "More" tab. Use Left Arrow to move back. Confirm that Tab does not move between individual tabs (roving tabindex keeps focus within the tablist).
  4. Screen reader / DevTools: Inspect the "More" control and confirm it has role="tab" and aria-selected="false". Confirm the tablist has the expected number of tabs (visible tabs + one for "More" when overflow is present).
  5. Widen the container so all tabs fit. Confirm the "More" button is no longer in the DOM and the tab count does not include it.

Screenshots or GIFs (if applicable)

No UI visuals changed; behavior and DOM/ARIA semantics were updated. A short GIF of arrow-key navigation to/from the "More" tab could be added if desired.

Thank You Gif (optional)

@cypress
Copy link

cypress bot commented Feb 24, 2026

Workday/canvas-kit    Run #10426

Run Properties:  status check passed Passed #10426  •  git commit b1d383f0be ℹ️: Merge 12a25e2ae70cf2efa4baf9affc539f12ca3ab117 into 42016317e81cb5fb59fe874accae...
Project Workday/canvas-kit
Branch Review william-3558-overflow-tabs-fixes
Run status status check passed Passed #10426
Run duration 02m 36s
Commit git commit b1d383f0be ℹ️: Merge 12a25e2ae70cf2efa4baf9affc539f12ca3ab117 into 42016317e81cb5fb59fe874accae...
Committer William Stanton
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 1
Tests that did not run due to a developer annotating a test with .skip  Pending 86
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 850
View all changes introduced in this branch ↗︎
UI Coverage  19.71%
  Untested elements 1505  
  Tested elements 367  
Accessibility  99.37%
  Failed rules  6 critical   5 serious   0 moderate   2 minor
  Failed elements 76  

@williamjstanton
Copy link
Collaborator Author

This PR can also apply to this feature issue:
#1443

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses accessibility and keyboard navigation issues with the Tabs overflow pattern (issue #3558). The "More" overflow button is transformed from a standard button outside the tablist into a first-class tab within the collection system. It now uses role="tab", participates in roving tabindex, and is navigable via Left/Right arrow keys instead of the Tab key. When no tabs are hidden, the overflow button is not rendered, ensuring accurate tab counts for assistive technologies.

Changes:

  • The overflow button is now included in the tabs collection as a synthetic item with role="tab" and roving focus behavior, navigable via arrow keys
  • Overflow calculation triggers immediately when the overflow button size is measured, ensuring responsive layout updates
  • Tab panels can now receive focus via ArrowDown key from the selected tab (panels set tabIndex={-1})

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
modules/react/tabs/lib/useTabsModel.tsx Adds synthetic overflow button item to collection; manages nonInteractive state and cursor position; implements focus management after menu selection
modules/react/tabs/lib/TabsOverflowButton.tsx Changes overflow button to use role="tab" with roving focus hooks; implements conditional rendering based on hidden items; adds custom measurement hook
modules/react/tabs/lib/TabsList.tsx Implements custom render function to filter synthetic overflow item; adds custom cursor reset logic for tabs; adds minWidth style for flex layout
modules/react/tabs/lib/TabsItem.tsx Adds ArrowDown handler to focus tab panel from selected tab
modules/react/collection/lib/useOverflowListModel.tsx Triggers immediate overflow recalculation when overflow target size changes
modules/react/tabs/stories/examples/OverflowTabs.tsx Updates example to use design system components and tokens; sets panel tabIndex={-1} for ArrowDown focus behavior
cypress/component/Tabs.spec.tsx Updates tests to reflect overflow button as tab with role="tab" instead of button; adjusts keyboard navigation and focus expectations
cypress/support/component.ts Changes Cypress mount import from cypress/react18 to cypress/react

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

if (panel) {
(panel as HTMLElement).focus();
}
return null as unknown as void; // prevent roving focus from handling this key
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The return statement return null as unknown as void is a type system workaround that obscures the intent. This is being used to signal to the roving focus handler to not process this key event. Consider using a more explicit mechanism such as calling event.stopPropagation() or documenting this pattern more clearly, or returning undefined which is the expected return type for event handlers. The type assertion undermines TypeScript's type safety without clear benefit.

Copilot uses AI. Check for mistakes.
model.events.goTo({id: targetId});
}
}
}, [model.state, model.events]);
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The useLayoutEffect depends on model.state and model.events, which are objects that may be recreated on every render. This could cause the effect to run more frequently than intended. Since the effect conditionally calls model.events.goTo() which updates state, there's a risk of infinite render loops if the conditions aren't carefully managed. Consider using more specific dependencies like model.state.cursorId, model.state.hiddenIds.length, model.state.items.length, and model.state.selectedIds instead of the entire model.state object. This would make the dependencies more explicit and reduce the risk of unintended re-runs.

Suggested change
}, [model.state, model.events]);
}, [
model.state.cursorId,
model.state.hiddenIds.length,
model.state.items.length,
model.state.selectedIds,
model.events.goTo,
]);

Copilot uses AI. Check for mistakes.
onBlur() {
if (!programmaticFocusRef.current) {
requestAnimationFrameRef.current = requestAnimationFrame(() => {
requestAnimationFrameRef.current = 0;
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The cursor reset logic assigns requestAnimationFrameRef.current = 0 after the requestAnimationFrame callback runs (line 135), but this value is never checked elsewhere. If the intent is to track whether there's a pending animation frame for cleanup purposes, the value should be stored when the rAF is scheduled and the cleanup in useMountLayout should check if it's non-zero before canceling. However, if the goal is simply to clean up the reference, setting it to 0 is redundant since the cleanup function will cancel any pending frame on unmount. Consider removing this line or documenting why it's needed.

Suggested change
requestAnimationFrameRef.current = 0;

Copilot uses AI. Check for mistakes.
Comment on lines +230 to +243
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const tabId = pendingFocusTabIdRef.current;
if (tabId) {
const tabElement = document.querySelector<HTMLElement>(
`[data-focus-id="${slugify(`${model.state.id}-${tabId}`)}"]`
);
if (tabElement) {
tabElement.focus();
}
pendingFocusTabIdRef.current = null;
}
});
});
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The double requestAnimationFrame scheduled in the onSelect handler (lines 230-243) is not canceled if the component unmounts before the callbacks execute. This could lead to attempted DOM queries after the component is unmounted, though it's handled safely by the null check. Consider storing the rAF IDs and canceling them in a cleanup effect or when the model is destroyed to prevent unnecessary work and potential memory leaks in complex applications where many tab instances are created and destroyed.

Copilot uses AI. Check for mistakes.
// require('./commands');

import {mount} from 'cypress/react18';
import {mount} from 'cypress/react';
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The import path has been changed from cypress/react18 to cypress/react. This change is not mentioned in the PR description or breaking changes section. If this is intentional, it should be documented as it may affect how Cypress tests are run, especially if the project specifically requires React 18 support or if there are differences in how the mount function behaves between these imports.

Copilot uses AI. Check for mistakes.
Comment on lines +225 to +243
// Focus the selected tab AFTER React has re-rendered.
// We use double requestAnimationFrame to ensure:
// 1. First rAF: React commits DOM changes from state updates
// 2. Second rAF: We focus after the popup's useReturnFocus has run
// This overrides the default behavior of returning focus to the overflow button.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const tabId = pendingFocusTabIdRef.current;
if (tabId) {
const tabElement = document.querySelector<HTMLElement>(
`[data-focus-id="${slugify(`${model.state.id}-${tabId}`)}"]`
);
if (tabElement) {
tabElement.focus();
}
pendingFocusTabIdRef.current = null;
}
});
});
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

The use of double requestAnimationFrame to focus the selected tab after menu closes is fragile and may not work reliably across different browsers or performance conditions. The comment states it's needed to run after useReturnFocus, but this creates a timing dependency on the internal implementation of the popup component. Consider using a more robust approach such as a callback from the menu model's onHide event, or using useLayoutEffect with proper dependencies to handle focus management. This approach is susceptible to race conditions if React's rendering timing changes or if the popup's focus restoration logic is modified.

Copilot uses AI. Check for mistakes.
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.

Overflow Tabs: The "More" button is not included with the tabs in the tab list container

2 participants