Skip to content
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

[Menu] Fix prop merging issues #1445

Merged
merged 16 commits into from
Feb 21, 2025

Conversation

michaldudak
Copy link
Member

@michaldudak michaldudak commented Feb 14, 2025

  1. Made it possible to prevent default Base UI logic by calling preventBaseUIHandler in event handlers (fixes [Menu] Trigger's default behavior cannot be prevented #1441) (before / after).
  2. Fixed the behavior of disabled non-interactive triggers (i.e., <Menu.Trigger disabled render={<span />} />). Clicking on such a trigger used to open the menu anyway. (before / after)
  3. Removed mergeReactProps calls where they were not necessary.
  4. Removed the disabled attribute from SubmenuTrigger. The submenus should be disabled by setting disabled on the Root part.
  5. Updated the fully featured menu experiment to cover more cases.

The main problem in 1. was that event handlers returned from Floating UI's useInteractions didn't respect preventBaseUIHandler as they have their own prop merging logic.
Instead of using these prop getters directly, I now call them without arguments and pass the result to our mergeReactProps function.

Another issue I found was that we had to nest mergeReactProps calls and props getters, such as getButtonProps(mergeReactProps(external, internal)). In some cases this was confusing and error-prone (point 2.), so I updated mergeReactProps to accept either props directly or a props getter function.

When a function is provided to mergeReactProps, it will be called with all the props defined to the right of it, so mergeReactProps(obj1, obj2, fn, obj3, obj4), will be equivalent to mergeReactProps(obj, obj2, fn(mergeReactProps(obj3, obj4))).
I assume that in such cases it is the props getter functionality to merge incoming and internal props and prevent default logic if needed.

@michaldudak michaldudak added the component: menu This is the name of the generic UI component, not the React module! label Feb 14, 2025
Copy link

netlify bot commented Feb 14, 2025

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 3f2d30a
🔍 Latest deploy log https://app.netlify.com/sites/base-ui/deploys/67b6f6d2f7f886000801a0cc
😎 Deploy Preview https://deploy-preview-1445--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@mj12albert

This comment was marked as resolved.

@mj12albert
Copy link
Member

When a function is provided to mergeReactProps, it will be called with all the props defined to the right of it

If I understand correctly, this: getButtonProps(mergeReactProps(external, internal))
now becomes this? mergeReactProps(getButtonProps, externalProps, internalProps)

@michaldudak

This comment was marked as resolved.

@michaldudak
Copy link
Member Author

If I understand correctly, this: getButtonProps(mergeReactProps(external, internal))
now becomes this? mergeReactProps(getButtonProps, externalProps, internalProps)

Yup, that's right.

}
},
onKeyDown(event: React.KeyboardEvent) {
onMouseDown(event: React.MouseEvent) {
Copy link
Member

Choose a reason for hiding this comment

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

Fixed the behavior of disabled non-interactive triggers

I also encountered this when these are still <button>s but "disabled" using aria-disabled+data-disabled and not the disabled attr to keep them focusable-when-disabled:

onKeyDown(event: React.KeyboardEvent) {
if (
disabled ||
(event.target === event.currentTarget && !isNativeButton() && event.key === ' ')
) {
event.preventDefault();
}
// Keyboard accessibility for non interactive elements
if (
event.target === event.currentTarget &&
!isNativeButton() &&
!isValidLink() &&
event.key === 'Enter' &&
!disabled
) {
onClickProp(event);
event.preventDefault();
}
},
onKeyUp(event: React.KeyboardEvent) {
// calling preventDefault in keyUp on a <button> will not dispatch a click event if Space is pressed
// https://codesandbox.io/p/sandbox/button-keyup-preventdefault-dn7f0
// Keyboard accessibility for non interactive elements
if (
event.target === event.currentTarget &&
!isNativeButton() &&
!disabled &&
event.key === ' '
) {
onClickProp(event);
}
},
onPointerDown(event: React.PointerEvent) {
if (disabled) {
event.preventDefault();
}
},

This fix looks more robust 😄
In mine, when the disabled menu trigger is focused, Up/Down arrows are still opening the menu (Enter/Space doesn't)

Copy link
Member Author

Choose a reason for hiding this comment

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

I figured that the disabled button shouldn't respond to key presses the same way it doesn't respond to clicks.

Comment on lines +25 to +26
* Event handlers returned by the functions are not automatically prevented when `preventBaseUIHandler` is called.
* They must check `event.baseUIHandlerPrevented` themselves and bail out if it's true.
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you elaborate more on this? Will all event handlers specified in our internal prop getters for components work by default without needing a check?

Copy link
Member Author

Choose a reason for hiding this comment

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

Whenever a function is passed to mergeReactProps, it will be called with the merged props and the preventBaseUIHandler is not taken into consideration. Most of our prop getter functions actually call mergeReactProps internally, with one notable exception: useButton. In its case, the logic for prop merging is more complex as it depends on the button's disabled state.

In the case of functions returned from Floating UI's useInteractions, they don't know anything about preventBaseUIHandler, so I'm calling them without parameters and provide the result to mergeReactProps so our merging logic is run on them. This alone should be sufficient to fix 1., but I refactored mergeReactProps to avoid having to nest prop getters and help prevent bugs like 2.

Co-authored-by: atomiks <[email protected]>
Signed-off-by: Michał Dudak <[email protected]>
Copy link
Contributor

@atomiks atomiks left a comment

Choose a reason for hiding this comment

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

The ordering logic makes sense to me

@michaldudak michaldudak marked this pull request as ready for review February 20, 2025 09:33
@mj12albert
Copy link
Member

2. Fixed the behavior of disabled non-interactive triggers

Was just testing this and found that dialog also has this issue:
https://codesandbox.io/p/sandbox/clever-alex-6472d7

I think triggers that currently don't use useButton (Dialog, AlertDialog, Popover) need to start using it with the fixes in this PR

@michaldudak
Copy link
Member Author

Yes, there's possibly more components with these problems. I started with the Menu to keep the PR size managable.

@michaldudak michaldudak merged commit 698a8ee into mui:master Feb 21, 2025
22 checks passed
@michaldudak michaldudak deleted the redesign-mergeReactProps branch February 21, 2025 07:47
atomiks added a commit to atomiks/base-ui that referenced this pull request Feb 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: menu This is the name of the generic UI component, not the React module!
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Menu] Trigger's default behavior cannot be prevented
3 participants