Repository: marigold-ui/marigold
Pull request #2877 (2023-03-22 09:22:17)

Components/menu section

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: f0429762305e8f6dd315b7029aafcfe0c44dfb5b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/marigold-ui/marigold/new/components/menu-section?filename=.changeset/weak-lions-promise.md&value=---%0A%22%40marigold%2Fdocs%22%3A%20patch%0A%22%40marigold%2Fcomponents%22%3A%20patch%0A%22%40marigold%2Ftheme-b2b%22%3A%20patch%0A%22%40marigold%2Ftheme-unicorn%22%3A%20patch%0A---%0A%0AComponents%2Fmenu%20section%0A)


codecov[bot]: ## [Codecov](https://codecov.io/gh/marigold-ui/marigold/pull/2877?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui) Report
> Merging [#2877](https://codecov.io/gh/marigold-ui/marigold/pull/2877?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui) (9e39dda) into [main](https://codecov.io/gh/marigold-ui/marigold/commit/f2c555cc0a39f1bf675443dcd24a754c079386b8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui) (f2c555c) will **decrease** coverage by `0.03%`.
> The diff coverage is `98.27%`.

[![Impacted file tree graph](https://codecov.io/gh/marigold-ui/marigold/pull/2877/graphs/tree.svg?width=650&height=150&src=pr&token=YIIcKX7TZu&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui)](https://codecov.io/gh/marigold-ui/marigold/pull/2877?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui)

```diff
@@             Coverage Diff             @@
##              main    #2877      +/-   ##
===========================================
- Coverage   100.00%   99.97%   -0.03%     
===========================================
  Files          107      108       +1     
  Lines         6852     6954     +102     
  Branches       318      326       +8     
===========================================
+ Hits          6852     6952     +100     
- Misses           0        2       +2     
```

| [Impacted Files](https://codecov.io/gh/marigold-ui/marigold/pull/2877?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui) | Coverage Δ | |
|---|---|---|
| [packages/components/src/Menu/MenuItem.tsx](https://codecov.io/gh/marigold-ui/marigold/pull/2877?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui#diff-cGFja2FnZXMvY29tcG9uZW50cy9zcmMvTWVudS9NZW51SXRlbS50c3g=) | `100.00% <ø> (ø)` | |
| [packages/components/src/Menu/MenuSection.tsx](https://codecov.io/gh/marigold-ui/marigold/pull/2877?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui#diff-cGFja2FnZXMvY29tcG9uZW50cy9zcmMvTWVudS9NZW51U2VjdGlvbi50c3g=) | `97.80% <97.80%> (ø)` | |
| [packages/components/src/Menu/Menu.tsx](https://codecov.io/gh/marigold-ui/marigold/pull/2877?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui#diff-cGFja2FnZXMvY29tcG9uZW50cy9zcmMvTWVudS9NZW51LnRzeA==) | `100.00% <100.00%> (ø)` | |


Patch:
diff --git a/.changeset/twelve-hats-push.md b/.changeset/twelve-hats-push.md
new file mode 100644
index 000000000..48a97fa7d
--- /dev/null
+++ b/.changeset/twelve-hats-push.md
@@ -0,0 +1,7 @@
+---
+"@marigold/docs": patch
+"@marigold/components": patch
+"@marigold/theme-b2b": patch
+---
+
+feat: added menu section
diff --git a/docs/src/demos/components/Menu/index.ts b/docs/src/demos/components/Menu/index.ts
index 6e00fed2b..c93b327ff 100644
--- a/docs/src/demos/components/Menu/index.ts
+++ b/docs/src/demos/components/Menu/index.ts
@@ -3,3 +3,4 @@ export * from './menu-disabled.demo';
 export * from './kebab-menu.demo';
 export * from './menu-open.demo';
 export * from './menu-open-dialog.demo';
+export * from './menu-section.demo';
diff --git a/docs/src/demos/components/Menu/menu-section.demo.tsx b/docs/src/demos/components/Menu/menu-section.demo.tsx
new file mode 100644
index 000000000..933c69f01
--- /dev/null
+++ b/docs/src/demos/components/Menu/menu-section.demo.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Button, Menu } from '@marigold/components';
+
+export const MenuWithSections = () => {
+  return (
+    <Menu.Trigger>
+      <Button variant="menu" size="small">
+        Open Menu
+      </Button>
+      <Menu aria-label="Menu with sections">
+        <Menu.Section title="Food">
+          <Menu.Item key="burger">🍔 Burger</Menu.Item>
+          <Menu.Item key="pizza">🍕 Pizza</Menu.Item>
+          <Menu.Item key="fries">🍟 Fries</Menu.Item>
+        </Menu.Section>
+        <Menu.Section title="Fruits">
+          <Menu.Item key="apple">🍎 Apple</Menu.Item>
+          <Menu.Item key="banana">🍌 Banana</Menu.Item>
+          <Menu.Item key="strawberry">🍓 Strawberry</Menu.Item>
+        </Menu.Section>
+      </Menu>
+    </Menu.Trigger>
+  );
+};
diff --git a/docs/src/pages/components/menu.mdx b/docs/src/pages/components/menu.mdx
index c17e60b34..fcbc85292 100644
--- a/docs/src/pages/components/menu.mdx
+++ b/docs/src/pages/components/menu.mdx
@@ -93,6 +93,14 @@ In this example you can see a simple `<Menu>` with some items to select. After s
 
 ```
 
+### Menu with Sections
+
+To group related MenuItems, you can use `<Menu.Section>` and pass `title` for the group name.
+
+```tsx preview file=components/Menu/menu-section.demo.tsx
+
+```
+
 ### Disabled Menu
 
 In this example the `<Menu.Trigger>` has set its prop `disabled`. So you can't interact with the `<Menu>` anymore.
diff --git a/docs/src/theme/components/Menu.style.ts b/docs/src/theme/components/Menu.style.ts
index 4d10b872f..e527e7504 100644
--- a/docs/src/theme/components/Menu.style.ts
+++ b/docs/src/theme/components/Menu.style.ts
@@ -14,7 +14,6 @@ export const Menu: Theme['components']['Menu'] = {
     item: {
       p: 'small-1',
       cursor: 'pointer',
-
       '&:hover': {
         color: 'brand.primary',
       },
diff --git a/packages/components/src/Menu/Menu.stories.tsx b/packages/components/src/Menu/Menu.stories.tsx
index a3a3cbeca..41d06b342 100644
--- a/packages/components/src/Menu/Menu.stories.tsx
+++ b/packages/components/src/Menu/Menu.stories.tsx
@@ -71,6 +71,29 @@ export const MenuOnly: Story = {
   ),
 };
 
+export const MenuSection: Story = {
+  render: () => (
+    <Menu.Trigger>
+      <Button variant="menu" size="small">
+        open menu
+      </Button>
+      <Menu aria-label="Menu with sections">
+        <Menu.Section title="Food">
+          <Menu.Item key="pizza">🍕 Pizza</Menu.Item>
+          <Menu.Item key="salad">🥗 Salad</Menu.Item>
+          <Menu.Item key="fries">🍟 Fries</Menu.Item>
+        </Menu.Section>
+        <Menu.Section title="Fruits">
+          <Menu.Item key="apple">🍎 Apple</Menu.Item>
+          <Menu.Item key="banana">🍌 Banana</Menu.Item>
+          <Menu.Item key="mango">🥭 Mango</Menu.Item>
+          <Menu.Item key="strawberry">🍓 Strawberry</Menu.Item>
+        </Menu.Section>
+      </Menu>
+    </Menu.Trigger>
+  ),
+};
+
 export const BasicActionMenu: Story = {
   render: args => {
     return (
diff --git a/packages/components/src/Menu/Menu.tsx b/packages/components/src/Menu/Menu.tsx
index ebe1552f7..ff3c60414 100644
--- a/packages/components/src/Menu/Menu.tsx
+++ b/packages/components/src/Menu/Menu.tsx
@@ -1,6 +1,6 @@
 import React, { Key, useRef } from 'react';
 import { useMenu } from '@react-aria/menu';
-import { Item } from '@react-stately/collections';
+import { Item, Section } from '@react-stately/collections';
 import { useTreeState } from '@react-stately/tree';
 import { CollectionElement } from '@react-types/shared';
 
@@ -15,11 +15,12 @@ import { useMenuContext } from './Context';
 import { MenuTrigger } from './MenuTrigger';
 import { MenuItem } from './MenuItem';
 import { useSyncRef } from '@react-aria/utils';
+import MenuSection from './MenuSection';
 
 // Theme Extension
 // ---------------
 export interface MenuThemeExtension
-  extends ThemeExtensionsWithParts<'Menu', ['container', 'item']> {}
+  extends ThemeExtensionsWithParts<'Menu', ['container', 'item', 'section']> {}
 
 // Props
 // ---------------
@@ -60,18 +61,31 @@ export const Menu = ({ variant, size, ...props }: MenuProps) => {
       css={styles.container}
       {...menuProps}
     >
-      {[...state.collection].map(item => (
-        <MenuItem
-          key={item.key}
-          item={item}
-          state={state}
-          onAction={props.onAction}
-          css={styles.item}
-        />
-      ))}
+      {[...state.collection].map(item => {
+        if (item.type === 'section') {
+          return (
+            <MenuSection
+              key={item.key}
+              item={item}
+              state={state}
+              onAction={props.onAction}
+            />
+          );
+        }
+        return (
+          <MenuItem
+            key={item.key}
+            item={item}
+            state={state}
+            onAction={props.onAction}
+            css={styles.item}
+          />
+        );
+      })}
     </Box>
   );
 };
 
 Menu.Trigger = MenuTrigger;
 Menu.Item = Item;
+Menu.Section = Section;
diff --git a/packages/components/src/Menu/MenuItem.tsx b/packages/components/src/Menu/MenuItem.tsx
index 4b7b71ec6..4d7e93854 100644
--- a/packages/components/src/Menu/MenuItem.tsx
+++ b/packages/components/src/Menu/MenuItem.tsx
@@ -29,15 +29,12 @@ export const MenuItem = ({ item, state, onAction, css }: MenuItemProps) => {
     state,
     ref
   );
-
   // Handles focus AND hover state
   const { isFocusVisible, focusProps } = useFocusRing();
   const stateProps = useStateProps({
     focus: isFocusVisible,
   });
-
   const { onPointerUp, ...props } = menuItemProps;
-
   return (
     <Box
       as="li"
diff --git a/packages/components/src/Menu/MenuSection.tsx b/packages/components/src/Menu/MenuSection.tsx
new file mode 100644
index 000000000..2c7bdd0c3
--- /dev/null
+++ b/packages/components/src/Menu/MenuSection.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { TreeState } from '@react-stately/tree';
+import { useMenuSection } from '@react-aria/menu';
+import { Key } from 'react';
+import { MenuItem } from './MenuItem';
+import { Node } from '@react-types/shared';
+import { useSeparator } from '@react-aria/separator';
+import { Box, CSSObject, useComponentStyles } from '@marigold/system';
+
+interface MenuSectionProps<T> {
+  item: Node<T>;
+  state: TreeState<T>;
+  onAction?: (key: Key) => void;
+  css?: CSSObject;
+}
+
+const MenuSection = ({
+  onAction,
+  item,
+  state,
+  css,
+}: MenuSectionProps<object>) => {
+  let { itemProps, headingProps, groupProps } = useMenuSection({
+    heading: item.rendered,
+    'aria-label': item['aria-label'],
+  });
+  let { separatorProps } = useSeparator({
+    elementType: 'li',
+  });
+
+  const styles = useComponentStyles('Menu', {}, { parts: ['item'] });
+  return (
+    <>
+      {item.key !== state.collection.getFirstKey() && (
+        <Box
+          as="li"
+          {...separatorProps}
+          __baseCSS={{
+            borderTop: '1px solid gray',
+            margin: '2px 5px',
+          }}
+        />
+      )}
+      <Box as="li" {...itemProps}>
+        {item.rendered && (
+          <Box
+            as="span"
+            {...headingProps}
+            __baseCSS={{
+              fontWeight: 'normal',
+              padding: '4px 16px',
+              fontSize: 'xxsmall',
+              color: 'gray50',
+            }}
+          >
+            {item.rendered}
+          </Box>
+        )}
+        <Box
+          as="ul"
+          {...groupProps}
+          style={{
+            padding: 0,
+            listStyle: 'none',
+          }}
+          css={css}
+        >
+          {[...item.childNodes].map(node => {
+            let item = (
+              <MenuItem
+                key={node.key}
+                item={node}
+                state={state}
+                onAction={onAction}
+                css={styles.item}
+              />
+            );
+            return item;
+          })}
+        </Box>
+      </Box>
+    </>
+  );
+};
+
+export default MenuSection;
diff --git a/themes/theme-b2b/src/components/Menu.style.ts b/themes/theme-b2b/src/components/Menu.style.ts
index 5858dc8ca..17aecc47f 100644
--- a/themes/theme-b2b/src/components/Menu.style.ts
+++ b/themes/theme-b2b/src/components/Menu.style.ts
@@ -1,5 +1,4 @@
 import type { Theme } from '@marigold/components';
-
 export const Menu: Theme['components']['Menu'] = {
   base: {
     container: {


Test patch:
diff --git a/packages/components/src/Menu/Menu.test.tsx b/packages/components/src/Menu/Menu.test.tsx
index 9a6322c5f..f22ac0643 100644
--- a/packages/components/src/Menu/Menu.test.tsx
+++ b/packages/components/src/Menu/Menu.test.tsx
@@ -437,3 +437,23 @@ test('supports onOpenChange property', () => {
   fireEvent.click(screen.getByRole('button'));
   expect(onOpenChange).toBeCalledTimes(1);
 });
+
+test('supports Menu with sections', () => {
+  render(
+    <OverlayProvider>
+      <Menu aria-label="Menu with sections">
+        <Menu.Section title="Food">
+          <Menu.Item key="burger">🍔 Burger</Menu.Item>
+          <Menu.Item key="pizza">🍕 Pizza</Menu.Item>
+        </Menu.Section>
+        <Menu.Section title="Fruits">
+          <Menu.Item key="apple">🍎 Apple</Menu.Item>
+          <Menu.Item key="banana">🍌 Banana</Menu.Item>
+        </Menu.Section>
+      </Menu>
+    </OverlayProvider>
+  );
+  expect(screen.getByText('Food')).toBeInTheDocument();
+  expect(screen.getByText('Fruits')).toBeInTheDocument();
+  expect(screen.getByRole('separator')).toBeInTheDocument();
+});