@navinc/base-react-components
Version:
Nav's Pattern Library
318 lines (286 loc) • 8.63 kB
JavaScript
import React, { useEffect, useRef, useState, useLayoutEffect } from 'react'
import styled from 'styled-components'
import htmr from 'htmr'
import AuthorCard from './author-card.js'
import BlockQuote from './block-quote.js'
import Copy from './copy.js'
import Header from './header.js'
import Icon from './icon.js'
import Link from './link.js'
import PropTypes from 'prop-types'
const ArticleContent = styled.div`
li {
font-size: 16px;
}
p,
span,
ul,
ol {
padding-bottom: 16px;
}
ul,
ol {
padding-left: 40px;
}
ol {
list-style-type: decimal;
}
ul {
list-style-type: disc;
}
.wp-block-image,
.callout-img {
text-align: center;
img {
max-width: 100%;
}
}
.title {
font-weight: bold;
}
/*
Creates an alert box that appears like a yellow banner,
selecting only those that are not children of the same (in case of nested alerts)
*/
:not(.alert-box-warning) > .alert-box-warning {
background-color: ${({ theme }) => theme.flounderYellow100};
position: relative;
padding: 16px;
border-radius: 12px;
box-shadow: 0 10px 11px -8px rgba(0, 0, 0, 0.12);
p {
margin-left: 40px;
}
.icon-container {
position: relative;
height: 0;
/* Renders the alert icon, parsing out # into its code so the SVG code does not break */
::before {
position: absolute;
top: 0;
left: 0;
content: ${({ theme = { flounderYellow200: '' } }) =>
`url('data:image/svg+xml; utf8, <svg xmlns="http://www.w3.org/2000/svg" fill="${String(
theme.flounderYellow200
).replace(
'#',
'%23'
)}" viewBox="0 0 24 24" width="24" height="24"><path d="M12,8 C12.552,8 13,8.447 13,9 L13,13 C13,13.553 12.552,14 12,14 C11.448,14 11,13.553 11,13 L11,9 C11,8.447 11.448,8 12,8 Z M12,17 C11.4477153,17 11,16.5522847 11,16 C11,15.4477153 11.4477153,15 12,15 C12.5522847,15 13,15.4477153 13,16 C13,16.5522847 12.5522847,17 12,17 Z M12,22 C6.4771525,22 2,17.5228475 2,12 C2,6.4771525 6.4771525,2 12,2 C17.5228475,2 22,6.4771525 22,12 C22,17.5228475 17.5228475,22 12,22 Z M12,20 C16.418278,20 20,16.418278 20,12 C20,7.581722 16.418278,4 12,4 C7.581722,4 4,7.581722 4,12 C4,16.418278 7.581722,20 12,20 Z"></path></svg>')`};
}
}
::before {
content: '';
width: 100%;
height: 8px;
position: absolute;
left: 0;
top: 0;
background: ${({ theme }) => theme.flounderYellow200};
border-radius: 14px 14px 0 0;
}
}
/* Non-urgent callout cards in the middle of a paragraph */
.wp-block-nav-card,
.nav-blocks-card-2 {
padding: 16px;
border-radius: 4px;
box-shadow: 0 3px 23px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
}
/* Table */
table {
border-collapse: separate;
border-spacing: 0;
td,
th {
padding: 16px;
border-right: 1px solid ${({ theme }) => theme.scuttleGray300};
border-bottom: 1px solid ${({ theme }) => theme.scuttleGray300};
vertical-align: top;
}
tr {
td:first-child,
th:first-child {
border-left: 1px solid ${({ theme }) => theme.scuttleGray300};
}
}
tr:first-child {
td:first-child,
th:first-child {
border-top-left-radius: 20px;
}
td:last-child,
th:last-child {
border-top-right-radius: 20px;
}
td,
th {
border-top: 1px solid ${({ theme }) => theme.scuttleGray300};
}
}
tr:last-child {
td:first-child {
border-bottom-left-radius: 20px;
}
td:last-child {
border-bottom-right-radius: 20px;
}
}
tbody {
tr:nth-child(even) {
td,
th {
background-color: ${({ theme }) => theme.bubbleBlue100};
}
}
}
}
`
const HeaderContentWrapper = styled.div`
text-align: left;
display: grid;
grid-gap: 8px;
padding-bottom: 24px;
`
const HeaderWrapper = styled.div`
display: flex;
justify-content: space-between;
`
const SectionWrapper = styled.div`
display: flex;
flex-direction: column;
padding: 18px;
scroll-margin-top: 200px;
`
const IconWrapper = styled.div`
text-align: right;
padding-top: 16px;
`
const CloseButton = styled.div`
display: flex;
justify-content: flex-end;
padding-top: 16px;
& > ${Icon} {
fill: ${({ theme }) => theme.neutral400};
margin-left: 8px;
}
&:hover {
cursor: pointer;
}
`
const transform = {
b: (node, props, children) => (
<Copy bold {...props}>
{node?.children ?? children}
</Copy>
),
p: Copy,
span: Copy,
a: Link,
h2: (node, props, children) => (
<Header size="md" {...props}>
{node?.children ?? children}
</Header>
),
h3: (node, props, children) => (
<Header size="sm" {...props}>
{node?.children ?? children}
</Header>
),
h4: (node, props, children) => (
<Header size="xs" {...props}>
{node?.children ?? children}
</Header>
),
blockquote: (node, props, children) => <BlockQuote {...props}>{node?.children ?? children}</BlockQuote>,
}
export const transformHTML = (content = '') => {
content = content.replace(/\r\n\r\n/g, '<br /><br />')
return htmr(content, { transform })
}
export const Article = ({ author, dateGmt, content, modifiedGmt, excerpt, title, shouldStartOpen }) => {
const article = useRef(null)
const [isOpen, setIsOpen] = useState(shouldStartOpen)
const [articleHeight, setArticleHeight] = useState(0)
const [hasFirstLoadFinished, setHasFirstLoadFinished] = useState(false)
const hashId = title?.split(' ').join('-')
const hasBeenUpdated = !!modifiedGmt
const dateString = hasBeenUpdated
? `Updated ${new Date(modifiedGmt).toLocaleDateString()}`
: new Date(dateGmt).toLocaleDateString()
const { name: authorName, bio, image, jobTitle } = author
useEffect(() => {
if (shouldStartOpen && !isOpen) {
setIsOpen(true)
}
}, [shouldStartOpen, isOpen])
// Calculating the offsetTop of the element should refresh each time it is toggled open/shut
useEffect(() => {
if (article.current) {
setArticleHeight(article.current?.offsetTop ?? 0)
}
}, [article, isOpen])
/*
When the article is closed, we should scroll to its top,
and should only depend on when isOpen changes.
*/
useLayoutEffect(() => {
if (!isOpen && hasFirstLoadFinished && articleHeight > 0) {
window.scrollTo({ top: articleHeight })
}
}, [isOpen, hasFirstLoadFinished, articleHeight])
// Set when the article has loaded so we prevent automatic scroll to the top
useEffect(() => {
if (!hasFirstLoadFinished) {
setHasFirstLoadFinished(true)
}
}, [hasFirstLoadFinished])
return (
hasFirstLoadFinished && (
<SectionWrapper id={hashId} ref={article}>
<HeaderWrapper data-testid={`article:header:${title}`} onClick={() => setIsOpen((isOpen) => !isOpen)}>
<HeaderContentWrapper>
<Header size="lg">{title}</Header>
<Copy light size="sm">{`${dateString} | by ${authorName}`}</Copy>
{!isOpen && <ArticleContent>{transformHTML(excerpt)}</ArticleContent>}
</HeaderContentWrapper>
<IconWrapper>
<Icon name={`actions/carrot-${isOpen ? 'up' : 'down'}`} />
</IconWrapper>
</HeaderWrapper>
{isOpen && (
<>
<ArticleContent data-testid={`article:content:${title}`}>{transformHTML(content)}</ArticleContent>
<AuthorCard description={bio} img={image} imgAlt={authorName} name={authorName} title={jobTitle} />
<CloseButton
data-testid={`close-article:${title}`}
onClick={() => {
setIsOpen(false)
}}
>
<Copy light>Close</Copy>
<Icon name="actions/carrot-up" />
</CloseButton>
</>
)}
</SectionWrapper>
)
)
}
Article.propTypes = {
/** Should have name, bio, jobTitle and image properties */
author: PropTypes.object,
/** Date that the article was written */
dateGmt: PropTypes.string,
/** The whole article that is pulled from Wordpress (raw html) */
content: PropTypes.string,
/** Date for the last time the article was updated */
modifiedGmt: PropTypes.string,
/** Minified text for the article, in raw html */
excerpt: PropTypes.string,
/** Title for the article */
title: PropTypes.string,
/** Defining if the article should start open */
shouldStartOpen: PropTypes.bool,
}
export default Article