Skip to content

feat: new Menu implementation #2106

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

Merged
merged 14 commits into from
May 7, 2025
Merged

feat: new Menu implementation #2106

merged 14 commits into from
May 7, 2025

Conversation

amje
Copy link
Contributor

@amje amje commented Feb 11, 2025

It is a rework of current components, Menu and DropdownMenu. More details in RFC.

Summary by Sourcery

Implement a new Menu component with advanced features, including nested menus, keyboard navigation, and accessibility support

New Features:

  • Create a comprehensive Menu component with support for nested menus
  • Add MenuTrigger, MenuItem, and MenuDivider subcomponents
  • Implement advanced keyboard navigation and accessibility features
  • Support for inline and popup menu styles

Enhancements:

  • Improve ListItemView component with additional props and styling
  • Add support for aria attributes in Button component
  • Enhance component flexibility with new props and interactions

Tests:

  • Add comprehensive test suite for Menu component
  • Test keyboard navigation, submenu interactions, and accessibility

@gravity-ui-bot
Copy link
Contributor

Preview is ready.

@gravity-ui-bot
Copy link
Contributor

Visual Tests Report is ready.

@amje amje force-pushed the new-menu branch 2 times, most recently from bae0d3c to aec7afc Compare February 12, 2025 15:49
@amje amje changed the title [DRAFT] new Menu feat: new Menu implementation Mar 12, 2025
@amje amje marked this pull request as ready for review March 12, 2025 17:19
@amje amje requested review from ValeraS and korvin89 as code owners March 12, 2025 17:19
@amje amje force-pushed the new-menu branch 4 times, most recently from c1a5564 to bea8851 Compare March 14, 2025 16:55
@amje amje force-pushed the new-menu branch 2 times, most recently from ccc03b7 to 814a938 Compare April 2, 2025 15:31
@amje
Copy link
Contributor Author

amje commented Apr 10, 2025

@sourcery-ai review

Copy link
Contributor

sourcery-ai bot commented Apr 10, 2025

Reviewer's Guide by Sourcery

This pull request introduces a new Menu component, enhancing the ListItemView component, and updating the Button component to support menu trigger functionality. It also updates the @gravity-ui/icons dependency.

Sequence diagram for opening a nested menu

sequenceDiagram
    participant User
    participant MenuTrigger
    participant Menu
    participant MenuItem
    participant SubMenu

    User->MenuTrigger: Clicks
    activate MenuTrigger
    MenuTrigger->Menu: Sets open state to true
    activate Menu
    Menu->MenuItem: Renders MenuItem with SubMenu
    MenuItem->SubMenu: Renders SubMenu with trigger
    SubMenu->MenuTrigger: Renders MenuTrigger
    User->MenuTrigger: Hovers over MenuTrigger
    activate MenuTrigger
    MenuTrigger->SubMenu: Sets open state to true
    activate SubMenu
    SubMenu-->User: Displays SubMenu
    deactivate SubMenu
    deactivate MenuTrigger
    deactivate Menu
    deactivate MenuTrigger
Loading

File-Level Changes

Change Details Files
Added a new Menu component with Menu.Item, Menu.Trigger, and Menu.Divider sub-components, implementing menu functionality using Floating UI for positioning and interactions.
  • Created Menu component with inline and popup modes.
  • Implemented Menu.Item with support for icons, hotkeys, and submenus.
  • Added Menu.Trigger component as a default trigger for the Menu.
  • Implemented Menu.Divider component for visual separation of menu items.
  • Utilized Floating UI hooks for positioning, keyboard navigation, and accessibility.
  • Added MenuContext to share menu state and props between components.
  • Created stories and tests for the new Menu component.
src/components/lab/Menu/Menu.tsx
src/components/lab/Menu/MenuItem.tsx
src/components/lab/Menu/__stories__/Menu.stories.tsx
src/components/lab/Menu/__tests__/Menu.test.tsx
src/components/lab/Menu/__stories__/utils.tsx
src/components/lab/Menu/types.ts
src/components/lab/Menu/utils.ts
src/components/lab/Menu/MenuTrigger.tsx
src/components/lab/Menu/MenuContext.ts
src/components/lab/Menu/MenuDivider.tsx
src/components/lab/Menu/MenuItemContext.ts
src/components/lab/Menu/Menu.scss
src/components/lab/Menu/MenuDivider.scss
Updated ListItemView component to support hovered state and allow passing props to the root component.
  • Added hovered prop to ListItemView.
  • Modified ListItemView to pass componentProps to the root component.
  • Updated ListItemView styles to include hover styles.
  • Added button-reset and link-reset mixins.
src/components/lab/ListItemView/ListItemView.tsx
src/components/lab/ListItemView/ListItemView.scss
Modified BreadcrumbsItem to use ListItemView and pass item props to the root component.
  • Updated BreadcrumbsItem to use ListItemView as the base component.
  • Passed item props to the root component of ListItemView via componentProps.
src/components/Breadcrumbs/BreadcrumbsItem.tsx
Enhanced Button component to automatically change its appearance when aria-haspopup and aria-expanded attributes are passed.
  • Added documentation for the menu trigger functionality.
  • Updated styles to support aria-haspopup and aria-expanded attributes.
src/components/Button/README-ru.md
src/components/Button/README.md
src/components/Button/Button.scss
Updated @gravity-ui/icons dependency.
  • Updated @gravity-ui/icons from version 2.12.0 to 2.13.0.
package.json
package-lock.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!
  • Generate a plan of action for an issue: Comment @sourcery-ai plan on
    an issue to generate a plan of action for it.

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @amje - I've reviewed your changes - here's some feedback:

Overall Comments:

  • Consider adding a visual focus state to Button when it's used as a MenuTrigger.
  • The ListItemView component now accepts componentProps, which are then spread onto the underlying component; this is a good pattern to follow for other components as well.
Here's what I looked at during the review
  • 🟡 General issues: 2 issues found
  • 🟢 Security: all looks good
  • 🟢 Testing: all looks good
  • 🟡 Complexity: 2 issues found
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines 108 to 114
return;
}

if (typeof onClick === 'function') {
onClick(e);
if (
typeof onClick === 'function' ||
typeof componentProps?.onClick === 'function'
) {
onClick?.(e);
componentProps?.onClick?.(e);
} else if (typeof onCollapseChange === 'function') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Double-calling onClick handlers may lead to unexpected behavior.

Both onClick from props and componentProps?.onClick are invoked if provided. Ensure that this dual invocation is intended and won’t result in duplicate side effects, especially in scenarios where both callbacks perform similar actions.

@@ -266,7 +266,7 @@ LANDING_BLOCK-->

`loading` — когда в фоновом режиме выполняются асинхронные процессы.

`selected` — когда пользователь может может включить (**Enable**) или отключить (**Disable**) кнопку.
`selected` — когда пользователь может включить (**Enable**) или отключить (**Disable**) кнопку.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (typo): Repeated word "может"

There seems to be a repetition of the word "может". Consider removing one.

Suggested implementation:

`selected` — когда пользователь может включить (**Enable**) или отключить (**Disable**) кнопку.

Ensure that there are no other occurrences of the duplicated word in other parts of the file.

// The component is needed to run submenu logic hooks.
// We get <nodeId> of the Popup using "useFloatingParentNodeId" here
// and <parentId> from using "useFloatingParentNodeId" outside the Popup.
function MenuPopupContent({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the nested hook logic and specialized behaviors into custom hooks and smaller subcomponents to improve readability and maintainability of the component, such as the submenu event management and inline menu item pointer handlers..

Consider extracting some of the nested hook logic and specialized behaviors into their own custom hooks and small subcomponents. For example, extracting the submenu event management in the popup into a custom hook can help isolate the side‐effects and make the main component less dense. Here’s a quick example:

// Create a custom hook for floating tree events
function useMenuTreeEvents({
  tree,
  nodeId,
  parentId,
  onRequestClose,
  open,
}: {
  tree: ReturnType<typeof useFloatingTree> | null;
  nodeId: string | null;
  parentId: string | null;
  onRequestClose: () => void;
  open: boolean;
}) {
  React.useEffect(() => {
    if (!tree || !nodeId) return;

    function handleTreeClick() {
      if (!parentId) {
        onRequestClose();
      }
    }

    function handleSubMenuOpen(event: { nodeId: string; parentId: string }) {
      if (event.nodeId !== nodeId && event.parentId === parentId) {
        onRequestClose();
      }
    }

    tree.events.on('click', handleTreeClick);
    tree.events.on('menuopen', handleSubMenuOpen);

    return () => {
      tree.events.off('click', handleTreeClick);
      tree.events.off('menuopen', handleSubMenuOpen);
    };
  }, [tree, nodeId, parentId, onRequestClose]);

  React.useEffect(() => {
    if (open && tree && nodeId) {
      tree.events.emit('menuopen', { parentId, nodeId });
    }
  }, [open, tree, nodeId, parentId]);
}

export function MenuPopupContent(props: /* ... */) {
  // destructure props
  const { open, onRequestClose, parentId, children, className, style, qa } = props;
  const tree = useFloatingTree();
  const nodeId = useFloatingParentNodeId();

  useMenuTreeEvents({ tree, nodeId, parentId, onRequestClose, open });

  return (
    <div className={b(null, className)} style={style} data-qa={qa}>
      {children}
    </div>
  );
}

Likewise, you might extract and isolate the inline menu item pointer handlers into a separate hook. This separation not only reduces the complexity in the main component but also makes each piece more testable and maintainable. The functionality remains identical while clarifying the responsibilities of each part of the component.

);
}

return content;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the submenu logic into a separate SubmenuWrapper component to simplify the MenuItem component and improve readability by reducing nested conditionals and isolating submenu-specific concerns

Consider extracting the submenu logic into its own component or hook. For example, you could create a dedicated `SubmenuWrapper` that handles the clone logic and open state. This would move the submenu processing out of `MenuItem`, reducing nested conditionals. For instance:

// New SubmenuWrapper.tsx
```tsx
import * as React from 'react';
import { mergeRefs, mergeProps } from '../../../hooks';
import { ListItemViewProps } from '../ListItemView/ListItemView';
import { MenuItemContext } from './MenuItemContext';
import type { MenuProps } from './types';

interface SubmenuWrapperProps {
  submenu: React.ReactElement<MenuProps>;
  content: React.ReactElement;
  handleRef: React.Ref<any>;
  onSubmenuOpenChange: (open: boolean) => void;
}

export function SubmenuWrapper({
  submenu,
  content,
  handleRef,
  onSubmenuOpenChange,
}: SubmenuWrapperProps) {
  return (
    <MenuItemContext.Provider value={{ setHasFocusInside: () => {} /* forward ref or handler as needed */ }}>
      {React.cloneElement<MenuProps>(submenu, {
        trigger: (triggerProps, triggerRef) =>
          React.cloneElement<ListItemViewProps & { ref?: React.Ref<any> }>(content, {
            ref: mergeRefs(triggerRef, handleRef),
            componentProps: mergeProps(triggerProps, content.props.componentProps),
          }),
        onOpenChange: (open, event, reason) => {
          submenu.props.onOpenChange?.(open, event, reason);
          onSubmenuOpenChange(open);
        },
      })}
    </MenuItemContext.Provider>
  );
}

Then update your MenuItem to use the new SubmenuWrapper:

if (submenu) {
  return (
    <SubmenuWrapper
      submenu={submenu}
      content={content}
      handleRef={handleRef}
      onSubmenuOpenChange={(open) => {
        setSubmenuOpen(open);
        if (!open) {
          setHasFocusInside(false);
        }
      }}
    />
  );
}

This refactoring isolates submenu-specific concerns and reduces the nesting in MenuItem while keeping all functionality intact.

const nodeId = useFloatingParentNodeId();

React.useEffect(() => {
if (!tree) return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Use block braces for ifs, whiles, etc. (use-braces)

Suggested change
if (!tree) return;
if (!tree) {


ExplanationIt is recommended to always use braces and create explicit statement blocks.

Using the allowed syntax to just write a single statement can lead to very confusing
situations, especially where subsequently a developer might add another statement
while forgetting to add the braces (meaning that this wouldn't be included in the condition).

Comment on lines +62 to +67
function handleTreeClick() {
// Closing only the root Menu so the closing animation runs once for all menus due to shared portal container
if (!parentId) {
onRequestClose();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Avoid function declarations, favouring function assignment expressions, inside blocks. (avoid-function-declarations-in-blocks)

ExplanationFunction declarations may be hoisted in Javascript, but the behaviour is inconsistent between browsers. Hoisting is generally confusing and should be avoided. Rather than using function declarations inside blocks, you should use function expressions, which create functions in-scope.

Comment on lines +69 to +74
function handleSubMenuOpen(event: {nodeId: string; parentId: string}) {
// Closing on sibling submenu open
if (event.nodeId !== nodeId && event.parentId === parentId) {
onRequestClose();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Avoid function declarations, favouring function assignment expressions, inside blocks. (avoid-function-declarations-in-blocks)

ExplanationFunction declarations may be hoisted in Javascript, but the behaviour is inconsistent between browsers. Hoisting is generally confusing and should be avoided. Rather than using function declarations inside blocks, you should use function expressions, which create functions in-scope.

@amje amje merged commit 07c9bd8 into main May 7, 2025
5 checks passed
@amje amje deleted the new-menu branch May 7, 2025 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants