diff --git a/cypress/component/Tabs.spec.tsx b/cypress/component/Tabs.spec.tsx index 963f012ea3..3e5100cc0d 100644 --- a/cypress/component/Tabs.spec.tsx +++ b/cypress/component/Tabs.spec.tsx @@ -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', () => { @@ -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'); }); }); }); @@ -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', () => { @@ -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', () => { @@ -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)$/); + }); }); }); }); @@ -607,22 +618,22 @@ 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', () => { @@ -630,7 +641,7 @@ describe('Tabs', () => { }); 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'); }); }); }); @@ -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'); }); }); @@ -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', () => { diff --git a/cypress/support/component.ts b/cypress/support/component.ts index 243c4caf61..9530afeb0c 100644 --- a/cypress/support/component.ts +++ b/cypress/support/component.ts @@ -29,7 +29,7 @@ import './commands'; // Alternatively you can use CommonJS syntax: // require('./commands'); -import {mount} from 'cypress/react18'; +import {mount} from 'cypress/react'; // Augment the Cypress namespace to include type definitions for // your custom command. diff --git a/modules/react/collection/lib/useOverflowListModel.tsx b/modules/react/collection/lib/useOverflowListModel.tsx index f5e593a2c4..64bd3638e3 100644 --- a/modules/react/collection/lib/useOverflowListModel.tsx +++ b/modules/react/collection/lib/useOverflowListModel.tsx @@ -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); }, /** diff --git a/modules/react/tabs/lib/TabsItem.tsx b/modules/react/tabs/lib/TabsItem.tsx index d055c4d1ac..db5cfbc4c9 100644 --- a/modules/react/tabs/lib/TabsItem.tsx +++ b/modules/react/tabs/lib/TabsItem.tsx @@ -20,6 +20,7 @@ import { isSelected, useListItemSelect, useOverflowListItemMeasure, + ListRenderItemContext, } from '@workday/canvas-kit-react/collection'; import {calc, createStencil, px2rem} from '@workday/canvas-kit-styling'; @@ -169,6 +170,36 @@ export const StyledTabItem = createComponent('button')({ }, }); +/** + * 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) { + 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 + }, + }; + } +); + export const useTabsItem = composeHooks( createElemPropsHook(useTabsModel)(({state}, _, elemProps: {'data-id'?: string} = {}) => { const name = elemProps['data-id'] || ''; @@ -185,7 +216,8 @@ export const useTabsItem = composeHooks( useListItemSelect, useOverflowListItemMeasure, useListItemRovingFocus, - useListItemRegister + useListItemRegister, + useTabsItemFocusPanelOnArrowDown ); export const TabsItem = createSubcomponent('button')({ diff --git a/modules/react/tabs/lib/TabsList.tsx b/modules/react/tabs/lib/TabsList.tsx index 82100143a3..896dc02926 100644 --- a/modules/react/tabs/lib/TabsList.tsx +++ b/modules/react/tabs/lib/TabsList.tsx @@ -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'; @@ -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; + 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 => { @@ -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, @@ -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( + model: ReturnType, + 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 ( + + {child} + + ); + }) + ) : ( + {children} + ); + + return items; +} + export const TabsList = createSubcomponent('div')({ displayName: 'Tabs.List', modelHook: useTabsModel, @@ -173,7 +253,7 @@ export const TabsList = createSubcomponent('div')({ }) )} > - {useListRenderItems(model, children)} + {useTabsListRenderItems(model, children)} {overflowButton} ); diff --git a/modules/react/tabs/lib/TabsOverflowButton.tsx b/modules/react/tabs/lib/TabsOverflowButton.tsx index 8b503e92c5..b9a010565f 100644 --- a/modules/react/tabs/lib/TabsOverflowButton.tsx +++ b/modules/react/tabs/lib/TabsOverflowButton.tsx @@ -6,17 +6,26 @@ import { composeHooks, createSubModelElemPropsHook, createSubcomponent, + useLocalRef, + useResizeObserver, } from '@workday/canvas-kit-react/common'; import {SystemIcon} from '@workday/canvas-kit-react/icon'; -import {useOverflowListTarget} from '@workday/canvas-kit-react/collection'; +import { + useOverflowListModel, + useListItemRovingFocus, + useListItemRegister, +} from '@workday/canvas-kit-react/collection'; import {useMenuTarget} from '@workday/canvas-kit-react/menu'; -import {useTabsModel} from './useTabsModel'; +import {useTabsModel, TABS_OVERFLOW_BUTTON_ID} from './useTabsModel'; import {StyledTabItem} from './TabsItem'; import {createStencil} from '@workday/canvas-kit-styling'; import {system} from '@workday/canvas-tokens-web'; import {mergeStyles} from '@workday/canvas-kit-react/layout'; +// Re-export for consumers who may need to reference the overflow button ID +export {TABS_OVERFLOW_BUTTON_ID} from './useTabsModel'; + export interface OverflowButtonProps { /** * The label text of the Tab. @@ -33,21 +42,84 @@ const tabsOverflowButtonStencil = createStencil({ }, }); +/** + * Measures the overflow button and reports its size to the model for overflow calculation. + * Unlike useOverflowListTarget, this does NOT set aria-hidden or visibility styles because + * we handle visibility via conditional rendering in the TabsOverflowButton component. + */ +const useTabsOverflowButtonMeasure = createElemPropsHook(useOverflowListModel)((model, ref) => { + const {elementRef, localRef} = useLocalRef(ref as React.Ref); + + useResizeObserver({ + ref: localRef, + onResize: ({width = 0, height = 0}) => { + if (localRef.current && (width > 0 || height > 0)) { + const styles = getComputedStyle(localRef.current); + const w = width + parseFloat(styles.marginLeft) + parseFloat(styles.marginRight); + const h = height + parseFloat(styles.marginTop) + parseFloat(styles.marginBottom); + model.events.setOverflowTargetSize({width: w, height: h}); + } + }, + }); + + return { + ref: elementRef, + }; +}); + export const useTabsOverflowButton = composeHooks( + // Measures the overflow button for overflow calculation (no aria-hidden or visibility styles) + useTabsOverflowButtonMeasure, + // Adds roving tabindex behavior for arrow key navigation within the tablist + useListItemRovingFocus, + // Registers the overflow button as an item in the collection for cursor navigation + useListItemRegister, + // Provides the stable data-id needed for collection registration createElemPropsHook(useTabsModel)(() => { return { - 'aria-haspopup': true, + 'data-id': TABS_OVERFLOW_BUTTON_ID, } as const; }), - useOverflowListTarget, - createSubModelElemPropsHook(useTabsModel)(m => m.menu, useMenuTarget) + // Connects to the menu sub-model for popup behavior + createSubModelElemPropsHook(useTabsModel)(m => m.menu, useMenuTarget), + // Syncs the cursor to the overflow button when it receives focus. + // This is needed because when the menu closes and focus returns to the overflow button, + // the cursor state needs to be updated so arrow key navigation works correctly. + createElemPropsHook(useTabsModel)(model => { + return { + onFocus() { + model.events.goTo({id: TABS_OVERFLOW_BUTTON_ID}); + }, + }; + }), + // Runs FIRST - Sets ARIA attributes for the overflow tab + // In composeHooks, hooks run right-to-left, and mergeProps gives precedence to elemProps + // (props from earlier-running hooks). So this runs first and its props win. + // Note: We intentionally do NOT include useListItemSelect here because clicking + // the overflow button should open the menu, not select it as the active tab + createElemPropsHook(useTabsModel)(() => { + return { + 'aria-haspopup': 'menu', + 'aria-selected': false, + role: 'tab', + } as const; + }) ); export const TabsOverflowButton = createSubcomponent('button')({ displayName: 'Tabs.OverflowButton', modelHook: useTabsModel, elemPropsHook: useTabsOverflowButton, -})(({children, ...elemProps}, Element) => { +})(({children, ...elemProps}, Element, model) => { + // Don't render the overflow button if there are no hidden items. + // This removes it from the DOM entirely, which: + // - Removes it from keyboard navigation (roving focus) + // - Removes it from screen reader announcements + // - Prevents the "8 tabs" announcement when only 7 are visible + if (model.state.hiddenIds.length === 0) { + return null; + } + return (