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

[POC] MenuButton - Option 2 implementation #35719

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions docs/pages/experiments/base/menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import * as React from 'react';
import MenuUnstyled from '@mui/base/MenuUnstyled';
import MenuItemUnstyled, { menuItemUnstyledClasses } from '@mui/base/MenuItemUnstyled';
import PopperUnstyled from '@mui/base/PopperUnstyled';
import { styled } from '@mui/system';
import MenuButtonUnstyled from '@mui/base/MenuUnstyled/MenuButtonUnstyled';

const blue = {
100: '#DAECFF',
200: '#99CCF3',
400: '#3399FF',
500: '#007FFF',
600: '#0072E5',
900: '#003A75',
};

const grey = {
50: '#f6f8fa',
100: '#eaeef2',
200: '#d0d7de',
300: '#afb8c1',
400: '#8c959f',
500: '#6e7781',
600: '#57606a',
700: '#424a53',
800: '#32383f',
900: '#24292f',
};

const StyledListbox = styled('ul')(
({ theme }) => `
font-family: IBM Plex Sans, sans-serif;
font-size: 0.875rem;
box-sizing: border-box;
padding: 6px;
margin: 12px 0;
min-width: 200px;
border-radius: 12px;
overflow: auto;
outline: 0px;
background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
`,
);

const StyledMenuItem = styled(MenuItemUnstyled)(
({ theme }) => `
list-style: none;
padding: 8px;
border-radius: 8px;
cursor: default;
user-select: none;

&:last-of-type {
border-bottom: none;
}

&.${menuItemUnstyledClasses.focusVisible} {
outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
}

&.${menuItemUnstyledClasses.disabled} {
color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]};
}

&:hover:not(.${menuItemUnstyledClasses.disabled}) {
background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
}
`,
);

const MenuButton = styled('button')(
({ theme }) => `
font-family: IBM Plex Sans, sans-serif;
font-size: 0.875rem;
box-sizing: border-box;
min-height: calc(1.5em + 22px);
border-radius: 12px;
padding: 12px 16px;
line-height: 1.5;
background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};

transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 120ms;

&:hover {
background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]};
border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]};
}

&:focus {
border-color: ${blue[400]};
outline: 3px solid ${theme.palette.mode === 'dark' ? blue[500] : blue[200]};
}
`,
);

const Popper = styled(PopperUnstyled)`
z-index: 1;
`;

const Page = styled('div')(`
max-width: 800px;
min-height: calc(100vh - 40px);
box-sizing: border-box;
margin: 20px auto;
padding: 20px;
background: ${grey[100]};
border-radius: 4px;
`);

export default function UnstyledMenuIntroduction() {
const [isOpen, setOpen] = React.useState(false);

const close = () => {
setOpen(false);
};

const handleOpenChange = (event: React.SyntheticEvent<HTMLButtonElement>, open: boolean) => {
setOpen(open);
};

const createHandleMenuClick = (menuItem: string) => {
return () => {
// eslint-disable-next-line no-console
console.log(`Clicked on ${menuItem}`);
close();
};
};

return (
<Page>
<MenuButtonUnstyled
slots={{ root: MenuButton }}
open={isOpen}
onOpenChange={handleOpenChange}
popup={
<MenuUnstyled
slots={{ root: Popper, listbox: StyledListbox }}
slotProps={{ root: { placement: 'bottom-start' }, listbox: { id: 'simple-menu' } }}
>
<StyledMenuItem onClick={createHandleMenuClick('Profile')}>Profile</StyledMenuItem>
<StyledMenuItem onClick={createHandleMenuClick('My account')}>
Language settings
</StyledMenuItem>
<StyledMenuItem onClick={createHandleMenuClick('Log out')}>Log out</StyledMenuItem>
</MenuUnstyled>
}
>
My account
</MenuButtonUnstyled>
</Page>
);
}
111 changes: 111 additions & 0 deletions packages/mui-base/src/MenuUnstyled/MenuButtonUnstyled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import * as React from 'react';
import {
unstable_useForkRef as useForkRef,
unstable_useControlled as useControlled,
} from '@mui/utils';
import { useSlotProps } from '../utils';

export interface MenuButtonProps {
className?: string;
open?: boolean;
onOpenChange?: (
event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>,
open: boolean,
) => void;
defaultOpen?: boolean;
children?: React.ReactNode;
popup?: React.ReactNode;
slots?: {
root?: React.ElementType;
};
slotProps?: {
root?: React.HTMLAttributes<HTMLButtonElement>;
};
}

interface MenuTriggerContextValue {
open: boolean;
buttonRef: React.RefObject<HTMLButtonElement>;
}

export const MenuTriggerContext = React.createContext<MenuTriggerContextValue | null>(null);

const MenuButton = React.forwardRef(function MenuButton(
props: MenuButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>,
) {
const {
children,
open: openProp,
defaultOpen,
onOpenChange,
popup,
slots = {},
slotProps = {},
...other
} = props;

const [isOpen, setOpen] = useControlled({
controlled: openProp,
default: defaultOpen,
name: 'MenuButton',
});

const buttonRef = React.useRef<HTMLButtonElement>(null);
const handleRef = useForkRef(buttonRef, ref);

const contextValue: MenuTriggerContextValue = React.useMemo(
() => ({ open: isOpen, buttonRef }),
[isOpen, buttonRef],
);

const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
setOpen((open) => {
return !open;
});

onOpenChange?.(event, !isOpen);
},
[isOpen, setOpen, onOpenChange],
);

const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setOpen(true);
onOpenChange?.(event, true);
}
},
[setOpen, onOpenChange],
);

React.useEffect(() => {
if (!isOpen) {
buttonRef.current?.focus();
}
}, [isOpen]);

const Root = slots.root || 'button';
const rootProps = useSlotProps({
elementType: Root,
externalForwardedProps: other,
externalSlotProps: slotProps.root,
additionalProps: {
ref: handleRef,
onClick: handleClick,
onKeyDown: handleKeyDown,
},
ownerState: { ...props, open: isOpen },
});

return (
<MenuTriggerContext.Provider value={contextValue}>
<Root {...rootProps}>{children}</Root>
{popup}
</MenuTriggerContext.Provider>
);
});

export default MenuButton;
14 changes: 12 additions & 2 deletions packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import useMenu from './useMenu';
import composeClasses from '../composeClasses';
import PopperUnstyled from '../PopperUnstyled';
import useSlotProps from '../utils/useSlotProps';
import { MenuTriggerContext } from './MenuButtonUnstyled';

function getUtilityClasses(ownerState: MenuUnstyledOwnerState) {
const { open } = ownerState;
Expand All @@ -39,18 +40,27 @@ const MenuUnstyled = React.forwardRef(function MenuUnstyled<
>(props: MenuUnstyledProps<BaseComponentType>, forwardedRef: React.Ref<any>) {
const {
actions,
anchorEl,
anchorEl: anchorElProp,
children,
component,
keepMounted = false,
listboxId,
onClose,
open = false,
open: openProp = false,
slotProps = {},
slots = {},
...other
} = props;

let open = openProp;
let anchorEl = anchorElProp;

const triggerContext = React.useContext(MenuTriggerContext);
if (triggerContext) {
open = triggerContext.open;
anchorEl = triggerContext.buttonRef.current;
}

const {
registerItem,
unregisterItem,
Expand Down