Open-Source Pattern Library Tutorial: Part 5 — Card View
- Part 1 — Setting Up Your Gatsby Application
- Part 2 — Deploying to Netlify & Netlify-CMS
- Part 3 — Building Pattern Pages
- Part 4 — Searchable List
- Part 5 — Card View
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: