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
Building a Live Preview with Eleventy, Contentful, and Liquid Templating https://frontendmasters.com/blog/building-a-live-preview-with-eleventy-contentful-and-liquid-templating/ https://frontendmasters.com/blog/building-a-live-preview-with-eleventy-contentful-and-liquid-templating/#respond Fri, 22 Mar 2024 15:53:17 +0000 https://frontendmasters.com/blog/?p=1367 As a part of the marketing team at Heyflow, I collaborate with people who work on the company’s website. Although all team members are technically acquainted, sometimes they struggle to update the website. The struggle is not being able to visualize what will change on the page when updating the content. Saving the updates and waiting for the staging environment to build is inefficient (even though our site build is less than a minute… still). As a result, the team requested a live preview of our pages.

We’re using Eleventy to build the site, and Contentful to manage the content. Here’s a video of the solution I came up with working:

After successfully implementing it on our company’s website, I built an Eleventy starter project and a demo site showing how it works.

Disclaimer: This article describes the live preview without the live editing option. That means that you can’t see instant page updates, but instead you need to click on the Refresh button to pull the latest updates.

The Plan

Contentful guides for building a live preview usually require using React, which I’m trying to avoid.

The live preview SDK works with JavaScript and has optimized integration for any React.js framework (like Next.js).

So I’ve built a serverless function that renders the whole Liquid template on request without React.js. The code is available on GitHub, and the demo is available at https://11ty-llp.netlify.app/.

A little appreciation for templating languages

Allow me a moment to express my admiration for templating engines, especially Liquid. I remember being amazed when I started using Mustache with PHP almost ten years ago. Outputting variables with Mustache tags made so much sense to me. It was much more readable than echoing PHP variables. I loved it and soon discovered other templating engines.

As my back-end career transformed into a front-end area, I discovered Handlebars, Twig, Pug, and Liquid. Pug, in particular, was the choice for my site around five years ago. I thought it was the right choice, but it didn’t stick. The main reasons were other projects I’ve been part of. These projects were Jekyll and Shopify, the two most prominent frameworks that used Jekyll as their templating engines. Since working with Liquid daily, I learned many ways to work around its limitations. So, it made perfect sense to use it on my site later when I migrated from Hexo to Eleventy.

What I never did was use it in Node.js to render files. In this project, that is exactly what I needed to do to make a live preview happen. Hooray for learning new things!

The website

The demo website uses Contentful, Eleventy, and Liquid — my favorite combination for building a static site. The Contentful content model is based on pages and components. Here’s how it looks in Visual Modeler.

The pages consist of components that could include other components. For example, the homepage has a hero component with a call-to-action button (CTA), which is also a component.

To fetch the data from Contentful, I’m using Content Delivery API to fetch every entity separately. That means I’m fetching pages, hero components, and call-to-action (CTA) components separately, which allows me to handle components individually and reuse the data throughout the site.

For example, here’s how to fetch the pages from Contentful by using the JavaScript data file in Eleventy. The following code snippet is placed inside the _data/pages.js file. Notice how I use only the transformed component object’s id and type.


const contentful = require("contentful")

const client = contentful.createClient({
  space: process.env.CONTENTFUL_SPACE_ID,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN_DELIVERY,
  environment: process.env.CONTENTFUL_ENVIRONMENT,
  host: 'cdn.contentful.com'
})

module.exports = async () => {
  return client.getEntries({ content_type: 'page' })
    .then((response) => response.items.map(item => {
      return {
        ...item.fields,
        components: item.fields.components.map(component => {
          return {
            id: component.sys.id,
            type: component.sys.contentType.sys.id
          }
        })
      }
    })
    .catch(console.error)
}

Here’s how to include the page components dynamically inside the Liquid template, pages.liquid. Notice how I pass the component’s id parameter to the Liquid partial and use the type parameter to determine the path of the included component.

{%- for component in page.components -%}
  {%- assign includePath = 'partials/' | append: component.type -%}
  {%- include includePath, id: component.id -%}
{%- endfor -%}

Here’s how to fetch hero sections from Contentful in the _data/hero.js file. Notice how I transform the CTA components object using only its id.

const contentful = require("contentful")

const client = contentful.createClient({
  space: process.env.CONTENTFUL_SPACE_ID,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN_DELIVERY,
  environment: process.env.CONTENTFUL_ENVIRONMENT,
  host: 'cdn.contentful.com'
})

module.exports = async () => {
  return client.getEntries({ content_type: 'hero' })
    .then((response) => response.items.map(item => {
      return {
        ...item.fields,
        id: item.sys.id,
        cta: item.fields.cta.map(cta => cta.sys.id)
      }
    })
    .catch(console.error)
})

Here’s how I search for the hero component I need. Notice how I use the id parameter previously passed from the Liquid template page.

{%- assign componentHero = hero | where: 'id', id | first -%}

Here’s how to add the CTA components to the hero component in the _includes/partials/hero.liquid Liquid partial. Notice how I pass the CTA’s id parameter to the Liquid cta partial.

{%- if componentHero.cta -%}
  <div class="hero__action">
    {%- for ctaId in componentHero.cta -%}
      {%- include 'partials/cta', id: ctaId -%}
    {%- endfor -%}
  </div>
{%- endif -%}

Here’s how to fetch CTA components from Contentful in the _data/cta.js file.

const contentful = require("contentful")

const client = contentful.createClient({
  space: process.env.CONTENTFUL_SPACE_ID,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN_DELIVERY,
  environment: process.env.CONTENTFUL_ENVIRONMENT,
  host: 'cdn.contentful.com'
})

module.exports = async () => {
  return client.getEntries({ content_type: 'cta' })
    .then((response) => response.items.map(item => {
      return {
        ...item.fields,
        id: item.sys.id
      }
    })
    .catch(console.error)
}

And here’s how to find and display the CTA component in the _includes/partials/cta.liquid Liquid partial. Notice how I use the id parameter previously passed from the hero Liquid template.

{%- assign componentCta = cta | where: 'id', id | first -%}
<a class="cta" href="{{ componentCta.url }}">{{ componentCta.text }}</a>

Now that we know how our page template works let’s see how to set up the live preview.

Live Preview

I’m using Netlify Functions, the LiquidJS package, and its render file method for live previewing. This approach has limitations—live editing and in-page changes are unavailable.

First, I need the Liquid page, where I can parse the URL parameters and make requests for the Netlify function. Here’s the code for the preview.html Liquid page.

---
title: Preview page
layout: default
permalink: "/preview/"
---

<script>
const preview = async () => {
  const urlParams = new URLSearchParams(window.location.search)
  const id = urlParams.get('id')
  const response = await fetch(`/.netlify/functions/preview-page/?pageId=${id}`)
  document.body.appendChild(await response.text())
}
preview();
</script>

Next, I need the Netlify Function. I placed it under the netlify/functions folder and named it preview-page.cjs.

.cjs means we’re using the CommonJS module for Node.js.

First, I need to include LiquidJS and initialize it (after installing it).

const liquid = 'liquidjs'
const path = 'path'

export default async (req) => {
  const engine = new liquid.Liquid({
    root: path.resolve(__dirname, '../../site/_includes/'),
  })
}

I need to fetch the data from Contentful using the Contentful Preview API. The difference between the Contentful Delivery API and the Contentful Preview API is that the preview will return draft and changed content.

I can reuse the code for fetching the Contentful content like explained in the previous section, but I need to make sure to use the preview token this time to fetch the unpublished changes. This is how my netlify/functions/data/pages.js file looks like.

const contentful = require("contentful")

const client = contentful.createClient({
  space: process.env.CONTENTFUL_SPACE_ID,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN_PREVIEW,
  environment: process.env.CONTENTFUL_ENVIRONMENT,
  host: 'preview.contentful.com'
})

module.exports = async () => {
  return client.getEntries({ content_type: 'page' })
    ...
}

I can import all content types in my serverless function now.

const pages = require('./data/pages')
const components = require('./data/components')
const cta = require('./data/cta')

Next, I need to parse the parameters and find the requested page by matching the requested page’s id.

export default async (req, context) => {
  const urlParams = new URLSearchParams(req.url.split('?').pop())
  const id = urlParams.get('pageId')
  const pagesArray = await pages()
  const page = pagesArray.find(page => page.pageId === id)
}

Now, I can render the page template by passing the page, component, and cta data from Contentful. Finally, I return the rendered HTML as a string.

export default async (req, context) => {
  const l = await engine
    .renderFile("helpers/page", {
    'page': page,
    'components': await components(),
    'cta': await cta()
  })

  return new Response(await l)
}

Here’s how the whole serverless function looks.

const liquid = require('liquidjs')
const pages = require('./data/pages')
const components = require('./data/components')
const cta = require('./data/cta')
const path = require('path')

export default async (req, context) => {
  const urlParams = new URLSearchParams(req.url.split('?').pop())
  const id = urlParams.get('pageId')
  const pagesArray = await pages()
  const page = pagesArray.find(page => page.pageId === id)

  if (page) {
    const engine = new liquid.Liquid({
      root: path.resolve(__dirname, '../../site/_includes/'),
      extname: '.html'
    })

    const l = await engine
      .renderFile("helpers/page", {
      'page': page,
      'components': await components(),
      'cta': await cta()
    })

    return new Response(await l)
  } else {
    return new Response(`<div style="margin:auto">
      <p>Couldn't fetch this page.</p>
      <p>Please check the <code>id</code>.</p>
      <br>
      <p><code>id: ${id}</code></p>
    </div>`)
  }
}

To test our function, I used the Netlify CLI. After running the netlify dev to run the serverless function locally, I’ve opened the localhost:8888/preview/?id=XYZ and this is what I got:

Conclusion

The live preview is convenient for all team members, including me. In the future, I plan to add live preview templates for other headless CMS platforms, like Strapi and WordPress.

]]>
https://frontendmasters.com/blog/building-a-live-preview-with-eleventy-contentful-and-liquid-templating/feed/ 0 1367