Skip to content

Commit

Permalink
feat(material): add the MenuItem component
Browse files Browse the repository at this point in the history
  • Loading branch information
juanrgm committed Nov 16, 2022
1 parent cf4d1e4 commit 96cd89c
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/flat-forks-build.md
@@ -0,0 +1,5 @@
---
"@suid/material": minor
---

Add the `MenuItem` component
2 changes: 1 addition & 1 deletion ROADMAP.md
Expand Up @@ -118,7 +118,7 @@
| ListItemText ||
| ListSubheader ||
| Menu ||
| MenuItem | |
| MenuItem | |
| MenuList ||
| MobileStepper | |
| Modal ||
Expand Down
255 changes: 255 additions & 0 deletions packages/material/src/MenuItem/MenuItem.tsx
@@ -0,0 +1,255 @@
import { MenuItemTypeMap } from ".";
import ButtonBase from "../ButtonBase";
import { dividerClasses } from "../Divider";
import ListContext from "../List/ListContext";
import { listItemIconClasses } from "../ListItemIcon";
import { listItemTextClasses } from "../ListItemText";
import styled, { skipRootProps } from "../styles/styled";
import { getMenuItemUtilityClass } from "./menuItemClasses";
import menuItemClasses from "./menuItemClasses";
import createComponentFactory from "@suid/base/createComponentFactory";
import { alpha } from "@suid/system";
import createRef from "@suid/system/createRef";
import { InPropsOf } from "@suid/types";
import clsx from "clsx";
import {
useContext,
splitProps,
mergeProps,
createRenderEffect,
} from "solid-js";

const $ = createComponentFactory<MenuItemTypeMap>()({
name: "MuiMenuItem",
selfPropNames: [
"autoFocus",
"classes",
"dense",
"disabled",
"disableGutters",
"divider",
"selected",
],
utilityClass: getMenuItemUtilityClass,
slotClasses: (ownerState) => ({
root: [
"root",
ownerState.dense && "dense",
ownerState.disabled && "disabled",
!ownerState.disableGutters && "gutters",
ownerState.divider && "divider",
ownerState.selected && "selected",
],
}),
});

const MenuItemRoot = styled(ButtonBase, {
skipProps: skipRootProps.filter((v) => v !== "classes"),
name: "MuiMenuItem",
slot: "Root",
overridesResolver: (props, styles) => {
const { ownerState } = props;

return [
styles.root,
ownerState.dense && styles.dense,
ownerState.divider && styles.divider,
!ownerState.disableGutters && styles.gutters,
];
},
})<
InPropsOf<MenuItemTypeMap> & {
dense: boolean;
divider: boolean;
disableGutters: boolean;
}
>(({ theme, ownerState }) => ({
...theme.typography.body1,
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
position: "relative",
textDecoration: "none",
minHeight: 48,
paddingTop: 6,
paddingBottom: 6,
boxSizing: "border-box",
whiteSpace: "nowrap",
...(!ownerState.disableGutters && {
paddingLeft: 16,
paddingRight: 16,
}),
...(ownerState.divider && {
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundClip: "padding-box",
}),
"&:hover": {
textDecoration: "none",
backgroundColor: theme.palette.action.hover,
// Reset on touch devices, it doesn't add specificity
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
[`&.${menuItemClasses.selected}`]: {
backgroundColor: alpha(
theme.palette.primary.main,
theme.palette.action.selectedOpacity
),
[`&.${menuItemClasses.focusVisible}`]: {
backgroundColor: alpha(
theme.palette.primary.main,
theme.palette.action.selectedOpacity + theme.palette.action.focusOpacity
),
},
},
[`&.${menuItemClasses.selected}:hover`]: {
backgroundColor: alpha(
theme.palette.primary.main,
theme.palette.action.selectedOpacity + theme.palette.action.hoverOpacity
),
// Reset on touch devices, it doesn't add specificity
"@media (hover: none)": {
backgroundColor: alpha(
theme.palette.primary.main,
theme.palette.action.selectedOpacity
),
},
},
[`&.${menuItemClasses.focusVisible}`]: {
backgroundColor: theme.palette.action.focus,
},
[`&.${menuItemClasses.disabled}`]: {
opacity: theme.palette.action.disabledOpacity,
},
[`& + .${dividerClasses.root}`]: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
[`& + .${dividerClasses.inset}`]: {
marginLeft: 52,
},
[`& .${listItemTextClasses.root}`]: {
marginTop: 0,
marginBottom: 0,
},
[`& .${listItemTextClasses.inset}`]: {
paddingLeft: 36,
},
[`& .${listItemIconClasses.root}`]: {
minWidth: 36,
},
...(!ownerState.dense && {
[theme.breakpoints.up("sm")]: {
minHeight: "auto",
},
}),
...(ownerState.dense && {
minHeight: 32, // https://material.io/components/menus#specs > Dense
paddingTop: 4,
paddingBottom: 4,
...theme.typography.body2,
[`& .${listItemIconClasses.root} svg`]: {
fontSize: "1.25rem",
},
}),
}));

/**
*
* Demos:
*
* - [Menus](https://mui.com/components/menus/)
*
* API:
*
* - [MenuItem API](https://mui.com/api/menu-item/)
* - inherits [ButtonBase API](https://mui.com/api/button-base/)
*/
const MenuItem = $.defineComponent(function MenuItem(inProps) {
const menuItemRef = createRef<HTMLElement>(inProps);
const props = $.useThemeProps({ props: inProps });
const [, other] = splitProps(props, [
"autoFocus",
"component",
"dense",
"divider",
"disableGutters",
"focusVisibleClassName",
"role",
"tabIndex",
]);

const baseProps = mergeProps(
{
autoFocus: false,
component: "li",
dense: false,
divider: false,
disableGutters: false,
role: "menuitem",
},
props
);

const context = useContext(ListContext);
const childContext = {
get dense() {
return baseProps.dense || context.dense || false;
},
get disableGutters() {
return baseProps.disableGutters;
},
};

createRenderEffect(() => {
if (baseProps.autoFocus) {
if (menuItemRef.current) {
menuItemRef.current.focus();
} else if (process.env.NODE_ENV !== "production") {
console.error(
"MUI: Unable to set focus to a MenuItem whose component has not been rendered."
);
}
}
}, [baseProps.autoFocus]);

const ownerState = mergeProps(props, {
get dense() {
return childContext.dense;
},
get divider() {
return baseProps.divider;
},
get disableGutters() {
return baseProps.disableGutters;
},
});

const classes = $.useClasses(props);
const tabIndex = () => {
if (!props.disabled) {
return props.tabIndex !== undefined ? props.tabIndex : -1;
}
};

return (
<ListContext.Provider value={childContext}>
<MenuItemRoot
ref={menuItemRef}
role={baseProps.role}
tabIndex={tabIndex()}
component={baseProps.component}
focusVisibleClassName={clsx(
ownerState.classes?.focusVisible,
props.focusVisibleClassName
)}
{...other}
ownerState={ownerState}
classes={classes}
/>
</ListContext.Provider>
);
});

export default MenuItem;
75 changes: 75 additions & 0 deletions packages/material/src/MenuItem/MenuItemProps.tsx
@@ -0,0 +1,75 @@
import { ExtendButtonBaseTypeMap } from "../ButtonBase";
import { Theme } from "../styles";
import { MenuItemClasses } from "./menuItemClasses";
import { SxProps } from "@suid/system";
import * as ST from "@suid/types";

export type MenuItemTypeMap<P = {}, D extends ST.ElementType = "li"> = {
name: "MuiMenuItem";
defaultPropNames:
| "autoFocus"
| "dense"
| "disabled"
| "disableGutters"
| "divider"
| "selected";
selfProps: {
/**
* If `true`, the list item is focused during the first mount.
* Focus will also be triggered if the value changes from false to true.
* @default false
*/
autoFocus?: boolean;

/**
* Override or extend the styles applied to the component.
*/
classes?: Partial<MenuItemClasses>;

/**
* If `true`, compact vertical padding designed for keyboard and mouse input is used.
* The prop defaults to the value inherited from the parent Menu component.
* @default false
*/
dense?: boolean;

/**
* If `true`, the component is disabled.
* @default false
*/
disabled?: boolean;

/**
* If `true`, the left and right padding is removed.
* @default false
*/
disableGutters?: boolean;

/**
* If `true`, a 1px light border is added to the bottom of the menu item.
* @default false
*/
divider?: boolean;

/**
* Use to apply selected styling.
* @default false
*/
selected?: boolean;

/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps<Theme>;
};
} & ExtendButtonBaseTypeMap<{
props: P & MenuItemTypeMap["selfProps"];
defaultComponent: D;
}>;

export type MenuItemProps<
D extends ST.ElementType = MenuItemTypeMap["defaultComponent"],
P = {}
> = ST.OverrideProps<MenuItemTypeMap<P, D>, D>;

export default MenuItemProps;
7 changes: 7 additions & 0 deletions packages/material/src/MenuItem/index.tsx
@@ -0,0 +1,7 @@
export { default } from "./MenuItem";
export * from "./MenuItem";

export * from "./menuItemClasses";
export { default as menuItemClasses } from "./menuItemClasses";

export * from "./MenuItemProps";
37 changes: 37 additions & 0 deletions packages/material/src/MenuItem/menuItemClasses.ts
@@ -0,0 +1,37 @@
import generateUtilityClass from "@suid/base/generateUtilityClass";
import generateUtilityClasses from "@suid/base/generateUtilityClasses";

export interface MenuItemClasses {
/** Styles applied to the root element. */
root: string;
/** State class applied to the root element if keyboard focused. */
focusVisible: string;
/** Styles applied to the root element if dense. */
dense: string;
/** State class applied to the root element if `disabled={true}`. */
disabled: string;
/** Styles applied to the root element if `divider={true}`. */
divider: string;
/** Styles applied to the inner `component` element unless `disableGutters={true}`. */
gutters: string;
/** State class applied to the root element if `selected={true}`. */
selected: string;
}

export type MenuItemClassKey = keyof MenuItemClasses;

export function getMenuItemUtilityClass(slot: string): string {
return generateUtilityClass("MuiMenuItem", slot);
}

const menuItemClasses: MenuItemClasses = generateUtilityClasses("MuiMenuItem", [
"root",
"focusVisible",
"dense",
"disabled",
"divider",
"gutters",
"selected",
]);

export default menuItemClasses;

0 comments on commit 96cd89c

Please sign in to comment.