diff --git a/.changeset/shaggy-carrots-talk.md b/.changeset/shaggy-carrots-talk.md new file mode 100644 index 0000000000..7ea4888b69 --- /dev/null +++ b/.changeset/shaggy-carrots-talk.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/menu': minor +--- + +Adds `title` and `glyph` props to `MenuGroup`. Providing a title to `MenuGroup` will visually indent the child `MenuItem` components, appearing nested within the group. \ No newline at end of file diff --git a/packages/menu/package.json b/packages/menu/package.json index 454e3ac92c..8dd2a2d18c 100644 --- a/packages/menu/package.json +++ b/packages/menu/package.json @@ -27,11 +27,13 @@ "@leafygreen-ui/hooks": "^8.1.3", "@leafygreen-ui/icon": "^12.5.4", "@leafygreen-ui/icon-button": "^15.0.21", + "@leafygreen-ui/input-option": "^1.1.4", "@leafygreen-ui/lib": "^13.6.0", "@leafygreen-ui/palette": "^4.0.9", "@leafygreen-ui/popover": "^11.4.0", "@leafygreen-ui/polymorphic": "^2.0.0", "@leafygreen-ui/tokens": "^2.9.0", + "@leafygreen-ui/typography": "^19.2.0", "lodash": "^4.17.21", "polished": "^4.3.1", "react-transition-group": "^4.4.5" diff --git a/packages/menu/src/Menu.stories.tsx b/packages/menu/src/Menu.stories.tsx index 20361ed842..e745ae87e3 100644 --- a/packages/menu/src/Menu.stories.tsx +++ b/packages/menu/src/Menu.stories.tsx @@ -20,7 +20,14 @@ import { TestUtils } from '@leafygreen-ui/popover'; const { getAlign, getJustify } = TestUtils; import { Size } from './types'; -import { Menu, MenuItem, MenuProps, MenuSeparator, SubMenu } from '.'; +import { + Menu, + MenuGroup, + MenuItem, + MenuProps, + MenuSeparator, + SubMenu, +} from '.'; const getDecoratorStyles = (args: Partial) => { return css` @@ -66,7 +73,7 @@ export default { align: 'bottom', usePortal: true, darkMode: false, - renderDarkMenu: true, + renderDarkMenu: false, }, argTypes: { open: { @@ -132,14 +139,13 @@ export const LiveExample = { Delete - Lorem - Ipsum - Adipiscing - Cursus - Ullamcorper - Vulputate - Inceptos - Risus + + Lorem + Ipsum + Dolor + Sit + Amet + ); }, diff --git a/packages/menu/src/MenuContext/GroupContext.tsx b/packages/menu/src/MenuContext/GroupContext.tsx new file mode 100644 index 0000000000..10f0f7d013 --- /dev/null +++ b/packages/menu/src/MenuContext/GroupContext.tsx @@ -0,0 +1,23 @@ +import React, { createContext, PropsWithChildren, useContext } from 'react'; + +export interface MenuGroupContextData { + depth: number; + hasIcon: boolean; +} + +export const MenuGroupContext = createContext({ + depth: 0, + hasIcon: false, +}); + +export const MenuGroupProvider = ({ + children, + depth, + hasIcon = false, +}: PropsWithChildren) => ( + + {children} + +); + +export const useMenuGroupContext = () => useContext(MenuGroupContext); diff --git a/packages/menu/src/MenuContext/MenuContext.tsx b/packages/menu/src/MenuContext/MenuContext.tsx index 7a4a9a3b45..59e0caf472 100644 --- a/packages/menu/src/MenuContext/MenuContext.tsx +++ b/packages/menu/src/MenuContext/MenuContext.tsx @@ -1,8 +1,24 @@ import { createContext, useContext } from 'react'; import { createDescendantsContext } from '@leafygreen-ui/descendants'; +import { Descendant } from '@leafygreen-ui/descendants'; +import { Theme } from '@leafygreen-ui/lib'; -import { MenuContextData } from './MenuContext.types'; +import { HighlightReducerReturnType } from '../HighlightReducer/highlight.types'; + +export interface MenuContextData { + theme: Theme; + darkMode: boolean; + + /** The index of the currently highlighted (focused) item */ + highlight?: Descendant; + + /** Sets the current highlight by index or id */ + setHighlight?: HighlightReducerReturnType['setHighlight']; + + /** Whether to render a dark menu in light mode */ + renderDarkMenu?: boolean; +} export const MenuDescendantsContext = createDescendantsContext( 'MenuDescendantsContext', @@ -15,5 +31,3 @@ export const MenuContext = createContext({ }); export const useMenuContext = () => useContext(MenuContext); - -export default MenuContext; diff --git a/packages/menu/src/MenuContext/MenuContext.types.ts b/packages/menu/src/MenuContext/MenuContext.types.ts deleted file mode 100644 index 3a41943903..0000000000 --- a/packages/menu/src/MenuContext/MenuContext.types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Descendant } from '@leafygreen-ui/descendants'; -import { Theme } from '@leafygreen-ui/lib'; - -import { HighlightReducerReturnType } from '../HighlightReducer/highlight.types'; - -export interface MenuContextData { - theme: Theme; - darkMode: boolean; - - /** The index of the currently highlighted (focused) item */ - // highlightIndex?: number; - highlight?: Descendant; - - setHighlight?: HighlightReducerReturnType['setHighlight']; - - /** Whether to a dark menu in light mode */ - renderDarkMenu?: boolean; -} diff --git a/packages/menu/src/MenuContext/SubMenuContext.tsx b/packages/menu/src/MenuContext/SubMenuContext.tsx new file mode 100644 index 0000000000..51e50bd6f2 --- /dev/null +++ b/packages/menu/src/MenuContext/SubMenuContext.tsx @@ -0,0 +1,23 @@ +import React, { createContext, PropsWithChildren, useContext } from 'react'; + +export interface SubMenuContextData { + depth: number; + hasIcon: boolean; +} + +export const SubMenuContext = createContext({ + depth: 0, + hasIcon: false, +}); + +export const SubMenuProvider = ({ + children, + depth, + hasIcon = false, +}: PropsWithChildren) => ( + + {children} + +); + +export const useSubMenuContext = () => useContext(SubMenuContext); diff --git a/packages/menu/src/MenuContext/index.ts b/packages/menu/src/MenuContext/index.ts index 86352e4de7..86c6880e74 100644 --- a/packages/menu/src/MenuContext/index.ts +++ b/packages/menu/src/MenuContext/index.ts @@ -1,5 +1,18 @@ export { - default as MenuContext, + type MenuGroupContext, + MenuGroupContextData, + MenuGroupProvider, + useMenuGroupContext, +} from './GroupContext'; +export { + MenuContext, + type MenuContextData, MenuDescendantsContext, useMenuContext, } from './MenuContext'; +export { + SubMenuContext, + type SubMenuContextData, + SubMenuProvider, + useSubMenuContext, +} from './SubMenuContext'; diff --git a/packages/menu/src/MenuGroup/MenuGroup.stories.tsx b/packages/menu/src/MenuGroup/MenuGroup.stories.tsx new file mode 100644 index 0000000000..9019f33d21 --- /dev/null +++ b/packages/menu/src/MenuGroup/MenuGroup.stories.tsx @@ -0,0 +1,90 @@ +/* eslint-disable react/jsx-key */ +import React from 'react'; +import { StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryObj } from '@storybook/react'; + +import { css } from '@leafygreen-ui/emotion'; +import Icon, { glyphs } from '@leafygreen-ui/icon'; + +import { MenuItem } from '../MenuItem'; +import { SubMenu } from '../SubMenu'; +import { withMenuContext } from '../testUtils/withMenuContextDecorator.testutils'; + +import { MenuGroup } from './MenuGroup'; + +export default { + title: 'Components/Menu/MenuGroup', + component: MenuGroup, + parameters: { + default: null, + }, + args: { + title: 'Group', + glyph: 'AllProducts', + darkMode: false, + }, + argTypes: { + darkMode: { + control: 'boolean', + }, + glyph: { + control: 'select', + options: [undefined, ...Object.keys(glyphs)], + }, + }, + decorators: [withMenuContext()], +} satisfies StoryMetaType; + +export const LiveExample = { + render: ({ glyph, ...args }) => ( +
+ + } + > + Apple + Banana + Carrot + + JalapeƱo + Habanero + Ghost + + + + Lasagna + Haggis + }> + Jellybeans + Chocolate + Cotton Candy + + +
+ ), + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, +} satisfies StoryObj; + +export const Generated = { + render: () => <>, + parameters: { + generate: { + combineArgs: { + darkMode: [false, true], + glyph: [undefined, ], + }, + decorator: withMenuContext(), + }, + }, +} satisfies StoryObj; diff --git a/packages/menu/src/MenuGroup/MenuGroup.styles.ts b/packages/menu/src/MenuGroup/MenuGroup.styles.ts new file mode 100644 index 0000000000..43cb204e26 --- /dev/null +++ b/packages/menu/src/MenuGroup/MenuGroup.styles.ts @@ -0,0 +1,19 @@ +import { css } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { color } from '@leafygreen-ui/tokens'; + +import { menuColor } from '../styles'; + +export const getMenuGroupItemStyles = (theme: Theme) => css` + cursor: unset; + background-color: ${menuColor[theme].background.default}; +`; + +export const getMenuGroupTitleStyles = (theme: Theme) => css` + color: ${color[theme].text.secondary.default}; +`; + +export const menuGroupULStyles = css` + margin: 0; + padding: 0; +`; diff --git a/packages/menu/src/MenuGroup/MenuGroup.tsx b/packages/menu/src/MenuGroup/MenuGroup.tsx index ed9da3b34f..74ca3bc097 100644 --- a/packages/menu/src/MenuGroup/MenuGroup.tsx +++ b/packages/menu/src/MenuGroup/MenuGroup.tsx @@ -1,23 +1,74 @@ import * as React from 'react'; import PropTypes from 'prop-types'; +import { useIdAllocator } from '@leafygreen-ui/hooks'; +import { InputOption, InputOptionContent } from '@leafygreen-ui/input-option'; +import { Overline } from '@leafygreen-ui/typography'; + +import { + MenuGroupProvider, + useMenuContext, + useMenuGroupContext, +} from '../MenuContext'; + +import { + getMenuGroupItemStyles, + getMenuGroupTitleStyles, + menuGroupULStyles, +} from './MenuGroup.styles'; import { MenuGroupProps } from './MenuGroup.types'; /** * # MenuGroup * * ``` - - Hello World! + + Item 1 * ``` * @param props.children Content to appear inside of the MenuGroup. * */ -export function MenuGroup({ children, className, ...rest }: MenuGroupProps) { +export function MenuGroup({ + children, + className, + title, + glyph, + ...rest +}: MenuGroupProps) { + const { theme, darkMode } = useMenuContext(); + const id = useIdAllocator({ prefix: 'lg-menu-group' }); + const { depth } = useMenuGroupContext(); + + const shouldRenderGroupHeader = !!title; + const hasIcon = shouldRenderGroupHeader && !!glyph; + // We only indent the child items if we render a title here, + // otherwise we just pass through + const nextGroupDepth = depth + (shouldRenderGroupHeader ? 1 : 0); + return (
- {children} + {title && ( + + + + {title} + + + + )} + +
    + {children} +
+
); } diff --git a/packages/menu/src/MenuGroup/MenuGroup.types.ts b/packages/menu/src/MenuGroup/MenuGroup.types.ts index 9e72464469..4b70856400 100644 --- a/packages/menu/src/MenuGroup/MenuGroup.types.ts +++ b/packages/menu/src/MenuGroup/MenuGroup.types.ts @@ -1,5 +1,15 @@ import { HTMLElementProps } from '@leafygreen-ui/lib'; export interface MenuGroupProps extends HTMLElementProps<'div'> { + /** + * Main text rendered in `MenuGroup`. + */ + title?: string; + + /** + * Slot to pass in an Icon rendered to the left of the text. + */ + glyph?: React.ReactElement; + /** * Content that will appear inside of MenuGroup component. * @type `` | `` | `` | `` diff --git a/packages/menu/src/MenuItem/InternalMenuItemContent.tsx b/packages/menu/src/MenuItem/InternalMenuItemContent.tsx index 2f236e69c7..887f702560 100644 --- a/packages/menu/src/MenuItem/InternalMenuItemContent.tsx +++ b/packages/menu/src/MenuItem/InternalMenuItemContent.tsx @@ -7,15 +7,18 @@ import { PolymorphicAs, useInferredPolymorphic, } from '@leafygreen-ui/polymorphic'; -import { color, spacing } from '@leafygreen-ui/tokens'; +import { color } from '@leafygreen-ui/tokens'; -import { useMenuContext } from '../MenuContext'; -import { useSubMenuContext } from '../SubMenu'; +import { + useMenuContext, + useMenuGroupContext, + useSubMenuContext, +} from '../MenuContext'; import { getDarkInLightModeMenuItemStyles, getMenuItemStyles, - getSubMenuItemStyles, + getNestedMenuItemStyles, } from './MenuItem.styles'; import { MenuItemProps, Variant } from './MenuItem.types'; @@ -53,9 +56,16 @@ export const InternalMenuItemContent = React.forwardRef< const { as } = useInferredPolymorphic(asProp, rest, 'button'); const { theme, darkMode, highlight, renderDarkMenu } = useMenuContext(); - const { depth, hasIcon: parentHasIcon } = useSubMenuContext(); - const isSubMenuItem = depth > 0; - const highlighted = id === highlight?.id; + const { depth: submenuDepth, hasIcon: submenuHasIcon } = + useSubMenuContext(); + const { depth: groupDepth, hasIcon: groupHasIcon } = useMenuGroupContext(); + const isNested = !!(submenuDepth || groupDepth); + + // @ts-expect-error + // highlighted isn't a prop on this component, but could be passed in from MenuItem. + // Generally this will not be provided, but is permitted here to support isolated visual testing in Storybook + const forceHighlight = rest.highlighted; + const highlighted = id === highlight?.id || forceHighlight; const defaultAnchorProps = as === 'a' @@ -78,7 +88,7 @@ export const InternalMenuItemContent = React.forwardRef< darkMode={darkMode} showWedge highlighted={highlighted} - data-depth={depth} + data-depth={submenuDepth} className={cx( getMenuItemStyles({ active, @@ -89,7 +99,13 @@ export const InternalMenuItemContent = React.forwardRef< }), { - [getSubMenuItemStyles({ theme, parentHasIcon })]: isSubMenuItem, + [getNestedMenuItemStyles({ + theme, + submenuDepth, + submenuHasIcon, + groupDepth, + groupHasIcon, + })]: isNested, // TODO: Remove dark-in-light mode styles // after https://jira.mongodb.org/browse/LG-3974 @@ -104,7 +120,7 @@ export const InternalMenuItemContent = React.forwardRef< &:after { background-color: ${color.dark.border.secondary.default}; } - `]: theme === 'light' && renderDarkMenu && depth > 0, + `]: theme === 'light' && renderDarkMenu && submenuDepth > 0, }, className, )} @@ -116,13 +132,6 @@ export const InternalMenuItemContent = React.forwardRef< description={description} rightGlyph={rightGlyph} preserveIconSpace={false} - className={cx({ - [css` - position: relative; - padding-left: ${parentHasIcon ? spacing[900] : spacing[600]}px; - border-top: 1px solid transparent; - `]: depth > 0, - })} >
=> (Instance, ctx) => { - const { - args: { darkMode: darkModeProp, renderDarkMenu, highlighted, ...props }, - } = ctx ?? { - args: { - darkMode: false, - renderDarkMenu: false, - highlighted: false, - }, - }; - - const ref = useRef(null); - const [testDescendant, setTestDescendant] = useState(); - useEffect(() => { - setTestDescendant({ - ref, - element: ref.current, - id: ref?.current?.getAttribute('data-id'), - index: Number(ref?.current?.getAttribute('data-index')), - } as Descendant); - }, []); - const darkMode = (renderDarkMenu || darkModeProp) ?? false; - const theme = darkMode ? Theme.Dark : Theme.Light; - - return ( - -
    - -
-
- ); - }; - export default { title: 'Components/Menu/MenuItem', component: MenuItem, @@ -80,7 +32,7 @@ export default { combineArgs: { darkMode: [false, true], }, - decorator: _withMenuContext(), + decorator: withMenuContext(), }, }, } satisfies StoryMetaType>; @@ -111,7 +63,7 @@ export const LiveExample = { {children} ), - decorators: [_withMenuContext()], + decorators: [withMenuContext()], parameters: { chromatic: { disableSnapshot: true, diff --git a/packages/menu/src/MenuItem/MenuItem.styles.ts b/packages/menu/src/MenuItem/MenuItem.styles.ts index 809c5551a0..2ff3296837 100644 --- a/packages/menu/src/MenuItem/MenuItem.styles.ts +++ b/packages/menu/src/MenuItem/MenuItem.styles.ts @@ -1,6 +1,7 @@ import { css, cx } from '@leafygreen-ui/emotion'; import { descriptionClassName, + inputOptionContentClassName, leftGlyphClassName, titleClassName, } from '@leafygreen-ui/input-option'; @@ -131,24 +132,6 @@ export const getMenuItemStyles = ({ }, ); -export const getSubMenuItemStyles = ({ - theme, - parentHasIcon, -}: { - theme: Theme; - parentHasIcon: boolean; -}) => css` - &:after { - content: ''; - position: absolute; - top: 0; - right: 0; - left: ${parentHasIcon ? spacing[900] : spacing[600]}px; - height: 1px; - background-color: ${menuColor[theme].border.default}; - } -`; - export const getMenuItemContentStyles = ({ hasGlyph, }: { @@ -160,6 +143,52 @@ export const getMenuItemContentStyles = ({ `} `; +interface NestedItemStyleArgs { + theme: Theme; + submenuDepth: number; + groupDepth: number; + submenuHasIcon: boolean; + groupHasIcon: boolean; +} + +/** Styling for nested items */ +export const getNestedMenuItemStyles = ({ + theme, + submenuDepth, + submenuHasIcon, + groupDepth, + groupHasIcon, +}: NestedItemStyleArgs) => { + const submenuInset = + submenuDepth * (submenuHasIcon ? spacing[1000] : spacing[300]); + const groupInset = groupDepth * (groupHasIcon ? spacing[600] : spacing[300]); + const totalInset = submenuInset + groupInset; + + return cx( + { + // The inset border for submenu items + [css` + &:after { + content: ''; + position: absolute; + top: 0; + right: 0; + left: ${totalInset}px; + height: 1px; + background-color: ${menuColor[theme].border.default}; + } + `]: submenuDepth > 0, + }, + css` + .${inputOptionContentClassName} { + position: relative; + padding-left: ${totalInset}px; + border-top: 1px solid transparent; + } + `, + ); +}; + // TODO: Remove dark-in-light mode styles // after https://jira.mongodb.org/browse/LG-3974 export const getDarkInLightModeMenuItemStyles = ({ diff --git a/packages/menu/src/SubMenu/SubMenu.tsx b/packages/menu/src/SubMenu/SubMenu.tsx index 5e6895eb3e..6df913f4ac 100644 --- a/packages/menu/src/SubMenu/SubMenu.tsx +++ b/packages/menu/src/SubMenu/SubMenu.tsx @@ -20,7 +20,12 @@ import { } from '@leafygreen-ui/polymorphic'; import { LGIDs } from '../constants'; -import { MenuDescendantsContext, useMenuContext } from '../MenuContext'; +import { + MenuDescendantsContext, + SubMenuProvider, + useMenuContext, + useSubMenuContext, +} from '../MenuContext'; import { InternalMenuItemContent } from '../MenuItem/InternalMenuItemContent'; import { @@ -31,7 +36,6 @@ import { submenuToggleStyles, } from './SubMenu.styles'; import { InternalSubMenuProps } from './SubMenu.types'; -import { SubMenuProvider, useSubMenuContext } from './SubMenuContext'; import { useChildrenHeight } from './useChildrenHeight'; import { useControlledState } from './useControlledState'; diff --git a/packages/menu/src/SubMenu/index.ts b/packages/menu/src/SubMenu/index.ts index 3e99bbf493..35c15083e7 100644 --- a/packages/menu/src/SubMenu/index.ts +++ b/packages/menu/src/SubMenu/index.ts @@ -1,3 +1,2 @@ export { SubMenu } from './SubMenu'; export { InternalSubMenuProps, SubMenuProps } from './SubMenu.types'; -export { useSubMenuContext } from './SubMenuContext'; diff --git a/packages/menu/src/testUtils/withMenuContextDecorator.testutils.tsx b/packages/menu/src/testUtils/withMenuContextDecorator.testutils.tsx new file mode 100644 index 0000000000..710befdd6e --- /dev/null +++ b/packages/menu/src/testUtils/withMenuContextDecorator.testutils.tsx @@ -0,0 +1,45 @@ +/* eslint-disable react/jsx-key, react/display-name, react-hooks/rules-of-hooks */ +import React from 'react'; +import { InstanceDecorator } from '@lg-tools/storybook-utils'; + +import { css } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; + +import { Menu } from '../Menu'; +import { MenuContext } from '../MenuContext'; +import { MenuItem } from '../MenuItem'; + +/** + * Implements a MenuContext wrapper around each `MenuItem`, `SubMenu` or `MenuGroup` + */ +export const withMenuContext = + (): InstanceDecorator => (Instance, ctx) => { + const { + args: { darkMode: darkModeProp, renderDarkMenu }, + } = ctx ?? { + args: { + darkMode: false, + renderDarkMenu: false, + }, + }; + + const darkMode = (renderDarkMenu || darkModeProp) ?? false; + const theme = darkMode ? Theme.Dark : Theme.Light; + + return ( +
+ + + +
+ ); + }; diff --git a/packages/menu/tsconfig.json b/packages/menu/tsconfig.json index 8441ac11ab..ef8bcaf4c9 100644 --- a/packages/menu/tsconfig.json +++ b/packages/menu/tsconfig.json @@ -48,6 +48,9 @@ { "path": "../tokens" }, + { + "path": "../typography" + }, { "path": "../leafygreen-provider" }