Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 44 additions & 33 deletions cypress/component/Tabs.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -492,8 +492,8 @@ describe('Tabs', () => {
});
});

it('should not show the "More" button', () => {
cy.findByRole('button', {name: 'More'}).should('not.exist');
it('should not show the "More" overflow tab', () => {
cy.findByRole('tab', {name: 'More'}).should('not.exist');
});

it('should have 7 tab items', () => {
Expand All @@ -514,8 +514,9 @@ describe('Tabs', () => {
cy.focused().tab();
});

it('should focus on the tab panel', () => {
cy.findByRole('tabpanel', {name: 'First Tab'}).should('have.focus');
it('should move focus out of the tablist to the next focusable (width control)', () => {
// OverflowTabs has a SegmentedControl below; the panel has tabIndex={-1}, so Tab goes to "100%" button
cy.findByRole('button', {name: '100%'}).should('have.focus');
});
});
});
Expand All @@ -534,12 +535,13 @@ describe('Tabs', () => {
});
});

it('should show the "More" button', () => {
cy.findByRole('button', {name: 'More'}).should('exist');
it('should show the "More" overflow tab', () => {
cy.findByRole('tab', {name: 'More'}).should('exist');
});

it('should show only 3 tab items', () => {
cy.findAllByRole('tab').should('have.length', 3);
it('should show visible content tabs plus the More overflow tab', () => {
// At 500px, typically 3–4 content tabs fit + More; exact count is layout-dependent
cy.findAllByRole('tab').should('have.length.at.least', 4).and('have.length.at.most', 5);
});

it('should not have scroll', () => {
Expand All @@ -548,23 +550,25 @@ describe('Tabs', () => {

context('when the "First Tab" is focused', () => {
beforeEach(() => {
cy.findByRole('tab', {name: 'First Tab'}).focus();
cy.findByRole('tab', {name: 'First Tab'}).click().focus();
});

context('when the Tab key is pressed', () => {
context('when the Right Arrow key is pressed 3 times (navigate to More tab)', () => {
beforeEach(() => {
cy.realPress('Tab');
cy.realPress('ArrowRight');
cy.realPress('ArrowRight');
cy.realPress('ArrowRight');
});

it('should focus on the "More" button', () => {
cy.findByRole('button', {name: 'More'}).should('have.focus');
it('should focus on the "More" overflow tab', () => {
cy.findByRole('tab', {name: 'More'}).should('have.focus');
});
});
});

context('when the "More" button is clicked', () => {
context('when the "More" overflow tab is clicked', () => {
beforeEach(() => {
cy.findByRole('button', {name: 'More'}).click();
cy.findByRole('tab', {name: 'More'}).click();
});

it('should show the Tab overflow menu', () => {
Expand All @@ -584,8 +588,15 @@ describe('Tabs', () => {
cy.findByRole('tab', {name: 'Sixth Tab'}).should('have.attr', 'aria-selected', 'true');
});

it('should move focus back to the "More" button', () => {
cy.findByRole('button', {name: 'More'}).should('have.focus');
it('should move focus to a tab (newly selected or More)', () => {
// Implementation focuses the selected tab when visible; otherwise menu returns focus to More
cy.wait(100); // allow menu close and double rAF focus logic to run
cy.focused().should('have.attr', 'role', 'tab');
cy.focused()
.invoke('text')
.then(text => {
expect(text.trim()).to.match(/^(Sixth Tab|More)$/);
});
});
});
});
Expand All @@ -607,30 +618,30 @@ describe('Tabs', () => {
});
});

it('should show the "More" button', () => {
cy.findByRole('button', {name: 'More'}).should('exist');
it('should show the "More" overflow tab', () => {
cy.findByRole('tab', {name: 'More'}).should('exist');
});

it('should not have scroll', () => {
cy.findByRole('tablist').its('scrollX').should('not.exist');
});

it('should show only 2 tab items', () => {
cy.findAllByRole('tab').should('have.length', 2);
it('should show 2 content tabs plus the More overflow tab (3 total)', () => {
cy.findAllByRole('tab').should('have.length', 3);
});

context('when the "More" button is clicked', () => {
context('when the "More" overflow tab is clicked', () => {
beforeEach(() => {
cy.findByRole('button', {name: '360px'}).should('have.attr', 'aria-pressed', 'true');
cy.findByRole('button', {name: 'More'}).click();
cy.findByRole('tab', {name: 'More'}).click();
});

it('should show the Tab overflow menu', () => {
cy.get('[role="menu"]').should('exist');
});

it('should have the third Tab as the first menu item', () => {
cy.get('button[role="menuitem"]').first().should('have.text', 'Third Tab');
cy.get('[role="menuitem"]').first().should('contain', 'Third Tab');
});
});
});
Expand All @@ -649,28 +660,28 @@ describe('Tabs', () => {
});
});

it('should show the "More" button', () => {
cy.findByRole('button', {name: 'More'}).should('exist');
it('should show the "More" overflow tab', () => {
cy.findByRole('tab', {name: 'More'}).should('exist');
});

it('should not have scroll', () => {
cy.findByRole('tablist').its('scrollX').should('not.exist');
});

it('should show no tab items', () => {
cy.findAllByRole('tab').should('have.length', 0);
it('should show only the More overflow tab (1 tab when all content tabs overflow)', () => {
cy.findAllByRole('tab').should('have.length', 1);
});

context('when the "More" button is clicked', () => {
context('when the "More" overflow tab is clicked', () => {
beforeEach(() => {
cy.findByRole('button', {name: 'More'}).click();
cy.findByRole('tab', {name: 'More'}).click();
});

it('should show the Tab overflow menu', () => {
cy.findByRole('menu', {name: 'More'}).should('exist');
});

it('should have the third Tab as the first menu item', () => {
it('should have focus on the first menu item (First Tab)', () => {
cy.findByRole('menuitem', {name: 'First Tab'}).should('have.focus');
});
});
Expand All @@ -682,8 +693,8 @@ describe('Tabs', () => {
cy.findByRole('button', {name: '500px'}).realTouch();
});

it('should not show the "More" button', () => {
cy.findByRole('button', {name: 'More'}).should('not.exist');
it('should not show the "More" overflow tab', () => {
cy.findByRole('tab', {name: 'More'}).should('not.exist');
});

it('should have scroll behavior', () => {
Expand Down
2 changes: 1 addition & 1 deletion cypress/support/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import './commands';
// Alternatively you can use CommonJS syntax:
// 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.

// Augment the Cypress namespace to include type definitions for
// your custom command.
Expand Down
9 changes: 9 additions & 0 deletions modules/react/collection/lib/useOverflowListModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ export const useOverflowListModel = createModelHook({
overflowTargetSizeRef.current =
model.state.orientation === 'horizontal' ? data.width || 0 : data.height || 0;
setOverflowTargetWidth(overflowTargetSizeRef.current);
const ids = getHiddenIds(
containerSizeRef.current,
containerGap,
overflowTargetSizeRef.current,
itemSizeCacheRef.current,
state.selectedIds,
config.items
);
setHiddenIds(ids);
},

/**
Expand Down
34 changes: 33 additions & 1 deletion modules/react/tabs/lib/TabsItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isSelected,
useListItemSelect,
useOverflowListItemMeasure,
ListRenderItemContext,
} from '@workday/canvas-kit-react/collection';
import {calc, createStencil, px2rem} from '@workday/canvas-kit-styling';

Expand Down Expand Up @@ -169,6 +170,36 @@ export const StyledTabItem = createComponent('button')<TabsItemProps>({
},
});

/**
* When the selected tab receives ArrowDown, move focus to its tab panel.
* Only applies to the selected tab; returning null from onKeyDown prevents the roving
* focus handler from also handling the key. Tabs.OverflowButton does not use this hook.
* Uses ListRenderItemContext for the item id since this hook runs before useListItemRegister
* merges in data-id.
*/
const useTabsItemFocusPanelOnArrowDown = createElemPropsHook(useTabsModel)(
({state}, _, elemProps: {'data-id'?: string} = {}) => {
const {item} = React.useContext(ListRenderItemContext);
const name = elemProps['data-id'] || item?.id || '';
const selected = !!name && isSelected(name, state);

return {
onKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (!selected || event.key !== 'ArrowDown') {
return;
}
event.preventDefault();
const panelId = slugify(`tabpanel-${state.id}-${name}`);
const panel = document.getElementById(panelId);
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.
},
};
}
);

export const useTabsItem = composeHooks(
createElemPropsHook(useTabsModel)(({state}, _, elemProps: {'data-id'?: string} = {}) => {
const name = elemProps['data-id'] || '';
Expand All @@ -185,7 +216,8 @@ export const useTabsItem = composeHooks(
useListItemSelect,
useOverflowListItemMeasure,
useListItemRovingFocus,
useListItemRegister
useListItemRegister,
useTabsItemFocusPanelOnArrowDown
);

export const TabsItem = createSubcomponent('button')({
Expand Down
90 changes: 85 additions & 5 deletions modules/react/tabs/lib/TabsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import {
ExtractProps,
useModalityType,
useLocalRef,
useMountLayout,
Generic,
} from '@workday/canvas-kit-react/common';
import {Flex, mergeStyles} from '@workday/canvas-kit-react/layout';
import {
useOverflowListMeasure,
useListRenderItems,
useListResetCursorOnBlur,
ListRenderItemContext,
isCursor,
} from '@workday/canvas-kit-react/collection';
import {orientationKeyMap} from '../../collection/lib/keyUtils';

import {useTabsModel} from './useTabsModel';
import {useTabsModel, TABS_OVERFLOW_BUTTON_ID} from './useTabsModel';
import {createStencil, px2rem} from '@workday/canvas-kit-styling';
import {system} from '@workday/canvas-tokens-web';

Expand Down Expand Up @@ -102,6 +105,50 @@ export const useTabOverflowScroll = createElemPropsHook(useTabsModel)(
}
);

/**
* Resets the tablist cursor on blur to the selected tab when visible, or to the first visible
* item (e.g. the overflow button) when the selected tab is hidden. This ensures the roving
* tabindex always lands on a focusable element when only the "More" button is visible.
*/
export const useTabsListResetCursorOnBlur = createElemPropsHook(useTabsModel)(({state, events}) => {
const programmaticFocusRef = React.useRef(false);
const requestAnimationFrameRef = React.useRef(0);

useMountLayout(() => {
return () => {
cancelAnimationFrame(requestAnimationFrameRef.current);
};
});

return {
onKeyDown(event: React.KeyboardEvent) {
if (Object.keys(orientationKeyMap[state.orientation]).indexOf(event.key) !== -1) {
programmaticFocusRef.current = true;
}
},
onFocus() {
programmaticFocusRef.current = false;
},
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.
const selectedId =
state.selectedIds !== 'all' && state.selectedIds.length
? state.selectedIds[0]
: undefined;
const visibleItems = state.items.filter(item => !state.hiddenIds.includes(item.id));
const targetId =
selectedId && !state.hiddenIds.includes(selectedId) ? selectedId : visibleItems[0]?.id;
if (targetId && !isCursor(state, targetId)) {
events.goTo({id: targetId});
}
});
}
},
};
});

export const useTabsList = composeHooks(
useTabOverflowScroll,
createElemPropsHook(useTabsModel)(model => {
Expand All @@ -111,13 +158,14 @@ export const useTabsList = composeHooks(
} as const;
}),
useOverflowListMeasure,
useListResetCursorOnBlur
useTabsListResetCursorOnBlur
);

export const tabsListStencil = createStencil({
base: {
display: 'flex',
position: 'relative',
minWidth: 0,
borderBottom: `${px2rem(1)} solid ${system.color.border.divider}`,
gap: system.space.x3,
paddingInline: system.space.x6,
Expand Down Expand Up @@ -156,6 +204,38 @@ export const tabsListStencil = createStencil({
},
});

/**
* Custom render function for tabs that filters out the synthetic overflow button item.
* This is needed because the overflow button is included in the model's items array
* for navigation purposes, but should not be rendered by the list render function.
*/
function useTabsListRenderItems<T>(
model: ReturnType<typeof useTabsModel>,
children: ((item: Generic, index: number) => React.ReactNode) | React.ReactNode
): React.ReactNode {
// Filter out the synthetic overflow button from rendering
const itemsToRender = model.state.items.filter(item => item.id !== TABS_OVERFLOW_BUTTON_ID);

const items =
typeof children === 'function' ? (
itemsToRender.map(item => {
const child = (children as (item: Generic, index: number) => React.ReactNode)(
item.value,
item.index
);
return (
<ListRenderItemContext.Provider key={item.id || item.index} value={{item}}>
{child}
</ListRenderItemContext.Provider>
);
})
) : (
<ListRenderItemContext.Provider value={{}}>{children}</ListRenderItemContext.Provider>
);

return items;
}

export const TabsList = createSubcomponent('div')({
displayName: 'Tabs.List',
modelHook: useTabsModel,
Expand All @@ -173,7 +253,7 @@ export const TabsList = createSubcomponent('div')({
})
)}
>
{useListRenderItems(model, children)}
{useTabsListRenderItems(model, children)}
{overflowButton}
</Element>
);
Expand Down
Loading
Loading