Skip to content

Commit

Permalink
Menu and MenuTrigger API (#17271)
Browse files Browse the repository at this point in the history
* Menu and MenuTrigger API

Adds the Menu and MenuTrigger compoenents to render popup and
trigger element to control the popup

Menu renders a wrapper slot around expected `MenuList` for popup
positioning

MenuTrigger renders no DOM but clones an only child with correct popup
event handling

* fix types

* fixes

* useCallback

* Change files

* fix controlled example

* fix tests
  • Loading branch information
ling1726 committed Mar 7, 2021
1 parent a8552af commit 379ec44
Show file tree
Hide file tree
Showing 24 changed files with 667 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add MenuTrigger examples",
"packageName": "@fluentui/react-examples",
"email": "lingfan.gao@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Add Menu, MenuTrigger components",
"packageName": "@fluentui/react-menu",
"email": "lingfan.gao@microsoft.com",
"dependentChangeType": "patch"
}
86 changes: 86 additions & 0 deletions packages/react-examples/src/react-menu/Menu/Menu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as React from 'react';
import {
Menu,
MenuTrigger,
MenuList,
MenuItem,
MenuItemRadio,
MenuItemCheckbox,
MenuGroup,
MenuDivider,
MenuGroupHeader,
} from '@fluentui/react-menu';
import { CutIcon, PasteIcon, EditIcon, AcceptIcon } from '@fluentui/react-icons-mdl2';

export const MenuExample = () => (
<>
<Menu>
<MenuTrigger>
<button>Toggle menu</button>
</MenuTrigger>

<MenuList>
<MenuItem>Item 1</MenuItem>
<MenuItem>Item 1</MenuItem>
<MenuItem>Item 1</MenuItem>
</MenuList>
</Menu>
</>
);

export const MenuControlledExample = () => {
const [open, setOpen] = React.useState(false);
return (
<>
<Menu open={open}>
<MenuTrigger>
<button onClick={() => setOpen(s => !s)}>Toggle menu</button>
</MenuTrigger>

<MenuList>
<MenuItem>Item 1</MenuItem>
<MenuItem>Item 1</MenuItem>
<MenuItem>Item 1</MenuItem>
</MenuList>
</Menu>
</>
);
};

export const MenuSelectionExample = () => (
<>
<Menu>
<MenuTrigger>
<button>Toggle menu</button>
</MenuTrigger>

<MenuList>
<MenuGroup>
<MenuGroupHeader>Checkbox group</MenuGroupHeader>
<MenuItemCheckbox icon={<CutIcon />} name="edit" value="cut" checkmark={<AcceptIcon />}>
Cut
</MenuItemCheckbox>
<MenuItemCheckbox icon={<PasteIcon />} name="edit" value="paste" checkmark={<AcceptIcon />}>
Paste
</MenuItemCheckbox>
<MenuItemCheckbox icon={<EditIcon />} name="edit" value="edit" checkmark={<AcceptIcon />}>
Edit
</MenuItemCheckbox>
</MenuGroup>
<MenuDivider />
<MenuGroup>
<MenuGroupHeader>Radio group</MenuGroupHeader>
<MenuItemRadio icon={<CutIcon />} name="font" value="segoe" checkmark={<AcceptIcon />}>
Segoe
</MenuItemRadio>
<MenuItemRadio icon={<PasteIcon />} name="font" value="calibri" checkmark={<AcceptIcon />}>
Caliri
</MenuItemRadio>
<MenuItemRadio icon={<EditIcon />} name="font" value="arial" checkmark={<AcceptIcon />}>
Arial
</MenuItemRadio>
</MenuGroup>
</MenuList>
</Menu>
</>
);
56 changes: 56 additions & 0 deletions packages/react-menu/etc/react-menu.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { ObjectShorthandProps } from '@fluentui/react-utilities';
import * as React from 'react';
import { ShorthandProps } from '@fluentui/react-utilities';

// @public
export const Menu: React.ForwardRefExoticComponent<MenuProps & React.RefAttributes<HTMLElement>>;

// @public
export const MenuDivider: React.ForwardRefExoticComponent<import("@fluentui/react-utilities").ComponentProps & React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>>;

Expand Down Expand Up @@ -141,6 +144,47 @@ export interface MenuListState extends MenuListProps {
toggleCheckbox: SelectableHandler;
}

// @public
export interface MenuProps extends MenuListProps {
children: React.ReactNode;
defaultOpen?: boolean;
menuPopup?: ShorthandProps<React.HTMLAttributes<HTMLElement>>;
open?: boolean;
}

// @public (undocumented)
export const menuShorthandProps: (keyof MenuProps)[];

// @public (undocumented)
export interface MenuState extends MenuProps {
menuList: React.ReactNode;
menuPopup: ObjectShorthandProps<React.HTMLAttributes<HTMLElement>>;
menuTrigger: React.ReactNode;
open: boolean;
ref: React.MutableRefObject<HTMLElement>;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}

// @public
export const MenuTrigger: React.ForwardRefExoticComponent<MenuTriggerProps & React.RefAttributes<HTMLElement>>;

// @public (undocumented)
export interface MenuTriggerProps {
children: React.ReactElement;
}

// @public (undocumented)
export const menuTriggerShorthandProps: (keyof MenuTriggerProps)[];

// @public (undocumented)
export interface MenuTriggerState extends MenuTriggerProps {
ref: React.MutableRefObject<HTMLElement>;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}

// @public
export const renderMenu: (state: MenuState) => JSX.Element;

// @public
export const renderMenuDivider: (state: MenuDividerState) => JSX.Element;

Expand All @@ -162,6 +206,9 @@ export const renderMenuItemRadio: (state: MenuItemRadioState) => JSX.Element;
// @public
export const renderMenuList: (state: MenuListState) => JSX.Element;

// @public
export const renderMenuTrigger: (state: MenuTriggerState) => import("react").ReactElement<any, string | ((props: any) => import("react").ReactElement<any, string | any | (new (props: any) => import("react").Component<any, any, any>)> | null) | (new (props: any) => import("react").Component<any, any, any>)>;

// @public (undocumented)
export type SelectableHandler = (e: React.MouseEvent | React.KeyboardEvent, name: string, value: string, checked: boolean) => void;

Expand All @@ -173,6 +220,9 @@ export const useCheckmarkStyles: (state: MenuItemSelectableState & {
// @public
export const useIconStyles: (selectors: MenuItemState) => string;

// @public
export const useMenu: (props: MenuProps, ref: React.Ref<HTMLElement>, defaultProps?: MenuProps | undefined) => MenuState;

// @public
export const useMenuDivider: (props: MenuDividerProps, ref: React.Ref<HTMLElement>, defaultProps?: MenuDividerProps | undefined) => MenuDividerState;

Expand Down Expand Up @@ -206,6 +256,12 @@ export const useMenuItemStyles: (state: MenuItemState) => void;
// @public
export const useMenuList: (props: MenuListProps, ref: React.Ref<HTMLElement>, defaultProps?: MenuListProps | undefined) => MenuListState;

// @public
export const useMenuStyles: (state: MenuState) => MenuState;

// @public
export const useMenuTrigger: (props: MenuTriggerProps, ref: React.Ref<HTMLElement>, defaultProps?: MenuTriggerProps | undefined) => MenuTriggerState;

// @public
export const useRootStyles: (selectors: MenuItemState) => string;

Expand Down
1 change: 1 addition & 0 deletions packages/react-menu/src/Menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './components/Menu/index';
1 change: 1 addition & 0 deletions packages/react-menu/src/MenuTrigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './components/MenuTrigger/index';
60 changes: 60 additions & 0 deletions packages/react-menu/src/components/Menu/Menu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as React from 'react';
import { Menu } from './Menu';
import * as renderer from 'react-test-renderer';
import { ReactWrapper } from 'enzyme';
import { isConformant } from '../../common/isConformant';
import { MenuTrigger } from '../MenuTrigger/index';
import { MenuList } from '../MenuList/index';
import { MenuItem } from '../MenuItem/index';

describe('Menu', () => {
isConformant({
disabledTests: [
'as-renders-html',
'as-renders-fc',
'component-handles-ref',
'component-has-root-ref',
'component-handles-classname',
'as-passes-as-value',
],
Component: Menu,
displayName: 'Menu',
requiredProps: {
children: [
<MenuTrigger key="trigger">
<button>MenuTrigger</button>
</MenuTrigger>,
<MenuList key="item">
<MenuItem>Item</MenuItem>
</MenuList>,
],
},
});

let wrapper: ReactWrapper | undefined;

afterEach(() => {
if (wrapper) {
wrapper.unmount();
wrapper = undefined;
}
});

/**
* Note: see more visual regression tests for Menu in /apps/vr-tests.
*/
it('renders a default state', () => {
const component = renderer.create(
<Menu>
<MenuTrigger>
<button>Menu trigger</button>
</MenuTrigger>
<MenuList>
<MenuItem>Item</MenuItem>
</MenuList>
</Menu>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});
18 changes: 18 additions & 0 deletions packages/react-menu/src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react';
import { useMenu } from './useMenu';
import { MenuProps } from './Menu.types';
import { renderMenu } from './renderMenu';
import { useMenuStyles } from './useMenuStyles';

/**
* Wrapper component that manages state for a popup MenuList and a MenuTrigger
* {@docCategory Menu }
*/
export const Menu = React.forwardRef<HTMLElement, MenuProps>((props, ref) => {
const state = useMenu(props, ref);

useMenuStyles(state);
return renderMenu(state);
});

Menu.displayName = 'Menu';
64 changes: 64 additions & 0 deletions packages/react-menu/src/components/Menu/Menu.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as React from 'react';
import { ObjectShorthandProps, ShorthandProps } from '@fluentui/react-utilities';
import { MenuListProps } from '../MenuList/index';

/**
* Extends and drills down Menulist props to simplify API
* {@docCategory Menu }
*/
export interface MenuProps extends MenuListProps {
/**
* Explicitly require children
*/

children: React.ReactNode;
/**
* Whether the popup is open
*/
open?: boolean;

/**
* Whether the popup is open by default
*/
defaultOpen?: boolean;

/**
* Wrapper to style and add events for the popup
*/
menuPopup?: ShorthandProps<React.HTMLAttributes<HTMLElement>>;
}

/**
* {@docCategory Menu }
*/
export interface MenuState extends MenuProps {
/**
* Ref to the root slot
*/
ref: React.MutableRefObject<HTMLElement>;

/**
* Whether the popup is open
*/
open: boolean;

/**
* Callback to open/close the popup
*/
setOpen: React.Dispatch<React.SetStateAction<boolean>>;

/**
* Internal react node that just simplifies handling children
*/
menuList: React.ReactNode;

/**
* Internal react node that just simplifies handling children
*/
menuTrigger: React.ReactNode;

/**
* Wrapper to style and add events for the popup
*/
menuPopup: ObjectShorthandProps<React.HTMLAttributes<HTMLElement>>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Menu renders a default state 1`] = `
<button
onClick={[Function]}
>
Menu trigger
</button>
`;
5 changes: 5 additions & 0 deletions packages/react-menu/src/components/Menu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './Menu';
export * from './Menu.types';
export * from './renderMenu';
export * from './useMenu';
export * from './useMenuStyles';
24 changes: 24 additions & 0 deletions packages/react-menu/src/components/Menu/renderMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from 'react';
import { getSlots } from '@fluentui/react-utilities';
import { MenuState } from './Menu.types';
import { menuShorthandProps } from './useMenu';
import { MenuProvider } from '../../menuContext';

/**
* Render the final JSX of Menu
* {@docCategory Menu }
*/
export const renderMenu = (state: MenuState) => {
const { slots, slotProps } = getSlots(state, menuShorthandProps);
const { open, setOpen, onCheckedValueChange, checkedValues, defaultCheckedValues } = state;

return (
<MenuProvider
value={{ open, setOpen, onCheckedValueChange, checkedValues, defaultCheckedValues, hasMenuContext: true }}
>
{state.menuTrigger}
{/** TODO use open state to control a real popup */}
{state.open && <slots.menuPopup {...slotProps.menuPopup} />}
</MenuProvider>
);
};

0 comments on commit 379ec44

Please sign in to comment.