This is the solution for Challenge #004 and it’s our first challenge using a JavaScript framework. We’re using React today.
Because of this, the base files are a little more complex, so today, I’ve given you a starter project. Before you do anything else, download the starter project here and move your terminal to the solution-004-base
directory inside there. Once you’ve done that, run npm install
.
Inside the src
directory, you can see we have an index.html
page, a css
directory which contains a components
directory, and a js
directory which also contains a components
directory. Because the majority of this solution is React-based, let’s start with that.
⚠️ Note: For the rest of this tutorial, we will be working inside the src
directory, so the rest of the paths below will presume you’re already in there.
JavaScriptpermalink
Inside the js
directory, create a file called App.js
with the following code:
- Code language
- js
import React from 'react'; import ProgressButton from './components/ProgressButton.js'; const App = () => ( <ProgressButton defaultText="Submit" errorText="Error" successText="Success" /> ); export default App;
What we’re doing here is creating a shell for our main.js
to pull in and render to the DOM. It might seem a little bit like an abstraction too far, but this is a personal preference of mine to keep the main React code grouped together.
Speaking of main.js
, let’s edit that file. Open it up and add the following:
- Code language
- js
import React from 'react'; import {render} from 'react-dom'; import App from './App.js'; render(<App />, document.querySelector('[data-app="main"]'));
Again, pretty straightforward. It’s pulling our app and using React DOM to insert our React app inside the [data-app="main"]
element which already exists in index.html
.
We’ve got our core JavaScript structure now, so let’s create some components. Before we get to the juicy stuff, let’s create a run-of-the-mill icon handler. Because we’ve got a couple of icons to work with, I’m going to create a state-driven component that builds an <svg>
on the fly. Inside js/components
, create a file called Icon.js
and put the following code in there:
- Code language
- js
import React from 'react'; import PropTypes from 'prop-types'; const paths = { success: 'M21.7237 3.42804L7.99978 18.4715L2.27582 12.1972C1.75449 11.6257 0.910494 11.6257 0.390497 12.1972C-0.129499 12.7686 -0.130832 13.6938 0.390497 14.2638L7.05712 21.5714C7.57845 22.1429 8.42244 22.1429 8.94244 21.5714L23.609 5.49464C24.1303 4.92318 24.1303 3.99804 23.609 3.42804C23.0877 2.85805 22.2437 2.85659 21.7237 3.42804V3.42804Z', error: 'M0.502696 2.92661L9.57609 12L0.502696 21.0734C-0.167565 21.7437 -0.167565 22.8288 0.502696 23.4973C1.17296 24.1659 2.25806 24.1676 2.92661 23.4973L12 14.4239L21.0734 23.4973C21.7437 24.1676 22.8288 24.1676 23.4973 23.4973C24.1659 22.827 24.1676 21.7419 23.4973 21.0734L14.4239 12L23.4973 2.92661C24.1676 2.25635 24.1676 1.17124 23.4973 0.502696C22.827 -0.165851 21.7419 -0.167565 21.0734 0.502696L12 9.57609L2.92661 0.502696C2.25635 -0.167565 1.17124 -0.167565 0.502696 0.502696C-0.165851 1.17296 -0.167565 2.25806 0.502696 2.92661Z', default: 'M7.92669 23.4979L18.2123 13.2123C18.8826 12.542 18.8826 11.4569 18.2123 10.7883L7.92669 0.50271C7.25641 -0.16757 6.17128 -0.16757 5.50271 0.50271C4.83414 1.17299 4.83243 2.25812 5.50271 2.92669L14.5763 12.0003L5.50271 21.074C4.83243 21.7442 4.83243 22.8294 5.50271 23.4979C6.17299 24.1665 7.25812 24.1682 7.92669 23.4979V23.4979Z' };
As with all React components, we import React, but in this one, we also import PropTypes which helps to make sure the props that we pass into the component are the correct data type.
After that, we have an object which defines the path for each icon variant. This is a handy trick because most of the <svg>
is identical each time, with only the d
attribute of the <path>
changing. Because of this, I can slice up the icon assets and make only the changing elements dynamic. Consider this Andy’s tip of the day!
Still inside Icon.js
, add the following code:
- Code language
- js
const Icon = ({pathKey = 'default'}) => { return ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="false" focusable="false" width="1em" height="1em" fill="currentColor" > <path d={paths[pathKey]}></path> </svg> ); }; Icon.propTypes = { pathKey: PropTypes.string }; export default Icon;
There’s a few bits to discuss here, so let’s break them down:
- We’re creating a functional component here instead of a class component. I prefer these as I think they’re a lot easier to read. I am also a fan of ES6 classes where appropriate, though (E.G. the system that checks you’re a paying member on this site is an ES6 class).
- We’re destructuring the props to grab the
pathKey
item. As you can see, we’re setting a default value ofdefault
in case nopathKey
prop is set. - As described earlier, we have our
<svg>
shell where the<path>
’sd
attribute is powered by the passed reference topaths
. - Lastly, we set our
PropTypes
as aString
and export ourIcon
function.
Your completed component should look like this:
- Code language
- js
import React from 'react'; import PropTypes from 'prop-types'; const paths = { success: 'M21.7237 3.42804L7.99978 18.4715L2.27582 12.1972C1.75449 11.6257 0.910494 11.6257 0.390497 12.1972C-0.129499 12.7686 -0.130832 13.6938 0.390497 14.2638L7.05712 21.5714C7.57845 22.1429 8.42244 22.1429 8.94244 21.5714L23.609 5.49464C24.1303 4.92318 24.1303 3.99804 23.609 3.42804C23.0877 2.85805 22.2437 2.85659 21.7237 3.42804V3.42804Z', error: 'M0.502696 2.92661L9.57609 12L0.502696 21.0734C-0.167565 21.7437 -0.167565 22.8288 0.502696 23.4973C1.17296 24.1659 2.25806 24.1676 2.92661 23.4973L12 14.4239L21.0734 23.4973C21.7437 24.1676 22.8288 24.1676 23.4973 23.4973C24.1659 22.827 24.1676 21.7419 23.4973 21.0734L14.4239 12L23.4973 2.92661C24.1676 2.25635 24.1676 1.17124 23.4973 0.502696C22.827 -0.165851 21.7419 -0.167565 21.0734 0.502696L12 9.57609L2.92661 0.502696C2.25635 -0.167565 1.17124 -0.167565 0.502696 0.502696C-0.165851 1.17296 -0.167565 2.25806 0.502696 2.92661Z', default: 'M7.92669 23.4979L18.2123 13.2123C18.8826 12.542 18.8826 11.4569 18.2123 10.7883L7.92669 0.50271C7.25641 -0.16757 6.17128 -0.16757 5.50271 0.50271C4.83414 1.17299 4.83243 2.25812 5.50271 2.92669L14.5763 12.0003L5.50271 21.074C4.83243 21.7442 4.83243 22.8294 5.50271 23.4979C6.17299 24.1665 7.25812 24.1682 7.92669 23.4979V23.4979Z' }; const Icon = ({pathKey = 'default'}) => { return ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="false" focusable="false" width="1em" height="1em" fill="currentColor" > <path d={paths[pathKey]}></path> </svg> ); }; Icon.propTypes = { pathKey: PropTypes.string }; export default Icon;
Now we’ve got that, let’s create the main component. Inside js/components
, create ProgressButton.js
. Inside that new file, add the following:
- Code language
- js
import React, {useEffect, useState} from 'react'; import PropTypes from 'prop-types'; import Icon from './Icon.js'; const ProgressButton = ({defaultText, errorText, successText}) => {}; ProgressButton.propTypes = { defaultText: PropTypes.defaultText, errorText: PropTypes.string, successText: PropTypes.string }; export default ProgressButton;
We’re following an almost identical pattern here, but this time we’re destructuring a bit more out of React, which will all become clear in a moment. We’re also importing our Icon
component. The rest is ground we’ve already covered, so let’s fill this shell component! Inside the ProgressButton
function, add the following code:
- Code language
- js
const [status, setStatus] = useState('waiting'); const [loaded, setLoaded] = useState(0); const setButtonState = state => { setStatus(state); setTimeout(() => setStatus('waiting'), 5000); }; const submit = () => { if (status === 'waiting') { setStatus('loading'); } };
Firstly, we’re using React Hooks a lot in this tutorial. React Hooks gives us this really handy setup where we can destructure a getter and setter for our component state by calling on useState
. We also pass the default state. I love this change in React because it makes the code so much more self-explanatory. You know straight away that when I use setStatus
, that we’re setting a value for the status
state item.
After that, we’ve got a couple of little functions:
setButtonState
is a helper that sets the button’s state to the passed value, but then resets it after a timeout for us.submit
is a click handler which sets thestatus
to loading only if thestatus
is currentlywaiting
, which is the default state. This prevents double submits.
Before the setButtonState
function, add the following code:
- Code language
- js
useEffect(() => { const availableStates = ['error', 'success']; switch (status) { case 'loading': if (loaded >= 100) { setButtonState( availableStates[Math.floor(Math.random() * availableStates.length)] ); setLoaded(0); return; } return setTimeout(() => setLoaded(15 + loaded), 500); } });
useEffect is another React Hook which provides a handy central place to handle lifecycle events. It fires (by default) when something changes in state, so we use this to “fake” a loading timer.
We set the loaded
state item, which in turns fires another useEffect
because the state has changed. Because we then increment that value in a setTimeout
, we get that staggered effect. When loaded
hits or exceeds 100
, we’re loaded, so we then pick a random item from the availableStates
array. Handy!
useEffect
is super confusing at first. Luckily, Amelia Wattenberger wrote this incredible article which explains how this and other hooks work.
Add this next block of code at the end of the ProgressButton
function (just after the submit
function):
- Code language
- js
return ( <div className="progress-button" data-state={status} style={`--loaded: ${loaded}%`}> <button type="button" className="progress-button__control" onClick={submit} disabled={status !== 'waiting'} > <span className="labelled-icon"> <span>{defaultText}</span> <Icon /> </span> </button> <div className="progress-button__alert" role="alert"> {status === 'error' || status === 'success' ? ( <div className={`progress-button__state bg-${status}`}> <div className="labelled-icon"> <span>{status === 'success' ? successText : errorText}</span> <Icon pathKey={status} /> </div> </div> ) : null} </div> <div aria-live="polite" role="status" className="visually-hidden"> {status === 'loading' ? <p>Loading. Please wait</p> : null} </div> </div> );
This is the return
of our function: some JSX. It’s our Progress Button HTML code—but because JSX is dynamic, we can author all of our state changes inline.
The first thing we do is set data-state={status}
and style={--loaded: ${loaded}%}
. These HTML attributes are used by the upcoming CSS to control various style changes. One is a custom property for the loading state and the other a simple representation of the components current state.
After this, we define our <button>
and attach the submit
function. We also disable the button on click to again, prevent double submissions.
Instead of changing the label/text of the <button>
element for the success and error states, we instead create an alert
element which will more effectively communicate the state changes to a screen reader. You can see how this is state controlled when the progress-button__alert
element only has content added if the status
is success
or error
. Inside that element we render an appropriate icon and render either successText
or errorText
.
Lastly, we add an aria-live="polite" role="status"
live region which communicates to a screenreader that the button is currently loading when our status
is loading
. This is because there’s a good chance they won’t be able to see the loading animation, so we need to let them know what’s happening. The polite
value for aria-live
means that any other more important announcements will happen first.
And that’s our main component done! If you run the project now (go out of src
and run npm start
) you will see the HTML and JS only version of our Progress Button!
💡 Because we use elements to render our state to screenreaders, we also get the added benefit of if there’s no CSS available, a user will be able to understand the state changes still.
CSSpermalink
Let’s make it look good now. Inside the css
directory, open up global.css
and add the following:
- Code language
- css
@import url('./reset.css'); /* Token utilities */ @import url('./tokens.css'); /* Main globals */ @import url('./main.css'); /* Components */ @import url('./components/labelled-icon.css'); @import url('./components/progress-button.css'); @import url('./components/visually-hidden.css');
Because we’re using a base project that I put together for React stuff like this, we get PostCSS for free, which let’s us import CSS files (as you can see, we’re already importing our usual reset_). We take advantage of that straight away and import tokens.css
and main.css
along with three components.
📝 I wrote about this base project, here. It’s an interesting look at the struggles of creating a React project that doesn’t have a huge footprint.
Inside the css
directory, create a file called main.css
and add the following to it:
- Code language
- css
:root { --color-primary: #21223e; --color-primary-light: #3c3f73; --color-success: #0c8d87; --color-error: #c01332; --color-light: #ffffff; } body { padding: 2rem; height: 100%; display: grid; place-items: center; font-family: 'Lato', sans-serif; }
Here are our :root
variables and global body
css which is just me applying our font and positioning everything in the center of the screen.
Now, in the same directory, create a file called tokens.css
and add the following:
- Code language
- css
/* State BG utils */ .bg-error { background: var(--color-error); } .bg-success { background: var(--color-success); }
Because our ProgressButton.js
file sets a bg-${status}
based on state, we create the CSS classes that set either a green or red background for us.
Now, create a file called visually-hidden.css
in css/components
and add the following to it:
- Code language
- css
.visually-hidden { border: 0; clip: rect(0 0 0 0); height: auto; margin: 0; overflow: hidden; padding: 0; position: absolute; width: 1px; white-space: nowrap; }
This does exactly what it says on the tin and visually hides text. We use this on our loading text element.
Next, create a file called labelled-icon.css
in css/components
and add the following:
- Code language
- css
.labelled-icon { display: flex; align-items: center; justify-content: center; position: relative; color: var(--color-light); padding: 0.9rem 2.5rem 1rem 2.5rem; font-weight: 900; letter-spacing: 0.02ch; text-transform: uppercase; line-height: 1; z-index: 1; } .labelled-icon > svg { margin-inline-start: 1rem; flex-shrink: 0; }
This is a little helper component that aligns a label up nicely with an icon element. It’s all straightforward apart from this cheeky little rule: margin-inline-start: 1rem;
. This adds margin to the inline-start
which because we read left-to-right in English: it’s on the left hand side. If the language was right-to-left, it would be the right hand side. You can read more on logical properties here.
Now for the main component. Create a file called progress-button.css
in css/components
and add the following:
- Code language
- css
.progress-button { display: inline-block; position: relative; border-radius: 0.5rem; overflow: hidden; font-size: 1.5rem; transition: all 200ms ease; box-shadow: none; }
If you remember rightly, the progress-button
element is actually a <div>
because it’s a container. This makes sure nothing overflows and sets radius
. This means everything inside it will be framed nicely.
Next, add the button styles:
- Code language
- css
.progress-button__control { display: inline-block; border: none; padding: 0; margin: 0; text-decoration: none; background: var(--color-primary); color: var(--color-light); font: inherit; cursor: pointer; -webkit-appearance: none; }
Here, we’re just removing some default button styles and setting the default dark state. Let’s now add our interactive states:
- Code language
- css
.progress-button[data-state='waiting']:hover, .progress-button[data-state='waiting']:focus-within { transform: translateY(-5px); box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.5); } .progress-button[data-state='waiting'] .progress-button__control:focus { outline: 1px solid currentColor; outline-offset: -0.5rem; }
We only want to show interactive styles when the element can be interacted with, so we use that handy data-state
attribute, which is updated with each state change, to make our CSS more specific.
Let’s now add our loader:
- Code language
- css
.progress-button__control::before { content: ''; display: block; position: absolute; top: 0; left: 0; height: 100%; width: var(--loaded, 0%); background: var(--color-primary-light); transition: width 200ms ease-in-out; }
We use a ::before
pseudo-element on the button itself and then use the state-controlled --loaded
custom property to control the width of it.
Finally, let’s wrap up this component with the alert element:
- Code language
- css
.progress-button__alert { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 2; } .progress-button__alert:empty { pointer-events: none; }
We use absolute positioning to fill the progress-button
container and then z-index
to guarantee that it sits above the button visually. This means that when we are in the success
or error
state, this alert will sit above and hide the disabled button. If the element is empty, we disable pointer events so clicks can get through to the button still.
That’s all the CSS. Your component should look like this:
- Code language
- css
.progress-button { display: inline-block; position: relative; border-radius: 0.5rem; overflow: hidden; font-size: 1.5rem; transition: all 200ms ease; box-shadow: none; } .progress-button__control { display: inline-block; border: none; padding: 0; margin: 0; text-decoration: none; background: var(--color-primary); color: var(--color-light); font: inherit; cursor: pointer; -webkit-appearance: none; } .progress-button[data-state='waiting']:hover, .progress-button[data-state='waiting']:focus-within { transform: translateY(-5px); box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.5); } .progress-button[data-state='waiting'] .progress-button__control:focus { outline: 1px solid currentColor; outline-offset: -0.5rem; } /* Loader */ .progress-button__control::before { content: ''; display: block; position: absolute; top: 0; left: 0; height: 100%; width: var(--loaded, 0%); background: var(--color-primary-light); transition: width 200ms ease-in-out; } /* Alert (status/error) state */ .progress-button__alert { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 2; } .progress-button__alert:empty { pointer-events: none; }
Progressive Enhancement and the Minimum Viable Experiencepermalink
This is totally dependent on JavaScript, which isn’t cool. We’ve got the ingredients to create a Minimum Viable Experience now. For me, the Minimum Viable Experience is a button that submits a form, without the fancy stuff, so let’s do that.
Open up index.html
and add the following inside <main data-app="main">
:
- Code language
- html
<form action="/" method="post"> <div class="progress-button"> <button type="button" class="progress-button__control"> <span class="labelled-icon"> <span>Submit</span> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="false" focusable="false" width="1em" height="1em" fill="currentColor"> <path d="M7.92669 23.4979L18.2123 13.2123C18.8826 12.542 18.8826 11.4569 18.2123 10.7883L7.92669 0.50271C7.25641 -0.16757 6.17128 -0.16757 5.50271 0.50271C4.83414 1.17299 4.83243 2.25812 5.50271 2.92669L14.5763 12.0003L5.50271 21.074C4.83243 21.7442 4.83243 22.8294 5.50271 23.4979C6.17299 24.1665 7.25812 24.1682 7.92669 23.4979V23.4979Z"></path> </svg> </span> </button> </div> </div>
Because React will empty the contents of this element, we can safely put a static version of our component, inside a form, knowing that it will be replaced. This means that when no JavaScript is available, a user can still submit the data.
And with that, we are done. It’s certainly been a more complex one this time, right?
Wrapping uppermalink
I really hope you’ve enjoyed this tutorial. You can grab a zip of the final code that I wrote and also see a live version too!
My favourite attempt for this challenge was by James Bateson. There’s some lovely transition work and the added context with the radio buttons is a nice touch.