Skip to content

Commit cb6e16d

Browse files
authored
feat(start): timeline component, scroll animations (#307)
* feat(timeline): basic styling * refactor(Timeline): extract styled components into own module * refactor(timeline): move timeline inside MainContainer * feat(timeline): onClick handlers, active styles * feat(start): animate between pages * fix(start): current page animation * fix(timeline): key * refactor(timeline): use name * refactor(timeline): use function component * feat(timeline): circle color transition * feat(timeline): active button style * refactor(timeline): use implicit return in arrow function * feat(start): view aria-hidden set non-focused views to hidden for accessibility * feat(timeline): focus styles, keyboard navigation * fix(timeline): off by one errors 🤡 * feat(start): aria attributes accessibility :D * feat(timeline): mobile responsiveness * refactor: no vw, remove padding on small screens
1 parent c8baab3 commit cb6e16d

File tree

8 files changed

+324
-116
lines changed

8 files changed

+324
-116
lines changed

next/package-lock.json

Lines changed: 22 additions & 50 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

next/src/components/start/Timeline.tsx

Lines changed: 0 additions & 43 deletions
This file was deleted.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import styled from "styled-components";
2+
import { device } from "../../../styles/device";
3+
4+
const lightPurple = "hsl(248, 50%, 81%)";
5+
const darkPurple = "hsl(248, 87%, 73%)";
6+
7+
type ButtonProps = {
8+
filled: boolean;
9+
active: boolean;
10+
};
11+
12+
export const Buttons = styled.div`
13+
position: absolute;
14+
left: 0;
15+
right: 0;
16+
display: flex;
17+
justify-content: space-between;
18+
outline: none;
19+
`;
20+
21+
export const Button = styled.button<ButtonProps>`
22+
background: ${({ filled }) => (filled ? darkPurple : lightPurple)};
23+
border: none;
24+
color: white;
25+
font-weight: bold;
26+
padding: 4px 8px;
27+
border-radius: 4px;
28+
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
29+
cursor: pointer;
30+
transition: background-color 0.3s, box-shadow 0.3s;
31+
32+
display: none;
33+
34+
@media ${device.tablet} {
35+
display: inline-block;
36+
}
37+
38+
:hover {
39+
background: ${({ filled }) => (filled ? "hsl(248, 87%, 65%)" : "#a09fe3")};
40+
}
41+
42+
${({ active }) =>
43+
active &&
44+
`
45+
${Buttons}:focus-visible && {
46+
background: hsl(248, 87%, 65%);
47+
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1),
48+
0 0 0 3px hsla(248, 87%, 65%, 0.4);
49+
outline: none;
50+
}
51+
`}
52+
`;
53+
54+
export const Wrapper = styled.div`
55+
position: relative;
56+
display: flex;
57+
align-items: center;
58+
`;
59+
60+
type CircleProps = {
61+
filled: boolean;
62+
};
63+
64+
export const Circle = styled.button<CircleProps>`
65+
border-radius: 100%;
66+
border: 5px solid ${({ filled }) => (filled ? darkPurple : lightPurple)};
67+
width: 25px;
68+
height: 25px;
69+
background-color: ${({ filled }) => (filled ? lightPurple : "#fcf7de")};
70+
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
71+
cursor: pointer;
72+
transition: background-color 0.2s;
73+
`;
74+
75+
export const Line = styled.div`
76+
flex: 1;
77+
height: 6px;
78+
background-image: linear-gradient(to right, #c0cff8, #b1a6ff);
79+
margin: 0 12.5px;
80+
`;
81+
82+
export const ProgressLineWrapper = styled.div`
83+
position: absolute;
84+
left: 12.5px;
85+
right: 12.5px;
86+
height: 6px;
87+
`;
88+
89+
type ProgressLineProps = {
90+
focusedView: number;
91+
numViews: number;
92+
};
93+
94+
export const ProgressLine = styled.div<ProgressLineProps>`
95+
height: 100%;
96+
background-color: ${darkPurple};
97+
width: ${({ focusedView, numViews }) => (focusedView / (numViews - 1)) * 100}%;
98+
transition: width 0.5s;
99+
100+
@media ${device.tablet} {
101+
width: ${({ focusedView, numViews }) => ((1 + 2 * focusedView) / (numViews * 2 - 1)) * 100}%;
102+
}
103+
`;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Fragment, KeyboardEvent, useRef } from "react";
2+
import {
3+
Button,
4+
Buttons,
5+
Circle,
6+
Line,
7+
ProgressLine,
8+
ProgressLineWrapper,
9+
Wrapper,
10+
} from "./Timeline-styled";
11+
12+
type Props = {
13+
focusedView: number;
14+
setFocusedView: (_focusedView: number) => void;
15+
viewNames: string[];
16+
};
17+
18+
const Timeline = ({ focusedView, setFocusedView, viewNames }: Props) => {
19+
const keyHandler = (e: KeyboardEvent) => {
20+
switch (e.key) {
21+
case "ArrowLeft":
22+
setFocusedView(focusedView - 1);
23+
break;
24+
case "ArrowRight":
25+
setFocusedView(focusedView + 1);
26+
break;
27+
}
28+
};
29+
30+
return (
31+
<Wrapper>
32+
<ProgressLineWrapper>
33+
<ProgressLine focusedView={focusedView} numViews={viewNames.length} />
34+
</ProgressLineWrapper>
35+
<Buttons
36+
onKeyDown={keyHandler}
37+
tabIndex={1}
38+
role="tablist"
39+
aria-label="Getting Started Steps"
40+
>
41+
{viewNames.map((name, idx) => (
42+
<Fragment key={idx}>
43+
<Circle filled={idx <= focusedView} onClick={() => setFocusedView(idx)} tabIndex={-1} />
44+
<Button
45+
id={`step${idx}-tab`}
46+
role="tab"
47+
aria-selected={idx === focusedView}
48+
aria-controls={`step${idx}-panel`}
49+
filled={idx <= focusedView}
50+
active={idx === focusedView}
51+
onClick={() => setFocusedView(idx)}
52+
tabIndex={-1}
53+
>
54+
{name}
55+
</Button>
56+
</Fragment>
57+
))}
58+
</Buttons>
59+
<Line />
60+
</Wrapper>
61+
);
62+
};
63+
64+
export default Timeline;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { PropsWithChildren, useEffect, useRef } from "react";
2+
import styled from "styled-components";
3+
import { device } from "../../../styles/device";
4+
import { ViewProps } from "./types";
5+
6+
const Wrapper = styled.div`
7+
flex: 1;
8+
flex-direction: column;
9+
display: flex;
10+
align-items: center;
11+
justify-content: center;
12+
border-radius: 4px;
13+
border: 1px solid transparent;
14+
transition: border-color 0.2s, box-shadow 0.2s;
15+
`;
16+
17+
const StyledView = styled.article`
18+
display: flex;
19+
flex-basis: 100%;
20+
flex-shrink: 0;
21+
transition: transform 1.25s;
22+
outline: none;
23+
24+
@media ${device.laptop} {
25+
padding: 2rem;
26+
}
27+
28+
:focus-visible ${Wrapper} {
29+
border-color: hsl(248, 50%, 81%);
30+
box-shadow: 0 0 0 3px hsla(248, 50%, 81%, 0.4);
31+
}
32+
`;
33+
34+
const View = ({ idx, focusedView, children }: PropsWithChildren<ViewProps>) => {
35+
const previousFocusedView = useRef(focusedView);
36+
const firstUpdate = useRef(true);
37+
const viewRef = useRef<HTMLDivElement | null>(null);
38+
39+
useEffect(() => {
40+
if (firstUpdate.current) {
41+
firstUpdate.current = false;
42+
return;
43+
}
44+
45+
if (previousFocusedView.current === idx) {
46+
viewRef.current?.animate(
47+
{ transform: "translateY(2rem) scale(0.9)" },
48+
{ duration: 1250, easing: "ease" },
49+
);
50+
} else {
51+
viewRef.current?.animate(
52+
[{ transform: "translateY(2rem) scale(0.9)" }, { transform: "none" }],
53+
{
54+
duration: 1250,
55+
easing: "ease",
56+
},
57+
);
58+
}
59+
60+
previousFocusedView.current = focusedView;
61+
}, [focusedView, idx]);
62+
63+
return (
64+
<StyledView
65+
id={`step${idx}-panel`}
66+
aria-hidden={idx !== focusedView}
67+
aria-labelledby={`step${idx}-tab`}
68+
style={{ transform: `translateX(${-100 * focusedView}%)` }}
69+
tabIndex={idx === focusedView ? 0 : -1}
70+
>
71+
<Wrapper ref={viewRef}>
72+
{children}
73+
{/* Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
74+
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
75+
laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
76+
voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
77+
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. */}
78+
</Wrapper>
79+
</StyledView>
80+
);
81+
};
82+
83+
export default View;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type ViewProps = {
2+
idx: number;
3+
focusedView: number;
4+
};

next/src/hooks/TimelineScroll.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,29 @@ import { MutableRefObject, useCallback, useRef, useState } from "react";
33
const useTimelineScroll = (
44
views: number,
55
throttle: number,
6-
predicate?: () => boolean
6+
predicate?: () => boolean,
77
): [
8-
MutableRefObject<boolean>,
9-
(_direction: number) => void,
10-
number,
11-
(_focusedView: number) => void
12-
] => {
8+
MutableRefObject<boolean>,
9+
(_direction: number) => void,
10+
number,
11+
(_focusedView: number) => void,
12+
] => {
1313
const [focusedView, _setFocusedView] = useState(0);
1414
const scrolling = useRef(false);
1515

1616
const setFocusedView = useCallback(
1717
(focusedView: number) => {
18+
if (focusedView < 0 || focusedView >= views) {
19+
return;
20+
}
21+
1822
_setFocusedView(focusedView);
1923
scrolling.current = true;
2024
setTimeout(() => {
2125
scrolling.current = false;
2226
}, throttle);
2327
},
24-
[throttle]
28+
[throttle, views],
2529
);
2630

2731
const handleScroll = useCallback(
@@ -30,16 +34,16 @@ const useTimelineScroll = (
3034
return;
3135
}
3236

33-
if (direction < 0 && focusedView > 0) {
37+
if (direction < 0) {
3438
setFocusedView(focusedView - 1);
35-
} else if (direction > 0 && focusedView < views - 1) {
39+
} else if (direction > 0) {
3640
setFocusedView(focusedView + 1);
3741
}
3842
},
39-
[focusedView, views, predicate, setFocusedView]
43+
[focusedView, predicate, setFocusedView],
4044
);
4145

4246
return [scrolling, handleScroll, focusedView, setFocusedView];
4347
};
4448

45-
export default useTimelineScroll;
49+
export default useTimelineScroll;

0 commit comments

Comments
 (0)