Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Mon, 04 Mar 2024 20:21:03 +0000 en-US hourly 1 https://wordpress.org/?v=6.5.2 225069128 Menus, toasts and more with the Popover API, the dialog element, invokers, anchor positioning and @starting-style https://frontendmasters.com/blog/menus-toasts-and-more/ https://frontendmasters.com/blog/menus-toasts-and-more/#respond Mon, 04 Mar 2024 20:21:03 +0000 https://frontendmasters.com/blog/?p=1104 Dropdowns, menus, tooltips, comboboxes, toasts — the popover attribute will make building a large variety of UI components easier. The popover attribute can be used on any HTML element, so you have the flexibility to choose whichever element is most appropriate semantically for each particular use case. Unlike a dialog, a popover is always non-modal — meaning they don’t block interaction with anything else on the page. To toggle a popover open and closed, a button element needs to include an invoketarget attribute with a value that matches the id of the popover.

<button invoketarget="foobar">Toggle popover</button>

<div id="foobar" popover>
  Popover content goes here...
</div>

A <button> with an invoketarget attribute is called an invoker. Invokers might eventually bring all sorts of power to HTML markup, but in its first iteration it’s limited to opening and closing popovers and dialogs. You don’t need onclick= or addEventListener, it’ll just work.

The fact that popovers work without JavaScript is nice, but toggling display: none on an element using JS was never challenging. Popovers do, however, bring far more to the table:

  • Popovers make use of the top layer.
  • Light-dismiss functionality: clicking outside of the popover will close the popover.
  • Hitting the escape key will close the popover.
  • Focus management: when you open a popover, the next tab stop will be the first focusable element inside the popover. If you’ve focused an element within the popover and then close the popover, focus is returned to the correct place (this was tricky to get right with JavaScript).

Browser support

The popover attribute is supported in Chrome, Safari, and Firefox 125. The popovertarget attribute currently has better browser support than invoketarget. popovertarget is popover-specific, offering a declarative way to toggle popovers open and closed. popovertarget will likely eventually be deprecated and replaced by the more flexible invoketarget. After popovers shipped in Chrome, some smart people realised it would also be handy to have a declarative way for buttons to open dialogs and perform other tasks, which is why there are two ways to do the same thing. A polyfill for invokers is available.

Light dismiss

The popover attribute can be set to either auto (the default) or manual. When set to auto, the popover has light dismiss functionality: if the user clicks outside of the popover, the popover is closed. Pressing the escape key will also close the popover. Only one auto popover is ever open at a time.

When set to manual, there is no light dismiss functionality and the escape key does not close the popover. The popover must be explicitly closed by pressing the button again (or by calling hidePopover() in JavaScript). Multiple manual popovers can be open at the same time.

<button invoketarget="foobar">Toggle popover</button>

<div id="foobar" popover="manual">
  Popover content goes here...
</div>

Invoker actions

Along with the invoketarget attribute, a button can also optionally include an invokeaction attribute. The different actions are listed below.

ActionDescription
showpopoverShow a popover.
hidepopoverClose a popover.
showmodalOpen a dialog element as modal.
closeClose a dialog element.

If you omit the invokeaction attribute, the default behaviour depends on the context: If the target set by invoketarget is a popover it will call .togglePopover(). If the target is a dialog it will call showModal() if the dialog is closed and will close the dialog if the dialog is open.

Using invokers for the dialog element looks much the same as the popover example:

<button invoketarget="my-dialog">Open Dialog</button>

<dialog id="my-dialog">
  Dialog content goes here.
  <button invoketarget="my-dialog" invokeaction="close">Close dialog</button>
</dialog>

Along with built-in actions, developers can write custom actions. This is outside the scope of this article as a custom action could do anything — it need not be related to dialogs or popovers.

While a selling point of invokers is forgoing JavaScript, they also provide a new JavaScript invoke event should you need more than the default behaviour. This event is fired on the popover or dialog, not the button.

document.querySelector("[popover]").addEventListener("invoke", function(event) {
    console.log(event.action);
    console.log(event.invoker);
    // do something useful here...
  });

Within the event handler you can get a reference to whichever button triggered the invocation with event.invoker and determine the action specified by invokeaction with event.action.

Popover methods and events

For many use cases, the popover API doesn’t require JavaScript. What if we want to display a toast notification without a user first interacting with a button, for example?

There are methods to show, hide, or toggle a popover element: .showPopover(), .hidePopover() and .togglePopover(), respectively.

document.getElementById('toast').showPopover();

There is a toggle event that fires on the popover both when the popover gets shown and when it gets hidden (there are no separate open or close events). This would be useful for a toast alert that automatically disappears after a set amount of time, for example, as there’s no markup or CSS-based way to do that.

Its worth checking that the popover isn’t already hidden before calling hidePopover(). We can do that with either .matches(':popover-open'), .checkVisibility(), or event.newState === 'open', all of which will return true if the popover is open.

toast.addEventListener("toggle", function (event) {
  if (event.target.matches(":popover-open")) {
    setTimeout(function () {
      toast.hidePopover();
    }, 3000);
  }
});

There’s also a beforetoggle method, which is similar but lets you call event.preventDefault() inside the event handler, should you need to — and it might come in useful for animations. The toggle event, by contrast, isn’t cancellable.

Default popover styles

By default a popover is set to position: fixed and displayed in the center of the viewport with a solid black border but you’re free to style it however you like. The styles the browser applies to a popover look something like this:

[popover] {
    position: fixed;
    width: fit-content;
    height: fit-content;
    inset: 0px;
    margin: auto;
    border: solid;
    padding: 0.25em;
}

If I wanted to position a popover in the bottom left, for example, I’d need to set top and right to either auto, initial or unset.

.toast {
    inset: unset;
    bottom: 12px;
    left: 12px;
}

Beyond z-index: The top layer

Some JavaScript frameworks have something called portals for rendering things like tooltips and dialogs. I always found portals difficult to work with. The React docs describe portals like so:

“Portals let your components render some of their children into a different place in the DOM. This lets a part of your component “escape” from whatever containers it may be in. For example, a component can display a modal dialog or a tooltip that appears above and outside of the rest of the page… You can use a portal to create a modal dialog that floats above the rest of the page, even if the component that summons the dialog is inside a container with overflow: hidden.”

When working with either the <dialog> element (rather than crafting one out of divs) or the popover attribute, you can avoid this issue entirely — no portals required. Their location in the DOM doesn’t matter. Its often convenient to collocate the markup for a popover or <dialog> together with the button that opens it. They can appear anywhere in your markup and won’t get cropped by overflow: hidden on a parent element. They make use of the top layer, which is a native web solution for rendering content above the rest of the document. The top layer sits above the document and always trumps z-index. An element in the top layer can also make use of a styleable ::backdrop pseudo-element.

Animate an element into and out of the top layer

By default, when a popover or dialog is opened, it instantly appears. You might want to add an entry animation — perhaps a quick opacity fade-in, for example. @starting-style is used to animate an element into view with a CSS transition (you don’t need @starting-style when working with @keyframes). @starting-style works both when you’re adding a new element to the DOM and when an element is already in the DOM but is being made visible by changing its display value from display: none. When in a closed state, both the popover attribute and the <dialog> element make use of display: none under the hood, so @starting-style can be used to animate them onto the page.

The following transition will fade and spin the popover into view, and scale down the size of the popover for the exit transition.

/*  Transition to these styles on entry, and from these styles on exit   */
[popover]:popover-open {
  opacity: 1;
  rotate: 0turn;
  transition: rotate .5s, opacity .5s, display .5s allow-discrete, overlay .5s allow-discrete;
}

/*   Entry transition starts with these styles  */
@starting-style {
  [popover]:popover-open {
    opacity: 0;
    rotate: 1turn;
  }
}

/*  Exit transition ends with these styles  */
[popover]:not(:popover-open) {
  scale: 0;
  transition: scale .3s, display .3s allow-discrete, overlay .3s allow-discrete;
}

The popover will transition from its @starting-style styles to its [popover]:popover-open styles every time it’s opened.

The overlay transition is necessary boilerplate when transitioning an element in or out of the top layer. The overlay property was added to CSS purely for this use case and has no other practical application. It is an unusual property to the extent that, outside of transitions, it can only be specified by the browser — you can’t set it with your own CSS. By default, a dialog or popover is instantly removed from the top layer when closed. This will lead to the element getting clipped and obscured. By transitioning overlay, the element stays in the top layer until the transition has finished.

transition-behavior is a new CSS property that can be set to either normal or allow-discrete. In the above code example I’m using the shorthand.

Similarly for the display property, by including it in the transition and specifying transition-behavior: allow-discrete we ensure that a change from display: none happens at the very start of the entrance transition and that a change to display: none happens at the very end of the exit transition.

@starting-style has some useful applications outside of working with popovers and dialogs, but that’s a topic for a different article.

You can transition the ::backdrop pseudo-element in a similar way.

e.g.

@starting-style {
  [popover]:popover-open::backdrop {
    opacity: 0;
  }
}

Now let’s look at doing the same transition with a <dialog> element:

/*  Transition to these styles on entry, and from these styles on exit   */
dialog:open {
  opacity: 1;
  rotate: 0turn;
  transition: rotate .5s, opacity .5s, display .5s allow-discrete, overlay .5s allow-discrete;
}

/*   Entry transition starts with these styles  */
@starting-style {
  dialog:open {
    opacity: 0;
    rotate: 1turn;
  }
}

/*  Exit transition ends with these styles.  */
dialog:closed {
  scale: 0;
  transition: scale .3s, display .3s allow-discrete, overlay .3s allow-discrete;
}

The :open and :closed selectors are new pseudo-selectors. They work for details, dialog, and select elements — but not for popovers. You can use dialog[open] and dialog:not([open]) for the time being for better browser support.

These examples all work in Chrome. @starting-style and transition-behavior are part of Interop 2024, meaning they’ll likely be fully supported by the end of the year. Safari 17.4 added support for transition-behavior: allow-discrete. Safari Technology Preview 189 added support for @starting-style. WebKit have yet to declare a position on the overlay property.

Anchor positioning

With a component like a toast or a dialog, we generally want to position the element in relation to the viewport. We typically display a dialog in the center of the screen, and a toast at the bottom. That’s easy to do. There are other times when you need to position an element in relation to another element on the page. For a dropdown menu, for example, we want to place the popover in relation to the button that opened it. This is more challenging.

Screenshot of the ... three dot menu on YouTube opened up showing a menu of three options: Clip, Save, and Report.

This sort of behaviour usually requires JavaScript and led to the creation of the popular JavaScript libraries Popper, Floating UI and Tether. With the addition of anchor positioning to CSS, we’ll no longer need to reach for JavaScript. The anchor() function allows developers to tether an absolutely positioned element to one or more other elements on the page. Unfortunately, it’s a work-in-progress so I’ll revisit the topic when the spec and implementation are more solid.

Conclusion

I covered a lot in this article but there’s more to come. The popover attribute can be useful all by itself but some forthcoming web APIs will help cover more use cases. Anchor positioning looks set to be the most useful CSS feature since grid. Stay tuned.

]]>
https://frontendmasters.com/blog/menus-toasts-and-more/feed/ 0 1104
Scroll-Locked Dialogs https://frontendmasters.com/blog/scroll-locked-dialogs/ https://frontendmasters.com/blog/scroll-locked-dialogs/#comments Mon, 19 Feb 2024 19:01:09 +0000 https://frontendmasters.com/blog/?p=938 I just wrote about the <dialog> element, with some basic usage and things to watch for. It’s a great addition to the web platform.

Here’s another interesting thing we can do, connecting it to another one of my favorite new things on the web platform: :has(). (You can see I’ve been pretty into it lately.) I was reading Locking scroll with :has() from Robb Owen, and he gets into how you might want to prevent the page from scrolling when a modal is open.

To prevent from losing the user’s place in the page whilst that modal is open – particularly on mobile devices – it’s good practice to prevent the page behind it from scrolling. That’s a scroll lock.

I like that. The user can’t interact with what’s behind the modal anyway, as focus is trapped into a modal (that’s what a modal is). So, might as well make sure they don’t inadvertently scroll away. Without doing anything, scrolling is definitely not locked.

My first thought was actually… I wonder if overscroll-behavior on the dialog (and maybe the ::backdrop too?) would prevent that, but some quick testing didn’t seem to work.

So to scroll lock the page, as Robb did in his article, we can do by hiding the overflow on the body. Robb did it like this:

body:has(.lock-scroll) {
  overflow: hidden;
}

Which would then lock if the body had a <dialog class="lock-scroll"> in it. That’s fine, but what I like about <dialog> is that it can sorta always be in the DOM, waiting to open, if you like. If you did that here, it would mean the scroll is always locked, which you certainly don’t want.

Instead, I had a play like:

body {
  ...

  &:has(dialog[open]) {
    overflow: hidden;
  }
}

So this just locks scrolling only when the dialog is open. When you call the API like showModal, it toggles that open attribute, so it’s safe to use.

This works, see:

But… check out that content shift above. When we hide the overflow on the body, there is a possibility (depending on the browser/platform/version/settings and “overlay” scrollbars) that the scrollbars are taking up horizontal space, and the removal of them causes content to reflow.

Fortunately, there is yet another modern CSS feature to save us here, if we determine this to be a problem for the site we’re working on: scrollbar-gutter. If we set:

html {
  scrollbar-gutter: stable;
}

Then the page will reserve space for that scrollbar whether there is scrolling or not, thus there will be no reflow when the scrollbar pops in and out. Now we’re cookin’ — see:

I don’t think scrollbar-gutter is a home run, sadly. It leaves a blank strip down the side of the page (no inherited background) when the scrollbar isn’t there, which can look awkward. Plus, “centered” content can look less centered because of the reserved space. Just one of those situations where you have to pick which is less annoying to you 🫠.

Demo:

]]>
https://frontendmasters.com/blog/scroll-locked-dialogs/feed/ 2 938
Basic Dialog Usage and Gotchas To Watch For https://frontendmasters.com/blog/basic-dialog-usage-and-gotchas-to-watch-for/ https://frontendmasters.com/blog/basic-dialog-usage-and-gotchas-to-watch-for/#comments Mon, 05 Feb 2024 18:07:03 +0000 https://frontendmasters.com/blog/?p=724 The <dialog> element in HTML is tremendous. We’ve got support across the board now, so using it is a smart plan. Just with basic usage, you get a centered modal dialog experience that comes up when you call it, a dimmed background, focus trapped within it, closes with the ESC key, and focus returning where it came from. You can style it all entirely predictably in CSS. Those things range from a little bit of a pain to downright hard to pull off if left to our own implementations. Now we get them all for free as they say.

You don’t automatically get a close button, so here’s a basic implementation that adds that.

<button class="show-dialog-button">Show Dialog</button>

<dialog id="dialog">
  <div class="dialog-title">
    Hi, I'm a dialog.
  </div>
  <button aria-label="Close Dialog" class="close-dialog-button">
    <svg ...>
  </button>
</dialog>

Now we need two click handlers, one for opening and one for closing.

const showDialogButton = document.querySelector(".show-dialog-button");
const closeDialogButton = document.querySelector(".close-dialog-button");
const dialog = document.querySelector("dialog");

showDialogButton.addEventListener("click", () => {
  dialog.showModal();
});

closeDialogButton.addEventListener("click", () => {
  dialog.close();
});

I think the naming of things here almost qualifies as a gotcha. So it’s showModal() eh? Why not showDialog(), since, ya know, it’s for the <dialog> element? So to close it it must be closeDialog() or hideDialog() surely, right? No. Just close(). Oh well I’m sure that was bikeshedded to death and there are probably ✨ reasons.

That code above is it really, that’s largely functional. And that, friends, is extremely cool. Here’s a quick live demo:

Here’s another little gotcha! Where do you want to position that close button?

I would think probably in the top right or top left of the dialog. So you might…

dialog {
  position: relative;

  .close-button {
    position: absolute;
    top: 0.5rem;
    right: 0.5rem;
  }
}

Danger danger!

By setting the <dialog> to position: relative;, something we naturally do when I’m thinking about using position: absolute; on a child element, we’ve introduced a potentially gnarly UX bug. Desktop browsers will now scroll the page all the way to the top when the dialog is opened, and you’ll see the dialog open and centered in the viewport. But on iOS, the window will not scroll up. This leads to a situation where you can open the dialog and… not see it at all.

The problem there is that, by default, the <dialog> is position: fixed;, which means it would show up no matter where the page is scrolled to (even without force-scrolling to the top). But we’ve (ok ok, I’ve) accidentally overridden it with relative creating this unexpected behavior.

It’s a little tempting to look into locking the scroll position while the dialog is open, perhaps with a simple :has()-based selector, but I’m not sure how much I care if the page can scroll while it’s open.

There is other little gotcha’s to think about as well, like the fact that content within dialogs are not find-on-page-able until opened. Which kinda makes sense, but content within details elements are, so it’s just something you need to know about and probably not accidentally hide content within you want available always. I’d listen to Scott O’Hara, myself.

If you want to see a <dialog> in production use, look no further. Posts right here on Boost have a “Take Quiz” button the sidebar that opens a dialog with a custom Web Component that the Frontend Masters team have built to help build you a custom course path.

Any other gotchas you’ve found with the dialog element?

Oh hey ya know what, if someone were to make like 8-10 really cool designs for the <dialog> and the ::backdrop, that would make for a pretty sweet guest post, I’d say.

]]>
https://frontendmasters.com/blog/basic-dialog-usage-and-gotchas-to-watch-for/feed/ 3 724