Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Wed, 03 Apr 2024 15:52:35 +0000 en-US hourly 1 https://wordpress.org/?v=6.5.2 225069128 Drawing a Line to Connect Elements with CSS Anchor Positioning https://frontendmasters.com/blog/drawing-a-line-to-connect-elements-with-css-anchor-positioning/ https://frontendmasters.com/blog/drawing-a-line-to-connect-elements-with-css-anchor-positioning/#respond Tue, 02 Apr 2024 18:02:50 +0000 https://frontendmasters.com/blog/?p=1511 The World Wide Web Consortium (W3C) published a First Public Working Draft of CSS Anchor Positioning last year, so I thought I would give it a try. I already had a perfect candidate to try it on: a component on my other site, adedicated.dev, which showcase my services by linking different words together.

To link different elements in columns, my component relies on heavy JavaScript calculation. Here’s that example. While I love solving a math problem here and there, I prefer browsers doing these kinds of calculations for me!

Let’s take a look at CSS Anchor Positioning and see how it might have a solution for us.

A Bit about CSS Anchor Positioning

CSS Anchor Positioning provides a better way to position an element in relation to another element. Think of a tooltip and how it is positioned related to the element that triggers it. The perfect tooltip usually “knows” if it overflows outside of the containing block. For example, if the tooltip doesn’t fit above its trigger element, it should go below it. CSS Anchor Positioning solves this problem for us, and that mean less JavaScript calculation.

It is worth noting that CSS Anchor Positioning is quite new API and it is prone to changes. At the time of this writing, the only browser that supports this feature is Chrome Canary, and it is behind the “Experimental Web Platform Features” flag.

The Demo

Back to my component. I have a three columns, and in each one I have a set of words which, when linked, form a new term. When you hover over any word, a random word in three different columns is highlighted and the final term is created. For example, “Creating WordPress Websites” or “Developing HubSpot Pages” or “Updating Shopify Layouts”. I thought it would be fun to showcase my skills in such a way. Here’s how the component works:

To solve the problem of linking different words, we need to prepare the HTML for that. I am using two <div>s for two links, first one for link between first and second column, and the other one for linking second and third column.

<div class="link link--alpha"></div>
<div class="link link--beta"></div>

First thing we need to do is to position our <div>s. For each level links, I had to set up the min-block-size (the logical equivalent of width in a left-to-right or right-to-left language — we’ll be using more of these logical properties as this article goes on):

.link {
  position: absolute;
  min-block-size: 2px;
}

Then we need a grid of all words. I am using unordered list and CSS Grid to achieve this.

<ul>
  <li>Creating</li>
  <li>WordPress</li>
  <li>Websites</li>

  <li>Developing</li>
  <li>HubSpot</li>
  <li>Pages</li>

  <li>Updating</li>
  <li>Shopify</li>
  <li>Layouts</li>

  <li>Implementing</li>
  <li>Jekyll</li>
  <li>Templates</li>

  <li>Optimizing</li>
  <li>Hugo</li>
  <li>Components</li>
</ul>
:root {
  --color-alpha: lightcyan;
  --color-beta: cyan;
  --color-gamma: indigo;
}

ul {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 32px;
  cursor: pointer;
}

li {
  color: var(--color-gamma);
  background-color: var(--color-alpha);
  border-radius: var(--space-alpha);
  position: relative;
  padding: 32px;
  transition: background-color 110ms, color 110ms;
}

To highlight the word, I am using data attributes on unordered list element, like so:

<ul data-col1="2" data-col2="3" data-col3="4">
  ...
</ul>

[data-col1="1"] li:nth-child(1),
[data-col1="2"] li:nth-child(4),
[data-col1="3"] li:nth-child(7),
[data-col1="4"] li:nth-child(10),
[data-col1="5"] li:nth-child(13),
[data-col2="1"] li:nth-child(2),
[data-col2="2"] li:nth-child(5),
[data-col2="3"] li:nth-child(8),
[data-col2="4"] li:nth-child(11),
[data-col2="5"] li:nth-child(14),
[data-col3="1"] li:nth-child(3),
[data-col3="2"] li:nth-child(6),
[data-col3="3"] li:nth-child(9),
[data-col3="4"] li:nth-child(12),
[data-col3="5"] li:nth-child(15) {
  background-color: var(--color-beta);
  transition: background-color var(--trd-beta), color var(--trd-beta);
}

Now for the fun stuff — let’s anchor some elements! We are going to define three anchor-names, each for a single column. These elements will be defined by the word element. That way our line elements will be able to use the position of linked word elements and link each other.

[data-col1="1"] li:nth-child(1),
[data-col1="2"] li:nth-child(4),
[data-col1="3"] li:nth-child(7),
[data-col1="4"] li:nth-child(10),
[data-col1="5"] li:nth-child(13) {
  anchor-name: --link-col1;
}

[data-col2="1"] li:nth-child(2),
[data-col2="2"] li:nth-child(5),
[data-col2="3"] li:nth-child(8),
[data-col2="4"] li:nth-child(11),
[data-col2="5"] li:nth-child(14) {
  anchor-name: --link-col2;
}

[data-col3="1"] li:nth-child(3),
[data-col3="2"] li:nth-child(6),
[data-col3="3"] li:nth-child(9),
[data-col3="4"] li:nth-child(12),
[data-col3="5"] li:nth-child(15) {
  anchor-name: --link-col3;
}

Here’s the image so you can visualize the link elements more easily.

Next, we need to define the offset for our element by using the anchor function. We want our first line (the left pink rectangle) to start outside and in the middle of the first word element and to end outside and in the middle of the second word element. 

.link--alpha {
  inset-block-start: anchor(--link-col1 center);
  inset-inline-start: anchor(--link-col1 right);
  inset-inline-end: anchor(--link-col2 left);
  inset-block-end: anchor(--link-col2 center);
}

(Editor’s note: I drew this crude diagram that follows to demonstrate how the placement of that pink rectangle works because it’s totally fascinating to me!)

It’s the same setup for the second line, but we are the referencing the second and third word elements instead of the first and second.

.link--beta {
  inset-block-start: anchor(--link-col2 center);
  inset-inline-start: anchor(--link-col2 right);
  inset-inline-end: anchor(--link-col3 left);
  inset-block-end: anchor(--link-col3 center);
}

To make the lines, I am using a linear gradient in the following fashion:

  • The first linear gradient is vertical line that is 100% in height and placed in the center of rectangle
  • The second linear gradient is horizontal line that starts in the top left corner and is 50% of width
  • The third linear gradient is horizontal line that starts in the bottom right corner and is 50% of width
.link {
  background-image: linear-gradient(to bottom, black, black), linear-gradient(to right, black, black), linear-gradient(to bottom, black, black);
  background-size: 2px, 50% 2px, 50% 2px;
  background-position: center, top left, bottom right;
  background-repeat: no-repeat;
}

Here’s how it looks now.

To generate different terms on each hover event and to automatically change the terms when no hover effects occur to make the whole component more appealing and inviting, we need to introduce a bit of JavaScript. Once the timeout expires, JavaScript will update the data-col1, data-col2, and data-col3 attributes.

const highlighter = (timeout = 4000) => {
  const $ul = document.querySelector('ul')
   
  const getRandomNumber = (min, max) => {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }
  
  const randomHighlighter = ($s, c) => {
    const nth1 = [1, 4, 7, 10, 13]
    const nth2 = [2, 5, 8, 11, 14]
    const nth3 = [3, 6, 9, 12, 15]
    
    let c1 = getRandomNumber(1, 5)
    let c2 = getRandomNumber(1, 5)
    let c3 = getRandomNumber(1, 5)

    if(c && nth1.indexOf(c) !== -1) {
      c1 = nth1.indexOf(c) + 1
    }

    if(c && nth2.indexOf(c) !== -1) {
      c2 = nth2.indexOf(c) + 1
    }

    if(c && nth3.indexOf(c) !== -1) {
      c3 = nth3.indexOf(c) + 1
    }

    if(c2 < c1) {
      document.body.classList.add('link-alpha-inverse')
    } else {
      document.body.classList.remove('link-alpha-inverse')
    }
    
    if(c3 < c2) {
      document.body.classList.add('link-beta-inverse')
    } else {
      document.body.classList.remove('link-beta-inverse')
    }

    $s.setAttribute('data-col1', c1)
    $s.setAttribute('data-col2', c2)
    $s.setAttribute('data-col3', c3)
  }

  if($ul) {
    const $lis = $ul.querySelectorAll('li')

    let hover = false;

    randomHighlighter($ul)

    const si = setInterval(() => {
      if(!hover) {
        randomHighlighter($ul)
      }
    }, timeout)

    $lis.forEach(($li, i) => {
      $li.addEventListener('mouseenter', () => {
        randomHighlighter($ul, i + 1)

        hover = true
      })
      
      $li.addEventListener('click', () => {
        randomHighlighter($ul, i + 1)

        hover = true
      })
    })

    $ul.addEventListener('mouseleave', () => {
      hover = false
    })
  }
}

highlighter()

There is one final problem that we need to resolve. In case when the second word is “higher” than the first word, the positioning will not work. That is because we cannot have “negative” elements, meaning the block end must be bigger or equal to block start property. To solve that problem, we will add another class to the body element.

// ...

if(c2 < c1) {
  document.body.classList.add('link-alpha-inverse')
} else {
  document.body.classList.remove('link-alpha-inverse')
}

if(c3 < c2) {
  document.body.classList.add('link-beta-inverse')
} else {
  document.body.classList.remove('link-beta-inverse')
}

// ...

Now we could adjust our line component’s CSS and fix the background positioning, too.

.link-alpha-inverse .link--alpha {
  inset-block-end: anchor(--link-col1 center);
  inset-block-start: anchor(--link-col2 center);
  background-position: center, bottom left, top right;
}

.link-beta-inverse .link--beta {
  inset-block-end: anchor(--link-col2 center);
  inset-block-start: anchor(--link-col3 center);
  background-position: center, bottom left, top right;
}

Conclusion

The original solution to this kind of problems required a whole lot of JavaScript calculations and clumsy inserting of <style> element to our HTML. With CSS Anchor Positioning, we use JavaScript only to update our data attributes and toggle body classes – all calculations and heavy lifting are done by our browser. I think that is wild and I cannot wait to see other useful places where this could be used.

Final Demo

Remember to see the lines, at the time of publication, you need to be in Chrome Canary with the Experimental Web Features flag turned on. If you want to see more JavaScript calculation heavy fallback, see here.

If you’re really into these ideas, definitely check out the web.dev article Tether elements to each other with CSS anchor positioning

]]>
https://frontendmasters.com/blog/drawing-a-line-to-connect-elements-with-css-anchor-positioning/feed/ 0 1511
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