Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Menu and MenuTrigger API #17271

Merged
merged 7 commits into from
Mar 7, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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} setOpen={setOpen}>
<MenuTrigger>
<button>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>
</>
);
55 changes: 55 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,46 @@ export interface MenuListState extends MenuListProps {
toggleCheckbox: SelectableHandler;
}

// @public
export interface MenuProps extends ComponentProps, React.HTMLAttributes<HTMLElement>, MenuListProps {
defaultOpen?: boolean;
menuPopup?: ShorthandProps<React.HTMLAttributes<HTMLElement>>;
open?: boolean;
setOpen?: React.Dispatch<React.SetStateAction<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 extends ComponentProps, React.HTMLAttributes<HTMLElement> {
}

// @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 +205,9 @@ export const renderMenuItemRadio: (state: MenuItemRadioState) => JSX.Element;
// @public
export const renderMenuList: (state: MenuListState) => JSX.Element;

// @public
export const renderMenuTrigger: (state: MenuTriggerState) => JSX.Element;

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

Expand All @@ -173,6 +219,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 +255,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';
30 changes: 30 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,30 @@
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';

describe('Menu', () => {
isConformant({
Component: Menu,
displayName: 'Menu',
});

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>Default Menu</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 { ComponentProps, 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 ComponentProps, React.HTMLAttributes<HTMLElement>, MenuListProps {
/**
* Whether the popup is open
*/
open?: boolean;

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

/**
* Callback to open/close the popup
*/
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
ling1726 marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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>>;
}
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';
26 changes: 26 additions & 0 deletions packages/react-menu/src/components/Menu/renderMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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} />}
</>
ling1726 marked this conversation as resolved.
Show resolved Hide resolved
</MenuProvider>
);
};
65 changes: 65 additions & 0 deletions packages/react-menu/src/components/Menu/useMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as React from 'react';
import { makeMergeProps, resolveShorthandProps, useMergedRefs, useControllableValue } from '@fluentui/react-utilities';
import { MenuProps, MenuState } from './Menu.types';
import { MenuTrigger } from '../MenuTrigger/index';
import { MenuList } from '../MenuList/index';

export const menuShorthandProps: (keyof MenuProps)[] = ['menuPopup'];

const mergeProps = makeMergeProps<MenuState>({ deepMerge: menuShorthandProps });

/**
* Create the state required to render Menu.
*
* The returned state can be modified with hooks such as useMenuStyles,
* before being passed to renderMenu.
*
* @param props - props from this instance of Menu
* @param ref - reference to root HTMLElement of Menu
* @param defaultProps - (optional) default prop values provided by the implementing type
*
* {@docCategory Menu }
*/
export const useMenu = (props: MenuProps, ref: React.Ref<HTMLElement>, defaultProps?: MenuProps): MenuState => {
const state = mergeProps(
{
ref: useMergedRefs(ref, React.useRef(null)),
menuPopup: { as: 'div' },
},
defaultProps,
resolveShorthandProps(props, menuShorthandProps),
);

// TODO Better way to narrow types ?
const children = React.Children.toArray(state.children) as React.ReactElement[];

// TODO throw warnings in development safely
if (children.length !== 2) {
// eslint-disable-next-line no-console
console.warn('Menu can only take one MenuTrigger and one MenuList as children');
}

children.forEach(child => {
if (child.type === MenuTrigger) {
state.menuTrigger = child;
}

if (child.type === MenuList) {
ling1726 marked this conversation as resolved.
Show resolved Hide resolved
state.menuList = child;
}
});

state.menuPopup.children = (Component, p) => {
return <Component {...p}> {state.menuList} </Component>;
};

const [open, setOpen] = useControllableValue(state.open, state.defaultOpen);
const { setOpen: setOpenProp } = state;
state.open = open !== undefined ? open : state.open;
state.setOpen = (...args) => {
setOpenProp && setOpenProp(...args);
setOpen(...args);
};
ling1726 marked this conversation as resolved.
Show resolved Hide resolved

return state;
};