diff --git a/change/@fluentui-react-cb9f45ee-a6ad-4ffc-afb6-62a53daac1fe.json b/change/@fluentui-react-cb9f45ee-a6ad-4ffc-afb6-62a53daac1fe.json new file mode 100644 index 00000000000000..1e275446e2024d --- /dev/null +++ b/change/@fluentui-react-cb9f45ee-a6ad-4ffc-afb6-62a53daac1fe.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: ContextualMenu should not include headers and dividers in ARIA index calculations", + "packageName": "@fluentui/react", + "email": "sarah.higley@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react/src/components/ContextualMenu/ContextualMenu.base.tsx b/packages/react/src/components/ContextualMenu/ContextualMenu.base.tsx index 5a96c925c444f8..7de3b215a108d0 100644 --- a/packages/react/src/components/ContextualMenu/ContextualMenu.base.tsx +++ b/packages/react/src/components/ContextualMenu/ContextualMenu.base.tsx @@ -71,6 +71,18 @@ const DEFAULT_PROPS: Partial = { beakWidth: 16, }; +/* return number of menu items, excluding headers and dividers */ +function getItemCount(items: IContextualMenuItem[]): number { + let totalItemCount = 0; + for (const item of items) { + if (item.itemType !== ContextualMenuItemType.Divider && item.itemType !== ContextualMenuItemType.Header) { + const itemCount = item.customOnRenderListLength ? item.customOnRenderListLength : 1; + totalItemCount += itemCount; + } + } + return totalItemCount; +} + export function getSubmenuItems( item: IContextualMenuItem, options?: { @@ -949,7 +961,7 @@ export const ContextualMenuBase: React.FunctionComponent = key: `section-${sectionProps.title}-title`, itemType: ContextualMenuItemType.Header, text: sectionProps.title, - id: id, + id, }; ariaLabelledby = id; } else { @@ -975,23 +987,34 @@ export const ContextualMenuBase: React.FunctionComponent = } if (sectionProps.items && sectionProps.items.length > 0) { + let correctedIndex = 0; return (
    • {sectionProps.topDivider && renderSeparator(index, itemClassNames, true, true)} {headerItem && renderListItem(headerItem, sectionItem.key || index, itemClassNames, sectionItem.title)} - {sectionProps.items.map((contextualMenuItem, itemsIndex) => - renderMenuItem( + {sectionProps.items.map((contextualMenuItem, itemsIndex) => { + const menuItem = renderMenuItem( contextualMenuItem, itemsIndex, - itemsIndex, - sectionProps.items.length, + correctedIndex, + getItemCount(sectionProps.items), hasCheckmarks, hasIcons, menuClassNames, - ), - )} + ); + if ( + contextualMenuItem.itemType !== ContextualMenuItemType.Divider && + contextualMenuItem.itemType !== ContextualMenuItemType.Header + ) { + const indexIncrease = contextualMenuItem.customOnRenderListLength + ? contextualMenuItem.customOnRenderListLength + : 1; + correctedIndex += indexIncrease; + } + return menuItem; + })} {sectionProps.bottomDivider && renderSeparator(index, itemClassNames, false, true)}
    @@ -1062,9 +1085,9 @@ export const ContextualMenuBase: React.FunctionComponent = onItemMouseEnter: onItemMouseEnterBase, onItemMouseLeave: onMouseItemLeave, onItemMouseMove: onItemMouseMoveBase, - onItemMouseDown: onItemMouseDown, - executeItemClick: executeItemClick, - onItemKeyDown: onItemKeyDown, + onItemMouseDown, + executeItemClick, + onItemKeyDown, expandedMenuItemKey, openSubMenu, dismissSubMenu: onSubMenuDismiss, @@ -1160,7 +1183,7 @@ export const ContextualMenuBase: React.FunctionComponent = ? getMenuClassNames(theme!, className) : getClassNames(styles, { theme: theme!, - className: className, + className, }); const hasIcons = itemsHaveIcons(items); @@ -1217,13 +1240,7 @@ export const ContextualMenuBase: React.FunctionComponent = // The menu should only return if items were provided, if no items were provided then it should not appear. if (items && items.length > 0) { - let totalItemCount = 0; - for (const item of items) { - if (item.itemType !== ContextualMenuItemType.Divider && item.itemType !== ContextualMenuItemType.Header) { - const itemCount = item.customOnRenderListLength ? item.customOnRenderListLength : 1; - totalItemCount += itemCount; - } - } + const totalItemCount = getItemCount(items); const calloutStyles = classNames.subComponentStyles ? (classNames.subComponentStyles.callout as IStyleFunctionOrObject< diff --git a/packages/react/src/components/ContextualMenu/ContextualMenu.test.tsx b/packages/react/src/components/ContextualMenu/ContextualMenu.test.tsx index c0b1a45a8b5772..02d07b21aecf46 100644 --- a/packages/react/src/components/ContextualMenu/ContextualMenu.test.tsx +++ b/packages/react/src/components/ContextualMenu/ContextualMenu.test.tsx @@ -470,7 +470,7 @@ describe('ContextualMenu', () => { className: 'SubMenuClass', }, ], - onDismiss: onDismiss, + onDismiss, }, }, ]; @@ -742,6 +742,61 @@ describe('ContextualMenu', () => { expect(menuItems.length).toEqual(10); }); + it('calculates index and total of menu items correctly', () => { + const items: IContextualMenuItem[] = [ + { key: 'header', text: 'header', itemType: ContextualMenuItemType.Header }, + { key: '1', text: 'One' }, + { key: 'divider', itemType: ContextualMenuItemType.Divider }, + { key: '2', text: 'Two' }, + ]; + + ReactTestUtils.act(() => { + ReactTestUtils.renderIntoDocument(); + }); + + const menuItems = document.querySelectorAll('li button'); + const total = menuItems[0].getAttribute('aria-setsize'); + const index1 = menuItems[0].getAttribute('aria-posinset'); + const index2 = menuItems[1].getAttribute('aria-posinset'); + + expect(total).toBe('2'); + expect(index1).toBe('1'); + expect(index2).toBe('2'); + }); + + it('calculates index and total of menu items in a section correctly', () => { + const items: IContextualMenuItem[] = [ + { + key: 'section1', + itemType: ContextualMenuItemType.Section, + sectionProps: { + topDivider: true, + bottomDivider: true, + title: 'Actions', + items: [ + { key: 'header', text: 'header', itemType: ContextualMenuItemType.Header }, + { key: '1', text: 'One' }, + { key: 'divider', itemType: ContextualMenuItemType.Divider }, + { key: '2', text: 'Two' }, + ], + }, + }, + ]; + + ReactTestUtils.act(() => { + ReactTestUtils.renderIntoDocument(); + }); + + const menuItems = document.querySelectorAll('li button'); + const total = menuItems[0].getAttribute('aria-setsize'); + const index1 = menuItems[0].getAttribute('aria-posinset'); + const index2 = menuItems[1].getAttribute('aria-posinset'); + + expect(total).toBe('2'); + expect(index1).toBe('1'); + expect(index2).toBe('2'); + }); + describe('with links', () => { const testUrl = 'http://test.com'; let items: IContextualMenuItem[];