I recently tried adding an input
to Radix's SubDropdownMenu
component.
The first issue I ran into was Radix calling focus
in the onPointerMove
of DropdownMenuItem
.
This UX works for buttons, but it can cause inputs to lose focus unexpectedly when you bump your mouse.
This was especially painful when the SubDropdownMenu
contained DropdownMenuItem
s.
Sure there is stopPropagation
and preventDefault
for overriding these events, but I wanted to know why Radix had made such a specific UX for their dropdown menus.
Another problem I faced was the conflicting keyboard interactions.
The ARIA spec for role="menu"
requires sub menus to open/close with right/left arrow keys, but arrow keys are used for moving your cursor within an input [1].
When a user clicks left
, should you close the submenu or move the cursor to the left?
I started to realize that having input
s within submenus is just clunky,
but replacing the submenu with a dialog containing your interactive content is a great workaround.
Here is an example I found in Linear:
In this post, I will show you how to build this flow from a menu to dialog with the Radix DropdownMenu
and ContextMenu
. Here is the final demo:
DropdownMenu
Let's start by creating a "DropdownContent" component so that our root component is easy to read.
You'll notice that I wrapped the Item
in a Popover.Trigger
.
You might have been expecting to see a Dialog.Trigger
, but rest assured the Radix Popover
is just a specific type of dialog.
This trigger will close the DropdownMenu
and open a Popover
.
Here is the App component where we render Popover.Root
and DropdownMenu.Root
.
Don't miss the fact that our Popover.PopoverAnchor
is wrapping DropdownMenuTrigger
.
This will ensure that our Popover
opens in the same location as the DropdownMenu
.
We are almost there! But as the DropdownMenu
closes the Popover
menu opens and then immediately closes.
Fixing Popover
flicker
On the surface level, we are seeing this flicker because DropdownMenu
defaults to modal={true}
. Lets dig into why this is an issue.
What is a modal?
The word modal is an adjective meaning "of or relating to mode, manner, or form" [2].
In web dev, people use modal as a noun, and what they mean is "modal dialog." This dialog prevents outside interaction and forces the user to make a decision. An app goes out of "app mode" and into "decision mode" when a modal is opened.
Other widgets can be modal as well. The Radix DropdownMenu
is one example of a modal widget that isn't a dialog.
What I think is happening in our case:
- Focus leaves the
DropdownMenu
when thePopover
is opened and it's input is focused. - This causes the
DropdownMenu
to close and return focus to theDropdownMenuTrigger
, which closes the newly openedPopover
. - This isn't instantaneous, so we see the
Popover
flicker in and out.
Popover vs Dialog
A Popover
is a specific type of dialog that is non-modal and is positioned relative to its trigger.
Originally, I didn't understand the difference between Popover
and Dialog
and not knowing that a Popover
is non-modal made finding this bug quite tricky.
Things shouldn't be modal if possible so we will set modal={false}
on our DropdownMenu
.
When composing Radix components, I have found that matching modal props helps avoid problems like this one.
Here is our demo with <DropdownMenu.Root modal={false} ...
Returning focus to the original trigger
When a dialog closes, the button that opened it should be refocused [3].
Our PopoverTrigger
is no longer being rendered since the DropdownMenu
closed.
So instead we will focus
the logical corresponding element which is the DropdownMenuTrigger
.
- We manually store a
ref
to theDropdownMenuTrigger
- We override
onCloseAutoFocus
preventing the default autoFocus, and providing our own by focusing thebuttonRef
Try closing the Popover
and to see the refocus behavior in action.
ContextMenu
A context menu is the menu that opens when you right click.
When you use the ContextMenu
component from Radix you are overriding this default right click menu.
The APIs for ContextMenu
and DropdownMenu
are similar.
This is great for quickly mirroring their behavior.
- We wrap the entire card with a
ContextMenu.Root
&ContextMenu.Trigger
- And create a
ContextContent
similar to ourDropdownContent
but withContextMenuItem
s
You might be thinking "can we combine these components to reduce the amount of markup required?"
A nice side effect of abstractions is that they reduce code, but their purpose is primarily to group instances of the same thing.
DropdownMenu
s and ContextMenu
s are semantically different things.
So it doesn't make sense to create an abstraction over them.
Fixing ContextMenu
Flicker
When we open the context menu from the dropdown menu our manual focus
on the buttonRef
causes the context menu to immediately close.
We really only want to call focus
on the trigger when the Popover
is closed and nothing else is focused.
We can check this seeing if the body
of our document
is focused in onCloseAutoFocus
.
This is looking much better, but in my mind it doesn't make sense to open the context menu from the dropdown menu.
preventDefault
isn't great here because it prevents both the custom and default context menus.
So we use stopPropagation
to prevent the custom ContextMenu
while still getting the default.
Now our Popover
instances have matching context menus.
Bonus: Virtually anchored Popover
Our demo is working great, but it would be kinda sexy if the Popover
was anchored to the ContextMenu
's position.
Radix uses Popper (now Floating UI) to position its menus, and Popper supports this concept with its virtual elements API.
With a little snooping I found that the PopoverAnchor
component has an undocumented property called virtualRef
which exposes this API.
The term virtualRef
indicates that Popper is expecting a subset of properties available on a reference to a DOM node.
Usually when an API specifies virtualRef
it's because this subset is small enough to be "faked."
Our situation is no exception. The type interface only requires getBoundingClientRect
.
This makes sense to me too because Popper is a library for positioning elements on the screen and getBoundingClientRect
is how you get that information
-
We create some state to hold the
position
of the right clicked event, and store it as aDOMRect
since that is whatgetBoundingClientRect
returns. -
We set that state in the
onContextMenu
of ourContextMenu.Trigger
.Popper only uses
x
&y
so we can setwidth
&height
to0
. -
The position of our
Popover
should match the position of ourContextMenu
. So we wrap ourDropdownMenu
with a newPopover.Root
allowing us to render another
PopoverContent
withsideOffset
andalign
matching theContextMenu
. -
When that
PopoverContent
is closed, we only refocus the trigger when focus is lost. So we check if ourbody
is theactiveElement
before callingfocus
. -
Finally, we render the
PopoverAnchor
creating thevirtualRef
on the fly like this:
The Popover
is opening relative to our right click, but it's a smidge to the left of the context menu.
This is because Radix ContextMenuContent
hard codes a sideOffset
of 2 [4]. We can compensate for this by hardcoding the same sideOffset
in the Popover
My first attempt was specifying like so:
But Radix's Popper middleware clamps the sideOffset
to the width
of the anchor.
Our virtualRef
's width
is 0px
so using sideOffset
is ineffective.
Instead we can fix this by making our position
2px
to the right.
There you have it. A dialog / menu combo for interactive item specific interactions.
Hope you found this post helpful! I'll see you in the next one :)