Tag: Component

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

, , ,

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]

How to Make a Media Query-less Card Component

Fun fact: it’s possible to create responsive components without any media queries at all. Certainly, if we had container queries, those would be very useful for responsive design at the component level. But we don’t. Still, with or without container queries, we can do things to make our components surprisingly responsive. We’ll use concepts from Intrinsic Web Design, brought to us by Jen Simmons.

Let’s dive together into the use case described below, the solutions regarding the actual state of CSS, and some other tricks I’ll give you.

A responsive “Cooking Recipe” card

I recently tweeted a video and Pen of a responsive card demo I built using a recipe for pizza as an example. (It’s not important to the technology here, but I dropped the recipe at the end because it’s delicious and gluten free.)

The demo here was a first attempt based on a concept from one of Stéphanie Walter’s talks. Here is a video to show you how the card will behave:

And if you want to play with it right now, here’s the Pen.

Let’s define the responsive layout

A key to planning is knowing the actual content you are working, and the importance of those details. Not that we should be hiding content at any point, but for layout and design reasons, it’s good to know what needs to be communicated first and so forth. We’ll be displaying the same content no matter the size or shape of the layout.

Let’s imagine the content with a mobile-first mindset to help us focus on what’s most important. Then when the screen is larger, like on a desktop, we can use the additional space for things like glorious whitespace and larger typography. Usually, a little prioritization like this is enough to be sure of what content is needed for the cards at any and all viewport sizes.

Let’s take the example of a cooking recipe teaser:

In her talk, Stéphanie had already did the job and prioritized the content for our cards. Here’s what she outlined, in order of importance:

  1. Image: because it’s a recipe, you eat with your eyes!
  2. Title: to be sure what you’re going to cook.
  3. Keywords: to catch key info at the first glance.
  4. Rating info: for social proof.
  5. Short description: for the people who read.
  6. Call to action: what you expect the user to do on this card.

This may seem like a lot, but we can get all of that into a single smart card layout!

Non-scalable typography

One of the constraints with the technique I’m going to show you is that you won’t be able to get scalable typography based on container width. Scalable typography (e.g. “fluid type”) is commonly done with the with viewport width (vw) unit, which is based on the viewport, not the parent element.

So, while we might be tempted to reach for fluid type as a non-media query solution for the content in our cards, we won’t be able to use fluid type based on some percentage of the container width nor element width itself, unfortunately. That won’t stop us from our goal, however!

A quick note on “pixel perfection”

Let’s talk to both sides here…

Designers: Pixel perfect is super ideal, and we can certainly be precise at a component level. But there has to be some trade-off at the layout level. Meaning you will have to provide some variations, but allow the in-betweens to be flexible. Things shift in responsive layouts and precision at every possible screen width is a tough ask. We can still make things look great at every scale though!

Developers: You’ll have to be able to fill the gaps between the layouts that have prescribed designs to allow content to be readable and consistent between those states. As a good practice, I also recommend trying to keep as much of a natural flow as possible.

You can also read the Ahmad’s excellent article on the state of pixel perfection.

A recipe for zero media queries

Remember, what we’re striving for is not just a responsive card, but one that doesn’t rely on any media queries. It’s not that media queries should be avoided; it’s more about CSS being powerful and flexible enough for us to have other options available.

To build our responsive card, I was wondering if flexbox would be enough or if I would need to do it with CSS grid instead. Turns out flexbox in indeed enough for us this time, using the behavior and magic of the flex-wrap and flex-basis properties in CSS.

The gist of flex-wrap is that it allows elements to break onto a new line when the space for content gets too tight. You can see the difference between flex with a no-wrap value and with wrapping in this demo:

The flex-basis value of 200px is more of an instruction than a suggestion for the browser, but if the container doesn’t offer enough space for it, the elements move down onto a new line. The margin between columns even force the initial wrapping.

I used this wrapping logic to create the base of my card. Adam Argyle also used it on the following demo features four form layouts with a mere 10 lines of CSS:

In his example, Adam uses flex-basis and flex-grow (used together in flex shorthand property) )to allow the email input to take three times the space occupied by the name input or the button. When the browser estimates there is not enough rooms to display everything on the same row, the layout breaks itself into multiple lines by itself, without us having to manage the changes in media queries.

I also used clamp() function to add even more flexibility. This function is kind of magical. It allows us to resolve a min() and a max() calculation in a single function. The syntax goes like this:

clamp(MIN, VALUE, MAX)

It’s like resolving a combination of the max() and min() functions:

max(MIN, min(VAL, MAX))

You can use it for all kind of properties that cover:  <length>, <frequency>, <angle>, <time>, <percentage>, <number>, or <integer>.

The “No-Media Query Responsive Card” demo

With all of these new-fangled CSS powers, I created a flexible responsive card without any media queries. It might be best to view this demo in a new tab, or with a 0.5x option in the embed below.

Something you want to note right away is that the HTML code for the 2 cards are exactly the same, the only difference is that the first card is within a 65% wide container, and the second one within a 35% wide container. You can also play with the dimension of your window to test its responsiveness.

The important part of the code in that demo is on these selectors:

  • .recipe is the parent flex container.
  • .pizza-box is a flex item that is the container for the card image.
  • .recipe-content is a second flex item and is the container for the card content. 

Now that we know how flex-wrap works, and how flex-basis and flex-grow  influence the element sizing, we just need to quickly explain the clamp() function because I used it for responsive font sizing in place of where we may have normally reached for fluid type.

I wanted to use calc() and custom properties to calculate font sizes based on the width of the parent container, but I couldn’t find a way, as a 100% value has a different interpretation depending on the context. I kept it for the middle value of my clamp() function, but the end result was over-engineered and didn’t wind up working as I’d hoped or expected.

/* No need, really */ font-size: clamp(1.4em, calc(.5em * 2.1vw), 2.1em);

Here’s where I landed instead:

font-size: clamp(1.4em, 2.1vw, 2.1em);

That’s what I did to make the card title’s size adjust against the screen size but, like we discussed much earlier when talking about fluid type, we won’t be able to size the text by the parent container’s width.

Instead, we’re basically saying this with that one line of CSS:

I want the font-size to equal to 2.1vw (2.1% of the viewport width), but please don’t let it go below 1.4em or above 2.1em.

This maintains the title’s prioritized importance by allowing it to stay larger than the rest of the content, while keeping it readable. And, hey, it still makes grows and shrinks on the screen size!

And let’s not forget about responsive images, The content requirements say the image is the most important piece in the bunch, so we definitely need to account for it and make sure it looks great at all screen sizes. Now, you may want to do something like this and call it a day:

max-width: 100%; height: auto;

But that’s doesnt always result in the best rendering of an image. Instead, we have the object-fit property, which not only responds to the height and width of the image’s content-box, but allows us to crop the image and control how it stretches inside the box when used with the object-position property.

img {   max-width: 100%;   min-height: 100%;   width: auto;   height: auto;   object-fit: cover;   object-position: 50% 50%; }

As you can see, that is a lot of properties to write down. It’s mandatory because of the explicit width and height properties in the HTML <img> code. If you remove the HTML part (which I don’t recommend for performance reason) you can keep the object-* properties in CSS and remove the others.

An alternative recipe for no media queries

Another technique is to use flex-grow as a unit-based growing value, with an absurdly enormous value for flex-basis. The idea is stolen straight from the Heydon Pickering’s great “Holy Albatross” demo.

The interesting part of the code is this:

/* Container */ .recipe {   --modifier: calc(70ch - 100%); 
   display: flex;   flex-wrap: wrap; } 
 /* Image dimension */ .pizza-box {   flex-grow: 3;   flex-shrink: 1;   flex-basis: calc(var(--modifier) * 999); } 
 /* Text content dimension */ .recipe-content {   flex-grow: 4;   flex-shrink: 1;   flex-basis: calc(var(--modifier) * 999); }

Proportional dimensions are created by flex-grow while the flex-basis dimension can be either invalid or extremely high. The value gets extremely high when calc(70ch - 100%), the value of  --modifier, reaches a positive value. When the values are extremely high each of them fills the space creating a column layout; when the values are invalid, they lay out inline.

The value of 70ch acts like the breakpoint in the recipe component (almost like a container query). Change it depending on your needs.

Let’s break down the ingredients once again

Here are the CSS ingredients we used for a media-query-less card component:

  • The clamp() function helps resolve a “preferred” vs. “minimum” vs. “maximum” value.
  • The flex-basis property with a negative value decides when the layout breaks into multiple lines.
  • The flex-grow property is used as a unit value for proportional growth.
  • The vw unit helps with responsive typography.
  • The  object-fit property provides finer responsiveness for the card image, as it allows us to alter the dimensions of the image without distorting it.

Going further with quantity queries

I’ve got another trick for you: we can adjust the layout depending on the number of items in the container. That’s not really a responsiveness brought by the dimension of a container, but more by the context where the content lays.

There is no actual media query for number of items. It’s a little CSS trick to reverse-count the number of items and apply style modifications accordingly.

The demo uses the following selector:

.container > :nth-last-child(n+3), .container > :nth-last-child(n+3) ~ * {   flex-direction: column; }

Looks tricky, right? This selector allows us to apply styles from the last-child and all it’s siblings. Neat! 

Una Kravets explains this concept really well. We can translate this specific usage like this:

  • .container > :nth-last-child(n+3): The third .container element or greater from the last .container in the group.
  • .container > :nth-last-child(n+3) ~ *: The same exact thing, but selects any .container element after the last one. This helps account for any other cards we add.

Hugo Giraudel’s “Selectors Explained” tool really helps translate complex selectors into plain English, if you’d like another translation of how these selectors work.

Another way to get “quantity” containers in CSS is to use binary conditions. But the syntax is not easy and seems a bit hacky. You can reach me on Twitter if you need to talk about that — or any other tricks and tips about CSS or design. pastedGraphic.png

Is this future proof?

All the techniques I presented you here can be used today in a production environment. They’re well supported and offer opportunities for graceful degradation.

Worst case scenario? Some unsupported browser, say Internet Explorer 9, won’t change the layout based on the conditions we specify, but the content will still be readable. So, it’s supported, but might not be “optimized” for the ideal experience.

Maybe one day we will finally get see the holy grail of container queries in the wild. Hopefully the Intrinsic Web Design patterns we’ve used here resonate with you and help you build flexible and “intrinsicly-responsive” components in the meantime.

Let’s get to the “rea” reason for this post… the pizza! 🍕


Gluten free pan pizza recipe

You can pick the toppings. The important part is the dough, and here is that:

Ingredients

  • 3¼ cups (455g) gluten free flour
  • 1 tablespoon, plus 1 teaspoon (29g) brown sugar
  • 2 teaspoons of kosher salt
  • 1/2 cube of yeast
  • 2½ cups (400 ml) whole almond milk
  • 4 tablespoons of melted margarine
  • 1 tablespoon of maizena

Instructions

  1. Mix all the dry ingredients together.
  2. Add the liquids.
  3. Let it double size for 2 hours. I’d recommend putting a wet dish towel over your bowl where the dough is, and place the dish close to a hot area (but not too hot because we don’t want it to cook right this second).
  4. Put it in the pan with oil. Let it double size for approximately 1 hour.
  5. Cook in the oven at 250 degrees for 20 minutes.

Thanks Stéphanie for the recipe 😁


The post How to Make a Media Query-less Card Component appeared first on CSS-Tricks.

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

CSS-Tricks

, , ,
[Top]

A Bit on Web Component Libraries

A run of Web Components news crossed my desk recently so I thought I’d group it up here.

To my mind, one of the best use cases for Web Components is pattern libraries. Instead of doing, say, <ul class="nav nav-tabs"> like you would do in Bootstrap or <div class="tabs"> like you would in Bulma, you would use a custom element, like <designsystem-tabs>.

The new Shoelace library uses the sl namespace for their components. It’s a whole pattern library entirely in Web Components. So the tabs there are <sl-tab-group> elements.

Why is that good? Well, for one thing, it brings a component model to the party. That means, if you’re working on a component, it has a template and a stylesheet that are co-located. Peeking under the hood of Shoelace, you can see this is all based on Stencil.

Another reason it’s good is that it means components can (and they do) use the Shadow DOM. This offers a form of isolation that comes right from the web platform. For CSS folks like us, that means the styling for a tab in the tab component is done with a .tab class (hey, wow, cool) but it is isolated in that component. Even with that generic of a name, I can’t accidentally mess with some other component on the page that uses that generic class, nor is some other outside CSS going to mess with the guts here. The Shadow DOM is a sort of wall of safety that prevents styles from leaking out or seeping in.

I just saw the FAST framework¹ too, which is also a set of components. It has tabs that are defined as <fast-tabs>. That reminds me of another thing I like about the Web Components as a pattern library approach: if feels like it’s API-driven, even starting with the name of the component itself, which is literally what you use in the HTML. The attributes on that element can be entirely made up. It seems the emerging standard is that you don’t even have to data-* prefix the attributes that you also make up to control the component. So, if I were to make a tabs component, it might be <chris-tabs active-tab="lunch" variation="rounded">.

Perhaps the biggest player using Web Components for a pattern library is Ionic. Their tabs are <ion-tabs>, and you can use them without involving any other framework (although they do support Angular, React, and Vue in addition to their own Stencil). Ionic has made lots of strides with this Web Components stuff, most recently supporting Shadow Parts. Here’s Brandy Carney explaining the encapsulation again:

Shadow DOM is useful for preventing styles from leaking out of components and unintentionally applying to other elements. For example, we assign a .button class to our ion-button component. If an Ionic Framework user were to set the class .button on one of their own elements, it would inherit the Ionic button styles in past versions of the framework. Since ion-button is now a Shadow Web Component, this is no longer a problem.

However, due to this encapsulation, styles aren’t able to bleed into inner elements of a Shadow component either. This means that if a Shadow component renders elements inside of its shadow tree, a user isn’t able to target the inner element with their CSS.

The encapsulation is a good thing, but indeed it does make styling “harder” (on purpose). There is an important CSS concept to know: CSS custom properties penetrate the Shadow DOM. However, it was decided — and I think rightly so — that “variablizing” every single thing in a design system is not a smart way forward. Instead, they give each bit of HTML inside the Shadow DOM a part, like <div part="icon">, which then gives gives the ability to “reach in from the outside” with CSS, like custom-component::part(icon) { }.

I think part-based styling hooks are mostly fine, and a smart way forward for pattern libraries like this, but I admit some part of it bugs me. The selectors don’t work how you’d expect. For example, you can’t conditionally select things. You also can’t select children or use the cascade. In other words, it’s just one-off, or like you’re reaching straight through a membrane with your hand. You can reach forward and either grab the thing or not, but you can’t do anything else at all.

Speaking of things that bug people, Andrea Giammarchi has a good point about the recent state of Web Components:

Every single library getting started, including mine, suggest we should import the library in order to define what [sic] supposed to be a “portable Custom Element”.

Google always suggests LitElement. Microsoft wants you to use FASTElement. Stencil has their own Component. hyperHTML has their own Component. Nobody is just using “raw” Web Components. It’s weird! What strikes me as the worst part about that is that Web Components are supposed to be this “native platform” thing meaning that we shouldn’t need to buy into some particular technology in order to use them. When we do, we’re just as locked to that as we would be if we just used React or whatever.

Andrea has some ideas in that article, including the use of some new and smaller library. I think what I’d like to see is a pattern library that just doesn’t use any library at all.

  1. FAST calls itself a “interface system,” then a “UI framework” in consecutive sentences on the homepage. Shoelaces calls itself a “library” but I’m calling it a “pattern library.” I find “design system” to be the most commonly used term to describe the concept, but often used more broadly than a specific technology. FAST uses that term in the code itself for the wrapper element that controls the theme. I’d say the terminology around all this stuff is far from settled.

The post A Bit on Web Component Libraries appeared first on CSS-Tricks.

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

CSS-Tricks

,
[Top]

How to Make a List Component with Emotion

I’ve been doing a bit of refactoring this week at Sentry and I noticed that we didn’t have a generic List component that we could use across projects and features. So, I started one, but here’s the rub: we style things at Sentry using Emotion, which I have only passing experience with and is described in the docs as…

[…] a library designed for writing css styles with JavaScript. It provides powerful and predictable style composition in addition to a great developer experience with features such as source maps, labels, and testing utilities. Both string and object styles are supported.

If you’ve never heard of Emotion, the general idea is this: when we’re working on big codebases with lots of components, we want to ensure that we can control the cascade of our CSS. So, let’s say you have an .active class in one file and you want to make sure that doesn’t impact the styles of a completely separate component in another file that also has a class of.active.

Emotion tackles this problem by adding custom strings to your classnames so they don’t conflict with other components. Here’s an example of the HTML it might output:

<div class="css-1tfy8g7-List e13k4qzl9"></div>

Pretty neat, huh? There’s lots of other tools and workflows out there though that do something very similar, such as CSS Modules.

To get started making the component, we first need to install Emotion into our project. I’m not going to walkthrough that stuff because it’s going to be different depending on your environment and setup. But once that’s complete we can go ahead and create a new component like this:

import React from 'react'; import styled from '@emotion/styled';  export const List = styled('ul')`   list-style: none;   padding: 0; `;

This looks pretty weird to me because, not only are we writing styles for the <ul> element, but we’re defining that the component should render a <ul>, too. Combining both the markup and the styles in one place feels odd but I do like how simple it is. It just sort of messes with my mental model and the separation of concerns between HTML, CSS, and JavaScript.

In another component, we can import this <List> and use it like this:

import List from 'components/list';  <List>This is a list item.</List>

The styles we added to our list component will then be turned into a classname, like .oefioaueg, and then added to the <ul> element we defined in the component.

But we’re not done yet! With the list design, I needed to be able to render a <ul> and an <ol> with the same component. I also needed a version that allows me to place an icon within each list item. Just like this:

The cool (and also kind of weird) thing about Emotion is that we can use the as attribute to select which HTML element we’d like to render when we import our component. We can use this attribute to create our <ol> variant without having to make a custom type property or something. And that happens to look just like this:

<List>This will render a ul.</List> <List as="ol">This will render an ol.</List>

That’s not just weird to me, right? It’s super neat, however, because it means that we don’t have to do any bizarro logic in the component itself just to change the markup.

It was at this point that I started to jot down what the perfect API for this component might look like though because then we can work our way back from there. This is what I imagined:

<List>   <ListItem>Item 1</ListItem>   <ListItem>Item 2</ListItem>   <ListItem>Item 3</ListItem> </List>  <List>   <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 1</ListItem>   <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 2</ListItem>   <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 3</ListItem> </List>  <List as="ol">   <ListItem>Item 1</ListItem>   <ListItem>Item 2</ListItem>   <ListItem>Item 3</ListItem> </List>

So after making this sketch I knew we’d need two components, along with the ability to nest icon subcomponents within the <ListItem>. We can start like this:

import React from 'react'; import styled from '@emotion/styled';  export const List = styled('ul')`   list-style: none;   padding: 0;   margin-bottom: 20px;    ol& {     counter-reset: numberedList;   } `;

That peculiar ol& syntax is how we tell emotion that these styles only apply to an element when it’s rendered as an <ol>. It’s often a good idea to just add a background: red; to this element to make sure your component is rendering things correctly.

Next up is our subcomponent, the <ListItem>. It’s important to note that at Sentry we also use TypeScript, so before we define our <ListItem> component, we’ll need to set our props up first:

type ListItemProps = {   icon?: React.ReactNode;   children?: string | React.ReactNode;   className?: string; };

Now we can add our <IconWrapper> component that will size an <Icon> component within the ListItem. If you remember from the example above, I wanted it to look something like this:

<List>   <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 1</ListItem>   <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 2</ListItem>   <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 3</ListItem> </List>

That IconBusiness component is a preexisting component and we want to wrap it in a span so that we can style it. Thankfully, we’ll need just a tiny bit of CSS to align the icon properly with the text and the <IconWrapper> can handle all of that for us:

type ListItemProps = {   icon?: React.ReactNode;   children?: string | React.ReactNode;   className?: string; };  const IconWrapper = styled('span')`   display: flex;   margin-right: 15px;   height: 16px;   align-items: center; `;

Once we’ve done this we can finally add our <ListItem> component beneath these two, although it is considerably more complex. We’ll need to add the props, then we can render the <IconWrapper> above when the icon prop exists, and render the icon component that’s passed into it as well. I’ve also added all the styles below so you can see how I’m styling each of these variants:

export const ListItem = styled(({icon, className, children}: ListItemProps) => (   <li className={className}>     {icon && (       <IconWrapper>         {icon}       </IconWrapper>     )}     {children}   </li> ))<ListItemProps>`   display: flex;   align-items: center;   position: relative;   padding-left: 34px;   margin-bottom: 20px; 	   /* Tiny circle and icon positioning */   &:before, 	& > $ {IconWrapper} {     position: absolute;     left: 0;   }    ul & {     color: #aaa;     /* This pseudo is the tiny circle for ul items */      &:before {       content: '';       width: 6px;       height: 6px;       border-radius: 50%;       margin-right: 15px;       border: 1px solid #aaa;       background-color: transparent;       left: 5px;       top: 10px;     } 		     /* Icon styles */     $ {p =>       p.icon &&       `       span {         top: 4px;       }       /* Removes tiny circle pseudo if icon is present */       &:before {         content: none;       }     `}   }   /* When the list is rendered as an <ol> */   ol & {     &:before {       counter-increment: numberedList;       content: counter(numberedList);       top: 3px;       display: flex;       align-items: center;       justify-content: center;       text-align: center;       width: 18px;       height: 18px;       font-size: 10px;       font-weight: 600;       border: 1px solid #aaa;       border-radius: 50%;       background-color: transparent;       margin-right: 20px;     }   } `;

And there you have it! A relatively simple <List> component built with Emotion. Although, after going through this exercise I’m still not sure that I like the syntax. I reckon it sort of makes the simple stuff really simple but the medium-sized components much more complicated than they should be. Plus, it could be pretty darn confusing to a newcomer and that worries me a bit.

But everything is a learning experience, I guess. Either way, I’m glad I had the opportunity to work on this tiny component because it taught me a few good things about TypeScript, React, and trying to make our styles somewhat readable.

The post How to Make a List Component with Emotion appeared first on CSS-Tricks.

CSS-Tricks

, ,
[Top]

Global and Component Style Settings with CSS Variables

The title of this Sara Soueidan article speaks to me. I’m a big fan of the idea that some CSS is best applied globally, and some CSS is best applied scoped to a component. I’m less interested in how that is done and more interested in just seeing that conceptual approach used in some fashion.

Sara details an approach where components don’t have too much styling by default, but have CSS custom properties applied to them that are ready to take values should you choose to set them.

For each pattern, I’ve found myself modifying the same properties whenever I needed to use it — like the font, colors (text, background, border), box shadow, spacing, etc. So I figured it would be useful and time-saving if I created variables for those properties, define those variables in the ‘root’ of the component, and ‘pass in’ the values for these variables when I use the pattern as I need. This way I can customize or theme the component by changing the property values in one rule set, instead of having to jump between multiple ones to do so.

Direct Link to ArticlePermalink

The post Global and Component Style Settings with CSS Variables appeared first on CSS-Tricks.

CSS-Tricks

, , , ,
[Top]

The Anatomy of a Tablist Component in Vanilla JavaScript Versus React

If you follow the undercurrent of the JavaScript community, there seems to be a divide as of late. It goes back over a decade. Really, this sort of strife has always been. Perhaps it is human nature.

Whenever a popular framework gains traction, you inevitably see people comparing it to rivals. I suppose that is to be expected. Everyone has a particular favorite.

Lately, the framework everyone loves (to hate?) is React. You often see it pitted against others in head-to-head blog posts and feature comparison matrices of enterprise whitepapers. Yet a few years ago, it seemed like jQuery would forever be king of the hill.

Frameworks come and go. To me, what is more interesting is when React — or any JS framework for that matter — gets pitted against the programming language itself. Because of course, under the hood, it is all built atop JS.

The two are not inherently at odds. I would even go so far as to say that if you do not have a good handle on JS fundamentals, you probably are not going to reap the full benefits of using React. It can still be helpful, similar to using a jQuery plugin without understanding its internals. But I feel like React presupposes more JS familiarity.

HTML is equally important. There exists a fair bit of FUD around how React affects accessibility. I think this narrative is inaccurate. In fact, the ESLint JSX a11y plugin will warn of possible accessibility violations in the console.

Console warnings from eslint-jsx-a11y-plugin
ESLint warnings about empty <a> tags

Recently, an annual study of the top 1 million sites was released. It shows that for sites using JS frameworks, there is an increased likelihood of accessibility problems. This is correlation, not causation.

This does not necessarily mean that the frameworks caused these errors, but it does indicate that home pages with these frameworks had more errors than on average.

In a manner of speaking, React’s magic incantations work regardless of whether you recognize the words. Ultimately, you are still responsible for the outcome.

Philosophical musings aside, I am a firm believer in choosing the best tool for the job. Sometimes, that means building a single page app with a Jamstack approach. Or maybe a particular project is better suited to offloading HTML rendering to the server, where it has historically been handled.

Either way, there inevitably comes the need for JS to augment the user experience. At Reaktiv Studios, to that end I have been attempting to keep most of our React components in sync with our “flat HTML” approach. I have been writing commonly used functionality in vanilla JS as well. This keeps our options open, so that our clients are free to choose. It also allows us to reuse the same CSS.

If I may, I would like to share how I built our <Tabs> and <Accordion> React components. I will also demonstrate how I wrote the same functionality without using a framework.

Hopefully, this lesson will feel like we are making a layered cake. Let us first start with the base markup, then cover the vanilla JS, and finish with how it works in React.

For reference, you can tinker with our live examples:

Reaktiv Studios UI components
Reaktiv Studios UI components

Flat HTML examples

Since we need JavaScript to make interactive widgets either way, I figured the easiest approach — from a server side implementation standpoint — would be to require only the bare minimum HTML. The rest can be augmented with JS.

The following are examples of markup for tabs and accordion components, showing a before/after comparison of how JS affects the DOM.

I have added id="TABS_ID" and id="ACCORDION_ID" for demonstrative purposes. This is to make it more obvious what is happening. But the JS that I will be explaining automatically generates unique IDs if nothing is supplied in the HTML. It would work fine either way, with or without an id specified.

<div class="tabs" id="TABS_ID">   <ul class="tabs__list">     <li class="tabs__item">       Tab 1     </li>     <!-- .tabs__item -->      <li class="tabs__item">       Tab 2     </li>     <!-- .tabs__item -->      <li class="tabs__item" disabled>       Tab 3 (disabled)     </li>     <!-- .tabs__item -->   </ul>   <!-- .tabs__list -->    <div class="tabs__panel">     <p>       Tab 1 content     </p>   </div>   <!-- .tabs__panel -->    <div class="tabs__panel">     <p>       Tab 2 content     </p>   </div>   <!-- .tabs__panel -->    <div class="tabs__panel">     <p>       NOTE: This tab is disabled.     </p>   </div>   <!-- .tabs__panel --> </div> <!-- .tabs -->

Tabs (with ARIA)

<div class="tabs" id="TABS_ID">   <ul class="tabs__list" role="tablist">     <li       aria-controls="tabpanel_TABS_ID_0"       aria-selected="false"       class="tabs__item"       id="tab_TABS_ID_0"       role="tab"       tabindex="0"     >       Tab 1     </li>     <!-- .tabs__item -->      <li       aria-controls="tabpanel_TABS_ID_1"       aria-selected="true"       class="tabs__item"       id="tab_TABS_ID_1"       role="tab"       tabindex="0"     >       Tab 2     </li>     <!-- .tabs__item -->      <li       aria-controls="tabpanel_TABS_ID_2"       aria-disabled="true"       aria-selected="false"       class="tabs__item"       disabled       id="tab_TABS_ID_2"       role="tab"     >       Tab 3 (disabled)     </li>     <!-- .tabs__item -->   </ul>   <!-- .tabs__list -->    <div     aria-hidden="true"     aria-labelledby="tab_TABS_ID_0"     class="tabs__panel"     id="tabpanel_TABS_ID_0"     role="tabpanel"   >     <p>       Tab 1 content     </p>   </div>   <!-- .tabs__panel -->    <div     aria-hidden="false"     aria-labelledby="tab_TABS_ID_1"     class="tabs__panel"     id="tabpanel_TABS_ID_1"     role="tabpanel"   >     <p>       Tab 2 content     </p>   </div>   <!-- .tabs__panel -->    <div     aria-hidden="true"     aria-labelledby="tab_TABS_ID_2"     class="tabs__panel"     id="tabpanel_TABS_ID_2"     role="tabpanel"   >     <p>       NOTE: This tab is disabled.     </p>   </div>   <!-- .tabs__panel --> </div> <!-- .tabs -->

Accordion (without ARIA)

<div class="accordion" id="ACCORDION_ID">   <div class="accordion__item">     Tab 1   </div>   <!-- .accordion__item -->    <div class="accordion__panel">     <p>       Tab 1 content     </p>   </div>   <!-- .accordion__panel -->    <div class="accordion__item">     Tab 2   </div>   <!-- .accordion__item -->    <div class="accordion__panel">     <p>       Tab 2 content     </p>   </div>   <!-- .accordion__panel -->    <div class="accordion__item" disabled>     Tab 3 (disabled)   </div>   <!-- .accordion__item -->    <div class="accordion__panel">     <p>       NOTE: This tab is disabled.     </p>   </div>   <!-- .accordion__panel --> </div> <!-- .accordion -->

Accordion (with ARIA)

<div   aria-multiselectable="true"   class="accordion"   id="ACCORDION_ID"   role="tablist" >   <div     aria-controls="tabpanel_ACCORDION_ID_0"     aria-selected="true"     class="accordion__item"     id="tab_ACCORDION_ID_0"     role="tab"     tabindex="0"   >     <i aria-hidden="true" class="accordion__item__icon"></i>     Tab 1   </div>   <!-- .accordion__item -->    <div     aria-hidden="false"     aria-labelledby="tab_ACCORDION_ID_0"     class="accordion__panel"     id="tabpanel_ACCORDION_ID_0"     role="tabpanel"   >     <p>       Tab 1 content     </p>   </div>   <!-- .accordion__panel -->    <div     aria-controls="tabpanel_ACCORDION_ID_1"     aria-selected="false"     class="accordion__item"     id="tab_ACCORDION_ID_1"     role="tab"     tabindex="0"   >     <i aria-hidden="true" class="accordion__item__icon"></i>     Tab 2   </div>   <!-- .accordion__item -->    <div     aria-hidden="true"     aria-labelledby="tab_ACCORDION_ID_1"     class="accordion__panel"     id="tabpanel_ACCORDION_ID_1"     role="tabpanel"   >     <p>       Tab 2 content     </p>   </div>   <!-- .accordion__panel -->    <div     aria-controls="tabpanel_ACCORDION_ID_2"     aria-disabled="true"     aria-selected="false"     class="accordion__item"     disabled     id="tab_ACCORDION_ID_2"     role="tab"   >     <i aria-hidden="true" class="accordion__item__icon"></i>     Tab 3 (disabled)   </div>   <!-- .accordion__item -->    <div     aria-hidden="true"     aria-labelledby="tab_ACCORDION_ID_2"     class="accordion__panel"     id="tabpanel_ACCORDION_ID_2"     role="tabpanel"   >     <p>       NOTE: This tab is disabled.     </p>   </div>   <!-- .accordion__panel --> </div> <!-- .accordion -->

Vanilla JavaScript examples

Okay. Now that we have seen the aforementioned HTML examples, let us walk through how we get from before to after.

First, I want to cover a few helper functions. These will make more sense in a bit. I figure it is best to get them documented first, so we can stay focused on the rest of the code once we dive in further.

File: getDomFallback.js

This function provides common DOM properties and methods as no-op, rather than having to make lots of typeof foo.getAttribute checks and whatnot. We could forego those types of confirmations altogether.

Since live HTML changes can be a potentially volatile environment, I always feel a bit safer making sure my JS is not bombing out and taking the rest of the page with it. Here is what that function looks like. It simply returns an object with the DOM equivalents of falsy results.

/*   Helper to mock DOM methods, for   when an element might not exist. */ const getDomFallback = () => {   return {     // Props.     children: [],     className: '',     classList: {       contains: () => false,     },     id: '',     innerHTML: '',     name: '',     nextSibling: null,     previousSibling: null,     outerHTML: '',     tagName: '',     textContent: '',      // Methods.     appendChild: () => Object.create(null),     cloneNode: () => Object.create(null),     closest: () => null,     createElement: () => Object.create(null),     getAttribute: () => null,     hasAttribute: () => false,     insertAdjacentElement: () => Object.create(null),     insertBefore: () => Object.create(null),     querySelector: () => null,     querySelectorAll: () => [],     removeAttribute: () => undefined,     removeChild: () => Object.create(null),     replaceChild: () => Object.create(null),     setAttribute: () => undefined,   }; };  // Export. export { getDomFallback };

File: unique.js

This function is a poor man’s UUID equivalent.

It generates a unique string that can be used to associate DOM elements with one another. It is handy, because then the author of an HTML page does not have to ensure that every tabs and accordion component have unique IDs. In the previous HTML examples, this is where TABS_ID and ACCORDION_ID would typically contain the randomly generated numeric strings instead.

// ========== // Constants. // ==========  const BEFORE = '0.'; const AFTER = '';  // ================== // Get unique string. // ==================  const unique = () => {   // Get prefix.   let prefix = Math.random();   prefix = String(prefix);   prefix = prefix.replace(BEFORE, AFTER);    // Get suffix.   let suffix = Math.random();   suffix = String(suffix);   suffix = suffix.replace(BEFORE, AFTER);    // Expose string.   return `$ {prefix}_$ {suffix}`; };  // Export. export { unique };

On larger JavaScript projects, I would typically use npm install uuid. But since we are keeping this simple and do not require cryptographic parity, concatenating two lightly edited Math.random() numbers will suffice for our string uniqueness needs.

File: tablist.js

This file does the bulk of the work. What is cool about it, if I do say so myself, is that there are enough similarities between a tabs component and an accordion that we can handle both with the same *.js file. Go ahead and scroll through the entirety, and then we will break down what each function does individually.

// Helpers. import { getDomFallback } from './getDomFallback'; import { unique } from './unique';  // ========== // Constants. // ==========  // Boolean strings. const TRUE = 'true'; const FALSE = 'false';  // ARIA strings. const ARIA_CONTROLS = 'aria-controls'; const ARIA_DISABLED = 'aria-disabled'; const ARIA_LABELLEDBY = 'aria-labelledby'; const ARIA_HIDDEN = 'aria-hidden'; const ARIA_MULTISELECTABLE = 'aria-multiselectable'; const ARIA_SELECTED = 'aria-selected';  // Attribute strings. const DISABLED = 'disabled'; const ID = 'id'; const ROLE = 'role'; const TABLIST = 'tablist'; const TABINDEX = 'tabindex';  // Event strings. const CLICK = 'click'; const KEYDOWN = 'keydown';  // Key strings. const ENTER = 'enter'; const FUNCTION = 'function';  // Tag strings. const LI = 'li';  // Selector strings. const ACCORDION_ITEM_ICON = 'accordion__item__icon'; const ACCORDION_ITEM_ICON_SELECTOR = `.$ {ACCORDION_ITEM_ICON}`;  const TAB = 'tab'; const TAB_SELECTOR = `[$ {ROLE}=$ {TAB}]`;  const TABPANEL = 'tabpanel'; const TABPANEL_SELECTOR = `[$ {ROLE}=$ {TABPANEL}]`;  const ACCORDION = 'accordion'; const TABLIST_CLASS_SELECTOR = '.accordion, .tabs'; const TAB_CLASS_SELECTOR = '.accordion__item, .tabs__item'; const TABPANEL_CLASS_SELECTOR = '.accordion__panel, .tabs__panel';  // =========== // Get tab ID. // ===========  const getTabId = (id = '', index = 0) => {   return `tab_$ {id}_$ {index}`; };  // ============= // Get panel ID. // =============  const getPanelId = (id = '', index = 0) => {   return `tabpanel_$ {id}_$ {index}`; };  // ============== // Click handler. // ==============  const globalClick = (event = {}) => {   // Get target.   const { key = '', target = getDomFallback() } = event;    // Get parent.   const { parentNode = getDomFallback(), tagName = '' } = target;    // Set later.   let wrapper = getDomFallback();    /*     =====     NOTE:     =====      We test for this, because the method does     not exist on `document.documentElement`.   */   if (typeof target.closest === FUNCTION) {     // Get wrapper.     wrapper = target.closest(TABLIST_CLASS_SELECTOR) || getDomFallback();   }    // Is `<li>`?   const isListItem = tagName.toLowerCase() === LI;    // Is multi?   const isMulti = wrapper.getAttribute(ARIA_MULTISELECTABLE) === TRUE;    // Valid key?   const isValidKey = !key || key.toLowerCase() === ENTER;    // Valid target?   const isValidTarget =     !target.hasAttribute(DISABLED) &&     target.getAttribute(ROLE) === TAB &&     parentNode.getAttribute(ROLE) === TABLIST;    // Valid event?   const isValidEvent = isValidKey && isValidTarget;    // Continue?   if (isValidEvent) {     // Get panel.     const panelId = target.getAttribute(ARIA_CONTROLS);     const panel = wrapper.querySelector(`#$ {panelId}`) || getDomFallback();      // Get booleans.     let boolPanel = panel.getAttribute(ARIA_HIDDEN) !== TRUE;     let boolTab = target.getAttribute(ARIA_SELECTED) !== TRUE;      // List item?     if (isListItem) {       boolPanel = FALSE;       boolTab = TRUE;     }      // [aria-multiselectable="false"]     if (!isMulti) {       // Get tabs & panels.       const childTabs = wrapper.querySelectorAll(TAB_SELECTOR);       const childPanels = wrapper.querySelectorAll(TABPANEL_SELECTOR);        // Loop through tabs.       childTabs.forEach((tab = getDomFallback()) => {         tab.setAttribute(ARIA_SELECTED, FALSE);       });        // Loop through panels.       childPanels.forEach((panel = getDomFallback()) => {         panel.setAttribute(ARIA_HIDDEN, TRUE);       });     }      // Set individual tab.     target.setAttribute(ARIA_SELECTED, boolTab);      // Set individual panel.     panel.setAttribute(ARIA_HIDDEN, boolPanel);   } };  // ==================== // Add ARIA attributes. // ====================  const addAriaAttributes = () => {   // Get elements.   const allWrappers = document.querySelectorAll(TABLIST_CLASS_SELECTOR);    // Loop through.   allWrappers.forEach((wrapper = getDomFallback()) => {     // Get attributes.     const { id = '', classList } = wrapper;     const parentId = id || unique();      // Is accordion?     const isAccordion = classList.contains(ACCORDION);      // Get tabs & panels.     const childTabs = wrapper.querySelectorAll(TAB_CLASS_SELECTOR);     const childPanels = wrapper.querySelectorAll(TABPANEL_CLASS_SELECTOR);      // Add ID?     if (!wrapper.getAttribute(ID)) {       wrapper.setAttribute(ID, parentId);     }      // Add multi?     if (isAccordion && wrapper.getAttribute(ARIA_MULTISELECTABLE) !== FALSE) {       wrapper.setAttribute(ARIA_MULTISELECTABLE, TRUE);     }      // ===========================     // Loop through tabs & panels.     // ===========================      for (let index = 0; index < childTabs.length; index++) {       // Get elements.       const tab = childTabs[index] || getDomFallback();       const panel = childPanels[index] || getDomFallback();        // Get IDs.       const tabId = getTabId(parentId, index);       const panelId = getPanelId(parentId, index);        // ===================       // Add tab attributes.       // ===================        // Tab: add icon?       if (isAccordion) {         // Get icon.         let icon = tab.querySelector(ACCORDION_ITEM_ICON_SELECTOR);          // Create icon?         if (!icon) {           icon = document.createElement(I);           icon.className = ACCORDION_ITEM_ICON;           tab.insertAdjacentElement(AFTER_BEGIN, icon);         }          // [aria-hidden="true"]         icon.setAttribute(ARIA_HIDDEN, TRUE);       }        // Tab: add id?       if (!tab.getAttribute(ID)) {         tab.setAttribute(ID, tabId);       }        // Tab: add controls?       if (!tab.getAttribute(ARIA_CONTROLS)) {         tab.setAttribute(ARIA_CONTROLS, panelId);       }        // Tab: add selected?       if (!tab.getAttribute(ARIA_SELECTED)) {         const bool = !isAccordion && index === 0;          tab.setAttribute(ARIA_SELECTED, bool);       }        // Tab: add role?       if (tab.getAttribute(ROLE) !== TAB) {         tab.setAttribute(ROLE, TAB);       }        // Tab: add tabindex?       if (tab.hasAttribute(DISABLED)) {         tab.removeAttribute(TABINDEX);         tab.setAttribute(ARIA_DISABLED, TRUE);       } else {         tab.setAttribute(TABINDEX, 0);       }        // Tab: first item?       if (index === 0) {         // Get parent.         const { parentNode = getDomFallback() } = tab;          /*           We do this here, instead of outside the loop.            The top level item isn't always the `tablist`.            The accordion UI only has `<dl>`, whereas           the tabs UI has both `<div>` and `<ul>`.         */         if (parentNode.getAttribute(ROLE) !== TABLIST) {           parentNode.setAttribute(ROLE, TABLIST);         }       }        // =====================       // Add panel attributes.       // =====================        // Panel: add ID?       if (!panel.getAttribute(ID)) {         panel.setAttribute(ID, panelId);       }        // Panel: add hidden?       if (!panel.getAttribute(ARIA_HIDDEN)) {         const bool = isAccordion || index !== 0;          panel.setAttribute(ARIA_HIDDEN, bool);       }        // Panel: add labelled?       if (!panel.getAttribute(ARIA_LABELLEDBY)) {         panel.setAttribute(ARIA_LABELLEDBY, tabId);       }        // Panel: add role?       if (panel.getAttribute(ROLE) !== TABPANEL) {         panel.setAttribute(ROLE, TABPANEL);       }     }   }); };  // ===================== // Remove global events. // =====================  const unbind = () => {   document.removeEventListener(CLICK, globalClick);   document.removeEventListener(KEYDOWN, globalClick); };  // ================== // Add global events. // ==================  const init = () => {   // Add attributes.   addAriaAttributes();    // Prevent doubles.   unbind();    document.addEventListener(CLICK, globalClick);   document.addEventListener(KEYDOWN, globalClick); };  // ============== // Bundle object. // ==============  const tablist = {   init,   unbind, };  // ======= // Export. // =======  export { tablist };

Function: getTabId and getPanelId

These two functions are used to create individually unique IDs for elements in a loop, based on an existing (or generated) parent ID. This is helpful to ensure matching values for attributes like aria-controls="…" and aria-labelledby="…". Think of those as the accessibility equivalents of <label for="…">, telling the browser which elements are related to one another.

const getTabId = (id = '', index = 0) => {   return `tab_$ {id}_$ {index}`; }; 
const getPanelId = (id = '', index = 0) => {   return `tabpanel_$ {id}_$ {index}`; }; 

Function: globalClick

This is a click handler that is applied at the document level. That means we are not having to manually add click handlers to a number of elements. Instead, we use event bubbling to listen for clicks further down in the document, and allow them to propagate up to the top. Conveniently, this is also how we can handle keyboard events such as the Enter key being pressed. Both are necessary to have an accessible UI.

In the first part of the function, we destructure key and target from the incoming event. Next, we destructure the parentNode and tagName from the target.

Then, we attempt to get the wrapper element. This would be the one with either class="tabs" or class="accordion". Because we might actually be clicking on the ancestor element highest in the DOM tree — which exists but possibly does not have the *.closest(…) method — we do a typeof check. If that function exists, we attempt to get the element. Even still, we might come up without a match. So we have one more getDomFallback to be safe.

// Get target. const { key = '', target = getDomFallback() } = event;  // Get parent. const { parentNode = getDomFallback(), tagName = '' } = target;  // Set later. let wrapper = getDomFallback();  /*   =====   NOTE:   =====    We test for this, because the method does   not exist on `document.documentElement`. */ if (typeof target.closest === FUNCTION) {   // Get wrapper.   wrapper = target.closest(TABLIST_CLASS_SELECTOR) || getDomFallback(); } 

Then, we store whether or not the tag that was clicked is a <li>. Likewise, we store a boolean about whether the wrapper element has aria-multiselectable="true". I will get back to that. We need this info later on.

We also interrogate the event a bit, to determine if it was triggered by the user pressing a key. If so, then we are only interested if that key was Enter. We also determine if the click happened on a relevant target. Remember, we are using event bubbling so really the user could have clicked anything.

We want to make sure it:

  • Is not disabled
  • Has role="tab"
  • Has a parent element with role="tablist"

Then we bundle up our event and target booleans into one, as isValidEvent.

// Is `<li>`? const isListItem = tagName.toLowerCase() === LI;  // Is multi? const isMulti = wrapper.getAttribute(ARIA_MULTISELECTABLE) === TRUE;  // Valid key? const isValidKey = !key || key.toLowerCase() === ENTER;  // Valid target? const isValidTarget =   !target.hasAttribute(DISABLED) &&   target.getAttribute(ROLE) === TAB &&   parentNode.getAttribute(ROLE) === TABLIST;  // Valid event? const isValidEvent = isValidKey && isValidTarget; 

Assuming the event is indeed valid, we make it past our next if check. Now, we are concerned with getting the role="tabpanel" element with an id that matches our tab’s aria-controls="…".

Once we have got it, we check whether the panel is hidden, and if the tab is selected. Basically, we first presuppose that we are dealing with an accordion and flip the booleans to their opposites.

This is also where our earlier isListItem boolean comes into play. If the user is clicking an <li> then we know we are dealing with tabs, not an accordion. In which case, we want to flag our panel as being visible (via aria-hiddden="false") and our tab as being selected (via aria-selected="true").

Also, we want to ensure that either the wrapper has aria-multiselectable="false" or is completely missing aria-multiselectable. If that is the case, then we loop through all neighboring role="tab" and all role="tabpanel" elements and set them to their inactive states. Finally, we arrive at setting the previously determined booleans for the individual tab and panel pairing.

// Continue? if (isValidEvent) {   // Get panel.   const panelId = target.getAttribute(ARIA_CONTROLS);   const panel = wrapper.querySelector(`#$ {panelId}`) || getDomFallback();    // Get booleans.   let boolPanel = panel.getAttribute(ARIA_HIDDEN) !== TRUE;   let boolTab = target.getAttribute(ARIA_SELECTED) !== TRUE;    // List item?   if (isListItem) {     boolPanel = FALSE;     boolTab = TRUE;   }    // [aria-multiselectable="false"]   if (!isMulti) {     // Get tabs & panels.     const childTabs = wrapper.querySelectorAll(TAB_SELECTOR);     const childPanels = wrapper.querySelectorAll(TABPANEL_SELECTOR);      // Loop through tabs.     childTabs.forEach((tab = getDomFallback()) => {       tab.setAttribute(ARIA_SELECTED, FALSE);     });      // Loop through panels.     childPanels.forEach((panel = getDomFallback()) => {       panel.setAttribute(ARIA_HIDDEN, TRUE);     });   }    // Set individual tab.   target.setAttribute(ARIA_SELECTED, boolTab);    // Set individual panel.   panel.setAttribute(ARIA_HIDDEN, boolPanel); }

Function: addAriaAttributes

The astute reader might be thinking:

You said earlier that we start with the most bare possible markup, yet the globalClick function was looking for attributes that would not be there. Why would you lie!?

Or perhaps not, for the astute reader would have also noticed the function named addAriaAttributes. Indeed, this function does exactly what it says on the tin. It breathes life into the base DOM structure, by adding all the requisite aria-* and role attributes.

This not only makes the UI inherently more accessible to assistive technologies, but it also ensures the functionality actually works. I prefer to build vanilla JS things this way, rather than pivoting on class="…" for interactivity, because it forces me to think about the entirety of the user experience, beyond what I can see visually.

First off, we get all elements on the page that have class="tabs" and/or class="accordion". Then we check if we have something to work with. If not, then we would exit our function here. Assuming we do have a list, we loop through each of the wrapping elements and pass them into the scope of our function as wrapper.

// Get elements. const allWrappers = document.querySelectorAll(TABLIST_CLASS_SELECTOR);  // Loop through. allWrappers.forEach((wrapper = getDomFallback()) => {   /*     NOTE: Cut, for brevity.   */ }); 

Inside the scope of our looping function, we destructure id and classList from wrapper. If there is no ID, then we generate one via unique(). We set a boolean flag, to identify if we are working with an accordion. This is used later.

We also get decendants of wrapper that are tabs and panels, via their class name selectors.

Tabs:

  • class="tabs__item" or
  • class="accordion__item"

Panels:

  • class="tabs__panel" or
  • class="accordion__panel"

We then set the wrapper’s id if it does not already have one.

If we are dealing with an accordion that lacks aria-multiselectable="false", we set its flag to true. Reason being, if developers are reaching for an accordion UI paradigm — and also have tabs available to them, which are inherently mutually exclusive — then the safer assumption is that the accordion should support expanding and collapsing of several panels.

// Get attributes. const { id = '', classList } = wrapper; const parentId = id || unique();  // Is accordion? const isAccordion = classList.contains(ACCORDION);  // Get tabs & panels. const childTabs = wrapper.querySelectorAll(TAB_CLASS_SELECTOR); const childPanels = wrapper.querySelectorAll(TABPANEL_CLASS_SELECTOR);  // Add ID? if (!wrapper.getAttribute(ID)) {   wrapper.setAttribute(ID, parentId); }  // Add multi? if (isAccordion && wrapper.getAttribute(ARIA_MULTISELECTABLE) !== FALSE) {   wrapper.setAttribute(ARIA_MULTISELECTABLE, TRUE); } 

Next, we loop through tabs. Wherein, we also handle our panels.

You may be wondering why this is an old school for loop, instead of a more modern *.forEach. The reason is that we want to loop through two NodeList instances: tabs and panels. Assuming they each map 1-to-1 we know they both have the same *.length. This allows us to have one loop instead of two.

Let us peer inside of the loop. First, we get unique IDs for each tab and panel. These would look like one of the two following scenarios. These are used later on, to associate tabs with panels and vice versa.

  • tab_WRAPPER_ID_0 or
    tab_GENERATED_STRING_0
  • tabpanel_WRAPPER_ID_0 or
    tabpanel_GENERATED_STRING_0
for (let index = 0; index < childTabs.length; index++) {   // Get elements.   const tab = childTabs[index] || getDomFallback();   const panel = childPanels[index] || getDomFallback();    // Get IDs.   const tabId = getTabId(parentId, index);   const panelId = getPanelId(parentId, index);    /*     NOTE: Cut, for brevity.   */ } 

As we loop through, we first ensure that an expand/collapse icon exists. We create it if necessary, and set it to aria-hidden="true" since it is purely decorative.

Next, we check on attributes for the current tab. If an id="…" does not exist on the tab, we add it. Likewise, if aria-controls="…" does not exist we add that as well, pointing to our newly created panelId.

You will notice there is a little pivot here, checking if we do not have aria-selected and then further determining if we are not in the context of an accordion and if the index is 0. In that case, we want to make our first tab look selected. The reason is that though an accordion can be fully collapsed, tabbed content cannot. There is always at least one panel visible.

Then we ensure that role="tab" exists.

It is worth noting we do some extra work, based on whether the tab is disabled. If so, we remove tabindex so that the tab cannot receive :focus. If the tab is not disabled, we add tabindex="0" so that it can receive :focus.

We also set aria-disabled="true", if need be. You might be wondering if that is redundant. But it is necessary to inform assistive technologies that the tab is not interactive. Since our tab is either a <div> or <li>, it technically cannot be disabled like an <input>. Our styles pivot on [disabled], so we get that for free. Plus, it is less cognitive overhead (as a developer creating HTML) to only worry about one attribute.

ℹ️ Fun Fact: It is also worth noting the use of hasAttribute(…) to detect disabled, instead of getAttribute(…). This is because the mere presence of disabled will cause form elements to be disabled.

If the HTML is compiled, via tools such as Parcel

  • Markup like this: <tag disabled>
  • Is changed to this: <tag disabled="">

In which case, getting the attribute is still a falsy string.

In the days of XHTML, that would have been disabled="disabled". But really, it was only ever the existence of the attribute that mattered. Not its value. That is why we simply test if the element has the disabled attribute.

Lastly, we check if we are on the first iteration of our loop where index is 0. If so, we go up one level to the parentNode. If that element does not have role="tablist", then we add it.

We do this via parentNode instead of wrapper because in the context of tabs (not accordion) there is a <ul>element around the tab <li> that needs role="tablist". In the case of an accordion, it would be the outermost <div> ancestor. This code accounts for both.

// Tab: add icon? if (isAccordion) {   // Get icon.   let icon = tab.querySelector(ACCORDION_ITEM_ICON_SELECTOR);    // Create icon?   if (!icon) {     icon = document.createElement(I);     icon.className = ACCORDION_ITEM_ICON;     tab.insertAdjacentElement(AFTER_BEGIN, icon);   }    // [aria-hidden="true"]   icon.setAttribute(ARIA_HIDDEN, TRUE); }  // Tab: add id? if (!tab.getAttribute(ID)) {   tab.setAttribute(ID, tabId); }  // Tab: add controls? if (!tab.getAttribute(ARIA_CONTROLS)) {   tab.setAttribute(ARIA_CONTROLS, panelId); }  // Tab: add selected? if (!tab.getAttribute(ARIA_SELECTED)) {   const bool = !isAccordion && index === 0;    tab.setAttribute(ARIA_SELECTED, bool); }  // Tab: add role? if (tab.getAttribute(ROLE) !== TAB) {   tab.setAttribute(ROLE, TAB); }  // Tab: add tabindex? if (tab.hasAttribute(DISABLED)) {   tab.removeAttribute(TABINDEX);   tab.setAttribute(ARIA_DISABLED, TRUE); } else {   tab.setAttribute(TABINDEX, 0); }  // Tab: first item? if (index === 0) {   // Get parent.   const { parentNode = getDomFallback() } = tab;    /*     We do this here, instead of outside the loop.      The top level item isn't always the `tablist`.      The accordion UI only has `<dl>`, whereas     the tabs UI has both `<div>` and `<ul>`.   */   if (parentNode.getAttribute(ROLE) !== TABLIST) {     parentNode.setAttribute(ROLE, TABLIST);   } }

Continuing within the earlier for loop, we add attributes for each panel. We add an id if needed. We also set aria-hidden to either true or false depending on the context of being an accordion (or not).

Likewise, we ensure that our panel points back to its tab trigger via aria-labelledby="…", and that role="tabpanel" has been set.

// Panel: add ID? if (!panel.getAttribute(ID)) {   panel.setAttribute(ID, panelId); }  // Panel: add hidden? if (!panel.getAttribute(ARIA_HIDDEN)) {   const bool = isAccordion || index !== 0;    panel.setAttribute(ARIA_HIDDEN, bool); }  // Panel: add labelled? if (!panel.getAttribute(ARIA_LABELLEDBY)) {   panel.setAttribute(ARIA_LABELLEDBY, tabId); }  // Panel: add role? if (panel.getAttribute(ROLE) !== TABPANEL) {   panel.setAttribute(ROLE, TABPANEL); } 

At the very end of the file, we have a few setup and teardown functions. As a way to play nicely with other JS that might be in the page, we provide an unbind function that removes our global event listeners. It can be called by itself, via tablist.unbind() but is mostly there so that we can unbind() before (re-)binding. That way we prevent doubling up.

Inside our init function, we call addAriaAttributes() which modifies the DOM to be accessible. We then call unbind() and then add our event listeners to the document.

Finally, we bundle both methods into a parent object and export it under the name tablist. That way, when dropping it into a flat HTML page, we can call tablist.init() when we are ready to apply our functionality.

// ===================== // Remove global events. // =====================  const unbind = () => {   document.removeEventListener(CLICK, globalClick);   document.removeEventListener(KEYDOWN, globalClick); };  // ================== // Add global events. // ==================  const init = () => {   // Add attributes.   addAriaAttributes();    // Prevent doubles.   unbind();    document.addEventListener(CLICK, globalClick);   document.addEventListener(KEYDOWN, globalClick); };  // ============== // Bundle object. // ==============  const tablist = {   init,   unbind, };  // ======= // Export. // =======  export { tablist };

React examples

There is a scene in Batman Begins where Lucius Fox (played by Morgan Freeman) explains to a recovering Bruce Wayne (Christian Bale) the scientific steps he took to save his life after being poisoned.

Lucius Fox: “I analyzed your blood, isolating the receptor compounds and the protein-based catalyst.”

Bruce Wayne: “Am I meant to understand any of that?”

Lucius Fox: “Not at all, I just wanted you to know how hard it was. Bottom line, I synthesized an antidote.”

Morgan Freeman and Christian Bale, sitting inside the Batmobile
“How do I configure Webpack?”

↑ When working with a framework, I think in those terms.

Now that we know “hard” it is — not really, but humor me — to do raw DOM manipulation and event binding, we can better appreciate the existence of an antidote. React abstracts a lot of that complexity away, and handles it for us automatically.

File: Tabs.js

Now that we are diving into React examples, we will start with the <Tabs> component.

// ============= // Used like so… // =============  <Tabs>   <div label="Tab 1">     <p>       Tab 1 content     </p>   </div>   <div label="Tab 2">     <p>       Tab 2 content     </p>   </div> </Tabs>

Here is the content from our Tabs.js file. Note that in React parlance, it is standard practice to name the file with the same capitalization as its export default component.

We start out with the same getTabId and getPanelId functions as in our vanilla JS approach, because we still need to make sure to accessibly map tabs to components. Take a look at the entirey of the code, and then we will continue to break it down.

import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { v4 as uuid } from 'uuid'; import cx from 'classnames';  // UI. import Render from './Render';  // =========== // Get tab ID. // ===========  const getTabId = (id = '', index = 0) => {   return `tab_$ {id}_$ {index}`; };  // ============= // Get panel ID. // =============  const getPanelId = (id = '', index = 0) => {   return `tabpanel_$ {id}_$ {index}`; };  // ========== // Is active? // ==========  const getIsActive = ({ activeIndex = null, index = null, list = [] }) => {   // Index matches?   const isMatch = index === parseFloat(activeIndex);    // Is first item?   const isFirst = index === 0;    // Only first item exists?   const onlyFirstItem = list.length === 1;    // Item doesn't exist?   const badActiveItem = !list[activeIndex];    // Flag as active?   const isActive = isMatch || onlyFirstItem || (isFirst && badActiveItem);    // Expose boolean.   return !!isActive; };  getIsActive.propTypes = {   activeIndex: PropTypes.number,   index: PropTypes.number,   list: PropTypes.array, };  // ================ // Get `<ul>` list. // ================  const getTabsList = ({ activeIndex = null, id = '', list = [], setActiveIndex = () => {} }) => {   // Build new list.   const newList = list.map((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;     const { disabled = null, label = '' } = itemProps;     const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = getIsActive({ activeIndex, index, list });      // =======     // Events.     // =======      const handleClick = (event = {}) => {       const { key = '' } = event;        if (!disabled) {         // Early exit.         if (key && key.toLowerCase() !== 'enter') {           return;         }          setActiveIndex(index);       }     };      // ============     // Add to list.     // ============      return (       <li         aria-controls={idPanel}         aria-disabled={disabled}         aria-selected={isActive}         className="tabs__item"         disabled={disabled}         id={idTab}         key={idTab}         role="tab"         tabIndex={disabled ? null : 0}         // Events.         onClick={handleClick}         onKeyDown={handleClick}       >         {label || `$ {index + 1}`}       </li>     );   });    // ==========   // Expose UI.   // ==========    return (     <Render if={newList.length}>       <ul className="tabs__list" role="tablist">         {newList}       </ul>     </Render>   ); };  getTabsList.propTypes = {   activeIndex: PropTypes.number,   id: PropTypes.string,   list: PropTypes.array,   setActiveIndex: PropTypes.func, };  // ================= // Get `<div>` list. // =================  const getPanelsList = ({ activeIndex = null, id = '', list = [] }) => {   // Build new list.   const newList = list.map((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;     const { children = '', className = null, style = null } = itemProps;     const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = getIsActive({ activeIndex, index, list });      // =============     // Get children.     // =============      let content = children || item;      if (typeof content === 'string') {       content = <p>{content}</p>;     }      // =================     // Build class list.     // =================      const classList = cx({       tabs__panel: true,       [String(className)]: className,     });      // ==========     // Expose UI.     // ==========      return (       <div         aria-hidden={!isActive}         aria-labelledby={idTab}         className={classList}         id={idPanel}         key={idPanel}         role="tabpanel"         style={style}       >         {content}       </div>     );   });    // ==========   // Expose UI.   // ==========    return newList; };  getPanelsList.propTypes = {   activeIndex: PropTypes.number,   id: PropTypes.string,   list: PropTypes.array, };  // ========== // Component. // ==========  const Tabs = ({   children = '',   className = null,   selected = 0,   style = null,   id: propsId = uuid(), }) => {   // ===============   // Internal state.   // ===============    const [id] = useState(propsId);   const [activeIndex, setActiveIndex] = useState(selected);    // =================   // Build class list.   // =================    const classList = cx({     tabs: true,     [String(className)]: className,   });    // ===============   // Build UI lists.   // ===============    const list = Array.isArray(children) ? children : [children];    const tabsList = getTabsList({     activeIndex,     id,     list,     setActiveIndex,   });    const panelsList = getPanelsList({     activeIndex,     id,     list,   });    // ==========   // Expose UI.   // ==========    return (     <Render if={list[0]}>       <div className={classList} id={id} style={style}>         {tabsList}         {panelsList}       </div>     </Render>   ); };  Tabs.propTypes = {   children: PropTypes.node,   className: PropTypes.string,   id: PropTypes.string,   selected: PropTypes.number,   style: PropTypes.object, };  export default Tabs;

Function: getIsActive

Due to a <Tabs> component always having something active and visible, this function contains some logic to determine whether an index of a given tab should be the lucky winner. Essentially, in sentence form the logic goes like this.

This current tab is active if:

  • Its index matches the activeIndex, or
  • The tabs UI has only one tab, or
  • It is the first tab, and the activeIndex tab does not exist.
const getIsActive = ({ activeIndex = null, index = null, list = [] }) => {   // Index matches?   const isMatch = index === parseFloat(activeIndex);    // Is first item?   const isFirst = index === 0;    // Only first item exists?   const onlyFirstItem = list.length === 1;    // Item doesn't exist?   const badActiveItem = !list[activeIndex];    // Flag as active?   const isActive = isMatch || onlyFirstItem || (isFirst && badActiveItem);    // Expose boolean.   return !!isActive; };

Function: getTabsList

This function generates the clickable <li role="tabs"> UI, and returns it wrapped in a parent <ul role="tablist">. It assigns all the relevant aria-* and role attributes, and handles binding the onClickand onKeyDown events. When an event is triggered, setActiveIndex is called. This updates the component’s internal state.

It is noteworthy how the content of the <li> is derived. That is passed in as <div label="…"> children of the parent <Tabs> component. Though this is not a real concept in flat HTML, it is a handy way to think about the relationship of the content. The children of that <div> become the the innards of our role="tabpanel" later.

const getTabsList = ({ activeIndex = null, id = '', list = [], setActiveIndex = () => {} }) => {   // Build new list.   const newList = list.map((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;     const { disabled = null, label = '' } = itemProps;     const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = getIsActive({ activeIndex, index, list });      // =======     // Events.     // =======      const handleClick = (event = {}) => {       const { key = '' } = event;        if (!disabled) {         // Early exit.         if (key && key.toLowerCase() !== 'enter') {           return;         }          setActiveIndex(index);       }     };      // ============     // Add to list.     // ============      return (       <li         aria-controls={idPanel}         aria-disabled={disabled}         aria-selected={isActive}         className="tabs__item"         disabled={disabled}         id={idTab}         key={idTab}         role="tab"         tabIndex={disabled ? null : 0}         // Events.         onClick={handleClick}         onKeyDown={handleClick}       >         {label || `$ {index + 1}`}       </li>     );   });    // ==========   // Expose UI.   // ==========    return (     <Render if={newList.length}>       <ul className="tabs__list" role="tablist">         {newList}       </ul>     </Render>   ); };

Function: getPanelsList

This function parses the incoming children of the top level component and extracts the content. It also makes use of getIsActive to determine whether (or not) to apply aria-hidden="true". As one might expect by now, it adds all the other relevant aria-* and role attributes too. It also applies any extra className or style that was passed in.

It also is “smart” enough to wrap any string content — anything lacking a wrapping tag already — in <p> tags for consistency.

const getPanelsList = ({ activeIndex = null, id = '', list = [] }) => {   // Build new list.   const newList = list.map((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;     const { children = '', className = null, style = null } = itemProps;     const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = getIsActive({ activeIndex, index, list });      // =============     // Get children.     // =============      let content = children || item;      if (typeof content === 'string') {       content = <p>{content}</p>;     }      // =================     // Build class list.     // =================      const classList = cx({       tabs__panel: true,       [String(className)]: className,     });      // ==========     // Expose UI.     // ==========      return (       <div         aria-hidden={!isActive}         aria-labelledby={idTab}         className={classList}         id={idPanel}         key={idPanel}         role="tabpanel"         style={style}       >         {content}       </div>     );   });    // ==========   // Expose UI.   // ==========    return newList; };

Function: Tabs

This is the main component. It sets an internal state for an id, to essentially cache any generated uuid() so that it does not change during the lifecycle of the component. React is finicky about its key attributes (in the previous loops) changing dynamically, so this ensures they remain static once set.

We also employ useState to track the currently selected tab, and pass down a setActiveIndex function to each <li> to monitor when they are clicked. After that, it is pretty straightfowrard. We call getTabsList and getPanelsList to build our UI, and then wrap it all up in <div role="tablist">.

It accepts any wrapper level className or style, in case anyone wants further tweaks during implementation. Providing other developers (as consumers) this flexibility means that the likelihood of needing to make further edits to the core component is lower. Lately, I have been doing this as a “best practice” for all components I create.

const Tabs = ({   children = '',   className = null,   selected = 0,   style = null,   id: propsId = uuid(), }) => {   // ===============   // Internal state.   // ===============    const [id] = useState(propsId);   const [activeIndex, setActiveIndex] = useState(selected);    // =================   // Build class list.   // =================    const classList = cx({     tabs: true,     [String(className)]: className,   });    // ===============   // Build UI lists.   // ===============    const list = Array.isArray(children) ? children : [children];    const tabsList = getTabsList({     activeIndex,     id,     list,     setActiveIndex,   });    const panelsList = getPanelsList({     activeIndex,     id,     list,   });    // ==========   // Expose UI.   // ==========    return (     <Render if={list[0]}>       <div className={classList} id={id} style={style}>         {tabsList}         {panelsList}       </div>     </Render>   ); };

If you are curious about the <Render> function, you can read more about that in this example.

File: Accordion.js

// ============= // Used like so… // =============  <Accordion>   <div label="Tab 1">     <p>       Tab 1 content     </p>   </div>   <div label="Tab 2">     <p>       Tab 2 content     </p>   </div> </Accordion>

As you may have deduced — due to the vanilla JS example handling both tabs and accordion — this file has quite a few similarities to how Tabs.js works.

Rather than belabor the point, I will simply provide the file’s contents for completeness and then speak about the specific areas in which the logic differs. So, take a gander at the contents and I will explain what makes <Accordion> quirky.

import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { v4 as uuid } from 'uuid'; import cx from 'classnames';  // UI. import Render from './Render';  // =========== // Get tab ID. // ===========  const getTabId = (id = '', index = 0) => {   return `tab_$ {id}_$ {index}`; };  // ============= // Get panel ID. // =============  const getPanelId = (id = '', index = 0) => {   return `tabpanel_$ {id}_$ {index}`; };  // ============================== // Get `tab` and `tabpanel` list. // ==============================  const getTabsAndPanelsList = ({   activeItems = {},   id = '',   isMulti = true,   list = [],   setActiveItems = () => {}, }) => {   // Build new list.   const newList = [];    // Loop through.   list.forEach((item = {}, index) => {     // =========     // Get data.     // =========      const { props: itemProps = {} } = item;      const {       children = '',       className = null,       disabled = null,       label = '',       style = null,     } = itemProps;      const idPanel = getPanelId(id, index);     const idTab = getTabId(id, index);     const isActive = !!activeItems[index];      // =======     // Events.     // =======      const handleClick = (event = {}) => {       const { key = '' } = event;        if (!disabled) {         // Early exit.         if (key && key.toLowerCase() !== 'enter') {           return;         }          // Keep active items?         const state = isMulti ? activeItems : null;          // Update active item.         const newState = {           ...state,           [index]: !activeItems[index],         };          // Set active item.         setActiveItems(newState);       }     };      // =============     // Get children.     // =============      let content = children || item;      if (typeof content === 'string') {       content = <p>{content}</p>;     }      // =================     // Build class list.     // =================      const classList = cx({       accordion__panel: true,       [String(className)]: className,     });      // ========     // Add tab.     // ========      newList.push(       <div         aria-controls={idPanel}         aria-disabled={disabled}         aria-selected={isActive}         className="accordion__item"         disabled={disabled}         id={idTab}         key={idTab}         role="tab"         tabIndex={disabled ? null : 0}         // Events.         onClick={handleClick}         onKeyDown={handleClick}       >         <i aria-hidden="true" className="accordion__item__icon" />         {label || `$ {index + 1}`}       </div>     );      // ==========     // Add panel.     // ==========      newList.push(       <div         aria-hidden={!isActive}         aria-labelledby={idTab}         className={classList}         id={idPanel}         key={idPanel}         role="tabpanel"         style={style}       >         {content}       </div>     );   });    // ==========   // Expose UI.   // ==========    return newList; };  getTabsAndPanelsList.propTypes = {   activeItems: PropTypes.object,   id: PropTypes.string,   isMulti: PropTypes.bool,   list: PropTypes.array,   setActiveItems: PropTypes.func, };  // ========== // Component. // ==========  const Accordion = ({   children = '',   className = null,   isMulti = true,   selected = {},   style = null,   id: propsId = uuid(), }) => {   // ===============   // Internal state.   // ===============    const [id] = useState(propsId);   const [activeItems, setActiveItems] = useState(selected);    // =================   // Build class list.   // =================    const classList = cx({     accordion: true,     [String(className)]: className,   });    // ===============   // Build UI lists.   // ===============    const list = Array.isArray(children) ? children : [children];    const tabsAndPanelsList = getTabsAndPanelsList({     activeItems,     id,     isMulti,     list,     setActiveItems,   });    // ==========   // Expose UI.   // ==========    return (     <Render if={list[0]}>       <div         aria-multiselectable={isMulti}         className={classList}         id={id}         role="tablist"         style={style}       >         {tabsAndPanelsList}       </div>     </Render>   ); };  Accordion.propTypes = {   children: PropTypes.node,   className: PropTypes.string,   id: PropTypes.string,   isMulti: PropTypes.bool,   selected: PropTypes.object,   style: PropTypes.object, };  export default Accordion;

Function: handleClick

While most of our <Accordion> logic is similar to <Tabs>, it differs in how it stores the currently active tab.

Since <Tabs> are always mutually exclusive, we only really need a single numeric index. Easy peasy.

However, because an <Accordion> can have concurrently visible panels — or be used in a mutually exclusive manner — we need to represent that to useState in a way that could handle both.

If you were beginning to think…

“I would store that in an object.”

…then congrats. You are right!

This function does a quick check to see if isMulti has been set to true. If so, we use the spread syntax to apply the existing activeItems to our newState object. We then set the current index to its boolean opposite.

const handleClick = (event = {}) => {   const { key = '' } = event;    if (!disabled) {     // Early exit.     if (key && key.toLowerCase() !== 'enter') {       return;     }      // Keep active items?     const state = isMulti ? activeItems : null;      // Update active item.     const newState = {       ...state,       [index]: !activeItems[index],     };      // Set active item.     setActiveItems(newState);   } };

For reference, here is how our activeItems object looks if only the first accordion panel is active and a user clicks the second. Both indexes would be set to true. This allows for viewing two expanded role="tabpanel" simultaneously.

/*   Internal representation   of `activeItems` state. */  {   0: true,   1: true, } 

Whereas if we were not operating in isMulti mode — when the wrapper has aria-multiselectable="false" — then activeItems would only ever contain one key/value pair.

Because rather than spreading the current activeItems, we would be spreading null. That effectively wipes the slate clean, before recording the currently active tab.

/*   Internal representation   of `activeItems` state. */  {   1: true, } 

Conclusion

Still here? Awesome.

Hopefully you found this article informative, and maybe even learned a bit more about accessibility and JS(X) along the way. For review, let us look one more time at our flat HTML example and and the React usage of our <Tabs>component. Here is a comparison of the markup we would write in a vanilla JS approach, versus the JSX it takes to generate the same thing.

I am not saying that one is better than the other, but you can see how React makes it possible to distill things down into a mental model. Working directly in HTML, you always have to be aware of every tag.

HTML

<div class="tabs">   <ul class="tabs__list">     <li class="tabs__item">       Tab 1     </li>     <li class="tabs__item">       Tab 2     </li>   </ul>   <div class="tabs__panel">     <p>       Tab 1 content     </p>   </div>   <div class="tabs__panel">     <p>       Tab 2 content     </p>   </div> </div>

JSX

<Tabs>   <div label="Tab 1">     Tab 1 content   </div>   <div label="Tab 2">     Tab 2 content   </div> </Tabs>

↑ One of these probably looks preferrable, depending on your point of view.

Writing code closer to the metal means more direct control, but also more tedium. Using a framework like React means you get more functionality “for free,” but also it can be a black box.

That is, unless you understand the underlying nuances already. Then you can fluidly operate in either realm. Because you can see The Matrix for what it really is: Just JavaScript™. Not a bad place to be, no matter where you find yourself.

The post The Anatomy of a Tablist Component in Vanilla JavaScript Versus React appeared first on CSS-Tricks.

CSS-Tricks

, , , , , ,
[Top]

Considerations for Creating a Card Component

Here’s a Card component in React:

const Card = props => {   return(     <div className="card">       <h2>{props.title}</h2>       <p>{props.content}</p>     </div>   ) }

It might be pretty useful! If you end up using this thing hundreds of times, now you have the ability to refactor a little bit of HTML across your app very easily. You already have that power in CSS because of the class name there, but now you have HTML control too. Feel it.

But wait. Maybe this is limiting… an <h2>? What if that really should have been an <h4> in some usages? What’s the approach there? Maybe an API of sorts?

const Card = props => {   return(     <div className="card">       {props.type === "big" && <h2>{props.title}</h2>}       {props.type !== "big" && <h4>{props.title}</h4>}       <p>{props.content}</p>     </div>   ) }

Or maybe we force a level to be passed in?

const Card = props => {   const HeaderTag = `h$  {props.level}`;   return(     <div className="card">       <HeaderTag>{props.title}</HeaderTag>       <p>{props.content}</p>     </div>   ) }

Or maybe that header is its own component?

And a forced paragraph tag wrapper around that content? That’s a little limiting, isn’t it? Maybe that should be a <div> so that it could take arbitrary HTML inside it, like multiple paragraphs.

const Card = props => {   return(     <div className="card">       <WhateverHeader>{props.title}</WhateverHeader>       <div>{props.content}</div>     </div>   ) }

Actually, why even ask for content with props? It’s probably easier to deal with a child component, especially if what is coming over is HTML.

const Card = props => {   return(     <div className="card">       <WhateverHeader>{props.title}</WhateverHeader>       {children}     </div>   ) }

There are more assumptions we could challenge too. Like card only for a class name… shouldn’t that be more flexible?

const Card = props => {   const classes = `card $  {props.className}`;   return(     <div className={classes}>       <WhateverHeader>{props.title}</WhateverHeader>       {children}     </div>   ) }

I’m still forcing card there. We could drop that so that it isn’t assumed, or build another aspect of the Card API providing a way to opt-out of it.

Even the <div> wrapper is presumptuous. Perhaps that tag name could be passed in so that you could make it into a <section> or <article> or whatever you want.

Maybe it’s better to assume nothing actually, making our card like this:

const Card = () => {   return(     <>       {children}     </>   ) }

That way anything you want to change, you have the freedom to change. At least then it’s flexibility while being relaxed about it, rather than this kind of “flexibility”:

<Card   parentTag="article"   headerLevel="3"   headerTitle="My Card"   contentWrapper="div"   cardVariation="extra-large"   contentContent=""   this=""   little=""   piggy=""   went=""   to=""   market="" />

That kind of extreme-API-zying just happens sometimes when you’re grasping for control and flexibility at the same time.

A component model with no guidance can lead to over-componentization also, like perhaps:

const Card = props => {   return(     <CardWrapperTheme>       <CardWrapper>         <CardTitle />         <CardContent />         <CardFooter />       </CardWrapper>     </CardWrapperTheme>   ) }

There might be perfectly good reasons to do that, or it might be the result of componentizing because it’s “free” and just feels like that’s how things are done in an architecture that supports it.

There is a balance. If a component is too strict, it runs the risk of that people won’t use them because they don’t give them what they need. And if they’re too loose, people might not use them because they don’t provide any value, and, even if they did use them, they don’t offer any cohesiveness.

I don’t have any answers here, I just find it fascinating.

The post Considerations for Creating a Card Component appeared first on CSS-Tricks.

CSS-Tricks

, , ,
[Top]