Open-Source Pattern Library Tutorial: Part 5 — Card View

Marcello Paiva
cross.team
Published in
6 min readDec 20, 2019

In the fifth and final part of this pattern library tutorial, we’re going to build a card view for our homepage. Then we’ll create a React context that will control whether the list on the homepage is displaying the list view or the card view.

Create Card-View

We’re going to start by building the component for our card view. Go ahead and create a new file in the src/components/ directory called card-view.js. Then paste in the following code:

import React from 'react';
import {
makeStyles,
Card,
CardHeader,
CardContent,
CardMedia,
IconButton,
Typography,
Chip,
Divider
} from '@material-ui/core';

const useStyles = makeStyles(theme => ({
card: {
maxWidth: 345,
borderTop: 'solid 4px #ff8001',

},
header: {
backgroundColor: '#333333',
color: '#fff',
},
media: {
height: 0,
paddingTop: '56.25%', // 16:9
},
footer: {
margin: theme.spacing(1),
flexDirection: 'column'
}
}));

export default function CardView({ cardData }) {
const classes = useStyles();
const { title, tags, caption, category, subcategory, assets = [] } = cardData
let image
let imgCaption
if (assets !== null && assets.length > 0) {
[{ asset: { image, caption: imgCaption }}] = assets
}

return (
<Card className={classes.card}>
<CardHeader
className={classes.header}
title={title}
/>
<CardMedia
className={classes.media}
image={image}
title={imgCaption}
/>
<CardContent>
<Typography variant="body2" color="textSecondary" component="p">
{caption}
</Typography>

</CardContent>
<Divider />
<div className={classes.footer}>
{category} : {subcategory}
<div>
{tags.map((tag) => <IconButton aria-label={tag}>
<Chip label={tag} color="primary"/>
</IconButton>)}
</div>
</div>
</Card>
);
}

This simple component takes cardData as a prop and uses that to populate the material-UI components that it's made up of.

Create View Context

Now we need to create our view context. If you’re unfamiliar with React contexts and want to learn more, you can do so here. But in short, they allow us to access and control data between our components without having to pass it along as a prop.

In the src/ directory, create a new folder called context/, and within that create a new file called view.js. Paste the following code into src/context/view.js:

import React, { useState } from 'react'

export const ViewContext = React.createContext({
cardView: false,
setCardView: () => {}
});

const ViewContextProvider = ({children}) => {
const [cardView, setCardView] = useState(false)
return (<ViewContext.Provider value={{cardView, setCardView}}>
{children}
</ViewContext.Provider>

)
}
export default ViewContextProvider

This context provides two fields: cardView which is a boolean value representing whether or not the current view is the card view; and setCardView which is a function that will allow us to change our cardView value.

Modify wrap-with-context.js

Remember the wrap-with-context.js file we created in Part 1? Let's go back to that file now and wrap it in our new context's provider like so:

const React = require("react")
const ViewContextProvider = require('./src/context/view').default
const ThemeProvider = require('./src/components/theme-provider').default

exports.wrapRootElement = ({ element }) => {
return (
<ViewContextProvider >
<ThemeProvider>
{element}
</ThemeProvider>
</ViewContextProvider>
)
}

Now our entire application will be able to access our ViewContext.

Modify Header

Now we have to make some changes to our Header component. We’re going to add a couple buttons to the top that will allow us to switch between our list and card views. Paste the following code into src/components/header.js:

import { Link } from 'gatsby'
import PropTypes from 'prop-types'
import React, { useContext } from 'react'
import { AppBar, Toolbar, Typography, makeStyles } from '@material-ui/core'
import HomeIcon from '@material-ui/icons/Home';
import IconButton from '@material-ui/core/IconButton';
import { ViewContext } from '../context/view'
import { ViewList, ViewModule } from "@material-ui/icons";
import { globalHistory as history } from '@reach/router'

const useStyles = makeStyles(theme => ({
link: {
textDecoration: 'none',
color: 'inherit',
display: 'flex',
alignItems: 'center',
flexGrow: 1
},
header: {
backgroundColor: '#333333',
borderTop: 'solid 2px #ff8001',
position: 'fixed',
},
title: {
margin: theme.spacing(1),
color: '#fff',
},
linkContainer: {
width: '100%',
display: 'flex',
justifyContent: 'center',
},
toolbar: {
width: '80%'
}
}))

const Header = ({ siteTitle }) => {
const classes = useStyles()
const handleChange = (cardView) => () => {
setCardView(cardView);
};
const { cardView, setCardView } = useContext(ViewContext)
const { location: { pathname } } = history

const onHomePage = pathname === '/'
const ViewListIcon = (cardView) => !cardView ? <ViewList color='primary'/> : <ViewList style={{ color: 'white' }} />
const ViewModuleIcon = (cardView) => cardView ? <ViewModule color='primary'/> : <ViewModule style={{ color: 'white' }}/>
return (
<AppBar className={classes.header}>
<div className={classes.linkContainer}>
<Toolbar className={classes.toolbar}>
<Link to='/' className={classes.link}>
<HomeIcon className={classes.title} />
<Typography variant='h6' component='h1' className={classes.title}>{ siteTitle }</Typography>
</Link>
{ onHomePage
? (<><IconButton onClick={handleChange(false)} aria-label="List View">{ViewListIcon(cardView)}</IconButton>
<IconButton onClick={handleChange(true)} aria-label="Grid View">{ViewModuleIcon(cardView)}</IconButton></>)
: null}

</Toolbar>
</div>
</AppBar>
)
}

Header.propTypes = {
siteTitle: PropTypes.string,
}

Header.defaultProps = {
siteTitle: ``,
}

export default Header

With these changes, the component will use the ViewContext to determine which of the buttons should be active, and whatever button is active will be orange while the other white. It also takes care of setting the ViewContext on click with the handleChange() function.

Modify Search

Now last but not least, we need to incorporate our card view into our search component. To do that, let’s paste the following code into src/components/search.js:

import React, { useState, useContext } from 'react'
import Link from 'gatsby-link'
import {
makeStyles,
TextField,
List,
Divider,
Typography,
Chip,
Hidden,
} from '@material-ui/core'
import { Index } from 'elasticlunr'
import { ViewContext } from '../context/view'
import CardView from '../components/card-view'
import Grid from '@material-ui/core/Grid';

const useStyles = makeStyles(theme => ({
textField: {
width: '100%',
margin: theme.spacing(4),
backgroundColor: '#fff',
},
list: {
width: '100%',
backgroundColor: '#fff',
border: '1px solid rgba(0, 0, 0, 0.12)',
},
link: {
textDecoration: 'none',
color: 'inherit',
},
listItem: {
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
paddingLeft: '16px',
paddingRight: '16px',
width: '100%',
position: 'relative',
boxSizing: 'border-box',
textAlign: 'left',
alignItems: 'center',
paddingTop: '8px',
paddingBottom: '8px',
textDecoration: 'none'
},
titleLine: {
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
},
listTitle: {
marginRight: theme.spacing(2),
},
category: {
fontWeight: 'bold',
marginRight: theme.spacing(1),
},
rightMargin: {
marginRight: theme.spacing(1),
},
}))

const Search = ({ data, searchIndex }) => {
const [query, setQuery] = useState(``)
const [results, setResults] = useState([])
const { cardView } = useContext(ViewContext)
let index

const classes = useStyles()

let patterns = data || []

const listItems = items => items.map(({ title, id, tags, slug, caption, category, subcategory }, pIndex) => {
const chips = tags ? tags.map((tag) =>
<Chip label={tag} color="primary" className={classes.rightMargin} />
) : []
return (
<li key={id}>
<Link to={slug} className={classes.link}>
<div className={classes.listItem}>
<div>
<div className={classes.titleLine}>
<Typography className={classes.listTitle}>{title}</Typography>
<div>
<Typography variant='caption' className={classes.category}>{category}:</Typography>
<Typography variant='caption' >{subcategory}</Typography>
</div>
</div>
<Typography variant='caption'>{caption}</Typography>
</div>
<Hidden xsDown><div>{chips}</div></Hidden>
</div>
{pIndex < items.length - 1 && <Divider />}
</Link>
</li>
)})

const cards = items => items.map((cardData, pIndex) => {
return (
<Grid item key={cardData.id} xs={6}>
<Link to={cardData.slug} className={classes.link}>
<CardView cardData={cardData}/>
</Link>
</Grid>
)})

const getOrCreateIndex = () => {
return index ? index : Index.load(searchIndex)
}

const search = query => index.search(`${query}`, { expand: true })
const mapResults = results => results.map(({ ref }) => index.documentStore.getDoc(ref))
const handleSearch = evt => {
const query = evt.target.value
index = getOrCreateIndex()
setQuery(query)
const mappedResults = mapResults(search(query))
setResults(mappedResults)
}

return (
<>
<TextField
id="search"
className={classes.textField}
label='Search'
placeholder='Search'
variant="outlined"
value={query}
onChange={handleSearch}
/>
{!cardView
? (<List className={classes.list}>
{ query ? listItems(results) : listItems(patterns) }
</List>)
: (<Grid container justify="center" align='center' spacing={1}>
{ query ? cards(results) : cards(patterns) }
</Grid>)
}
</>
)
}

export default Search

The main change in this file is that now we’re importing the ViewContext and using that to determine whether the list view is rendered, or the card view which is generated by the new cards function.

Now push up the changes you just made to master and wait for Netlify to deploy your new build. Once that’s done go ahead and visit your site and you should see the new buttons in the header. And if you click the white button it will switch views.

If you are having issues or want to look at the source code for this part, you can checkout the master branch of the plib-tutorial repo:

https://github.com/MarcelloPaiva/plib-tutorial

--

--

Marcello Paiva
cross.team

I am a front-end web developer with a passion for accessibility!