Tag: Component

Web Component Pseudo-Classes and Pseudo-Elements are Easier Than You Think

We’ve discussed a lot about the internals of using CSS in this ongoing series on web components, but there are a few special pseudo-elements and pseudo-classes that, like good friends, willingly smell your possibly halitotic breath before you go talk to that potential love interest. You know, they help you out when you need it most. And, like a good friend will hand you a breath mint, these pseudo-elements and pseudo-classes provide you with some solutions both from within the web component and from outside the web component — the website where the web component lives.

I’m specifically referring to the ::part and ::slotted pseudo-elements, and the :defined, :host, and :host-context pseudo-classes. They give us extra ways to interact with web components. Let’s examine them closer.

Article series

The ::part pseudo-element

::part, in short, allows you to pierce the shadow tree, which is just my Lord-of-the-Rings-y way to say it lets you style elements inside the shadow DOM from outside the shadow DOM. In theory, you should encapsulate all of your styles for the shadow DOM within the shadow DOM, i.e. within a <style> element in your <template> element.

So, given something like this from the very first part of this series, where you have an <h2> in your <template>, your styles for that <h2> should all be in the <style> element.

<template id="zprofiletemplate">   <style>     h2 {       font-size: 3em;       margin: 0 0 0.25em 0;       line-height: 0.8;     }     /* other styles */   </style>   <div class="profile-wrapper">     <div class="info">       <h2>         <slot name="zombie-name">Zombie Bob</slot>       </h2>       <!-- other zombie profile info -->     </div> </template>

But sometimes we might need to style an element in the shadow DOM based on information that exists on the page. For instance, let’s say we have a page for each zombie in the undying love system with matches. We could add a class to profiles based on how close of a match they are. We could then, for instance, highlight a match’s name if he/she/it is a good match. The closeness of a match would vary based on whose list of potential matches is being shown and we won’t know that information until we’re on that page, so we can’t bake the functionality into the web component. Since the <h2> is in the shadow DOM, though, we can’t access or style it from outside the shadow DOM meaning a selector of zombie-profile h2 on the matches page won’t work.

But, if we make a slight adjustment to the <template> markup by adding a part attribute to the <h2>:

<template id="zprofiletemplate">   <style>     h2 {       font-size: 3em;       margin: 0 0 0.25em 0;       line-height: 0.8;     }     /* other styles */   </style>   <div class="profile-wrapper">     <div class="info">       <h2 part="zname">         <slot name="zombie-name">Zombie Bob</slot>       </h2>       <!-- other zombie profile info -->     </div> </template>

Like a spray of Bianca in the mouth, we now have the superpowers to break through the shadow DOM barrier and style those elements from outside of the <template>:

/* External stylesheet */ .high-match::part(zname) {   color: blue; } .medium-match::part(zname) {   color: navy; } .low-match::part(zname) {   color: slategray; }

There are lots of things to consider when it comes to using CSS ::part. For example, styling an element inside of a part is a no-go:

/* frowny-face emoji */ .high-match::part(zname) span { ... }

But you can add a part attribute on that element and style it via its own part name.

What happens if we have a web component inside another web component, though? Will ::part still work? If the web component appears in the page’s markup, i.e. you’re slotting it in, ::part works just fine from the main page’s CSS.

<zombie-profile class="high-match">   <img slot="profile-image" src="https://assets.codepen.io/1804713/leroy.png" />   <span slot="zombie-name">Leroy</span>   <zombie-details slot="zdetails">     <!-- Leroy's details -->   </zombie-details> </zombie-profile>

But if the web component is in the template/shadow DOM, then ::part cannot pierce both shadow trees, just the first one. We need to bring the ::part into the light… so to speak. We can do that with an exportparts attribute.

To demonstrate this we’ll add a “watermark” behind the profiles using a web component. (Why? Believe it or not this was the least contrived example I could come up with.) Here are our templates: (1) the template for <zombie-watermark>, and (2) the same template for <zombie-profile> but with added a <zombie-watermark> element on the end.

<template id="zwatermarktemplate">   <style>     div {     text-transform: uppercase;       font-size: 2.1em;       color: rgb(0 0 0 / 0.1);       line-height: 0.75;       letter-spacing: -5px;     }     span {       color: rgb( 255 0 0 / 0.15);     }   </style>   <div part="watermark">     U n d y i n g  L o v e  U n d y i n g  L o v e  U n d y i n g  L o v e  <span part="copyright">©2 0 2 7 U n d y i n g  L o v e  U n L t d .</span>   <!-- Repeat this a bunch of times so we can cover the background of the profile -->   </div>  </template> <template id="zprofiletemplate">   <style>     ::part(watermark) {       color: rgb( 0 0 255 / 0.1);     }     /* More styles */   </style>   <!-- zombie-profile markup -->   <zombie-watermark exportparts="copyright"></zombie-watermark> </template> <style>   /* External styles */   ::part(copyright) {     color: rgb( 0 100 0 / 0.125);   } </style>

Since ::part(watermark) is only one shadow DOM above the <zombie-watermark>, it works fine from within the <zombie-profile>’s template styles. Also, since we’ve used exportparts="copyright" on the <zombie-watermark>, the copyright part has been pushed up into the <zombie-profile>‘s shadow DOM and ::part(copyright) now works even in external styles, but ::part(watermark) will not work outside the <zombie-profile>’s template.

We can also forward and rename parts with that attribute:

<zombie-watermark exportparts="copyright: cpyear"></zombie-watermark>
/* Within zombie-profile's shadow DOM */  /* happy-face emoji */ ::part(cpyear) { ... }  /* frowny-face emoji */ ::part(copyright) { ... }

Structural pseudo-classes (:nth-child, etc.) don’t work on parts either, but you can use pseudo-classes like :hover. Let’s animate the high match names a little and make them shake as they’re lookin’ for some lovin’. Okay, I heard that and agree it’s awkward. Let’s… uh… make them more, shall we say, noticeable, with a little movement.

.high::part(name):hover {   animation: highmatch 1s ease-in-out; }

The ::slotted pseudo-element

The ::slotted CSS pseudo-element actually came up when we covered interactive web components. The basic idea is that ::slotted represents any content in a slot in a web component, i.e. the element that has the slot attribute on it. But, where ::part pierces through the shadow DOM to make a web component’s elements accessible to outside styles, ::slotted remains encapsulated in the <style> element in the component’s <template> and accesses the element that’s technically outside the shadow DOM.

In our <zombie-profile> component, for example, each profile image is inserted into the element through the slot="profile-image".

<zombie-profile>   <img slot="profile-image" src="photo.jpg" />    <!-- rest of the content --> </zombie-profile>

That means we can access that image — as well as any image in any other slot — like this:

::slotted(img) {   width: 100%;   max-width: 300px;   height: auto;   margin: 0 1em 0 0; }

Similarly, we could select all slots with ::slotted(*) regardless of what element it is. Just beware that ::slotted has to select an element — text nodes are immune to ::slotted zombie styles. And children of the element in the slot are inaccessible.

The :defined pseudo-class

:defined matches all defined elements (I know, surprising, right?), both built-in and custom. If your custom element is shuffling along like a zombie avoiding his girlfriend’s dad’s questions about his “living” situation, you may not want the corpses of the content to show while you’re waiting for them to come back to life errr… load.

You can use the :defined pseudo-class to hide a web component before it’s available — or “defined” — like this:

:not(:defined) {   display: none; }

You can see how :defined acts as a sort of mint in the mouth of our component styles, preventing any broken content from showing (or bad breath from leaking) while the page is still loading. Once the element’s defined, it’ll automatically appear because it’s now, you know, defined and not not defined.

I added a setTimeout of five seconds to the web component in the following demo. That way, you can see that <zombie-profile> elements are not shown while they are undefined. The <h1> and the <div> that holds the <zombie-profile> components are still there. It’s just the <zombie-profile> web component that gets display: none since they are not yet defined.

The :host pseudo-class

Let’s say you want to make styling changes to the custom element itself. While you could do this from outside the custom element (like tightening that N95), the result would not be encapsulated, and additional CSS would have to be transferred to wherever this custom element is placed.

It’d be very convenient then to have a pseudo-class that can reach outside the shadow DOM and select the shadow root. That CSS pseudo-class is :host.

In previous examples throughout this series, I set the <zombie-profile> width from the main page’s CSS, like this:

zombie-profile {   width: calc(50% - 1em); }

With :host, however, I can set that width from inside the web component, like this:

:host {   width: calc(50% - 1em); }

In fact, there was a div with a class of .profile-wrapper in my examples that I can now remove because I can use the shadow root as my wrapper with :host. That’s a nice way to slim down the markup.

You can do descendant selectors from the :host, but only descendants inside the shadow DOM can be accessed — nothing that’s been slotted into your web component (without using ::slotted).

Showing the parts of the HTML that are relevant to the :host pseudo-element.

That said, :host isn’t a one trick zombie. It can also take a parameter, e.g. a class selector, and will only apply styling if the class is present.

:host(.high) {   border: 2px solid blue; }

This allows you to make changes should certain classes be added to the custom element.

You can also pass pseudo-classes in there, like :host(:last-child) and :host(:hover).

The :host-context pseudo-class

Now let’s talk about :host-context. It’s like our friend :host(), but on steroids. While :host gets you the shadow root, it won’t tell you anything about the context in which the custom element lives or its parent and ancestor elements.

:host-context, on the other hand, throws the inhibitions to the wind, allowing you to follow the DOM tree up the rainbow to the leprechaun in a leotard. Just note that at the time I’m writing this, :host-context is unsupported in Firefox or Safari. So use it for progressive enhancement.

Here’s how it works. We’ll split our list of zombie profiles into two divs. The first div will have all of the high zombie matches with a .bestmatch class. The second div will hold all the medium and low love matches with a .worstmatch class.

<div class="profiles bestmatch">   <zombie-profile class="high">     <!-- etc. -->   </zombie-profile>   <!-- more profiles --> </div>  <div class="profiles worstmatch">   <zombie-profile class="medium">     <!-- etc. -->   </zombie-profile>   <zombie-profile class="low">     <!-- etc. -->   </zombie-profile>   <!-- more profiles --> </div>

Let’s say we want to apply different background colors to the .bestmatch and .worstmatch classes. We are unable to do this with just :host:

:host(.bestmatch) {   background-color: #eef; } :host(.worstmatch) {   background-color: #ddd; }

That’s because our best and worst match classes are not on our custom elements. What we want is to be able to select the profiles’s parent elements from within the shadow DOM. :host-context pokes past the custom element to match the, er, match classes we want to style.

:host-context(.bestmatch) {   background-color: #eef; } :host-context(.worstmatch) {   background-color: #ddd; }

Well, thanks for hanging out despite all the bad breath. (I know you couldn’t tell, but above when I was talking about your breath, I was secretly talking about my breath.)

How would you use ::part, ::slotted, :defined, :host, and :host-context in your web component? Let me know in the comments. (Or if you have cures to chronic halitosis, my wife would be very interested in to hear more.)


Web Component Pseudo-Classes and Pseudo-Elements are Easier Than You Think originally published on CSS-Tricks. You should get the newsletter.

CSS-Tricks

, , , , ,

How Do You Handle Component Spacing in a Design System?

Say you’ve got a <Card /> component. It’s highly likely it shouldn’t be butted right up against any other components with no spacing around it. That’s true for… pretty much every component. So, how do you handle component spacing in a design system?

Do you apply spacing using margin directly on the <Card />? Perhaps margin-block-end: 1rem; margin-inline-end: 1rem; so it pushes away from the two sides where more content natural flows? That’s a little presumptuous. Perhaps the cards are children inside a <Grid /> component and the grid applies a gap: 1rem. That’s awkward, as now the <Card /> component spacing is going to conflict with the <Grid /> component spacing, which is very likely not what you want, not to mention the amount of space is hard coded.

Example of a component spacing where a card component is to the left of an accordion component and above an article, with 50 pixels of spacing between all three elements. Lorem i-sum text throughout in a mono font. The card has a Calvin and Hobbes comic image.
Adding space to the inline start and block end of a card component.

Different perspectives on component spacing

Eric Bailey got into this recently and looked at some options:

  • You could bake spacing into every component and try to be as clever as you can about it. (But that’s pretty limiting.)
  • You could pass in component spacing, like <Card space="xxl" />. (That can be a good approach, likely needs more than one prop, maybe even one for each direction, which is quite verbose.)
  • You could use no component spacing and create something like a <Spacer /> or <Layout /> component specifically for spacing between components. (It breaks up the job of components nicely, but can also be verbose and add unnecessary DOM weight.)

This conversation has a wide spectrum of viewpoints, some as extreme as Max Stoiber saying just never use margin ever at all. That’s a little dogmatic for me, but I like that it’s trying to rethink things. I do like the idea of taking the job of spacing and layout away from components themselves — like, for example, those content components should completely not care where they are used and let layout happen a level up from them.

Adam Argyle predicted a few years back that the use of margin in CSS would decline as the use of gap rises. He’s probably going to end up right about this, especially now that flexbox has gap and that developers have an appetite these dats to use CSS Flexbox and Grid on nearly everything at both a macro and micro level.


How Do You Handle Component Spacing in a Design System? originally published on CSS-Tricks. You should get the newsletter and become a supporter.

CSS-Tricks

, , , ,
[Top]

How to Make a Component That Supports Multiple Frameworks in a Monorepo

Your mission — should you decide to accept it — is to build a Button component in four frameworks, but, only use one button.css file!

This idea is very important to me. I’ve been working on a component library called AgnosticUI where the purpose is building UI components that aren’t tied to any one particular JavaScript framework. AgnosticUI works in React, Vue 3, Angular, and Svelte. So that’s exactly what we’ll do today in this article: build a button component that works across all these frameworks.

The source code for this article is available on GitHub on the the-little-button-that-could-series branch.

Table of contents

Why a monorepo?

We’re going to set up a tiny Yarn workspaces-based monorepo. Why? Chris actually has a nice outline of the benefits in another post. But here’s my own biased list of benefits that I feel are relevant for our little buttons endeavor:

Coupling

We’re trying to build a single button component that uses just one button.css file across multiple frameworks. So, by nature, there’s some purposeful coupling going on between the various framework implementations and the single-source-of-truth CSS file. A monorepo setup provides a convenient structure that facilitates copying our single button.css component into various framework-based projects.

Workflow

Let’s say the button needs a tweak — like the “focus-ring” implementation, or we screwed up the use of aria in the component templates. Ideally, we’d like to correct things in one place rather than making individual fixes in separate repositories.

Testing

We want the convenience of firing up all four button implementations at the same time for testing. As this sort of project grows, it’s safe to assume there will be more proper testing. In AgnosticUI, for example, I’m currently using Storybook and often kick off all the framework Storybooks, or run snapshot testing across the entire monorepo.

I like what Leonardo Losoviz has to say about the monorepo approach. (And it just so happens to align with with everything we’ve talked about so far.)

I believe the monorepo is particularly useful when all packages are coded in the same programming language, tightly coupled, and relying on the same tooling.

Setting up

Time to dive into code — start by creating a top-level directory on the command-line to house the project and then cd into it. (Can’t think of a name? mkdir buttons && cd buttons will work fine.)

First off, let’s initialize the project:

$  yarn init yarn init v1.22.15 question name (articles): littlebutton question version (1.0.0):  question description: my little button project question entry point (index.js):  question repository url:  question author (Rob Levin):  question license (MIT):  question private:  success Saved package.json

That gives us a package.json file with something like this:

{   "name": "littlebutton",   "version": "1.0.0",   "description": "my little button project",   "main": "index.js",   "author": "Rob Levin",   "license": "MIT" }

Creating the baseline workspace

We can set the first one up with this command:

mkdir -p ./littlebutton-css

Next, we need to add the two following lines to the monorepo’s top-level package.json file so that we keep the monorepo itself private. It also declares our workspaces:

// ... "private": true, "workspaces": ["littlebutton-react", "littlebutton-vue", "littlebutton-svelte", "littlebutton-angular", "littlebutton-css"]

Now descend into the littlebutton-css directory. We’ll again want to generate a package.json with yarn init. Since we’ve named our directory littlebutton-css (the same as how we specified it in our workspaces in package.json) we can simply hit the Return key and accept all the prompts:

$  cd ./littlebutton-css && yarn init yarn init v1.22.15 question name (littlebutton-css):  question version (1.0.0):  question description:  question entry point (index.js):  question repository url:  question author (Rob Levin):  question license (MIT):  question private:  success Saved package.json

At this point, the directory structure should look like this:

├── littlebutton-css │   └── package.json └── package.json

We’ve only created the CSS package workspace at this point as we’ll be generating our framework implementations with tools like vite which, in turn, generate a package.json and project directory for you. We will have to remember that the name we choose for these generated projects must match the name we’ve specified in the package.json for our earlier workspaces to work.

Baseline HTML & CSS

Let’s stay in the ./littlebutton-css workspace and create our simple button component using vanilla HTML and CSS files.

touch index.html ./css/button.css

Now our project directory should look like this:

littlebutton-css ├── css │   └── button.css ├── index.html └── package.json

Let’s go ahead and connect some dots with some boilerplate HTML in ./index.html:

<!doctype html> <html lang="en"> <head>   <meta charset="utf-8">   <title>The Little Button That Could</title>   <meta name="description" content="">   <meta name="viewport" content="width=device-width, initial-scale=1">   <link rel="stylesheet" href="css/button.css"> </head> <body>   <main>     <button class="btn">Go</button>   </main> </body> </html>

And, just so we have something visual to test, we can add a little color in ./css/button.css:

.btn {   color: hotpink; }
A mostly unstyled button with hot-pink text from the monorepo framework.

Now open up that index.html page in the browser. If you see an ugly generic button with hotpink text… success!

Framework-specific workspaces

So what we just accomplished is the baseline for our button component. What we want to do now is abstract it a bit so it’s extensible for other frameworks and such. For example, what if we want to use the button in a React project? We’re going to need workspaces in our monorepo for each one. We’ll start with React, then follow suit for Vue 3, Angular, and Svelte.

React

We’re going to generate our React project using vite, a very lightweight and blazingly fast builder. Be forewarned that if you attempt to do this with create-react-app, there’s a very good chance you will run into conflicts later with react-scripts and conflicting webpack or Babel configurations from other frameworks, like Angular.

To get our React workspace going, let’s go back into the terminal and cd back up to the top-level directory. From there, we’ll use vite to initialize a new project — let’s call it littlebutton-react — and, of course, we’ll select react as the framework and variant at the prompts:

$  yarn create vite yarn create v1.22.15 [1/4] 🔍  Resolving packages... [2/4] 🚚  Fetching packages... [3/4] 🔗  Linking dependencies... [4/4] 🔨  Building fresh packages...  success Installed "create-vite@2.6.6" with binaries:       - create-vite       - cva ✔ Project name: … littlebutton-react ✔ Select a framework: › react ✔ Select a variant: › react  Scaffolding project in /Users/roblevin/workspace/opensource/guest-posts/articles/littlebutton-react...  Done. Now run:    cd littlebutton-react   yarn   yarn dev  ✨  Done in 17.90s.

We initialize the React app with these commands next:

cd littlebutton-react yarn yarn dev

With React installed and verified, let’s replace the contents of src/App.jsx to house our button with the following code:

import "./App.css";  const Button = () => {   return <button>Go</button>; };  function App() {   return (     <div className="App">       <Button />     </div>   ); }  export default App;

Now we’re going to write a small Node script that copies our littlebutton-css/css/button.css right into our React application for us. This step is probably the most interesting one to me because it’s both magical and ugly at the same time. It’s magical because it means our React button component is truly deriving its styles from the same CSS written in the baseline project. It’s ugly because, well, we are reaching up out of one workspace and grabbing a file from another. ¯_(ツ)_/¯

Add the following little Node script to littlebutton-react/copystyles.js:

const fs = require("fs"); let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8"); fs.writeFileSync("./src/button.css", css, "utf8");

Let’s place a node command to run that in a package.json script that happens before the dev script in littlebutton-react/package.json. We’ll add a syncStyles and update the dev to call syncStyles before vite:

"syncStyles": "node copystyles.js", "dev": "yarn syncStyles && vite",

Now, anytime we fire up our React application with yarn dev, we’ll first be copying the CSS file over. In essence, we’re “forcing” ourselves to not diverge from the CSS package’s button.css in our React button.

But we want to also leverage CSS Modules to prevent name collisions and global CSS leakage, so we have one more step to do to get that wired up (from the same littlebutton-react directory):

touch src/button.module.css

Next, add the following to the new src/button.module.css file:

.btn {   composes: btn from './button.css'; }

I find composes (also known as composition) to be one of the coolest features of CSS Modules. In a nutshell, we’re copying our HTML/CSS version of button.css over wholesale then composing from our one .btn style rule.

With that, we can go back to our src/App.jsx and import the CSS Modules styles into our React component with this:

import "./App.css"; import styles from "./button.module.css";  const Button = () => {   return <button className={styles.btn}>Go</button>; };  function App() {   return (     <div className="App">       <Button />     </div>   ); }  export default App;

Whew! Let’s pause and try to run our React app again:

yarn dev

If all went well, you should see that same generic button, but with hotpink text. Before we move on to the next framework, let’s move back up to our top-level monorepo directory and update its package.json:

{   "name": "littlebutton",   "version": "1.0.0",   "description": "toy project",   "main": "index.js",   "author": "Rob Levin",   "license": "MIT",   "private": true,   "workspaces": ["littlebutton-react", "littlebutton-vue", "littlebutton-svelte", "littlebutton-angular"],   "scripts": {     "start:react": "yarn workspace littlebutton-react dev"   } }

Run the yarn command from the top-level directory to get the monorepo-hoisted dependencies installed.

The only change we’ve made to this package.json is a new scripts section with a single script to start the React app. By adding start:react we can now run yarn start:react from our top-level directory and it will fire up the project we just built in ./littlebutton-react without the need for cd‘ing — super convenient!

We’ll tackle Vue and Svelte next. It turns out that we can take a pretty similar approach for these as they both use single file components (SFC). Basically, we get to mix HTML, CSS, and JavaScript all into one single file. Whether you like the SFC approach or not, it’s certainly adequate enough for building out presentational or primitive UI components.

Vue

Following the steps from vite’s scaffolding docs we’ll run the following command from the monorepo’s top-level directory to initialize a Vue app:

yarn create vite littlebutton-vue --template vue

This generates scaffolding with some provided instructions to run the starter Vue app:

cd littlebutton-vue yarn yarn dev

This should fire up a starter page in the browser with some heading like “Hello Vue 3 + Vite.” From here, we can update src/App.vue to:

<template>   <div id="app">     <Button class="btn">Go</Button>   </div> </template>  <script> import Button from './components/Button.vue'  export default {   name: 'App',   components: {     Button   } } </script>

And we’ll replace any src/components/* with src/components/Button.vue:

<template>   <button :class="classes"><slot /></button> </template>  <script> export default {   name: 'Button',   computed: {     classes() {       return {         [this.$ style.btn]: true,       }     }   } } </script>  <style module> .btn {   color: slateblue; } </style>

Let’s break this down a bit:

  • :class="classes" is using Vue’s binding to call the computed classes method.
  • The classes method, in turn, is utilizing CSS Modules in Vue with the this.$ style.btn syntax which will use styles contained in a <style module> tag.

For now, we’re hardcoding color: slateblue simply to test that things are working properly within the component. Try firing up the app again with yarn dev. If you see the button with our declared test color, then it’s working!

Now we’re going to write a Node script that copies our littlebutton-css/css/button.css into our Button.vue file similar to the one we did for the React implementation. As mentioned, this component is a SFC so we’re going to have to do this a little differently using a simple regular expression.

Add the following little Node.js script to littlebutton-vue/copystyles.js:

const fs = require("fs"); let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8"); const vue = fs.readFileSync("./src/components/Button.vue", "utf8"); // Take everything between the starting and closing style tag and replace const styleRegex = /<style module>([sS]*?)</style>/; let withSynchronizedStyles = vue.replace(styleRegex, `<style module>n$ {css}n</style>`); fs.writeFileSync("./src/components/Button.vue", withSynchronizedStyles, "utf8");

There’s a bit more complexity in this script, but using replace to copy text between opening and closing style tags via regex isn’t too bad.

Now let’s add the following two scripts to the scripts clause in the littlebutton-vue/package.json file:

"syncStyles": "node copystyles.js", "dev": "yarn syncStyles && vite",

Now run yarn syncStyles and look at ./src/components/Button.vue again. You should see that our style module gets replaced with this:

<style module> .btn {   color: hotpink; } </style>

Run the Vue app again with yarn dev and verify you get the expected results — yes, a button with hotpink text. If so, we’re good to move on to the next framework workspace!

Svelte

Per the Svelte docs, we should kick off our littlebutton-svelte workspace with the following, starting from the monorepo’s top-level directory:

npx degit sveltejs/template littlebutton-svelte cd littlebutton-svelte yarn && yarn dev

Confirm you can hit the “Hello World” start page at http://localhost:5000. Then, update littlebutton-svelte/src/App.svelte:

<script>   import Button from './Button.svelte'; </script> <main>   <Button>Go</Button> </main>

Also, in littlebutton-svelte/src/main.js, we want to remove the name prop so it looks like this:

import App from './App.svelte';  const app = new App({   target: document.body });  export default app;

And finally, add littlebutton-svelte/src/Button.svelte with the following:

<button class="btn">   <slot></slot> </button>  <script> </script>  <style>   .btn {     color: saddlebrown;   } </style>

One last thing: Svelte appears to name our app: "name": "svelte-app" in the package.json. Change that to "name": "littlebutton-svelte" so it’s consistent with the workspaces name in our top-level package.json file.

Once again, we can copy our baseline littlebutton-css/css/button.css into our Button.svelte. As mentioned, this component is a SFC, so we’re going to have to do this using a regular expression. Add the following Node script to littlebutton-svelte/copystyles.js:

const fs = require("fs"); let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8"); const svelte = fs.readFileSync("./src/Button.svelte", "utf8"); const styleRegex = /<style>([sS]*?)</style>/; let withSynchronizedStyles = svelte.replace(styleRegex, `<style>n$ {css}n</style>`); fs.writeFileSync("./src/Button.svelte", withSynchronizedStyles, "utf8");

This is super similar to the copy script we used with Vue, isn’t it? We’ll add similar scripts to our package.json script:

"dev": "yarn syncStyles && rollup -c -w", "syncStyles": "node copystyles.js",

Now run yarn syncStyles && yarn dev. If all is good, we once again should see a button with hotpink text.

If this is starting to feel repetitive, all I have to say is welcome to my world. What I’m showing you here is essentially the same process I’ve been using to build my AgnosticUI project!

Angular

You probably know the drill by now. From the monorepo’s top-level directory, install Angular and create an Angular app. If we were creating a full-blown UI library we’d likely use ng generate library or even nx. But to keep things as straightforward as possible we’ll set up a boilerplate Angular app as follows:

npm install -g @angular/cli ### unless you already have installed ng new littlebutton-angular ### choose no for routing and CSS ? Would you like to add Angular routing? (y/N) N ❯ CSS    SCSS   [ https://sass-lang.com/documentation/syntax#scss ]    Sass   [ https://sass-lang.com/documentation/syntax#the-indented-syntax ]    Less   [ http://lesscss.org ]  cd littlebutton-angular && ng serve --open

With the Angular setup confirmed, let’s update some files. cd littlebutton-angular, delete the src/app/app.component.spec.ts file, and add a button component in src/components/button.component.ts, like this:

import { Component } from '@angular/core';  @Component({   selector: 'little-button',   templateUrl: './button.component.html',   styleUrls: ['./button.component.css'], }) export class ButtonComponent {}

Add the following to src/components/button.component.html:

<button class="btn">Go</button>

And put this in the src/components/button.component.css file for testing:

.btn {   color: fuchsia; }

In src/app/app.module.ts:

import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser';  import { AppComponent } from './app.component'; import { ButtonComponent } from '../components/button.component';  @NgModule({   declarations: [AppComponent, ButtonComponent],   imports: [BrowserModule],   providers: [],   bootstrap: [AppComponent], }) export class AppModule {}

Next, replace src/app/app.component.ts with:

import { Component } from '@angular/core';  @Component({   selector: 'app-root',   templateUrl: './app.component.html',   styleUrls: ['./app.component.css'], }) export class AppComponent {}

Then, replace src/app/app.component.html with:

<main>   <little-button>Go</little-button> </main>

With that, let’s run yarn start and verify our button with fuchsia text renders as expected.

Again, we want to copy over the CSS from our baseline workspace. We can do that by adding this to littlebutton-angular/copystyles.js:

const fs = require("fs"); let css = fs.readFileSync("../littlebutton-css/css/button.css", "utf8"); fs.writeFileSync("./src/components/button.component.css", css, "utf8");

Angular is nice in that it uses ViewEncapsulation that defaults to to emulate which mimics, according to the docs,

[…] the behavior of shadow DOM by preprocessing (and renaming) the CSS code to effectively scope the CSS to the component’s view.

This basically means we can literally copy over button.css and use it as-is.

Finally, update the package.json file by adding these two lines in the scripts section:

"start": "yarn syncStyles && ng serve", "syncStyles": "node copystyles.js",

With that, we can now run yarn start once more and verify our button text color (which was fuchsia) is now hotpink.

What have we just done?

Let’s take a break from coding and think about the bigger picture and what we’ve just done. Basically, we’ve set up a system where any changes to our CSS package’s button.css will get copied over into all the framework implementations as a result of our copystyles.js Node scripts. Further, we’ve incorporated idiomatic conventions for each of the frameworks:

  • SFC for Vue and Svelte
  • CSS Modules for React (and Vue within the SFC <style module> setup)
  • ViewEncapsulation for Angular

Of course I state the obvious that these aren’t the only ways to do CSS in each of the above frameworks (e.g. CSS-in-JS is a popular choice), but they are certainly accepted practices and are working quite well for our greater goal — to have a single CSS source of truth to drive all framework implementations.

If, for example, our button was in use and our design team decided we wanted to change from 4px to 3px border-radius, we could update the one file, and any separate implementations would stay synced.

This is compelling if you have a polyglot team of developers that enjoy working in multiple frameworks, or, say an offshore team (that’s 3× productive in Angular) that’s being tasked to build a back-office application, but your flagship product is built in React. Or, you’re building an interim admin console and you’d love to experiment with using Vue or Svelte. You get the picture.

Finishing touches

OK, so we have the monorepo architecture in a really good spot. But there’s a few things we can do to make it even more useful as far as the developer experience goes.

Better start scripts

Let’s move back up to our top-level monorepo directory and update its package.json scripts section with the following so we can kick any framework implementation without cd‘ing:

// ... "scripts": {   "start:react": "yarn workspace littlebutton-react dev",   "start:vue": "yarn workspace littlebutton-vue dev ",   "start:svelte": "yarn workspace littlebutton-svelte dev",   "start:angular": "yarn workspace littlebutton-angular start" },

Better baseline styles

We can also provide a better set of baseline styles for the button so it starts from a nice, neutral place. Here’s what I did in the littlebutton-css/css/button.css file.

View Full Snippet
.btn {   --button-dark: #333;   --button-line-height: 1.25rem;   --button-font-size: 1rem;   --button-light: #e9e9e9;   --button-transition-duration: 200ms;   --button-font-stack:     system-ui,     -apple-system,     BlinkMacSystemFont,     "Segoe UI",     Roboto,     Ubuntu,     "Helvetica Neue",     sans-serif;    display: inline-flex;   align-items: center;   justify-content: center;   white-space: nowrap;   user-select: none;   appearance: none;   cursor: pointer;   box-sizing: border-box;   transition-property: all;   transition-duration: var(--button-transition-duration);   color: var(--button-dark);   background-color: var(--button-light);   border-color: var(--button-light);   border-style: solid;   border-width: 1px;   font-family: var(--button-font-stack);   font-weight: 400;   font-size: var(--button-font-size);   line-height: var(--button-line-height);   padding-block-start: 0.5rem;   padding-block-end: 0.5rem;   padding-inline-start: 0.75rem;   padding-inline-end: 0.75rem;   text-decoration: none;   text-align: center; }  /* Respect users reduced motion preferences */ @media (prefers-reduced-motion) {   .btn {     transition-duration: 0.001ms !important;   } }

Let’s test this out! Fire up each of the four framework implementations with the new and improved start scripts and confirm the styling changes are in effect.

Neutral (gray) styled button from the monorepo framework

One CSS file update proliferated to four frameworks — pretty cool, eh!?

Set a primary mode

We’re going to add a mode prop to each of our button’s and implement primary mode next. A primary button could be any color but we’ll go with a shade of green for the background and white text. Again, in the baseline stylesheet:

.btn {   --button-primary: #14775d;   --button-primary-color: #fff;   /* ... */ }

Then, just before the @media (prefers-reduced-motion) query, add the following btn-primary to the same baseline stylesheet:

.btn-primary {   background-color: var(--button-primary);   border-color: var(--button-primary);   color: var(--button-primary-color); }

There we go! Some developer conveniences and better baseline styles!

Updating each component to take a mode property

Now that we’ve added our new primary mode represented by the .btn-primary class, we want to sync the styles for all four framework implementations. So, let’s add some more package.json scripts to our top level scripts:

"sync:react": "yarn workspace littlebutton-react syncStyles", "sync:vue": "yarn workspace littlebutton-vue syncStyles", "sync:svelte": "yarn workspace littlebutton-svelte syncStyles", "sync:angular": "yarn workspace littlebutton-angular syncStyles"

Be sure to respect JSON’s comma rules! Depending on where you place these lines within your scripts: {...}, you’ll want to make sure there are no missing or trailing commas.

Go ahead and run the following to fully synchronize the styles:

yarn sync:angular && yarn sync:react && yarn sync:vue && yarn sync:svelte

Running this doesn’t change anything because we haven’t applied the primary class yet, but you should at least see the CSS has been copied over if you go look at the framework’s button component CSS.

React

If you haven’t already, double-check that the updated CSS got copied over into littlebutton-react/src/button.css. If not, you can run yarn syncStyles. Note that if you forget to run yarn syncStyles our dev script will do this for us when we next start the application anyway:

"dev": "yarn syncStyles && vite",

For our React implementation, we additionally need to add a composed CSS Modules class in littlebutton-react/src/button.module.css that is composed from the new .btn-primary:

.btnPrimary {   composes: btn-primary from './button.css'; }

We’ll also update littlebutton-react/src/App.jsx:

import "./App.css"; import styles from "./button.module.css";  const Button = ({ mode }) => {   const primaryClass = mode ? styles[`btn$ {mode.charAt(0).toUpperCase()}$ {mode.slice(1)}`] : '';   const classes = primaryClass ? `$ {styles.btn} $ {primaryClass}` : styles.btn;   return <button className={classes}>Go</button>; };  function App() {   return (     <div className="App">       <Button mode="primary" />     </div>   ); }  export default App;

Fire up the React app with yarn start:react from the top-level directory. If all goes well, you should now see your green primary button.

A dark green button with white text positioning in the center of the screen.

As a note, I’m keeping the Button component in App.jsx for brevity. Feel free to tease out the Button component into its own file if that bothers you.

Vue

Again, double-check that the button styles were copied over and, if not, run yarn syncStyles.

Next, make the following changes to the <script> section of littlebutton-vue/src/components/Button.vue:

<script> export default {   name: 'Button',   props: {     mode: {       type: String,       required: false,       default: '',       validator: (value) => {         const isValid = ['primary'].includes(value);         if (!isValid) {           console.warn(`Allowed types for Button are primary`);         }         return isValid;       },     }   },   computed: {     classes() {       return {         [this.$ style.btn]: true,         [this.$ style['btn-primary']]: this.mode === 'primary',       }     }   } } </script>

Now we can update the markup in littlebutton-vue/src/App.vue to use the new mode prop:

<Button mode="primary">Go</Button>

Now you can yarn start:vue from the top-level directory and check for the same green button.

Svelte

Let’s cd into littlebutton-svelte and verify that the styles in littlebutton-svelte/src/Button.svelte have the new .btn-primary class copied over, and yarn syncStyles if you need to. Again, the dev script will do that for us anyway on the next startup if you happen to forget.

Next, update the Svelte template to pass the mode of primary. In src/App.svelte:

<script>   import Button from './Button.svelte'; </script> <main>   <Button mode="primary">Go</Button> </main>

We also need to update the top of our src/Button.svelte component itself to accept the mode prop and apply the CSS Modules class:

<button class="{classes}">   <slot></slot> </button> <script>   export let mode = "";   const classes = [     "btn",     mode ? `btn-$ {mode}` : "",   ].filter(cls => cls.length).join(" "); </script>

Note that the <styles> section of our Svelte component shouldn’t be touched in this step.

And now, you can yarn dev from littlebutton-svelte (or yarn start:svelte from a higher directory) to confirm the green button made it!

Angular

Same thing, different framework: check that the styles are copied over and run yarn syncStyles if needed.

Let’s add the mode prop to the littlebutton-angular/src/app/app.component.html file:

<main>   <little-button mode="primary">Go</little-button> </main>

Now we need to set up a binding to a classes getter to compute the correct classes based on if the mode was passed in to the component or not. Add this to littlebutton-angular/src/components/button.component.html (and note the binding is happening with the square brackets):

<button [class]="classes">Go</button>

Next, we actually need to create the classes binding in our component at littlebutton-angular/src/components/button.component.ts:

import { Component, Input } from '@angular/core';  @Component({   selector: 'little-button',   templateUrl: './button.component.html',   styleUrls: ['./button.component.css'], }) export class ButtonComponent {   @Input() mode: 'primary' | undefined = undefined;    public get classes(): string {     const modeClass = this.mode ? `btn-$ {this.mode}` : '';     return [       'btn',       modeClass,     ].filter(cl => cl.length).join(' ');   } }

We use the Input directive to take in the mode prop, then we create a classes accessor which adds the mode class if it’s been passed in.

Fire it up and look for the green button!

Code complete

If you’ve made it this far, congratulations — you’ve reached code complete! If something went awry, I’d encourage you to cross-reference the source code over at GitHub on the the-little-button-that-could-series branch. As bundlers and packages have a tendency to change abruptly, you might want to pin your package versions to the ones in this branch if you happen to experience any dependency issues.

Take a moment to go back and compare the four framework-based button component implementations we just built. They’re still small enough to quickly notice some interesting differences in how props get passed in, how we bind to props, and how CSS name collisions are prevented among other subtle differences. As I continue to add components to AgnosticUI (which supports these exact same four frameworks), I’m continually pondering which offers the best developer experience. What do you think?

Homework

If you’re the type that likes to figure things out on your own or enjoys digging in deeper, here are ideas.

Button states

The current button styles do not account for various states, like :hover. I believe that’s a good first exercise.

/* You should really implement the following states    but I will leave it as an exercise for you to     decide how to and what values to use. */ .btn:focus {   /* If you elect to remove the outline, replace it      with another proper affordance and research how      to use transparent outlines to support windows      high contrast   */ } .btn:hover { } .btn:visited { } .btn:active { } .btn:disabled { }

Variants

Most button libraries support many button variations for things like sizes, shapes, and colors. Try creating more than the primary mode we already have. Maybe a secondary variation? A warning or success? Maybe filled and outline? Again, you can look at AgnosticUI’s buttons page for ideas.

CSS custom properties

If you haven’t started using CSS custom properties yet, I’d strongly recommend it. You can start by having a look at AgnosticUI’s common styles. I heavily lean on custom properties in there. Here are some great articles that cover what custom properties are and how you might leverage them:

Types

No… not typings, but the <button> element’s type attribute. We didn’t cover that in our component but there’s an opportunity to extend the component to other use cases with valid types, like button, submit, and reset. This is pretty easy to do and will greatly improve the button’s API.

More ideas

Gosh, you could do so much — add linting, convert it to Typescript, audit the accessibility, etc.

The current Svelte implementation is suffering from some pretty loose assumptions as we have no defense if the valid primary mode isn’t passed — that would produce a garbage CSS class:

mode ? `btn-$ {mode}` : "",

You could say, “Well, .btn-garbage as a class isn’t exactly harmful.” But it’s probably a good idea to style defensively when and where possible.

Potential pitfalls

There are some things you should be aware of before taking this approach further:

  • Positional CSS based on the structure of the markup will not work well for the CSS Modules based techniques used here.
  • Angular makes positional techniques even harder as it generates :host element representing each component view. This means you have these extra elements in between your template or markup structure. You’ll need to work around that.
  • Copying styles across workspace packages is a bit of an anti-pattern to some folks. I justify it because I believe the benefits outweigh the costs; also, when I think about how monorepos use symlinks and (not-so-failproof) hoisting, I don’t feel so bad about this approach.
  • You’ll have to subscribe to the decoupled techniques used here, so no CSS-in-JS.

I believe that all approaches to software development have their pros and cons and you ultimately have to decide if sharing a single CSS file across frameworks works for you or your specific project. There are certainly other ways you could do this (e.g. using littlebuttons-css as an npm package dependency) if needed.

Conclusion

Hopefully I’ve whet your appetite and you’re now really intrigued to create UI component libraries and/or design systems that are not tied to a particular framework. Maybe you have a better idea on how to achieve this — I’d love to hear your thoughts in the comments!

I’m sure you’ve seen the venerable TodoMVC project and how many framework implementations have been created for it. Similarly, wouldn’t it be nice to have a UI component library of primitives available for many frameworks? Open UI is making great strides to properly standardize native UI component defaults, but I believe we’ll always need to insert ourselves to some extent. Certainly, taking a good year to build a custom design system is quickly falling out of favor and companies are seriously questioning their ROI. Some sort of scaffolding is required to make the endeavor practical.

The vision of AgnosticUI is to have a relatively agnostic way to build design systems quickly that are not tied down to a particular frontend framework. If you’re compelled to get involved, the project is still very early and approachable and I’d love some help! Plus, you’re already pretty familiar with the how the project works now that you’ve gone through this tutorial!


How to Make a Component That Supports Multiple Frameworks in a Monorepo originally published on CSS-Tricks. You should get the newsletter and become a supporter.

CSS-Tricks

, , , ,
[Top]

Tonic (Component Framework)

I enjoy little frameworks like Tonic. It’s essentially syntactic sugar over <web-components /> to make them feel easier to use. Define a Class, template literal an HTML template, probably some other fancy helpers, and you’ve got a component that doesn’t feel terribly different to something like a React component, except you need no build process or other exotic tooling.

Here’s a Hello World + Counter example:

They have a whole bunch of examples (in a separate repo). You can snag and use them, and they are pretty nice! So that makes Tonic a bit like a design system as well as a web component framework.

To be fair, it’s not that different from Lit, which Google is behind and pushing pretty actively.

Here’s a Hello, World + Counter with Lit:

And Dave was just showing me petite-vue the other day, so I figured I might as well do that one, too:

I’d say that petite-vue example wins for just how super easy that is to pull of in just declarative HTML. But of course, there are a bunch of other considerations from specific features, syntax, philosophy, and size. Just looking at size, if I pop open the Network tab in DevTools and see the over-the-wire JavaScript for each demo…

  • Tonic = 5.1 KB
  • Lit = 12.6 KB
  • petite-vue = 8.1 KB

They are all basically the same: tiny.

I’ve never actually built anything real in any of them, so I’m not the best to judge one from the other. But they all seem pretty neat to me, particularly because they require no build step.


The post Tonic (Component Framework) appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, ,
[Top]

Building a Settings Component

This is a tremendous CSS-focused tutorial from Adam Argyle. I really like the “just for gap” concept here. Grid is extremely powerful, but you don’t have to use all its abilities every time you reach for it. Here, Adam reaches for it for very light reasons like using it as an in-between border alternative as well as more generic spacing. I guess he’s putting money where his mouth is in terms of gap superseding margin!

I also really like calling out Una Kravet’s awesome name for flexible grids: RAM. Perhaps you’ve seen the flexible-number-of-columns trick with CSS grid? The bonus trick here (which I first saw from Evan Minto) is to use min(). That way, not only are large layouts covered, but even the very smallest layouts have no hard-coded minimum (like if 100% is smaller than 10ch here):

.el {   display: grid;   grid-template-columns: repeat(auto-fit, minmax(min(10ch, 100%), 35ch)); }

There is a ton more trickery in the blog post. The “color pops” with :focus-within is fun and clever. So much practical CSS in building something so practical! 🧡 more blog posts like this, please. Fortunately, we don’t have to wait, as Adam has other component-focused posts like this one on Tabs and this one on Sidenav.

Direct Link to ArticlePermalink


The post Building a Settings Component appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, ,
[Top]

Building a Settings Component

This is a tremendous CSS-focused tutorial from Adam Argyle. I really like the “just for gap” concept here. Grid is extremely powerful, but you don’t have to use all its abilities every time you reach for it. Here, Adam reaches for it for very light reasons like using it as an in-between border alternative as well as more generic spacing. I guess he’s putting money where his mouth is in terms of gap superseding margin!

I also really like calling out Una Kravet’s awesome name for flexible grids: RAM. Perhaps you’ve seen the flexible-number-of-columns trick with CSS grid? The bonus trick here (which I first saw from Evan Minto) is to use min(). That way, not only are large layouts covered, but even the very smallest layouts have no hard-coded minimum (like if 100% is smaller than 10ch here):

.el {   display: grid;   grid-template-columns: repeat(auto-fit, minmax(min(10ch, 100%), 35ch)); }

There is a ton more trickery in the blog post. The “color pops” with :focus-within is fun and clever. So much practical CSS in building something so practical! 🧡 more blog posts like this, please. Fortunately, we don’t have to wait, as Adam has other component-focused posts like this one on Tabs and this one on Sidenav.

Direct Link to ArticlePermalink


The post Building a Settings Component appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, ,
[Top]

React Component Tests for Humans

React component tests should be interesting, straightforward, and easy for a human to build and maintain.

Yet, the current state of the testing library ecosystem is not sufficient to motivate developers to write consistent JavaScript tests for React components. Testing React components—and the DOM in general—often require some kind of higher-level wrapper around popular testing frameworks like Jest or Mocha.

Here’s the problem

Writing component tests with the tools available today is boring, and even when you get to writing them, it takes lots of hassle. Expressing test logic following a jQuery-like style (chaining) is confusing. It doesn’t jive with how React components are usually built.

The Enzyme code below is readable, but a bit too bulky because it uses too many words to express something that is ultimately simple markup.

expect(screen.find(".view").hasClass("technologies")).to.equal(true); expect(screen.find("h3").text()).toEqual("Technologies:"); expect(screen.find("ul").children()).to.have.lengthOf(4); expect(screen.contains([   <li>JavaScript</li>,   <li>ReactJs</li>,   <li>NodeJs</li>,   <li>Webpack</li> ])).to.equal(true); expect(screen.find("button").text()).toEqual("Back"); expect(screen.find("button").hasClass("small")).to.equal(true);

The DOM representation is just this:

<div className="view technologies">   <h3>Technologies:</h3>   <ul>     <li>JavaScript</li>     <li>ReactJs</li>     <li>NodeJs</li>     <li>Webpack</li>   </ul>   <button className="small">Back</button> </div>

What if you need to test heavier components? While the syntax is still bearable, it doesn’t help your brain grasp the structure and logic. Reading and writing several tests like this is bound to wear you out—it certainly wears me out. That’s because React components follow certain principles to generate HTML code at the end. Tests that express the same principles, on the other hand, are not straightforward. Simply using JavaScript chaining won’t help in the long run.

There are two main issues with testing in React:

  • How to even approach writing tests specifically for components
  • How to avoid all the unnecessary noise

Let’s further expand those before jumping into the real examples.

Approaching React component tests

A simple React component may look like this:

function Welcome(props) {   return <h1>Hello, {props.name}</h1>; }

This is a function that accepts a props object and returns a DOM node using the JSX syntax.

Since a component can be represented by a function, it is all about testing functions. We need to account for arguments and how they influence the returned result. Applying that logic to React components, the focus in the tests should be on setting up props and testing for the DOM rendered in the UI. Since user actions like mouseover, click, typing, etc. may also lead to UI changes, you will need to find a way to programmatically trigger those too.

Hiding the unnecessary noise in tests

Tests require a certain level of readability achieved by both slimming the wording down and following a certain pattern to describe each scenario.

Component tests flow through three phases:

  1. Preparation (setup): The component props are prepared.
  2. Render (action): The component needs to render its DOM to the UI before either triggering any actions on it or testing for certain texts and attributes. That’s when actions can be programmatically triggered.
  3. Validation (verify): The expectations are set, verifying certain side effects over the component markup.

Here is an example:

it("should click a large button", () => {   // 1️⃣ Preparation   // Prepare component props   props.size = "large";    // 2️⃣ Render   // Render the Button's DOM and click on it   const component = mount(<Button {...props}>Send</Button>);   simulate(component, { type: "click" });    // 3️⃣ Validation   // Verify a .clicked class is added    expect(component, "to have class", "clicked"); });

For simpler tests, the phases can merge:

it("should render with a custom text", () => {   // Mixing up all three phases into a single expect() call   expect(     // 1️⃣ Preparation     <Button>Send</Button>,      // 2️⃣ Render     "when mounted",     // 3️⃣ Validation     "to have text",      "Send"   ); });

Writing component tests today

Those two examples above look logical but are anything but trivial. Most of the testing tools do not provide such a level of abstraction, so we have to handle it ourselves. Perhaps the code below looks more familiar.

it("should display the technologies view", () => {   const container = document.createElement("div");   document.body.appendChild(container);      act(() => {     ReactDOM.render(<ProfileCard {...props} />, container);   });      const button = container.querySelector("button");      act(() => {     button.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));   });      const details = container.querySelector(".details");      expect(details.classList.contains("technologies")).toBe(true);   expect(details.querySelector("h3").textContent, "to be", "Technologies");   expect(details.querySelector("button").textContent, "to be", "View Bio"); });

Compare that with the same test, only with an added layer of abstraction:

it("should display the technologies view", () => {   const component = mount(<ProfileCard {...props} />);    simulate(component, {     type: "click",     target: "button",   });    expect(     component,     "queried for first",     ".details",     "to exhaustively satisfy",     <div className="details technologies">       <h3>Technologies</h3>       <div>         <button>View Bio</button>       </div>     </div>   ); });

It does look much better. Less code, obvious flow, and more DOM instead of JavaScript. This is not a fiction test, but something you can achieve with UnexpectedJS today.

The following section is a deep dive into testing React components without getting too deep into UnexpectedJS. Its documentation more than does the job. Instead, we’ll focus on usage, examples, and possibilities.

Writing React Tests with UnexpectedJS

UnexpectedJS is an extensible assertion toolkit compatible with all test frameworks. It can be extended with plugins, and some of those plugins are used in the test project below. Probably the best thing about this library is the handy syntax it provides to describe component test cases in React.

The example: A Profile Card component

The subject of the tests is a Profile card component.

A card component where the persons name, photo, and number of posts are displayed to the left in a single column with a light red background, and the bio is displayed on the right in paragraph form with a title against a white background.

And here is the full component code of ProfileCard.js:

// ProfileCard.js export default function ProfileCard({   data: {     name,     posts,     isOnline = false,     bio = "",     location = "",     technologies = [],     creationDate,     onViewChange,   }, }) {   const [isBioVisible, setIsBioVisible] = useState(true);    const handleBioVisibility = () => {     setIsBioVisible(!isBioVisible);     if (typeof onViewChange === "function") {       onViewChange(!isBioVisible);     }   };    return (     <div className="ProfileCard">       <div className="avatar">         <h2>{name}</h2>         <i className="photo" />         <span>{posts} posts</span>         <i className={`status $ {isOnline ? "online" : "offline"}`} />       </div>       <div className={`details $ {isBioVisible ? "bio" : "technologies"}`}>         {isBioVisible ? (           <>             <h3>Bio</h3>             <p>{bio !== "" ? bio : "No bio provided yet"}</p>             <div>               <button onClick={handleBioVisibility}>View Skills</button>               <p className="joined">Joined: {creationDate}</p>             </div>           </>         ) : (           <>             <h3>Technologies</h3>             {technologies.length > 0 && (               <ul>                 {technologies.map((item, index) => (                   <li key={index}>{item}</li>                 ))}               </ul>             )}             <div>               <button onClick={handleBioVisibility}>View Bio</button>               {!!location && <p className="location">Location: {location}</p>}             </div>           </>         )}       </div>     </div>   ); }

We will work with the component’s desktop version. You can read more about device-driven code split in React but note that testing mobile components is still pretty straightforward.

Setting up the example project

Not all tests are covered in this article, but we will certainly look at the most interesting ones. If you want to follow along, view this component in the browser, or check all its tests, go ahead and clone the GitHub repo.

## 1. Clone the project: git clone git@github.com:moubi/profile-card.git  ## 2. Navigate to the project folder: cd profile-card  ## 3. Install the dependencies: yarn  ## 4. Start and view the component in the browser: yarn start  ## 5. Run the tests: yarn test

Here’s how the <ProfileCard /> component and UnexpectedJS tests are structured once the project has spun up:

/src   └── /components       ├── /ProfileCard       |   ├── ProfileCard.js       |   ├── ProfileCard.scss       |   └── ProfileCard.test.js       └── /test-utils            └── unexpected-react.js

Component tests

Let’s take a look at some of the component tests. These are located in src/components/ProfileCard/ProfileCard.test.js. Note how each test is organized by the three phases we covered earlier.

  1. Setting up required component props for each test.
beforeEach(() => {   props = {     data: {       name: "Justin Case",       posts: 45,       creationDate: "01.01.2021",     },   }; });

Before each test, a props object with the required <ProfileCard /> props is composed, where props.data contains the minimum info for the component to render.

  1. Render with a default set of props.

This test checks the whole DOM produced by the component when passing name, posts, and creationDate fields.

Here’s what the result produces in the UI:

And here’s the test case for it:

it("should render default", () => {   // "to exhaustively satisfy" ensures all classes/attributes are also matching   expect(     <ProfileCard {...props} />,     "when mounted",     "to exhaustively satisfy",     <div className="ProfileCard">       <div className="avatar">         <h2>Justin Case</h2>         <i className="photo" />         <span>45{" posts"}</span>         <i className="status offline" />       </div>       <div className="details bio">         <h3>Bio</h3>         <p>No bio provided yet</p>         <div>           <button>View Skills</button>           <p className="joined">{"Joined: "}01.01.2021</p>         </div>       </div>     </div>   ); });
  1. Render with status online.

Now we check if the profile renders with the “online” status icon.

And the test case for that:

it("should display online icon", () => {   // Set the isOnline prop   props.data.isOnline = true;    // The minimum to test for is the presence of the .online class   expect(     <ProfileCard {...props} />,     "when mounted",     "queried for first",     ".status",     "to have class",     "online"   ); });
  1. Render with bio text.

<ProfileCard /> accepts any arbitrary string for its bio.

So, let’s write a test case for that:

it("should display online icon", () => {   // Set the isOnline prop   props.data.isOnline = true;    // The minimum to test for is the presence of the .online class   expect(     <ProfileCard {...props} />,     "when mounted",     "queried for first",     ".status",     "to have class",     "online"   ); });
  1. Render “Technologies” view with an empty list.

Clicking on the “View Skills” link should switch to a list of technologies for this user. If no data is passed, then the list should be empty.

Here’s that test case:

it("should display the technologies view", () => {   // Mount <ProfileCard /> and obtain a ref   const component = mount(<ProfileCard {...props} />);    // Simulate a click on the button element ("View Skills" link)   simulate(component, {     type: "click",     target: "button",   });    // Check if the .details element contains the technologies view   expect(     component,     "queried for first",     ".details",     "to exhaustively satisfy",     <div className="details technologies">       <h3>Technologies</h3>       <div>         <button>View Bio</button>       </div>     </div>   ); });
  1. Render a list of technologies.

If a list of technologies is passed, it will display in the UI when clicking on the “View Skills” link.

Yep, another test case:

it("should display list of technologies", () => {   // Set the list of technologies   props.data.technologies = ["JavaScript", "React", "NodeJs"];     // Mount ProfileCard and obtain a ref   const component = mount(<ProfileCard {...props} />);    // Simulate a click on the button element ("View Skills" link)   simulate(component, {     type: "click",     target: "button",   });    // Check if the list of technologies is present and matches prop values   expect(     component,     "queried for first",     ".technologies ul",     "to exhaustively satisfy",     <ul>       <li>JavaScript</li>       <li>React</li>       <li>NodeJs</li>     </ul>   ); });
  1. Render a user location.

That information should render in the DOM only if it was provided as a prop.

The test case:

it("should display location", () => {   // Set the location    props.data.location = "Copenhagen, Denmark";    // Mount <ProfileCard /> and obtain a ref   const component = mount(<ProfileCard {...props} />);      // Simulate a click on the button element ("View Skills" link)   // Location render only as part of the Technologies view   simulate(component, {     type: "click",     target: "button",   });    // Check if the location string matches the prop value   expect(     component,     "queried for first",     ".location",     "to have text",     "Location: Copenhagen, Denmark"   ); });
  1. Calling a callback when switching views.

This test does not compare DOM nodes but does check if a function prop passed to <ProfileCard /> is executed with the correct argument when switching between the Bio and Technologies views.

it("should call onViewChange prop", () => {   // Create a function stub (dummy)   props.data.onViewChange = sinon.stub();      // Mount ProfileCard and obtain a ref   const component = mount(<ProfileCard {...props} />);    // Simulate a click on the button element ("View Skills" link)   simulate(component, {     type: "click",     target: "button",   });    // Check if the stub function prop is called with false value for isBioVisible   // isBioVisible is part of the component's local state   expect(     props.data.onViewChange,     "to have a call exhaustively satisfying",     [false]   ); });

Running all the tests

Now, all of the tests for <ProfileCard /> can be executed with a simple command:

yarn test

Notice that tests are grouped. There are two independent tests and two groups of tests for each of the <ProfileCard /> views—bio and technologies. Grouping makes test suites easier to follow and is a nice way to organize logically-related UI units.

Some final words

Again, this is meant to be a fairly simple example of how to approach React component tests. The essence is to look at components as simple functions that accept props and return a DOM. From that point on, choosing a testing library should be based on the usefulness of the tools it provides for handling component renders and DOM comparisons. UnexpectedJS happens to be very good at that in my experience.

What should be your next steps? Look at the GitHub project and give it a try if you haven’t already! Check all the tests in ProfileCard.test.js and perhaps try to write a few of your own. You can also look at src/test-utils/unexpected-react.js which is a simple helper function exporting features from the third-party testing libraries.

And lastly, here are a few additional resources I’d suggest checking out to dig even deeper into React component testing:


The post React Component Tests for Humans appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , ,
[Top]

Embedding an Interactive Analytics Component with Cumul.io and Any Web Framework

In this article, we explain how to build an integrated and interactive data visualization layer into an application with Cumul.io. To do so, we’ve built a demo application that visualizes Spotify Playlist analytics! We use Cumul.io as our interactive dashboard as it makes integration super easy and provides functionality that allow interaction between the dashboard and applications (i.e. custom events). The app is a simple JavaScript web app with a Node.js server, although you can, if you want, achieve the same with Angular, React and React Native while using Cumul.io dashboards too.

Here, we build dashboards that display data from the The Kaggle Spotify Dataset 1921–2020, 160k+ Tracks and also data via the Spotify Web API when a user logs in. We’ve built dashboards as an insight into playlist and song characteristics. We’ve added some Cumul.io custom events that will allow any end user visiting these dashboards to select songs from a chart and add them to one of their own Spotify playlists. They can also select a song to display more info on them, and play them from within the application. The code for the full application is also publicly available in an open repository.

Here’s a sneak peak into what the end result for the full version looks like:

What are Cumul.io custom events and their capabilities?

Simply put, Cumul.io custom events are a way to trigger events from a dashboard, to be used in the application that the dashboard is integrated in. You can add custom events into selected charts in a dashboard, and have the application listen for these events.

Why? The cool thing about this tool is in how it allows you to reuse data from an analytics dashboard, a BI tool, within the application it’s built into. It gives you the freedom to define actions based on data, that can be triggered straight from within an integrated dashboard, while keeping the dashboard, analytics layer a completely separate entity to the application, that can be managed separately to it.

What they contain: Cumul.io custom events are attached to charts rather than dashboards as a whole. So the information an event has is limited to the information a chart has.

An event is simply put a JSON object. This object will contain fields such as the ID of the dashboard that triggered it, the name of the event and a number of other fields depending on the type of chart that the event was triggered from. For example, if the event was triggered from a scatter plot, you will receive the x-axis and y-axis values of the point it was triggered from. On the other hand, if it were triggered from a table, you would receive column values for example. See examples of what these events will look like from different charts:

// 'Add to Playlist' custom event from a row in a table {  "type":"customEvent",  "dashboard":"xxxx",  "name":"xxxx",  "object":"xxxx",  "data":{    "language":"en",    "columns":[      {"id":"Ensueno","value":"Ensueno","label":"Name"},       {"id":"Vibrasphere","value":"Vibrasphere","label":"Artist"},       {"value":0.406,"formattedValue":"0.41","label":"Danceability"},       {"value":0.495,"formattedValue":"0.49","label":"Energy"},       {"value":180.05,"formattedValue":"180.05","label":"Tempo (bpm)"},       {"value":0.568,"formattedValue":"0.5680","label":"Accousticness"},       {"id":"2007-01-01T00:00:00.000Z","value":"2007","label":"Release Date (Yr)"},    ],    "event":"add_to_playlist"  } }
//'Song Info' custom event from a point in a scatter plot {  "type":"customEvent",  "dashboard":"xxxx",  "name":"xxxx",  "object":"xxxx",  "data":{    "language":"en",    "x-axis":{"id":0.601,"value":"0.601","label":"Danceability"},    "y-axis":{"id":0.532,"value":"0.532","label":"Energy"},    "name":{"id":"xxxx","value":"xxx","label":"Name"},    "event":"song_info"   } }

The possibilities with this functionality are virtually limitless. Granted, depending on what you want to do, you may have to write a couple more lines of code, but it is unarguably quite a powerful tool!

The dashboard

We won’t actually go through the dashboard creation process here and we’ll focus on the interactivity bit once it’s integrated into the application. The dashboards integrated in this walk through have already been created and have custom events enabled. You can, of course create your own ones and integrate those instead of the one we’ve pre-built (you can create an account with a free trial). But before, some background info on Cumul.io dashboards;

Cumul.io offers you a way to create dashboards from within the platform, or via its API. In either case, dashboards will be available within the platform, decoupled from the application you want to integrate it into, so can be maintained completely separately.

On your landing page you’ll see your dashboards and can create a new one:

You can open one and drag and drop any chart you want:

You can connect data which you can then drag and drop into those charts:

And, that data can be one of a number of things. Like a pre-existing database which you can connect to Cumul.io, a dataset from a data warehouse you use, a custom built plugin etc.

Enabling custom events

We have already enabled these custom events to the scatter plot and table in the dashboard used in this demo, which we will be integrating in the next section. If you want to go through this step, feel free to create your own dashboards too!

First thing you need to do will be to add custom events to a chart. To do this, first select a chart in your dashboard you’d like to add an event to. In the chart settings, select Interactivity and turn Custom Events on:

To add an event, click edit and define its Event Name and Label. Event Name is what your application will receive and Label is the one that will show up on your dashboard. In our case, we’ve added 2 events; ‘Add to Playlist’ and ‘Song Info’:

This is all the setup you need for your dashboard to trigger an event on a chart level. Before you leave the editor, you will need your dashboard ID to integrate the dashboard later. You can find this in the Settings tab of your dashboard. The rest of the work remains on application level. This will be where we define what we actually want to do once we receive any of these events.

Takeaway points

  1. Events work on a chart level and will include information within the limits of the information on the chart
  2. To add an event, go to the chart settings on the chart you want to add them to
  3. Define name and label of event. And you’re done!
  4. (Don’t forget to take note of the dashboard ID for integration)

Using custom events in your own platform

Now that you’ve added some events to the dashboard, the next step is to use them. The key point here is that, once you click an event in your dashboard, your application that integrates the dashboard receives an event. The Integration API provides a function to listen to these events, and then it’s up to you to define what you do with them. For more information on the API and code examples for your SDK, you can also check out the relevant developer docs.

For this section, we’re also providing an open GitHub repository (separate to the repository for the main application) that you can use as a starting project to add custom events to.

The cumulio-spotify-datatalks repository is structured so that you can checkout on the commit called skeleton to start from the beginning. All the following commits will represent a step we go through here. It’s a boiled down version of the full application, focusing on the main parts of the app that demonstrates Custom Events. I’ll be skipping some steps such as the Spotify API calls which are in src/spotify.js, so as to limit this tutorial to the theme of ‘adding and using custom events’.

Useful info for following steps

Let’s have a look at what happens in our case. We had created two events; add_to_playlist and song_info. We want visitors of our dashboard to be able to add a song to their own playlist of choice in their own Spotify account. In order to do so, we take the following steps:

  1. Integrate the dashboard with your app
  2. Listen to incoming events

Integrate the dashboard with your app

First, we need to add a dashboard to our application. Here we use the Cumul.io Spotify Playlist dashboard as the main dashboard and the Song Info dashboard as the drill through dashboard (meaning we create a new dashboard within the main one that pops up when we trigger an event). If you have checked out on the commit called skeleton and npm run start, the application should currently just open up an empty ‘Cumul.io Favorites’ tab, with a Login button at the top right. For instructions on how to locally run the project, go to the bottom of the article:

To integrate a dashboard, we will need to use the Cumulio.addDashboard() function. This function expects an object with dashboard options. Here’s what we do to add the dashboard:

In src/app.js, we create an object that stores the dashboard IDs for the main dashboard and the drill through dashboard that displays song info alongside a dashboardOptions object:

// create dashboards object with the dashboard ids and dashboardOptions object  // !!!change these IDs if you want to use your own dashboards!!! const dashboards = {   playlist: 'f3555bce-a874-4924-8d08-136169855807',    songInfo: 'e92c869c-2a94-406f-b18f-d691fd627d34', };  const dashboardOptions = {   dashboardId: dashboards.playlist,   container: '#dashboard-container',   loader: {     background: '#111b31',     spinnerColor: '#f44069',     spinnerBackground: '#0d1425',     fontColor: '#ffffff'   } };

We create a loadDashboard() function that calls Cumulio.addDashboard(). This function optionally receives a container and modifies the dashboardOptions object before adding dashboard to the application.

// create a loadDashboard() function that expects a dashboard ID and container  const loadDashboard = (id, container) => {   dashboardOptions.dashboardId = id;   dashboardOptions.container = container || '#dashboard-container';     Cumulio.addDashboard(dashboardOptions); };

Finally, we use this function to add our playlist dashboard when we load the Cumul.io Favorites tab:

export const openPageCumulioFavorites = async () => {   ui.openPage('Cumul.io playlist visualized', 'cumulio-playlist-viz');   /**************** INTEGRATE DASHBOARD ****************/   loadDashboard(dashboards.playlist); };

At this point, we’ve integrated the playlist dashboard and when we click on a point in the Energy/Danceability by Song scatter plot, we get two options with the custom events we added earlier. However, we’re not doing anything with them yet.

Listen to incoming events

Now that we’ve integrated the dashboard, we can tell our app to do stuff when it receives an event. The two charts that have ‘Add to Playlist’ and ‘Song Info’ events here are:

First, we need to set up our code to listen to incoming events. To do so, we need to use the Cumulio.onCustomEvent() function. Here, we chose to wrap this function in a listenToEvents() function that can be called when we load the Cumul.io Favorites tab. We then use if statements to check what event we’ve received:

const listenToEvents = () => {   Cumulio.onCustomEvent((event) => {     if (event.data.event === 'add_to_playlist'){       //DO SOMETHING     }     else if (event.data.event === 'song_info'){       //DO SOMETHING     }   }); };

This is the point after which things are up to your needs and creativity. For example, you could simply print a line out to your console, or design your own behaviour around the data you receive from the event. Or, you could also use some of the helper functions we’ve created that will display a playlist selector to add a song to a playlist, and integrate the Song Info dashboard. This is how we did it;

Add song to playlist

Here, we will make use of the addToPlaylistSelector() function in src/ui.js. This function expects a Song Name and ID, and will display a window with all the available playlists of the logged in user. It will then post a Spotify API request to add the song to the selected playlist. As the Spotify Web API requires the ID of a song to be able to add it, we’ve created a derived Name & ID field to be used in the scatter plot.

An example event we receive on add_to_playlist will include the following for the scatter plot:

"name":{"id":"So Far To Go&id=3R8CATui5dGU42Ddbc2ixE","value":"So Far To Go&id=3R8CATui5dGU42Ddbc2ixE","label":"Name & ID"}

And these columns for the table:

"columns":[  {"id":"Weapon Of Choice (feat. Bootsy Collins) - Remastered Version","value":"Weapon Of Choice (feat. Bootsy Collins) - Remastered Version","label":"Name"},  {"id":"Fatboy Slim","value":"Fatboy Slim","label":"Artist"},    // ...  {"id":"3qs3aHNUcqFGv7jMYJJCYa","value":"3qs3aHNUcqFGv7jMYJJCYa","label":"ID"} ]

We extract the Name and ID of the song from the event via the getSong() function, then call the ui.addToPlaylistSelector() function:

/*********** LISTEN TO CUSTOM EVENTS AND ADD EXTRAS ************/ const getSong = (event) => {   let songName;   let songArtist;   let songId;   if (event.data.columns === undefined) {     songName = event.data.name.id.split('&id=')[0];     songId = event.data.name.id.split('&id=')[1];   }   else {     songName = event.data.columns[0].value;     songArtist = event.data.columns[1].value;     songId = event.data.columns[event.data.columns.length - 1].value;   }   return {id: songId, name: songName, artist: songArtist}; };  const listenToEvents = () => {   Cumulio.onCustomEvent(async (event) => {     const song = getSong(event);     console.log(JSON.stringify(event));     if (event.data.event === 'add_to_playlist'){       await ui.addToPlaylistSelector(song.name, song.id);     }     else if (event.data.event === 'song_info'){       //DO SOMETHING     }   }); };

Now, the ‘Add to Playlist’ event will display a window with the available playlists that a logged in user can add the song to:

Display more song info

The final thing we want to do is to make the ‘Song Info’ event display another dashboard when clicked. It will display further information on the selected song, and include an option to play the song. It’s also the step where we get into more some more complicated use cases of the API which may need some background knowledge. Specifically, we make use of Parameterizable Filters. The idea is to create a parameter on your dashboard, for which the value can be defined while creating an authorization token. We include the parameter as metadata while creating an authorization token.

For this step, we have created a songId parameter that is used in a filter on the Song Info dashboard:

Then, we create a getDashboardAuthorizationToken() function. This expects metadata which it then posts to the /authorization endpoint of our server in server/server.js:

const getDashboardAuthorizationToken = async (metadata) => {   try {     const body = {};     if (metadata && typeof metadata === 'object') {       Object.keys(metadata).forEach(key => {         body[key] = metadata[key];       });     }      /*       Make the call to the backend API, using the platform user access credentials in the header       to retrieve a dashboard authorization token for this user     */     const response = await fetch('/authorization', {       method: 'post',       body: JSON.stringify(body),       headers: { 'Content-Type': 'application/json' }     });      // Fetch the JSON result with the Cumul.io Authorization key & token     const responseData = await response.json();     return responseData;   }   catch (e) {     return { error: 'Could not retrieve dashboard authorization token.' };   } };

Finally, we use the load the songInfo dashboard when the song_info event is triggered. In order to do this, we create a new authorization token using the song ID:

const loadDashboard = (id, container, key, token) => {   dashboardOptions.dashboardId = id;   dashboardOptions.container = container || '#dashboard-container';      if (key && token) {     dashboardOptions.key = key;     dashboardOptions.token = token;   }    Cumulio.addDashboard(dashboardOptions); };

We make some modifications to the loadDashboard() function so as to use the new token:

const loadDashboard = (id, container, key, token) =u003e {n  dashboardOptions.dashboardId = id;n  dashboardOptions.container = container || '#dashboard-container';  nn  if (key u0026u0026 token) {n    dashboardOptions.key = key;n    dashboardOptions.token = token;n  }nn  Cumulio.addDashboard(dashboardOptions);n};

Then call the ui.displaySongInfo(). The final result looks as follows:

const listenToEvents = () => {   Cumulio.onCustomEvent(async (event) => {     const song = getSong(event);     if (event.data.event === 'add_to_playlist'){       await ui.addToPlaylistSelector(song.name, song.id);     }     else if (event.data.event === 'song_info'){       const token = await getDashboardAuthorizationToken({ songId: [song.id] });       loadDashboard(dashboards.songInfo, '#song-info-dashboard', token.id, token.token);       await ui.displaySongInfo(song);     }   }); };

And voilá! We are done! In this demo we used a lot of helper functions I haven’t gone through in detail, but you are free clone the demo repository and play around with them. You can even disregard them and build your own functionality around the custom events.

Conclusion

For any one intending to have a layer of data visualisation and analytics integrated into their application, Cumul.io provides a pretty easy way of achieving it as I’ve tried to demonstrate throughout this demo. The dashboards remain decoupled entities to the application that can then go on to be managed separately. This becomes quite an advantage if say you’re looking at integrated analytics within a business setting and you’d rather not have developers going back and fiddling with dashboards all the time.

Events you can trigger from dashboards and listen to in their host applications on the other hand allows you to define implementations based off of the information in those decoupled dashboards. This can be anything from playing a song in our case to triggering a specific email to be sent. The world is your oyster in this sense, you decide what to do with the data you have from your analytics layer. In other words, you get to reuse the data from your dashboards, it doesn’t have to just stay there in its dashboard and analytics world 🙂

Steps to run this project

Before you start:

  1. Clone the cumulio-spotify-datatalks repository with npm install
  2. Create a .env file in the root directory and add the following from your Cumul.io and Spotify Developer accounts:
  3. From Cumul.io: CUMULIO_API_KEY=xxx CUMULIO_API_TOKEN=xxx
  4. From Spotify: SPOTIFY_CLIENT_ID=xxx SPOTIFY_CLIENT_SECRET=xxx ACCESS_TOKEN=xxx REFRESH_TOKEN=xxxnpm run start
  5. On your browser, go to http://localhost:3000/ and log into your Spotify account 🥳


The post Embedding an Interactive Analytics Component with Cumul.io and Any Web Framework appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , , ,
[Top]

How to Create a Timeline Task List Component Using SVG

I’m thoroughly convinced that SVG unlocks a whole entire world of building interfaces on the web. It might seem daunting to learn SVG at first, but you have a spec that was designed to create shapes and yet, still has elements, like text, links, and aria labels available to you. You can accomplish some of the same effects in CSS, but it’s a little more particular to get positioning just right, especially across viewports and for responsive development.

What’s special about SVG is that all the positioning is based on a coordinate system, a little like the game Battleship. That means deciding where everything goes and how it’s drawn, as well as how it’s relative to each other, can be really straightforward to reason about. CSS positioning is for layout, which is great because you have things that correspond to one another in terms of the flow of the document. This otherwise positive trait is harder to work with if you’re making a component that’s very particular, with overlapping and precisely placed elements.

Truly, once you learn SVG, you can draw anything, and have it scale on any device. Even this very site uses SVG for custom UI elements, such as my avatar, above (meta!).

That little half circle below the author image is just SVG markup.

We won’t cover everything about SVGs in this post (you can learn some of those fundamentals here, here, here and here), but in order to illustrate the possibilities that SVG opens up for UI component development, let’s talk through one particular use case and break down how we would think about building something custom.

The timeline task list component

Recently, I was working on a project with my team at Netlify. We wanted to show the viewer which video in a series of videos in a course they were currently watching. In other words, we wanted to make some sort of thing that’s like a todo list, but shows overall progress as items are completed. (We made a free space-themed learning platform and it’s hella cool. Yes, I said hella.)

Here’s how that looks:

So how would we go about this? I’ll show an example in both Vue and React so that you can see how it might work in both frameworks.

The Vue version

We decided to make the platform in Next.js for dogfooding purposes (i.e. trying out our own Next on Netlify build plugin), but I’m more fluent in Vue so I wrote the initial prototype in Vue and ported it over to React.

Here is the full CodePen demo:

Let’s walk through this code a bit. First off, this is a single file component (SFC), so the template HTML, reactive script, and scoped styles are all encapsulated in this one file.

We’ll store some dummy tasks in data, including whether each task is completed or not. We’ll also make a method we can call on a click directive so that we can toggle whether the state is done or not.

<script> export default {   data() {     return {       tasks: [         {           name: 'thing',           done: false         },         // ...       ]     };   },   methods: {     selectThis(index) {       this.tasks[index].done = !this.tasks[index].done     }   } }; </script> 

Now, what we want to do is create an SVG that has a flexible viewBox depending on the amount of elements. We also want to tell screen readers that this a presentational element and that we will provide a title with a unique id of timeline. (Get more information on creating accessible SVGs.)

<template>   <div id="app">     <div>       <svg :viewBox="`0 0 30 $ {tasks.length * 50}`"            xmlns="http://www.w3.org/2000/svg"             width="30"             stroke="currentColor"             fill="white"            aria-labelledby="timeline"            role="presentation">            <title id="timeline">timeline element</title>         <!-- ... -->       </svg>     </div>   </div> </template>

The stroke is set to currentColor to allow for some flexibility — if we want to reuse the component in multiple places, it will inherit whatever color is used on the encapsulating div.

Next, inside the SVG, we want to create a vertical line that’s the length of the task list. Lines are fairly straightforward. We have x1 and x2 values (where the line is plotted on the x-axis), and similarly, y1 and y2.

<line x1="10" x2="10" :y1="num2" :y2="tasks.length * num1 - num2" />

The x-axis stays consistently at 10 because we’re drawing a line downward rather than left-to-right. We’ll store two numbers in data: the amount we want our spacing to be, which will be num1, and the amount we want our margin to be, which will be num2.

data() {   return {     num1: 32,     num2: 15,     // ...   } }

The y-axis starts with num2, which is subtracted from the end, as well as the margin. The tasks.length is multiplied by the spacing, which is num1.

Now, we’ll need the circles that lie on the line. Each circle is an indicator for whether a task has been completed or not. We’ll need one circle for each task, so we’ll use v-for with a unique key, which is the index (and is safe to use here as they will never reorder). We’ll connect the click directive with our method and pass in the index as a param as well.

CIrcles in SVG are made up of three attributes. The middle of the circle is plotted at cx and cy, and then we draw a radius with r. Like the line, cx starts at 10. The radius is 4 because that’s what’s readable at this scale. cy will be spaced like the line: index times the spacing (num1), plus the margin (num2).

Finally, we’ll put use a ternary to set the fill. If the task is done, it will be filled with currentColor. If not, it will be filled with white (or whatever the background is). This could be filled with a prop that gets passed in the background, for instance, where you have light and dark circles.

<circle    @click="selectThis(i)"    v-for="(task, i) in tasks"   :key="task.name"   cx="10"   r="4"   :cy="i * num1 + num2"   :fill="task.done ? 'currentColor' : 'white'"   class="select"/>

Finally, we are using CSS grid to align a div with the names of tasks. This is laid out much in the same way, where we’re looping through the tasks, and are also tied to that same click event to toggle the done state.

<template>   <div>     <div        @click="selectThis(i)"       v-for="(task, i) in tasks"       :key="task.name"       class="select">       {{ task.name }}     </div>   </div> </template>

The React version

Here is where we ended up with the React version. We’re working towards open sourcing this so that you can see the full code and its history. Here are a few modifications:

  • We’re using CSS modules rather than the SCFs in Vue
  • We’re importing the Next.js link, so that rather than toggling a “done” state, we’re taking a user to a dynamic page in Next.js
  • The tasks we’re using are actually stages of the course —or “Mission” as we call them — which are passed in here rather than held by the component.

Most of the other functionality is the same 🙂

import styles from './MissionTracker.module.css'; import React, { useState } from 'react'; import Link from 'next/link';  function MissionTracker({ currentMission, currentStage, stages }) {  const [tasks, setTasks] = useState([...stages]);  const num1 = [32];  const num2 = [15];   const updateDoneTasks = (index) => () => {    let tasksCopy = [...tasks];    tasksCopy[index].done = !tasksCopy[index].done;    setTasks(tasksCopy);  };   const taskTextStyles = (task) => {    const baseStyles = `$ {styles['tracker-select']} $ {styles['task-label']}`;     if (currentStage === task.slug.current) {      return baseStyles + ` $ {styles['is-current-task']}`;    } else {      return baseStyles;    }  };   return (    <div className={styles.container}>      <section>        {tasks.map((task, index) => (          <div            key={`mt-$ {task.slug}-$ {index}`}            className={taskTextStyles(task)}          >            <Link href={`/learn/$ {currentMission}/$ {task.slug.current}`}>              {task.title}            </Link>          </div>        ))}      </section>       <section>        <svg          viewBox={`0 0 30 $ {tasks.length * 50}`}          className={styles['tracker-svg']}          xmlns="http://www.w3.org/2000/svg"          width="30"          stroke="currentColor"          fill="white"          aria-labelledby="timeline"          role="presentation"        >          <title id="timeline">timeline element</title>           <line x1="10" x2="10" y1={num2} y2={tasks.length * num1 - num2} />          {tasks.map((task, index) => (            <circle              key={`mt-circle-$ {task.name}-$ {index}`}              onClick={updateDoneTasks(index)}              cx="10"              r="4"              cy={index * +num1 + +num2}              fill={                task.slug.current === currentStage ? 'currentColor' : 'black'              }              className={styles['tracker-select']}            />          ))}        </svg>      </section>    </div>  ); }  export default MissionTracker;

Final version

You can see the final working version here:

This component is flexible enough to accommodate lists small and large, multiple browsers, and responsive sizing. It also allows the user to have better understanding of where they are in their progress in the course.

But this is just one component. You can make any number of UI elements: knobs, controls, progress indicators, loaders… the sky’s the limit. You can style them with CSS, or inline styles, you can have them update based on props, on context, on reactive data, the sky’s the limit! I hope this opens some doors on how you yourself can develop more engaging UI elements for the web.


The post How to Create a Timeline Task List Component Using SVG appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , , , ,
[Top]

All the Ways to Make a Web Component

This is a neat page that compares a ton of different libraries with web components. One of the things I learned after posting “A Bit on Web Components Libraries” is that the web platform APIs were designed for libraries to be built around them. Interesting, right?

This page makes a counter component. By extending HTMLElement natively, they do it in 1,293 bytes, then each library adds things on top of that. The big libraries, like Vue and React, are clearly much bigger (but bring a ton of other functionality to the table). One of the biggest is CanJS (230,634 bytes), which isn’t aiming to be small, but, from their about page: “It targets experienced developers building complex applications with long futures ahead of them.” If the goal is small, Svelte is true to its mission of nearly compiling itself away ending at just 3,592 bytes, a third of the size of the super tiny lit-html and half the size of uhtml — both of which are just tiny abstractions that offer nicer templating and re-rendering.

Direct Link to ArticlePermalink


The post All the Ways to Make a Web Component appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

,
[Top]