-
-
Notifications
You must be signed in to change notification settings - Fork 32.2k
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 1 implementation #35671
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,160 @@ | ||||||||||||||
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')( | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that it would be better to call it
Suggested change
|
||||||||||||||
({ 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 }} | ||||||||||||||
label="My account" | ||||||||||||||
Comment on lines
+142
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The use of slots feels strange. Aren't the slot means to replace existing implementation for customization use cases? In this case, I would imagine that what's rendered as a menu trigger will most of the time be "userland". So I think that someone like this:
Suggested change
would make more sense. Also notice that it's an element, not a component in this proposal. Why? For the extra flexibility needed when dealing with userland APIs. By "userland", I mean API that is used by the product teams in the normal course of building a product using a design system. This is to be distinguished from the API used by the product infrastructure team, the one building the design system with MUI Base. Actually, in the demo, I think that this terminology would be clearer like this, to signal it can be any button:
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Why do you think developers will want to use something different than There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I'm more thinking about the following use cases: <MenuButtonUnstyled
slots={{ root: Button }}
label="My account"
variant="outlined"
size="small"
> I think that at the very least <MenuButtonUnstyled
slots={{ root: Button }}
variant="outlined"
size="small"
>
My account
</MenuButtonUnstyled> It could be clearer with this to match the terminology used in Material UI / Joy UI An even more flexible option (it would use <MenuTrigger>
<Button variant="outlined" size="small">
My account
</Button>
> with one downside: more boilerplate, which developers can abstract if they want (easy), but they can choose, we don't decide for them. |
||||||||||||||
open={isOpen} | ||||||||||||||
onOpenChange={handleOpenChange} | ||||||||||||||
> | ||||||||||||||
<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> | ||||||||||||||
</MenuButtonUnstyled> | ||||||||||||||
</Page> | ||||||||||||||
); | ||||||||||||||
} |
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; | ||||||
label?: string; | ||||||
open?: boolean; | ||||||
onOpenChange?: ( | ||||||
event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, | ||||||
open: boolean, | ||||||
) => void; | ||||||
defaultOpen?: boolean; | ||||||
children?: 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, | ||||||
label, | ||||||
open: openProp, | ||||||
defaultOpen, | ||||||
onOpenChange, | ||||||
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'; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only thing I am not sure here is the name There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, it makes sense. With a small caveat - we usually forward extra props to the root slot. I wonder where they should go in this case. I'd say the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm, isn't it better to keep only the From my understanding, MUI Base provides Changing it to |
||||||
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}>{label}</Root> | ||||||
{children} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't the MenuButton pass these props (or from context)?
Suggested change
One important reason of having a MenuButton component (in my opinion) is that developers don't have to declare the <MenuButtonUnstyled
slots={{ root: MenuButton }}
label="My account"
>
<MenuUnstyled
slots={{ root: Popper, listbox: StyledListbox }}
slotProps={{ root: { placement: 'bottom-start' }, listbox: { id: 'simple-menu' } }}
> There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Having There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 for using context |
||||||
</MenuTriggerContext.Provider> | ||||||
); | ||||||
}); | ||||||
|
||||||
export default MenuButton; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we don't need the
Styled
prefix if the base component hasUnstyled
in its name? This could be done here and in all the docs:The convention could be, anything with
Unstyled
needs to be styled to be used. The rest is ready to use, likePortal
.