Skip to content

Commit

Permalink
Feat(PPDSC-2572): uncontrolled sub menu (#518)
Browse files Browse the repository at this point in the history
* feat(PPDSC-2572): temp

* feat(PPDSC-2572): poc uncontrolled nested sub menu pattern

* feat(PPDSC-2572): fix a11y error in storybook

* feat(PPDSC-2572): update snapshots

* feat(PPDSC-2572): update sub menu story and exclude from visual tests

* feat(PPDSC-2572): uncontrolled logic tests

* feat(PPDSC-2572): tests for click outside behaviour

* feat(PPDSC-2572): close on click outside

* feat(PPDSC-2572): support default expanded state

* feat(PPDSC-2572): correct coverage report

* feat(PPDSC-2572): fix build error

* feat(PPDSC-2572): alignment for sub menus

* feat(PPDSC-2572): close menu on item click

* feat(PPDSC-2572): merge contexts for simplicity

* feat(PPDSC-2572): fix coverage

* feat(PPDSC-2572): remove unnecessary ids

* feat(PPDSC-2572): use floating-ui use id
  • Loading branch information
mstuartf authored Jan 4, 2023
1 parent 563b73c commit e6b824f
Show file tree
Hide file tree
Showing 9 changed files with 2,464 additions and 20 deletions.
2,070 changes: 2,070 additions & 0 deletions src/menu/__tests__/__snapshots__/menu.test.tsx.snap

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions src/menu/__tests__/menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1810,6 +1810,60 @@ export const StoryMenuFullDemo = () => {
};
StoryMenuFullDemo.storyName = 'sub-menu-full-demo';

const SubMenuNestedUncontrolledContainer = styled.div`
min-height: 300px;
`;
export const StorySubMenuNestedUncontrolled = () => (
<SubMenuNestedUncontrolledContainer>
<StorybookSubHeading>
Sub menu - uncontrolled behaviour example
</StorybookSubHeading>
<Menu aria-label="menu-nested-uncontrolled" align="start">
<MenuSub title="Item 1">
<MenuItem href={href}>Item 1.1</MenuItem>
<MenuItem href={href}>Item 1.2</MenuItem>
<MenuSub title="Item 1.3">
<MenuItem href={href}>Item 1.3.1</MenuItem>
<MenuSub title="Item 1.3.2" defaultExpanded>
<MenuItem href={href}>Item 1.3.2.1</MenuItem>
</MenuSub>
</MenuSub>
<MenuSub title="Item 1.4">
<MenuItem href={href}>Item 1.4.1</MenuItem>
<MenuSub title="Item 1.4.2">
<MenuItem href={href}>Item 1.4.2.1</MenuItem>
</MenuSub>
</MenuSub>
</MenuSub>
</Menu>
</SubMenuNestedUncontrolledContainer>
);
StorySubMenuNestedUncontrolled.storyName = 'sub-menu-nested-uncontrolled';
StorySubMenuNestedUncontrolled.parameters = {
percy: {skip: true},
};

const AlignmentExampleContainer = styled.div`
min-height: 400px;
`;
export const StorySubMenuAlignmentExample = () => (
<AlignmentExampleContainer>
<StorybookSubHeading>Sub menu alignment example</StorybookSubHeading>
<Menu aria-label="menu-alignment-example" align="start" vertical>
<MenuSub title="Start-aligned sub menu">
<MenuItem href={href}>Start-aligned menu item</MenuItem>
<MenuSub title="Center-aligned sub menu" align="center">
<MenuItem href={href}>Center-aligned menu item</MenuItem>
<MenuSub title="End-aligned sub menu" defaultExpanded align="end">
<MenuItem href={href}>End-aligned menu item</MenuItem>
</MenuSub>
</MenuSub>
</MenuSub>
</Menu>
</AlignmentExampleContainer>
);
StorySubMenuAlignmentExample.storyName = 'sub-menu-alignment-example';

export default {
title: 'Components/menu',
component: () => 'None',
Expand Down
189 changes: 187 additions & 2 deletions src/menu/__tests__/menu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import {fireEvent} from '@testing-library/react';
import {Menu, MenuDivider, MenuGroup, MenuItem, MenuSub} from '..';
import {Menu, MenuDivider, MenuGroup, MenuItem, MenuProps, MenuSub} from '..';
import {
renderToFragmentWithTheme,
renderWithImplementation,
Expand Down Expand Up @@ -126,6 +126,17 @@ describe('MenuItem', () => {
const fragment = renderToFragmentWithTheme(MenuWithItem, props);
expect(fragment).toMatchSnapshot();
});
it('calls menu item on click', () => {
const onClick = jest.fn();
const props = {
children: [<IconFilledAddCircleOutline key="i" />, menuItemContent],
href,
onClick,
};
const {getByText} = renderWithTheme(MenuWithItem, props);
fireEvent.click(getByText('Menu item'));
expect(onClick).toHaveBeenCalled();
});
it('renders menu item with anchor attributes', () => {
const props = {
children: menuItemContent,
Expand Down Expand Up @@ -179,7 +190,7 @@ describe('MenuItem', () => {
);
expect(fragment).toMatchSnapshot();
});
test('fire tracking event', async () => {
test('fire tracking event', () => {
const mockFireEvent = jest.fn();
const props = {
children: menuItemContent,
Expand Down Expand Up @@ -597,3 +608,177 @@ describe('MenuSub', () => {
expect(fragment).toMatchSnapshot();
});
});

describe('Uncontrolled nested menu', () => {
const TestMenu = (props: Omit<MenuProps, 'children' | 'align'>) => (
<>
<Menu {...props} align="start">
<MenuSub title="menuSub1">
<MenuItem href={href}>Item 1</MenuItem>
</MenuSub>
<MenuSub title="menuSub2" align="center">
<MenuItem href={href}>Item 2</MenuItem>
<MenuSub title="menuSub3" align="end">
<MenuItem href={href}>Item 3</MenuItem>
</MenuSub>
</MenuSub>
</Menu>
<div data-testid="outside-element" />
</>
);

describe('horizontal', () => {
it('collapses expanded sub menus when new sub menu expanded', () => {
const {getByText} = renderWithTheme(TestMenu);
const menuSub1 = getByText('menuSub1');
const menuSub2 = getByText('menuSub2');
fireEvent.click(menuSub1);
expect(menuSub1.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(menuSub2);
expect(menuSub1.parentNode).toHaveAttribute('aria-expanded', 'false');
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
});

it('collapses expanded sub menus when clicked', () => {
const {getByText} = renderWithTheme(TestMenu);
const menuSub1 = getByText('menuSub1');
const menuSub2 = getByText('menuSub2');
fireEvent.click(menuSub1);
fireEvent.click(menuSub2);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(menuSub2);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'false');
});

it('does not collapse expanded sub menus when descendant sub menu expanded', () => {
const {getByText} = renderWithTheme(TestMenu);
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
fireEvent.click(menuSub2);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(menuSub3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
});

it('collapses sub menu when ancestor is collapsed', () => {
const {getByText} = renderWithTheme(TestMenu);
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
fireEvent.click(menuSub2);
fireEvent.click(menuSub3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(menuSub2);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'false');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'false');
});

it('collapses all sub menus on click outside', () => {
const {getByText, getByTestId} = renderWithTheme(TestMenu);
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
fireEvent.click(menuSub2);
fireEvent.click(menuSub3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(getByTestId('outside-element'));
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'false');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'false');
});

it('collapses all sub menus on menu item click', () => {
const {getByText} = renderWithTheme(TestMenu);
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
const menuItem3 = getByText('Item 3');
fireEvent.click(menuSub2);
fireEvent.click(menuSub3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(menuItem3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'false');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'false');
});

it('collapses single tier on click if expanded', () => {
const {getByText} = renderWithTheme(TestMenu);
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
fireEvent.click(menuSub2);
fireEvent.click(menuSub3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(menuSub3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'false');
});

it('sets alignment for each sub menu', () => {
const {getByText, asFragment} = renderWithTheme(TestMenu);
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
fireEvent.click(menuSub2);
fireEvent.click(menuSub3);
expect(asFragment()).toMatchSnapshot();
});
});

describe('vertical', () => {
it('does not collapse expanded sub menus when new sub menu expanded', () => {
const {getByText} = renderWithTheme(TestMenu, {vertical: true});
const menuSub1 = getByText('menuSub1');
const menuSub2 = getByText('menuSub2');
fireEvent.click(menuSub1);
expect(menuSub1.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(menuSub2);
expect(menuSub1.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
});

it('does not collapse all sub menus on click outside', () => {
const {getByText, getByTestId} = renderWithTheme(TestMenu, {
vertical: true,
});
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
fireEvent.click(menuSub2);
fireEvent.click(menuSub3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(getByTestId('outside-element'));
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
});

it('does not collapse all sub menus on menu item click', () => {
const {getByText} = renderWithTheme(TestMenu, {
vertical: true,
});
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
const menuItem3 = getByText('Item 3');
fireEvent.click(menuSub2);
fireEvent.click(menuSub3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(menuItem3);
expect(menuSub2.parentNode).toHaveAttribute('aria-expanded', 'true');
expect(menuSub3.parentNode).toHaveAttribute('aria-expanded', 'true');
});

it('sets alignment for each sub menu', () => {
const {getByText, asFragment} = renderWithTheme(TestMenu, {
vertical: true,
});
const menuSub2 = getByText('menuSub2');
const menuSub3 = getByText('menuSub3');
fireEvent.click(menuSub2);
fireEvent.click(menuSub3);
expect(asFragment()).toMatchSnapshot();
});
});
});
16 changes: 15 additions & 1 deletion src/menu/context.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import {createContext, useContext} from 'react';
import {MenuProps} from './types';

export type OnExpandedChangeFn = (nestedId: string | null) => void;

/* istanbul ignore next */
const defaultMenuContextArgs = {
updateExpandedMenuSubId: () => {},
expandedMenuSubId: null,
parentSubMenuId: null,
};

export const MenuContext = createContext<
Pick<MenuProps, 'vertical' | 'size' | 'align' | 'overrides'> & {
// MenuSub components can call this function when they are clicked
updateExpandedMenuSubId: OnExpandedChangeFn;
// MenuSub components access this state to know if they are expanded / collapsed
expandedMenuSubId: string | null;
isSubMenu?: boolean;
parentSubMenuId: string | null;
}
>({});
>(defaultMenuContextArgs);
export const MenuContextProvider = MenuContext.Provider;

export const useMenuContext = () => {
Expand Down
12 changes: 12 additions & 0 deletions src/menu/menu-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const MenuItem = React.forwardRef<HTMLLIElement, MenuItemProps>(
selected,
eventContext = {},
eventOriginator = 'menu-item',
onClick: onClickProp,
...rest
},
ref,
Expand All @@ -24,9 +25,19 @@ export const MenuItem = React.forwardRef<HTMLLIElement, MenuItemProps>(
size,
align,
overrides: menuOverrides,
updateExpandedMenuSubId,
isSubMenu,
} = useMenuContext();

const onClick: MenuItemProps['onClick'] = e => {
if (!vertical) {
updateExpandedMenuSubId(null);
}
if (onClickProp) {
onClickProp(e);
}
};

const theme = useTheme();
const menuItemOverrides: MenuItemProps['overrides'] = {
...get(
Expand Down Expand Up @@ -60,6 +71,7 @@ export const MenuItem = React.forwardRef<HTMLLIElement, MenuItemProps>(
...menuItemOverrides,
}}
aria-current={selected && 'page'}
onClick={onClick}
>
{children}
</StyledButton>
Expand Down
Loading

0 comments on commit e6b824f

Please sign in to comment.