mirror of
https://github.com/brianlovin/security-checklist.git
synced 2026-03-11 08:55:31 +00:00
Merge pull request #130 from joachimesque/progress-indicator
[Design] Add progress indicator on header
This commit is contained in:
commit
11e84bfa90
7 changed files with 364 additions and 17 deletions
172
components/Header/Confetti.js
Normal file
172
components/Header/Confetti.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// @flow
|
||||
|
||||
// The code in this component was based on Yoav Kadosh’s React Confetti
|
||||
// on Codepen : // https://codepen.io/ykadosh/pen/aaoZRB
|
||||
// distributed under the following license:
|
||||
//
|
||||
// Copyright (c) 2019 by Yoav Kadosh (https://codepen.io/ykadosh/pen/aaoZRB)
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to
|
||||
// deal in the Software without restriction, including without limitation the
|
||||
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
// sell copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
// IN THE SOFTWARE.
|
||||
|
||||
import React, { useState, useRef, useEffect, Fragment } from 'react';
|
||||
import { ParticleZone } from './style';
|
||||
|
||||
type ConfettiProps = {
|
||||
fireConfetti: boolean,
|
||||
};
|
||||
type ParticlesProps = {
|
||||
count: number,
|
||||
};
|
||||
type Props = {};
|
||||
|
||||
|
||||
const COLORS = ['#2ecc71','#3498db','#e67e22','#e67e22','#e74c3c'];
|
||||
const TOP_OFFSET = typeof window != 'undefined' ? window.innerHeight : 0;
|
||||
const INNER_WIDTH = typeof window != 'undefined' ? window.innerWidth : 0;
|
||||
const LEFT_OFFSET = 250;
|
||||
|
||||
const generateWholeNumber = (min, max) => min + Math.floor(Math.random()*(max - min));
|
||||
|
||||
const generateRandomColor = () => COLORS[generateWholeNumber(0,COLORS.length)];
|
||||
|
||||
export function CircularParticle(props: Props) {
|
||||
const currentCircularConfetti = useRef(null);
|
||||
|
||||
const SIZE_RANGE = [5, 10];
|
||||
const ROTATION_RANGE = [0, 45];
|
||||
|
||||
const size = generateWholeNumber(...SIZE_RANGE);
|
||||
const style = {
|
||||
backgroundColor: generateRandomColor(),
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size,
|
||||
transform: `rotateZ(${generateWholeNumber(...ROTATION_RANGE)}deg)`,
|
||||
left: generateWholeNumber(0, INNER_WIDTH),
|
||||
top: generateWholeNumber(-TOP_OFFSET, 0)
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const {left} = style;
|
||||
setTimeout(() => {
|
||||
const node = currentCircularConfetti.current;
|
||||
if (node) {
|
||||
node.style.top = window.innerHeight + generateWholeNumber(0, window.innerHeight) + 'px';
|
||||
node.style.left = left + generateWholeNumber(-LEFT_OFFSET, LEFT_OFFSET) + 'px';
|
||||
}
|
||||
},0);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={currentCircularConfetti} style={style}/>
|
||||
);
|
||||
}
|
||||
|
||||
function SquiggleParticle(props: Props) {
|
||||
const currentSquiggleConfetti = useRef(null);
|
||||
const SIZE_RANGE = [15, 45];
|
||||
const ROTATION_RANGE = [-15, 15];
|
||||
|
||||
const size = generateWholeNumber(...SIZE_RANGE);
|
||||
const style = {
|
||||
fill: generateRandomColor(),
|
||||
width: size,
|
||||
height: size,
|
||||
transform: `rotateZ(${generateWholeNumber(...ROTATION_RANGE)}deg)`,
|
||||
left: generateWholeNumber(0, INNER_WIDTH),
|
||||
top: generateWholeNumber(-TOP_OFFSET, 0)
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const {left} = style;
|
||||
setTimeout(() => {
|
||||
const node = currentSquiggleConfetti.current;
|
||||
if (node){
|
||||
node.style.top = window.innerHeight + generateWholeNumber(0, window.innerHeight) + 'px';
|
||||
node.style.left = left + generateWholeNumber(-LEFT_OFFSET, LEFT_OFFSET) + 'px';
|
||||
node.style.transform = `rotateZ(${generateWholeNumber(...ROTATION_RANGE)}deg)`;
|
||||
}
|
||||
},0);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={currentSquiggleConfetti} style={style}>
|
||||
<svg
|
||||
width={style.width}
|
||||
height={style.height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512">
|
||||
<path fill={style.fill} d="M428.127,0l-12.716,10.062l12.718-10.06c8.785,11.101,19.716,24.917,19.716,51.051 s-10.932,39.951-19.716,51.053c-7.382,9.331-12.716,16.072-12.716,30.927c0,14.854,5.334,21.594,12.716,30.925 c8.784,11.101,19.716,24.917,19.716,51.05c0,26.135-10.931,39.949-19.715,51.051c-7.383,9.331-12.717,16.072-12.717,30.927 c0,14.855,5.332,21.593,12.711,30.919l-25.435,20.124c-8.781-11.097-19.708-24.909-19.708-51.042 c0-26.135,10.931-39.949,19.715-51.051c7.383-9.331,12.717-16.072,12.717-30.927c0-14.855-5.335-21.595-12.717-30.926 c-8.784-11.101-19.715-24.916-19.715-51.049s10.931-39.95,19.715-51.051c7.383-9.331,12.717-16.072,12.717-30.928 c0-14.855-5.335-21.596-12.718-30.927L428.127,0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Particles(props: ParticlesProps) {
|
||||
let {count: n} = props;
|
||||
const particles = [];
|
||||
const types = [SquiggleParticle, CircularParticle, CircularParticle];
|
||||
|
||||
while(n--) {
|
||||
const Particle = types[generateWholeNumber(0,3)];
|
||||
particles.push(
|
||||
<Particle key={n}/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ParticleZone>
|
||||
{particles}
|
||||
</ParticleZone>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function Confetti(props: ConfettiProps) {
|
||||
const [particles, setParticles] = useState([]);
|
||||
|
||||
var id = 1;
|
||||
|
||||
function clean(id) {
|
||||
setParticles(particles.filter(_id => _id !== id))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props.fireConfetti) {
|
||||
id = id;
|
||||
id++;
|
||||
|
||||
setParticles([...particles, id])
|
||||
|
||||
setTimeout(() => {
|
||||
clean(id);
|
||||
}, 5000);
|
||||
}
|
||||
},
|
||||
[props.fireConfetti]
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{props.fireConfetti && particles.map(id => (
|
||||
<Particles key={id} count={Math.floor(INNER_WIDTH / 20)}/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,27 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Container, ButtonRowContainer, Label, LogoLink } from './style';
|
||||
import {
|
||||
Container,
|
||||
ButtonRowContainer,
|
||||
Label,
|
||||
LogoLink,
|
||||
Progression,
|
||||
ProgressBar,
|
||||
ProgressLabel } from './style';
|
||||
import { PrimaryButton, GhostButton } from '../Button';
|
||||
import Logo from './Logo';
|
||||
import Confetti from './Confetti';
|
||||
|
||||
type Props = {
|
||||
showHeaderShadow: boolean,
|
||||
displayProgress: boolean,
|
||||
totalItemsCount: number,
|
||||
currentCount: number,
|
||||
};
|
||||
|
||||
export default function Header(props: Props) {
|
||||
const { showHeaderShadow } = props;
|
||||
const { showHeaderShadow, totalItemsCount, currentCount, displayProgress } = props;
|
||||
|
||||
return (
|
||||
<Container showHeaderShadow={showHeaderShadow} data-cy="header">
|
||||
|
|
@ -39,6 +50,28 @@ export default function Header(props: Props) {
|
|||
Contribute
|
||||
</PrimaryButton>
|
||||
</ButtonRowContainer>
|
||||
|
||||
{ displayProgress && (
|
||||
<Progression
|
||||
id="progress"
|
||||
aria-label={`${currentCount} of ${totalItemsCount} completed`}
|
||||
tabIndex="0"
|
||||
>
|
||||
<ProgressBar
|
||||
id="progress_bar"
|
||||
aria-describedby="progress_tooltip"
|
||||
disabled={currentCount > 0 ? false : true}
|
||||
/>
|
||||
<ProgressLabel
|
||||
id="progress_tooltip"
|
||||
role="tooltip"
|
||||
>
|
||||
{ currentCount === totalItemsCount
|
||||
? `🎉 Checklist complete! 🎉`
|
||||
: `${currentCount} of ${totalItemsCount} completed`}
|
||||
</ProgressLabel>
|
||||
<Confetti fireConfetti={currentCount === totalItemsCount} />
|
||||
</Progression>)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,24 @@
|
|||
import styled from 'styled-components';
|
||||
import { theme } from '../theme';
|
||||
import { hexa } from '../globals';
|
||||
import { Shadows } from '../globals';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas: 'logo actions';
|
||||
padding: 16px;
|
||||
grid-template-areas: "logo actions";
|
||||
padding: 16px 16px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: ${props =>
|
||||
props.showHeaderShadow ? props.theme.bg.default : props.theme.bg.wash};
|
||||
background: ${theme.bg.default};
|
||||
z-index: 3;
|
||||
box-shadow: ${props =>
|
||||
props.showHeaderShadow ? '0 4px 8px rgba(0,0,0,0.04)' : 'none'};
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
@media (max-width: 968px) {
|
||||
padding: 8px 16px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas: 'logo actions';
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -33,6 +30,88 @@ export const Logo = styled.h1`
|
|||
color: ${theme.text.default};
|
||||
`;
|
||||
|
||||
export const Progression = styled.div`
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
bottom: -16px;
|
||||
display: block;
|
||||
background: transparent;
|
||||
|
||||
&:active, &:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ProgressBar = styled.div`
|
||||
display: block;
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
margin: 16px 0 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-image: linear-gradient(to left, #a913de, #6ac9ff);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
|
||||
z-index: 5;
|
||||
|
||||
${Progression}:focus &,
|
||||
${Progression}:active & {
|
||||
box-shadow: 0 0 0 1px ${theme.bg.default},
|
||||
0 0 0 3px ${props => hexa(props.theme.brand.default, 0.25)};
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
background: ${theme.border.default};
|
||||
max-width: var(--progress);
|
||||
transition: max-width ${theme.animations.default}, background ${theme.animations.default};
|
||||
}
|
||||
&[disabled]::after {
|
||||
background: ${theme.bg.default};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ProgressLabel = styled.p`
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
bottom: -25%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: ${theme.bg.default};
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
transition: all ${theme.animations.default};
|
||||
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
${Shadows.default};
|
||||
|
||||
${Progression}:hover ${ProgressBar}:not([disabled]) + &,
|
||||
${Progression}:focus ${ProgressBar}:not([disabled]) + &,
|
||||
${Progression}:active ${ProgressBar}:not([disabled]) + &
|
||||
{
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
bottom: -100%;
|
||||
}
|
||||
|
||||
@media (max-width: 968px) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
`
|
||||
|
||||
export const ButtonRowContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
|
@ -49,6 +128,7 @@ export const LogoLink = styled.a`
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
|
|
@ -65,3 +145,17 @@ export const Label = styled.h1`
|
|||
left: -9999px;
|
||||
visibility: none;
|
||||
`;
|
||||
|
||||
export const ParticleZone = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
& > div, & > svg {
|
||||
position: absolute;
|
||||
transition: all 5s ease-out;
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -17,18 +17,25 @@ import {
|
|||
ScrollToTop,
|
||||
} from './style';
|
||||
import * as gtag from '../../lib/gtag';
|
||||
import { getLocalStorageLength } from '../../lib/localStorage';
|
||||
import data from '../../config/data';
|
||||
|
||||
export { SectionHeading, Heading, Subheading };
|
||||
|
||||
type Props = {
|
||||
children: Node,
|
||||
displayProgress: boolean,
|
||||
};
|
||||
|
||||
const totalItemsCount = Object.keys(data).length;
|
||||
|
||||
export default function Page(props: Props) {
|
||||
const { children } = props;
|
||||
const { children, displayProgress } = props;
|
||||
const [lastTrackedPageview, setLastTrackedPageview] = useState(null);
|
||||
const [showHeaderShadow, setHeaderShadow] = useState(false);
|
||||
const [scrollToTopVisible, setScrollToTopVisible] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentCount, setCurrentCount] = useState(0);
|
||||
|
||||
function handleScroll() {
|
||||
const headerShadowState = window && window.pageYOffset > 0;
|
||||
|
|
@ -39,6 +46,13 @@ export default function Page(props: Props) {
|
|||
|
||||
const throttledScroll = throttle(300, handleScroll);
|
||||
|
||||
function updateProgress() {
|
||||
const checkedItemsCount = getLocalStorageLength();
|
||||
const progressPercentage = checkedItemsCount * 100 / totalItemsCount;
|
||||
setProgress(progressPercentage);
|
||||
setCurrentCount(checkedItemsCount);
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (window) {
|
||||
window.scrollTo(0, 0);
|
||||
|
|
@ -46,6 +60,8 @@ export default function Page(props: Props) {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateProgress();
|
||||
|
||||
if (window) {
|
||||
window.addEventListener('scroll', throttledScroll);
|
||||
}
|
||||
|
|
@ -56,7 +72,18 @@ export default function Page(props: Props) {
|
|||
setLastTrackedPageview(null);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [progress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window && displayProgress) {
|
||||
window.addEventListener('storage:updated', updateProgress );
|
||||
}
|
||||
return () => {
|
||||
if (window && displayProgress) {
|
||||
window.removeEventListener('storage:updated', updateProgress );
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
|
|
@ -71,7 +98,18 @@ export default function Page(props: Props) {
|
|||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Container>
|
||||
<Header showHeaderShadow={showHeaderShadow} />
|
||||
<style>{`
|
||||
:root {
|
||||
--progress: ${progress ? 100 - progress : 100}%;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<Header
|
||||
showHeaderShadow={showHeaderShadow}
|
||||
displayProgress={displayProgress}
|
||||
totalItemsCount={totalItemsCount}
|
||||
currentCount={currentCount}
|
||||
/>
|
||||
<InnerContainer>{children}</InnerContainer>
|
||||
<Footer />
|
||||
<ScrollToTop
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ export const storeItem = (key, value) => {
|
|||
if (!localStorage) return;
|
||||
|
||||
try {
|
||||
return localStorage.setItem(key, JSON.stringify(value));
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
window.dispatchEvent(new Event('storage:updated'));
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
|
|
@ -18,6 +19,15 @@ export const removeItemFromStorage = key => {
|
|||
if (!localStorage) return;
|
||||
|
||||
try {
|
||||
return localStorage.removeItem(key);
|
||||
localStorage.removeItem(key);
|
||||
window.dispatchEvent(new Event('storage:updated'));
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
export const getLocalStorageLength = () => {
|
||||
if (!localStorage) return;
|
||||
|
||||
try {
|
||||
return localStorage.length;
|
||||
} catch (err) {}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import Page, { SectionHeading, Heading, Subheading } from '../components/Page';
|
|||
|
||||
export default function About() {
|
||||
return (
|
||||
<Page showEmailCapture={false}>
|
||||
<Page showEmailCapture={false} displayProgress={false}>
|
||||
<NextSeo
|
||||
config={{
|
||||
title: 'Security Checklist · About',
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ class Index extends React.Component<{}> {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<Page>
|
||||
<Page displayProgress={true}>
|
||||
<SectionHeading>
|
||||
<Heading>Be safe on the internet.</Heading>
|
||||
<Subheading>
|
||||
|
|
|
|||
Loading…
Reference in a new issue