Skip to content

Commit

Permalink
Build devcard carousel
Browse files Browse the repository at this point in the history
  • Loading branch information
foxyblocks committed Jun 21, 2023
1 parent a5006ed commit e1a9ccb
Show file tree
Hide file tree
Showing 11 changed files with 1,585 additions and 51 deletions.
6 changes: 5 additions & 1 deletion components/atoms/Button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { ForwardedRef } from "react";
import clsx from "clsx";

export interface ButtonsProps extends React.ButtonHTMLAttributes<HTMLButtonElement | HTMLAnchorElement> {
variant: "primary" | "default" | "outline" | "link" | "text";
variant: "primary" | "default" | "dark" | "outline" | "link" | "text";
loading?: boolean;
href?: string;
}
Expand All @@ -16,6 +16,9 @@ const Button = React.forwardRef<HTMLElement, ButtonsProps>(
default: `bg-white border-light-slate-8 text-light-slate-11 hover:bg-light-slate-2 ${
disabled ? "bg-light-slate-4 text-light-slate-9 pointer-events-none" : ""
}`,
dark: `bg-dark-slate-6 border-dark-slate-8 text-light-orange-2 hover:bg-dark-slate-8 ${
disabled ? "bg-dark-slate-4 text-dark-slate-9 pointer-events-none" : ""
}`,
outline: `bg-orange-50 border-orange-500 text-orange-600 hover:bg-orange-100 ${
disabled ? "bg-light-orange-3 pointer-events-none text-light-orange-7 border-light-orange-5" : ""
}`,
Expand All @@ -26,6 +29,7 @@ const Button = React.forwardRef<HTMLElement, ButtonsProps>(
className,
props.variant === "primary" && styles.primary,
props.variant === "default" && styles.default,
props.variant === "dark" && styles.dark,
props.variant === "outline" && styles.outline,
props.variant === "link" && styles.link,
disabled && "bg-light-orange-7 hover:bg-light-orange-7 border-none pointer-events-none",
Expand Down
28 changes: 18 additions & 10 deletions components/molecules/DevCard/dev-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ export interface DevCardProps {
avatarURL: string;
prs?: number;
contributions?: number;
isLoading: boolean;
activity?: Activity;
bio?: string;
prVelocity?: number;
flipped?: boolean;
isLoading: boolean;
isFlipped?: boolean;
isInteractive?: boolean;
onFlip?: () => void;
}

Expand Down Expand Up @@ -54,18 +55,25 @@ const face = cntl`
export default function DevCard(props: DevCardProps) {
const ref = useRef(null);
const { docX, docY, posX, posY, elW, elH } = useMouse(ref);
const [isFlipped, setIsFlipped] = useState(props.flipped);
const [isFlipped, setIsFlipped] = useState(props.isFlipped ?? false);
const isInteractive = props.isInteractive ?? true;

useEffect(() => {
setIsFlipped(props.flipped);
}, [props.flipped]);
setIsFlipped(props.isFlipped ?? false);
}, [props.isFlipped]);

useEffect(() => {
if (!props.isInteractive) {
setIsFlipped(props.isFlipped ?? false);
}
}, [props.isInteractive, props.isFlipped]);

const halfWidth = elW / 2;
const halfHeight = elH / 2;
const elCenterX = posX + halfWidth;
const elCenterY = posY + halfHeight;
const mouseOffsetX = elCenterX - docX;
const mouseOffsetY = elCenterY - docY;
const mouseOffsetX = isInteractive ? elCenterX - docX : 0;
const mouseOffsetY = isInteractive ? elCenterY - docY : 0;
// Cap the offset so that it asymptomatically approaches the max offset

const calcAngleX = asymptoticLinear(mouseOffsetX / 20, 20, 0.1) + (isFlipped ? 180 : 0);
Expand Down Expand Up @@ -95,7 +103,7 @@ export default function DevCard(props: DevCardProps) {
function handleCardClick(event: React.MouseEvent<HTMLElement, MouseEvent>) {
// flip the card if the click is not on the button
if (!(event.target instanceof HTMLAnchorElement || event.target instanceof HTMLButtonElement)) {
if (props["flipped"] === undefined) {
if (props.isFlipped === undefined) {
setIsFlipped(!isFlipped);
} else {
props.onFlip?.();
Expand Down Expand Up @@ -174,10 +182,10 @@ export default function DevCard(props: DevCardProps) {
alt="avatar"
width={116}
height={116}
className="absolute top-1/2 left-1/2 border-white border-2 block rounded-full w-28 h-28"
className="absolute top-1/2 left-1/2 bg-white border-white border-2 block rounded-full w-28 h-28"
style={{ transform: "translate(-50%, -75%)" }}
/>
{isFlipped ? null : <div className="glare" style={glareStyle} />}
{isFlipped || !isInteractive ? null : <div className="glare" style={glareStyle} />}
</div>
<div
className={`DevCard-back ${face}`}
Expand Down
44 changes: 44 additions & 0 deletions components/organisms/DevCardCarousel/dev-card-carousel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* @jest-environment jsdom
*/

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import DevCardCarousel from "./dev-card-carousel";
import { STUB_DEV_CARDS } from "components/organisms/DevCardCarousel/stubData";

describe("DevCardCarousel", () => {
it("should render", () => {
render(<DevCardCarousel cards={[...STUB_DEV_CARDS]} />);
});

describe("when the user clicks on a card", () => {
it("should trigger the onSelect", () => {
const onSelect = jest.fn();
render(<DevCardCarousel cards={[...STUB_DEV_CARDS]} onSelect={onSelect} />);
const thirdDevCard = screen.getByTitle(STUB_DEV_CARDS[2].username);
userEvent.click(thirdDevCard);
expect(onSelect).toHaveBeenCalledWith(STUB_DEV_CARDS[2].username);
});
});

describe("when the user uses the arrow keys", () => {
describe("when the user presses the right arrow key", () => {
it("should select last card", () => {
const onSelect = jest.fn();
render(<DevCardCarousel cards={[...STUB_DEV_CARDS]} onSelect={onSelect} />);
userEvent.keyboard("{arrowright}");
expect(onSelect).toHaveBeenCalledWith(STUB_DEV_CARDS.slice(-1)[0].username);
});
});
describe("when the user presses the left arrow key", () => {
it("should select the second card", () => {
const onSelect = jest.fn();
render(<DevCardCarousel cards={[...STUB_DEV_CARDS]} onSelect={onSelect} />);
userEvent.keyboard("{arrowleft}");
expect(onSelect).toHaveBeenCalledWith(STUB_DEV_CARDS[1].username);
});
});
});
});
130 changes: 130 additions & 0 deletions components/organisms/DevCardCarousel/dev-card-carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import cntl from "cntl";
import DevCard, { DevCardProps } from "components/molecules/DevCard/dev-card";
import { animated, to, useSprings } from "react-spring";
import { useGesture } from "@use-gesture/react";
import { useCallback, useEffect, useState } from "react";
import { useKey } from "react-use";

interface DevCardCarouselProps {
isLoading?: boolean;
cards: Omit<DevCardProps, "isLoading">[];
onSelect?: (username: string) => void;
}

const startTo = (index: number, delay = false) => ({
x: -50 * index,
scale: 1 - index * 0.1,
delay: delay ? index * 100 : 0,
zIndex: 100 - index,
coverOpacity: index * 0.1,
immediate: (key: string) => key === "zIndex",
});

const startFrom = (_i: number) => ({ x: 0, scale: 1, y: 0, zIndex: 0, coverOpacity: 0 });

const transform = (x: number, y: number, s: number) => `translate(${x}px, ${y}px) scale(${s})`;

export default function DevCardCarousel(props: DevCardCarouselProps) {
const [cardOrder, setCardOrder] = useState(props.cards.map((card, index) => index));
const [springProps, api] = useSprings(props.cards.length, (i: number) => ({
...startTo(i, true),
from: startFrom(i),
})); // Create a bunch of springs using the helpers above

const bind = useGesture({
onHover: (state) => {
const hoverIndex = state.args[0];
api.start((i) => {
// move the card up in y value while hovering
return { y: state.hovering && i === hoverIndex ? -20 : 0 };
});
},
});

const handleSelect = useCallback(
(cardOrderIndex: number) => {
const cardIndex = cardOrder[cardOrderIndex];
props.onSelect?.(props.cards[cardIndex].username);
// take all cards above the clicked card and move them down
setCardOrder((cards) => {
const cardsAfterIndex = cards.slice(cardOrderIndex);
const cardsBeforeIndexInclusive = cards.slice(0, cardOrderIndex);

return [...cardsAfterIndex, ...cardsBeforeIndexInclusive];
});
},
[cardOrder, props, setCardOrder]
);

useKey(
"ArrowRight",
() => {
handleSelect(cardOrder.length - 1);
},
{},
[handleSelect]
);
useKey(
"ArrowLeft",
() => {
handleSelect(1);
},
{},
[handleSelect]
);

function handleClick(cardOrderIndex: number) {
handleSelect(cardOrderIndex);
}

useEffect(() => {
api.start((i) => {
const newIndex = cardOrder.indexOf(i);
return { ...startTo(newIndex) };
});
}, [cardOrder, api]);

return (
<div>
<div className="grid place-content-center">
{springProps.map(({ x, y, scale, zIndex, coverOpacity }, index) => {
const cardProps = props.cards[index];
const cardOrderIndex = cardOrder.indexOf(index);
const className = cntl`
DevCardCarousel-card
rounded-3xl
left-0
top-0
w-full
h-full
relative
cursor-pointer
`;
return (
<animated.div
{...bind(index)}
key={cardProps.username}
className={className}
style={{
gridArea: "1 / 1",
zIndex: zIndex,
transform: to([x, y, scale], transform),
transformOrigin: "left center",
}}
>
<DevCard isLoading={false} isInteractive={index === cardOrder[0]} {...cardProps} />
<animated.div
className="DevCardCarousel-darken absolute left-0 right-0 top-0 bottom-0 bg-black rounded-3xl z-10"
title={cardProps.username}
style={{ opacity: coverOpacity, pointerEvents: index === cardOrder[0] ? "none" : "auto" }}
onClick={() => {
handleClick(cardOrderIndex);
}}
></animated.div>
</animated.div>
);
})}
</div>
</div>
);
}
62 changes: 62 additions & 0 deletions components/organisms/DevCardCarousel/stubData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export const STUB_DEV_CARDS = [
{
username: "foxyblocks",
name: "Chris Schlensker",
avatarURL: "https://avatars.githubusercontent.com/u/555044?v=4",
prs: 2,
contributions: 57,
bio: "This is the story all about how my life got flipped turned upside down, and I'd like to take a minute just sit right there, I'll tell you how I became the prince of a town called Bel-Air.",
activity: "high",
prVelocity: 102,
},
{
username: "codebytere",
name: "Shelley Vohr",
avatarURL: "https://avatars.githubusercontent.com/u/2036040?v=4",
prs: 31,
contributions: 1,
bio: "This is the story all about how my life got flipped turned upside down, and I'd like to take a minute just sit right there, I'll tell you how I became the prince of a town called Bel-Air.",
activity: "high",
prVelocity: 67,
},
{
username: "miniak",
name: "Milan Burda",
avatarURL: "https://avatars.githubusercontent.com/u/1281234?v=4",
prs: 31,
contributions: 1,
bio: "This is the story all about how my life got flipped turned upside down, and I'd like to take a minute just sit right there, I'll tell you how I became the prince of a town called Bel-Air.",
activity: "high",
prVelocity: 67,
},
{
username: "ckerr",
name: "Charles Kerr",
avatarURL: "https://avatars.githubusercontent.com/u/70381?v=4",
prs: 31,
contributions: 1,
bio: "This is the story all about how my life got flipped turned upside down, and I'd like to take a minute just sit right there, I'll tell you how I became the prince of a town called Bel-Air.",
activity: "high",
prVelocity: 67,
},
{
username: "JeanMeche",
name: "Matthieu Riegler",
avatarURL: "https://avatars.githubusercontent.com/u/1300985?v=4",
prs: 31,
contributions: 1,
bio: "This is the story all about how my life got flipped turned upside down, and I'd like to take a minute just sit right there, I'll tell you how I became the prince of a town called Bel-Air.",
activity: "high",
prVelocity: 67,
},
{
username: "annacmc",
name: "Anna McPhee",
avatarURL: "https://avatars.githubusercontent.com/u/30754158?v=4",
prs: 31,
contributions: 1,
bio: "This is the story all about how my life got flipped turned upside down, and I'd like to take a minute just sit right there, I'll tell you how I became the prince of a town called Bel-Air.",
activity: "high",
prVelocity: 67,
},
] as const;
14 changes: 14 additions & 0 deletions img/bubble-bg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit e1a9ccb

Please sign in to comment.