Anchored Menus and a Lesson in Scoping
Anchored Menus and a Lesson in Scoping êŽë š
I had this (bad) idea.
Itâs related to popovers and anchor-positioned menus. I love this pairing: with only HTML and CSS we can make a button that opens/closes anything we want. A tooltip or a menu is a wonderful use-case.
This isnât a terribly difficult thing to do, but, you have to remember a bunch of stuff and put certain unique values on certain elements exactly.
- Remember the right
commandattribute value on the button - Put a unique
idon the menu. - Match up the
commandforattribute on the button to that id. - Make sure the button has an unique
anchor-name. - Match up the
position-anchoron the menu to that unique name. - Make sure youâre using good anchor positioning fallbacks.
<button
commandfor="menu-12345"
command="toggle-popover"
style="anchor-name: --menu-button-12345;"
>
Toggle Menu
</button>
<menu
id="menu-12345"
style="position-anchor: --menu-button-12345"
>
Menu
</menu>
That feels like kind of a lot to remember and get right.
Hereâs my (bad) idea: make a quick <web-component> that does those things. On the surface, maybe that makes sense. It did to me. But the ridiculous part is that now it introduces JavaScript into things in a place we didnât need JavaScript before, which makes it more fragile (and potentially render later) than it would without.
So Iâm not advocating for use here, but I did learn some things along the way that I found interesting and worth sharing.
Light DOM Web Component
I called it <a-menu> just to be short and slightly cheeky.
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('a-menu')
export class AMenu extends LitElement {
@property({ attribute: 'button-name' }) buttonName = 'Menu';
private menuId = `menu-${Math.random().toString(36).substr(2, 9)}`;
// Disable the Shadow DOM
createRenderRoot() {
return this;
}
firstUpdated() {
const menu = this.querySelector('menu');
if (menu) {
menu.setAttribute('popover', 'auto');
menu.id = this.menuId;
}
}
render() {
return html` <style>
a-menu {
display: inline-block;
button {
position-anchor: --menu-button-${menuId};
}
menu {
position-anchor: --menu-button-${menuId};
position-area: block-end span-inline-start;
position-try: flip-block, flip-inline, flip-block flip-inline;
inset: unset;
margin: 0;
}
}
</style>
<button
commandfor="${this.menuId}"
command="toggle-popover"
> ${this.buttonName} </button>
`;
}
}`
Then usage is as simple as this:
<a-menu button-name="My Menu">
<menu>
<li><button>Edit</button></li>
<li><button>Delete</button></li>
<li><button>Share</button></li>
</menu>
</a-menu>
Notice we donât need to:
- Remember a unique ID on the menu.
- Remember the popover commands.
- Remember to attach an
anchor-nameorposition-anchorto put the menu next to the button.
⊠but now we have a problem
Even though weâre putting a unique ID on the menu and using unique custom idents on the anchors, the first menu will open in the position of the last button. Why? Because weâre using the Light DOM here, and the last generic a-menu menu {} selector will override the first one, making all buttons/menus use the values of the last one.
Problem Demo
Using @scope
It occured to me that a potential fix here is the newfangled @scope in CSS. If we updated the style block to be this instead:
@scope {
:scope {
display: inline-block;
button {
anchor-name: --menu-button-${this.menuId};
}
menu {
position-anchor: --menu-button-${this.menuId};
position-area: block-end span-inline-start;
position-try: flip-block, flip-inline, flip-block flip-inline;
inset: unset;
margin: 0;
border: 0;
padding: 0.5rem;
background: light-dark(white, black);
border-radius: 4px;
box-shadow: 0 10px 10px lch(0% 0 0 / 0.2);
}
}
}
This fixes the problem because each <style> block only applies directly to the <a-menu> web component it lives inside of.
Kind of a nice little use case for @scope. ButâŠ
Using anchor-scope (instead)
It turns out there is an even cleaner fix for this, because anchor positioning actually has its own version of scoping just for it. Itâs called anchor-scope.
Rather than scoping everything, as well as requiring a unique custom ident for the anchor, we can tell the root web component to scope that custom ident to itself. Meaning that anything internally that is looking for that custom ident should look in this little neck-of-the-DOM-woods and no further.
a-menu {
anchor-scope: --menu-button;
display: inline-block;
button {
anchor-name: --menu-button;
}
menu {
position-anchor: --menu-button;
position-area: block-end span-inline-start;
position-try: flip-block, flip-inline, flip-block flip-inline;
inset: unset;
margin: 0;
border: 0;
padding: 0.5rem;
background: light-dark(white, black);
border-radius: 4px;
box-shadow: 0 10px 10px lch(0% 0 0 / 0.2);
}
}
Now it doesnât matter if multiple elements are all using the same custom ident for an anchor because they are all scoped to their own parents.
Bonus: Implied Anchors
I learned another thing recently that helps just a smidge here too. That position-anchor weâre putting on the menu? Itâs simply not needed. Because our <button> opens our <menu> with those popover commands, which match the id and commandfor, the <menu> has an âimplied anchorâ of the <button>. Thatâs amazing to me. You donât normally see it because popovers have margin: auto; on them in the UA stylesheet which centers them on the screen and kinda overrides the anchor. But as soon as that is removed, like weâre doing with margin: 0;, it âjust worksâ.
Iâm totally adding this to my reset stylesheet. (And I like how Manuel is down with the perfect fallbacks for anchors, position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;, which we also came to here.)
Conclusion
Again, this isnât a smart web component to actually use because weâve moved a very nice HTML/CSS only feature into requiring JavaScript. But hey, we learned some stuff along the way.