Build a Modern, Customized File Uploading User Interface in React with Plain CSS
jsmanifest
Posted on June 15, 2019
Find me on medium.
Building a user interface around a file input component is a very handy skill to learn as you can go from a 90s look to a more modern finish to compliment your web pages that depend on it — especially when we can’t style it like any normal HTML element. When users are using your technology, they're not just using it--they're most likely judging your app and its technology as well without you knowing it.
Here's the thing: We can easily show them a file input, let them select files from using the default html element and just have them submit files and call it a day. But what is happening in between? What do users like to see when something is happening? An interface that doesn't tell them anything, or an interface that tells them everything?
What if the user's internet disconnects? What if the server doesn't respond with anything? What if file 8 of 14 is big for them? What if the user was waiting for the upload process to finish for 10 minutes and wanted to see how far it has gotten from there? Or which files have already been uploaded?
In a previous tutorial (you can find it if you search my posts), I went over building the logic of the getting this api in place. The point of that post was to teach the logic. You can stop there and use it to build your own custom user interface around it. Or you can also build the logic part yourself and read this post for ideas on how to implement UX for any file uploading component. These posts were created for two separate reasons but are perfectly compatible. I'm just going to provide the logic in this post so we can focus on the user interface. The decision is yours :)
While I was coding the user interface, it was getting pretty long that I was contemplating if I should just bring the amount of components down and show a basic UX version. However, a lot of posts these days don't go too far in depth. So i'd like to take this opportunity to have fun and go in more depth to the implementations.
I was deciding whether to use my favorite CSS library styled-components to make this tutorial, however I ended up choosing not to because I wanted to show that a complex user interface can be built without any additional tools. The tools are just a convenience. You just need to learn a little bit of CSS, not the tools.
And last but not least, here is a preview of what we will be building in this post:
Without further ado, let's get started!
In this tutorial we are going to quickly generate a react project with create-react-app.
Go ahead and create a project using the command below. For this tutorial i’ll call our project upload-app.
npx create-react-app upload-app
Now go into the directory once it's done:
cd upload-app
I promised to just provide the logic of the file uploading implementation so we can immediately get started with building the user interface. So here is a custom hook we'll be using, called useApp.js
:
src/useApp.js
import { useCallback, useEffect, useReducer, useRef } from 'react'
// mock upload func
const api = {
uploadFile({ timeout = 550 }) {
return new Promise((resolve) => {
setTimeout(() => {
resolve()
}, timeout)
})
},
}
const logUploadedFile = (num, color = 'green') => {
const msg = `%cUploaded ${num} files.`
const style = `color:${color};font-weight:bold;`
console.log(msg, style)
}
// Constants
const LOADED = 'LOADED'
const INIT = 'INIT'
const PENDING = 'PENDING'
const FILES_UPLOADED = 'FILES_UPLOADED'
const UPLOAD_ERROR = 'UPLOAD_ERROR'
const initialState = {
files: [],
pending: [],
next: null,
uploading: false,
uploaded: {},
status: 'idle',
}
const reducer = (state, action) => {
switch (action.type) {
case 'load':
return { ...state, files: action.files, status: LOADED }
case 'submit':
return { ...state, uploading: true, pending: state.files, status: INIT }
case 'next':
return {
...state,
next: action.next,
status: PENDING,
}
case 'file-uploaded':
return {
...state,
next: null,
pending: action.pending,
uploaded: {
...state.uploaded,
[action.prev.id]: action.prev.file,
},
}
case 'files-uploaded':
return { ...state, uploading: false, status: FILES_UPLOADED }
case 'set-upload-error':
return { ...state, uploadError: action.error, status: UPLOAD_ERROR }
default:
return state
}
}
const useApp = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const onSubmit = useCallback(
(e) => {
e.preventDefault()
if (state.files.length) {
dispatch({ type: 'submit' })
} else {
window.alert("You don't have any files loaded.")
}
},
[state.files.length],
)
const onChange = (e) => {
if (e.target.files.length) {
const arrFiles = Array.from(e.target.files)
const files = arrFiles.map((file, index) => {
const src = window.URL.createObjectURL(file)
return { file, id: index, src }
})
dispatch({ type: 'load', files })
}
}
// Sets the next file when it detects that its ready to go
useEffect(() => {
if (state.pending.length && state.next == null) {
const next = state.pending[0]
dispatch({ type: 'next', next })
}
}, [state.next, state.pending])
const countRef = useRef(0)
// Processes the next pending thumbnail when ready
useEffect(() => {
if (state.pending.length && state.next) {
const { next } = state
api
.uploadFile(next)
.then(() => {
const prev = next
logUploadedFile(++countRef.current)
const pending = state.pending.slice(1)
dispatch({ type: 'file-uploaded', prev, pending })
})
.catch((error) => {
console.error(error)
dispatch({ type: 'set-upload-error', error })
})
}
}, [state])
// Ends the upload process
useEffect(() => {
if (!state.pending.length && state.uploading) {
dispatch({ type: 'files-uploaded' })
}
}, [state.pending.length, state.uploading])
return {
...state,
onSubmit,
onChange,
}
}
export default useApp
Explanation:
Here is a quick summary of what is going on there:
When users select files, the onChange handler gets invoked. The e argument contains the files we want, accessible by e.target.files. These will be the files that will be rendered one by one in the interface. However, this files object isn't an array--it's actually a FileList. This is a problem because we can't simply map over this or we'll be given an error. So we convert it to an array and attach it to state.files, allowing the UI to render them row by row in the UI. When the user submits the form, the onSubmit hander gets invoked. It dispatches an action which sends a signal to one or more useEffects that it's time to start. There are several useEffects and each of them are assigned different tasks and conditions. One is used for starting the flow, one is used for continuing the flow, and one is used to end the flow.
What we are going to do next is open up the App.js
file and replace the default code with:
src/App.js
import React from 'react'
import useApp from './useApp'
import './styles.css'
const Input = (props) => (
<input
type="file"
accept="image/*"
name="img-loader-input"
multiple
{...props}
/>
)
const App = ({ children }) => {
const {
files,
pending,
next,
uploading,
uploaded,
status,
onSubmit,
onChange,
} = useApp()
return (
<form className="form" onSubmit={onSubmit}>
<div>
<Input onChange={onChange} />
<button type="submit">Submit</button>
</div>
<div>
{files.map(({ file, src, id }, index) => (
<div key={`file-row${index}`}>
<img src={src} alt="" />
<div>{file.name}</div>
</div>
))}
</div>
</form>
)
}
export default App
And here is our starting CSS file:
src/styles.css
body {
padding: 12px;
background: #171c1f;
color: #fff;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 500;
}
button {
transition: all 0.2s ease-out;
margin: 4px;
cursor: pointer;
background: rgb(116, 20, 63);
border: 0;
color: #fff;
min-width: 90px;
padding: 8px 12px;
outline: none;
text-transform: uppercase;
letter-spacing: 1.3px;
font-size: 0.6rem;
border: 1px solid #fff;
}
button:hover {
background: none;
color: #fff;
}
button:active {
background: #fa3402;
}
If you run the app, it will look like this:
This is pretty basic. There's really no information to show about these images and UI looks like a page from the 90s.
When you click submit, you can see in the console messages that these are being processed one by one just to be sure:
But once it finishes you can continue the flow of the app with anything--like redirecting the user to a success page or showing them dog pictures in a modal.
The problem is that the user doesn't know what is going on. They could be waiting for 10 minutes and the page would still stay the same.
So we're going to change this up a bit to have them up to date with everything that is going on from the moment of instantiation to the end of the uploading process.
We'll go ahead and customize the file input so that it looks nicer. We want our users to think we are unique and the best. So we must go above and beyond :)
Currently, our file input looks like this:
Now since we don't want the user to hit their exit button and never come back, we have to design this further. There are several ways to customize a file input.
This file input component that we are going to make next won't actually be the real input element, but it will disguise itself as the input element by allowing the file browser to be opened when a user clicks on it.
Create a file called FileUploader.js
and place this code in it:
import React from 'react'
const FileUploader = ({ children, triggerInput, inputRef, onChange }) => {
let hiddenInputStyle = {}
// If user passes in children, display children and hide input.
if (children) {
hiddenInputStyle = {
position: 'absolute',
top: '-9999px',
}
}
return (
<div className="cursor-pointer" onClick={triggerInput}>
<input
style={hiddenInputStyle}
ref={inputRef}
type="file"
accept="image/*"
multiple
onChange={onChange}
/>
<div className="uploader">{children}</div>
</div>
)
}
export default FileUploader
The real file input is the child of the root div element here. The triggerInput will be a function that allows us to tap into the inputRef ref that is attached to the file input element. (We will look at this in the hook in a second).
Now if we render this component and pass in a children, the hiddenInputStyle will be applied to the real file input so that it will forcefully show our custom component instead to the UI. This is how we are overriding the default file input in the interface.
Inside our hook we defined the triggerInput handler inside:
src/useApp.js
const triggerInput = (e) => {
e.persist()
inputRef.current.click()
}
Returning it at the end so the caller can access it:
src/useApp.js
return {
...state,
onSubmit,
onChange,
triggerInput,
}
Great! Now we are going to make the component that will disguise itself as the real file input. It can be anything, but for the sake of this tutorial it will be a mini "screen" to the user--guiding them to upload their files and taking them to the next screen by using graphical and textual updates. Since we were rendering children in the render method of FileUploader, we can render this screen as a child of FileUploader. We want this whole screen to be able to open the file browser when we need it to.
This screen will display text with a background. I'm going to use an image as a background here by creating a folder called images
in the src
directory. I'll be placing images used throughout the tutorial here and import images from it.
Make another file called FileUploaderScreen.js
:
src/FileUploaderScreen.js
import React from 'react'
import idleSrc from './images/jade_input_bg.jpg'
const FileUploaderScreen = (props) => (
<div className="uploader-input">
<div
style={{ backgroundImage: `url("${idleSrc}")` }}
className="uploader-overlay"
/>
</div>
)
export default FileUploaderScreen
Here are the styles I used for the component:
.form {
max-width: 400px;
margin: auto;
}
.uploader {
display: flex;
justify-content: center;
flex-direction: column;
width: 100%;
box-sizing: border-box;
}
.uploader-input {
position: relative;
transition: all 3s ease-out;
box-sizing: border-box;
width: 100%;
height: 150px;
border: 1px solid rgb(194, 92, 67);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.uploader-input:hover {
filter: brightness(100%) contrast(90%);
border: 1px solid rgb(223, 80, 44);
}
.uploader-input:active {
filter: brightness(70%);
}
.uploader-input-content {
color: #fff;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.uploader-overlay {
transition: all 2s ease-out;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
background-size: cover;
}
.uploader-overlay:hover {
filter: brightness(75%);
}
.uploader-overlay:active {
filter: brightness(40%);
}
.cursor-pointer {
cursor: pointer;
}
Since we're allowed to pass in the imported image as a string to the backgroundImage style property, I used it as the value for the background image.
We mentioned that we want this screen to open up a file browser when clicked so we're going to have to render this inside the FileUploader.
Lets go ahead and put this FileUploader and FileUploaderScreen inside our App.js
file now:
src/App.js
import React from 'react'
import useApp from './useApp'
import FileUploader from './FileUploader'
import FileUploaderScreen from './FileUploaderScreen'
import './styles.css'
const App = ({ children }) => {
const inputRef = React.createRef()
const {
files,
pending,
next,
uploading,
uploaded,
status,
onSubmit,
onChange,
triggerInput,
} = useApp({ inputRef })
return (
<form className="form" onSubmit={onSubmit}>
<FileUploader
triggerInput={triggerInput}
inputRef={inputRef}
onChange={onChange}
>
<FileUploaderScreen />
</FileUploader>
<div>
{files.map(({ file, src, id }, index) => (
<div key={`file-row${index}`}>
<img src={src} alt="" />
<div>{file.name}</div>
</div>
))}
</div>
</form>
)
}
export default App
Now when you click the file upload screen, you should be able to select files:
Lets make the background image switch to a different one when the user selects files.
How do we do that?
This is where we have to use that status state property we defined in our custom hook earlier:
const initialState = {
files: [],
pending: [],
next: null,
uploading: false,
uploaded: {},
status: IDLE,
}
If you look back at our useEffects and reducer, we made the useEffects dispatch actions depending on what was happening:
src/useApp.js
const reducer = (state, action) => {
switch (action.type) {
case 'load':
return { ...state, files: action.files, status: LOADED }
case 'submit':
return { ...state, uploading: true, pending: state.files, status: INIT }
case 'next':
return {
...state,
next: action.next,
status: PENDING,
}
case 'file-uploaded':
return {
...state,
next: null,
pending: action.pending,
uploaded: {
...state.uploaded,
[action.prev.id]: action.prev.file,
},
}
case 'files-uploaded':
return { ...state, uploading: false, status: FILES_UPLOADED }
case 'set-upload-error':
return { ...state, uploadError: action.error, status: UPLOAD_ERROR }
default:
return state
}
}
src/useApp.js
// Sets the next file when it detects that its ready to go
useEffect(() => {
if (state.pending.length && state.next == null) {
const next = state.pending[0]
dispatch({ type: 'next', next })
}
}, [state.next, state.pending])
const countRef = useRef(0)
// Processes the next pending thumbnail when ready
useEffect(() => {
if (state.pending.length && state.next) {
const { next } = state
api
.uploadFile(next)
.then(() => {
const prev = next
logUploadedFile(++countRef.current)
const pending = state.pending.slice(1)
dispatch({ type: 'file-uploaded', prev, pending })
})
.catch((error) => {
console.error(error)
dispatch({ type: 'set-upload-error', error })
})
}
}, [state])
// Ends the upload process
useEffect(() => {
if (!state.pending.length && state.uploading) {
dispatch({ type: 'files-uploaded' })
}
}, [state.pending.length, state.uploading])
In addition if you look back at the onChange handler, you will see one of these action types being dispatched:
const onChange = (e) => {
if (e.target.files.length) {
const arrFiles = Array.from(e.target.files)
const files = arrFiles.map((file, index) => {
const src = window.URL.createObjectURL(file)
return { file, id: index, src }
})
dispatch({ type: 'load', files })
}
}
Since we know that dispatching 'load' will update state.status to 'LOADED' we can use that in our FileUploaderScreen to change images whenever state.status updates to 'LOADING'.
So what we'll do is use a switch case to assign the src to the backgroundImage style property depending on the value of state.status:
import React from 'react'
import idleSrc from './images/jade_input_bg.jpg'
const FileUploaderScreen = ({ status }) => {
let src
switch (status) {
case 'IDLE':
src = idleSrc
break
default:
src = idleSrc
break
}
return (
<div className="uploader-input">
<div
style={{ backgroundImage: `url("${src}")` }}
className="uploader-overlay"
/>
</div>
)
}
export default FileUploaderScreen
We might as well define some other images to use for other statuses as well:
import React from 'react'
import idleSrc from './images/jade_input_bg.jpg'
import pendingSrc from './images/art-arts-and-crafts-bright-1124884.jpg'
import uploadedSrc from './images/adventure-background-blur-891252.jpg'
import errorSrc from './images/121911.jpg'
const FileUploaderScreen = ({ status }) => {
let src
switch (status) {
case 'IDLE':
src = idleSrc
break
case 'LOADED':
case 'PENDING':
src = pendingSrc
break
case 'FILES_UPLOADED':
src = uploadedSrc
break
case 'UPLOAD_ERROR':
src = errorSrc
break
default:
src = idleSrc
break
}
return (
<div className="uploader-input">
<div
style={{ backgroundImage: `url("${src}")` }}
className="uploader-overlay"
/>
</div>
)
}
export default FileUploaderScreen
Every time the user does something, the image will be different. This is so that we don't bore the user so they're constantly occupied. Do whatever you want to make them stay on your website instead of bouncing away :). Just keep it rated G of course.
Anyways, If you try to select files right now the screen will not update. That is because we need to pass down the status prop to FileUploaderScreen:
src/App.js
<FileUploader
triggerInput={triggerInput}
inputRef={inputRef}
onChange={onChange}
>
<FileUploaderScreen status={status} />
</FileUploader>
I don't know about you but I really think those ugly, disproportionate thumbnails need to be tackled next. This isn't the 90s anymore, we have React!
So what we're going to do is we're going to scale them down to fit in file row components (list of rows). In each row, the thumbnail will have a width size of 50px and the height size of 50px. This will ensure that we have enough room on the right to display the file name and file sizes to the user in a clean and professional way.
Create a new file called FileRow.js
and add this in:
import React from 'react'
import Spinner from './Spinner'
const getReadableSizeFromBytes = (bytes) => {
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
let l = 0
let n = parseInt(bytes, 10) || 0
while (n >= 1024 && ++l) n /= 1024
// include a decimal point and a tenths-place digit if presenting
// less than ten of KB or greater units
return n.toFixed(n < 10 && l > 0 ? 1 : 0) + ' ' + units[l]
}
const Caption = ({ children, label, block, ...rest }) => (
<div
style={{ display: block ? 'block' : 'flex', alignItems: 'center' }}
{...rest}
>
<span style={{ color: '#fff' }}>{label}: </span>
<span style={{ color: '#2b8fba' }}>{children}</span>
</div>
)
const FileRow = ({ isUploaded, isUploading, file, src, id, index }) => (
<div
style={{
opacity: isUploaded ? 0.2 : 1,
}}
className="file-row"
>
{isUploading && (
<Spinner center xs>
Uploading...
</Spinner>
)}
<div className="file-row-thumbarea">
<img src={src} alt="" />
<Caption className="file-row-filename" label="File Name" block>
{file.name}
</Caption>
</div>
<div className="file-row-additional-info">
<Caption className="file-row-filesize" label="File Size">
{getReadableSizeFromBytes(file.size)}
</Caption>
</div>
</div>
)
const isEqual = (currProps, nextProps) => {
if (currProps.index !== nextProps.index) {
return false
}
if (currProps.isUploaded !== nextProps.isUploaded) {
return false
}
if (currProps.isUploading !== nextProps.isUploading) {
return false
}
return true
}
export default React.memo(FileRow, isEqual)
Styles I used:
.file-list {
font-size: 0.75rem;
}
.file-row {
position: relative;
transition: all 0.15s ease-in;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
max-height: 50px;
animation: fade 0.6s ease-in;
}
.file-row:hover {
opacity: 0.7 !important;
}
@keyframes fade {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.file-row-thumbarea {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
flex-grow: 1;
}
.file-row-thumbarea img {
transition: all 0.15s ease-out;
border: 1px solid rgb(170, 26, 110);
width: 50px;
height: 50px;
object-fit: cover;
}
.file-row-filename {
flex-grow: 1;
padding: 0 12px;
font-size: 0.7rem;
}
.file-row-additional-info {
opacity: 0.7;
}
.file-row-filesize {
font-style: italic;
font-size: 0.7rem;
padding: 3px 6px;
border-radius: 6px;
width: 90px;
text-align: center;
border: 1px solid rgb(112, 78, 58);
animation: border-glow 2s ease-in forwards;
}
@keyframes border-glow {
0% {
border: 1px solid rgb(94, 68, 54);
}
100% {
border: 1px solid rgb(255, 74, 2);
}
}
Here's what's happening:
- We defined a FileRow component that will receive the necessary props to render its children components. file, src, id, and index comes from the state.files array set by the onChange handler inside our
useApp
custom hook. - isUploading's purpose here is to render an "Uploading..." text and a loading spinner right on top of it when it is being uploaded somewhere.
- isUploaded's purpose is to shade out rows when their file object is inside state.uploaded--mapped by their id. (This was why we had *state.uploaded *if you were wondering)
- Since we don't want each row to render each time a state is updated, we had to wrap it with a React.memo to memoize the props so that they update only when index, isUploading or isUploaded changes. While these files are uploading, these props will never change unless something important happened, so it is safe to apply these conditions.
- getReadableSizeFromBytes was provided so that we render a human readable file size. Otherwise, users will be reading numbers like 83271328.
- Spinner is a loading spinner
For the purposes of this tutorial I used react-md-spinner. Also, I used the classnames package to combine/conditionally render class names for conditional styling for more ease of control.
Note: If you decide to follow through with react-md-spinner/classnames and get this error:
Cannot find module babel-preset-react-app/node_modules/@babel/runtime
You might need to install @babel/runtime
(Thanks Morris Warachi)
src/Spinner.js
import React from 'react'
import MDSpinner from 'react-md-spinner'
import cx from 'classnames'
const Spinner = ({
children,
containerProps,
spinnerProps,
xs,
sm,
center,
}) => (
<div
className={cx('spinner-container', {
'flex-center': !!center,
})}
{...containerProps}
>
<div>
<div>
<MDSpinner
size={xs ? 15 : sm ? 50 : 100}
borderSize={xs ? 1 : 2}
{...spinnerProps}
/>
</div>
<h4
className={cx('spinner', {
'spinner-xs': !!xs,
})}
>
{children}
</h4>
</div>
</div>
)
export default Spinner
Styles I used:
.spinner-container {
position: relative;
box-sizing: border-box;
padding: 15px;
text-align: center;
display: flex;
justify-content: center;
}
.spinner {
color: #fff;
margin-top: 18px;
}
.spinner-xs {
margin-top: 4px;
}
.flex-center {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
Now if you try to select files, the interface looks a lot smoother than before:
What we need to do next is to make the screen display textual updates so that users aren't confused about what is happening. Otherwise the file uploader screen is being useless because it's just rotating images right now.
The trick here is to use the very powerful state.status property like we did with the image rotations.
Knowing this, we can make it render custom components on each status update.
Go to the FileUploaderScreen.js
file and start by conditionally rendering the "init/idle" component:
import React from 'react'
import idleSrc from './images/jade_input_bg.jpg'
import pendingSrc from './images/art-arts-and-crafts-bright-1124884.jpg'
import uploadedSrc from './images/adventure-background-blur-891252.jpg'
import errorSrc from './images/121911.jpg'
const Init = () => (
<div style={{ textAlign: 'center' }}>
<h2>Upload Files</h2>
<small>Click here to select your files</small>
</div>
)
const FileUploaderScreen = ({ status }) => {
let src
switch (status) {
case 'IDLE':
src = idleSrc
break
case 'LOADED':
case 'PENDING':
src = pendingSrc
break
case 'FILES_UPLOADED':
src = uploadedSrc
break
case 'UPLOAD_ERROR':
src = errorSrc
break
default:
src = idleSrc
break
}
return (
<div className="uploader-input">
{status === 'IDLE' && <Init />}
<div
style={{ backgroundImage: `url("${src}")` }}
className="uploader-overlay"
/>
</div>
)
}
export default FileUploaderScreen
It seems like our image is a little bright right now. So we're going to define a couple of class styles to update brightnesses depending on which image is rendered:
.brightness100 {
filter: brightness(100%);
}
.brightness75 {
filter: brightness(75%);
}
.brightness50 {
filter: brightness(50%);
}
.opacity05 {
opacity: 0.25;
}
.grayscale {
filter: grayscale(100%) brightness(60%);
}
src/FileUploaderScreen.js
const FileUploaderScreen = ({ status }) => {
let src
switch (status) {
case 'IDLE':
src = idleSrc
break
case 'LOADED':
case 'PENDING':
src = pendingSrc
break
case 'FILES_UPLOADED':
src = uploadedSrc
break
case 'UPLOAD_ERROR':
src = errorSrc
break
default:
src = idleSrc
break
}
return (
<div className="uploader-input">
{status === 'IDLE' && <Init />}
<div
style={{ backgroundImage: `url("${src}")` }}
className={cx('uploader-overlay', {
brightness50: status === 'IDLE',
brightness100: status === 'LOADED',
opacity05: status === 'PENDING',
grayscale: status === 'FILES_UPLOADED',
})}
/>
</div>
)
}
It should be easier to see now:
Using the same concept as we did with the Init component earlier, we can implement the rest of the components the same way:
src/FileUploaderScreen.js
import React from 'react'
import cx from 'classnames'
import FileUploader from './FileUploader'
import fileUploadBg from './images/jade_input_bg.jpg'
import Spinner from './Spinner'
import artsCrafts from './images/art-arts-and-crafts-bright-1124884.jpg'
import adventureBeginsBg from './images/adventure-background-blur-891252.jpg'
import errorSrc from './images/121911.jpg'
const Init = () => (
<div style={{ textAlign: 'center' }}>
<h2>Upload Files</h2>
<small>Click here to select your files</small>
</div>
)
const Loaded = ({ total, getFileUploaderProps }) => (
<div className="loaded">
<h2>{total} files loaded</h2>
<div>What would you like to do?</div>
<div className="loaded-actions">
<FileUploader {...getFileUploaderProps()}>
<button type="button">Upload More</button>
</FileUploader>
<div>
<button type="submit">Submit</button>
</div>
</div>
</div>
)
const Pending = ({ files, pending }) => {
const total = files.length
const remaining = Math.abs(pending.length - total)
return (
<div className="pending">
<Spinner sm>
Uploading <span className="text-attention">{remaining}</span> of{' '}
<span className="text-attention">{total}</span> files
</Spinner>
</div>
)
}
const Success = () => (
<div className="success-container">
<div>
<h2>Congratulations!</h2>
<small>You uploaded your files. Get some rest.</small>
<br />
<small>Look for the arrow!</small>
</div>
</div>
)
const Error = ({ uploadError }) => (
<div>
<h2 style={{ color: 'red' }}>
An error occurred!
<br />
{uploadError && uploadError.message}
</h2>
</div>
)
const FileUploaderScreen = ({
status,
files,
pending,
uploadError,
triggerInput,
getFileUploaderProps,
}) => {
let src
switch (status) {
case 'IDLE':
src = fileUploadBg
break
case 'LOADED':
case 'PENDING':
src = artsCrafts
break
case 'FILES_UPLOADED':
src = adventureBeginsBg
break
case 'UPLOAD_ERROR':
src = errorSrc
break
default:
src = fileUploadBg
break
}
return (
<div className="uploader-input">
{status === 'IDLE' && <Init />}
{status === 'LOADED' && (
<Loaded
triggerInput={triggerInput}
getFileUploaderProps={getFileUploaderProps}
total={files.length}
/>
)}
{status === 'PENDING' && <Pending files={files} pending={pending} />}
{status === 'FILES_UPLOADED' && <Success />}
{status === 'UPLOAD_ERROR' && <Error uploadError={uploadError} />}
<div
style={{ backgroundImage: `url("${src}")` }}
className={cx('uploader-overlay', {
brightness50: status === 'IDLE',
brightness100: status === 'LOADED',
opacity05: status === 'PENDING',
grayscale: status === 'FILES_UPLOADED',
})}
/>
</div>
)
}
export default FileUploaderScreen
Here are all the styles used for them:
.loaded {
text-align: center;
}
.loaded h2 {
margin: 0;
}
.loaded-actions {
display: flex;
justify-content: center;
align-items: center;
}
.pending {
transition: all 1s ease-in;
}
.pending span.text-attention {
margin: auto 3px;
}
.success-container {
padding: 7px;
color: #fff;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.success-container h2 {
margin: 0;
}
The Loaded component is rendered when state.status's value is 'LOADED'. The odd thing here is that the "Upload More" button is being wrapped by the FileUploader that we created in the beginning. "What is that doing there?" you might ask.
After the file upload screen gets passed the initial step, we no longer want the entire component to trigger the file browser anymore. I'll go over this a little more very soon.
The Pending component is used to show that uploading is in process so that they know something is happening while they are waiting. This part is very important for our users!
The Success component is displayed immediately after the upload process is done.
And finally, the Error component is displayed when there was an error while uploading. This is to help the user understand what the current situation is without having them find out themselves.
The next thing we are going to do is update App.js
:
src/App.js
import React from 'react'
import useApp from './useApp'
import FileUploader from './FileUploader'
import FileUploaderScreen from './FileUploaderScreen'
import FileRow from './FileRow'
import './styles.css'
const App = ({ children }) => {
const inputRef = React.createRef()
const {
files,
pending,
next,
uploading,
uploaded,
status,
onSubmit,
onChange,
triggerInput,
getFileUploaderProps,
} = useApp({ inputRef })
const initialFileUploaderProps = getFileUploaderProps({
triggerInput: status === 'IDLE' ? triggerInput : undefined,
onChange: status === 'IDLE' ? onChange : undefined,
})
return (
<form className="form" onSubmit={onSubmit}>
<FileUploader {...initialFileUploaderProps}>
<FileUploaderScreen
triggerInput={triggerInput}
getFileUploaderProps={getFileUploaderProps}
files={files}
pending={pending}
status={status}
/>
</FileUploader>
<div className={files.length ? 'file-list' : ''}>
{files.map(({ id, ...rest }, index) => (
<FileRow
key={`thumb${index}`}
isUploaded={!!uploaded[id]}
isUploading={next && next.id === id}
id={id}
{...rest}
/>
))}
</div>
</form>
)
}
export default App
We added a new function getFileUploaderProps to our useApp hook:
const getFileUploaderProps = (opts) => ({
inputRef,
triggerInput,
onChange,
status: state.status,
...opts,
})
The reason why we extracted this part out to a separate function is because in the initial file uploader screen we applied the triggerInput and onChange handler directly on the root component in FileUploader. After the first screen changes, we don't want the whole file uploader screen component to trigger file browser anymore (since we did provided an Upload More button on the second screen).
That is why we just had this in the App component:
const initialFileUploaderProps = getFileUploaderProps({
triggerInput: status === 'IDLE' ? triggerInput : undefined,
onChange: status === 'IDLE' ? onChange : undefined,
})
And used it to spread its arguments to FileUploader:
<FileUploader {...initialFileUploaderProps}>
<FileUploaderScreen
triggerInput={triggerInput}
getFileUploaderProps={getFileUploaderProps}
files={files}
pending={pending}
status={status}
/>
</FileUploader>
Now, FileUploader will have all 4 arguments passed in like normal but will have undefined values from props.triggerInput and props.onChange for the rest of the screens. In react, onClick handlers won't fire when they are undefined. This disables the click handler so we can instead assign the Upload More button to be the new handler for selecting files.
Here's what the app looks like now:
So far so good. But it seems like the loading spinner in the file rows list are awkwardly pushing things to the side when their file is being uploaded.
Did you notice there was a flex-center property applied on the Spinner component?
const Spinner = ({
children,
containerProps,
spinnerProps,
xs,
sm,
center,
}) => (
<div
className={cx('spinner-container', {
'flex-center': !!center,
})}
{...containerProps}
>
<div>
<div>
<MDSpinner
size={xs ? 15 : sm ? 50 : 100}
borderSize={xs ? 1 : 2}
{...spinnerProps}
/>
</div>
<h4
className={cx('spinner', {
'spinner-xs': !!xs,
})}
>
{children}
</h4>
</div>
</div>
)
Yes, we're missing the css. So lets slap that right into the css file:
.flex-center {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
Conclusion
And that concludes the end of this tutorial! If you want to see the bonus part (the green arrow pointing down to the Next Page button, you can see the implementation in the source code at github here).
I apologize in advance for the rush towards the end of this tutorial. I wasn't sure if it was getting too long or getting too boring :) Let me know how this tutorial went for you!
Thank you for reading and look forward for more quality posts coming from me in the future!
Follow me on medium
Posted on June 15, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.