UNPKG

@navinc/base-react-components

Version:
318 lines (286 loc) 8.63 kB
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