Skip to content

Commit

Permalink
feat(lld): 🧩 add new portfolio content card component (#8387)
Browse files Browse the repository at this point in the history
* feat(lld): allow creating button icons with new icons

* feat(lld): add the PortfolioContentCard component

* feat(lld): add the PortfolioContentCard stories

* feat(lld): add a carousel portfolio content cards story

* chore(lld): make `PortfolioContentCardProps.image` explicitly optional

* chore(lld): rename shortDescription to description
As it's a default field on all content cards

* chore(lld): export PortfolioContentCard on `@ledgerhq/react-ui`

* feat(lld): add an `onChange` prop to the Carousel to track diplayed slides

* fix(lld): keep the height of the carousel slides constant

* fix(lld): account for the DS being broken on LLD

* fix(lld): show pointer cursor everywhere on the cards

* feat(lld): force dark close button

* chore: update change log

* feat(lld): make card description optional

* chore(lld): simplify the Carousel stories
  • Loading branch information
thesan authored Nov 22, 2024
1 parent a6c762e commit b9cffce
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 25 deletions.
5 changes: 5 additions & 0 deletions .changeset/loud-mangos-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/react-ui": minor
---

Add new portfolio content card component
8 changes: 6 additions & 2 deletions libs/ui/packages/react/src/components/cta/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface BaseProps extends BaseStyledProps, BordersProps {
}

export interface ButtonProps extends BaseProps, React.RefAttributes<HTMLButtonElement> {
Icon?: React.ComponentType<{ size: number; color?: string }>;
Icon?: React.ReactElement | React.ComponentType<{ size: number; color?: string }>;
children?: React.ReactNode;
onClick?: (event: React.SyntheticEvent<HTMLButtonElement>) => void;
iconSize?: number;
Expand Down Expand Up @@ -236,7 +236,11 @@ const Button = (
ref?: React.ForwardedRef<HTMLButtonElement>,
): React.ReactElement => {
const iconNodeSize = iconSize || fontSizes[props.fontSize ?? 4];
const IconNode = useMemo(() => Icon && <Icon size={iconNodeSize} />, [iconNodeSize, Icon]);
const IconNode = useMemo(() => {
if (!Icon) return null;
if (typeof Icon === "object") return Icon;
return <Icon size={iconNodeSize} />;
}, [iconNodeSize, Icon]);

return (
<Base {...props} ref={ref} iconButton={!(Icon == null) && !children} onClick={onClick}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import React, { FC, ReactElement, useContext } from "react";

import PortfolioContentCard from "../ContentCard/PortfolioContentCard";
import Carousel from "./";
import { Props } from "./types";

const CarouselStory = (args: Omit<Props, "children"> & { children: number }) => {
const slides = Array.from({ length: args.children }, (_, index) => (
<div
key={index}
style={{
backgroundColor: "hsl(" + Math.random() * 360 + ", 100%, 75%)",
padding: "16px 24px",
borderRadius: "5px",
}}
>
Slide {index}
</div>
));

return <Carousel variant={args.variant} children={slides} />;
};
type Args = Omit<Props, "children"> & { children: number };
type Parameters = { Slide: FC<{ index: number }> };

const SlideContext = React.createContext<ReactElement[]>([]);
export default {
title: "Layout/Carousel",
argTypes: {
Expand All @@ -33,6 +23,9 @@ export default {
defaultValue: "default",
control: "inline-radio",
},
onChange: {
description: "Function called when a new slide is shown.",
},
},
args: {
variant: "default",
Expand All @@ -45,7 +38,55 @@ export default {
},
},
},
render: CarouselStory,
} satisfies Meta;
decorators: [
(Story: FC, { args, parameters }: { args: Args; parameters: Parameters }) => (
<SlideContext.Provider
value={Array.from({ length: args.children }, (_, index) => (
<parameters.Slide key={index} index={index} />
))}
>
<Story />
</SlideContext.Provider>
),
],
render: ({ children, ...props }: Args) => (
<Carousel {...props}>{useContext(SlideContext)}</Carousel>
),
} satisfies Meta<Args>;

export const Default: StoryObj<Args> = {
parameters: {
Slide: (({ index }) => (
<div
style={{
backgroundColor: `hsl(${Math.random() * 360}, 100%, 75%)`,
padding: "16px 24px",
borderRadius: "5px",
}}
>
Slide {index}
</div>
)) satisfies Parameters["Slide"],
},
};

export const PortfolioContentCards: StoryObj<Args> = {
parameters: {
Slide: (({ index }) => (
<PortfolioContentCard
title="Ledger Recover"
description="Get peace of mind and start your free trial."
cta={index % 2 ? undefined : "Start my free trial"}
tag={index % 3 ? undefined : "New"}
image={(index + 1) % 4 ? IMAGE_SRC : undefined}
onClick={() => onSlideAction(`click on slide ${index}`)}
onClose={() => onSlideAction(`close slide ${index}`)}
/>
)) satisfies Parameters["Slide"],
},
};

const onSlideAction = action("onSlideAction");

export const Default: StoryObj = {};
const IMAGE_SRC =
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='450' height='526' viewBox='0 0 450 526'><defs><linearGradient id='g' x0='0' y0='0' x1='1' y1='1'><stop stop-color='%23000' offset='0%' /><stop stop-color='%23FFF' offset='100%' /></linearGradient></defs><path d='M0,0 H450 V526 Q0,526 0,0 z' fill='url(%23g)' /></svg>";
10 changes: 8 additions & 2 deletions libs/ui/packages/react/src/components/layout/Carousel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ const EmblaContainer = styled.div`
`;

const EmblaSlide = styled.div`
display: flex;
flex: 0 0 100%;
min-width: 0;
> * {
flex-basis: 100%;
}
`;

const CarouselContainer = styled.div<Pick<Props, "variant">>`
Expand All @@ -30,7 +34,7 @@ const CarouselContainer = styled.div<Pick<Props, "variant">>`
/**
* This component uses the https://github.com/davidjerleke/embla-carousel library.
*/
const Carousel = ({ children, variant = "default" }: Props) => {
const Carousel = ({ children, variant = "default", onChange }: Props) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });

Expand All @@ -40,7 +44,9 @@ const Carousel = ({ children, variant = "default" }: Props) => {
const newIndex = emblaApi.selectedScrollSnap();
setCurrentIndex(newIndex);
emblaApi.scrollTo(newIndex);
}, [emblaApi]);

onChange?.(newIndex);
}, [emblaApi, onChange]);

useEffect(() => {
if (!emblaApi) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ export type Variant = "content-card" | "default";
export type Props = {
children: ReactElement[];
variant?: Variant;
onChange?: (index: number) => void;
};

/**
* Carousel's sub props to be passed to any component used by the carousel..
*/
export type SubProps = Required<Props> & {
export type SubProps = Required<Pick<Props, "children" | "variant">> & {
emblaApi: UseEmblaCarouselType[1];
currentIndex: number;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react";
import PortfolioContentCard, { PortfolioContentCardProps } from ".";

export default {
title: "Layout/ContentCard/PortfolioContentCard",
component: PortfolioContentCard,
argTypes: {
title: {
description: "Title of the card.",
},
cta: {
description: "Call to action text.",
},
description: {
description: "Description of the card.",
},
tag: {
description: "Tag to be displayed on top of the card.",
},
image: {
description: "Image to be displayed on the right of the card.",
},
onClick: {
description: "Function to be called when the card is clicked.",
},
onClose: {
description: "Function to be called when the close button is clicked.",
},
},
args: {
title: "Ledger Recover",
description: "Get peace of mind and start your free trial.",
cta: "Start my free trial",
image:
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='450' height='526' viewBox='0 0 450 526'><defs><linearGradient id='g' x0='0' y0='0' x1='1' y1='1'><stop stop-color='%23000' offset='0%' /><stop stop-color='%23FFF' offset='100%' /></linearGradient></defs><path d='M0,0 H450 V526 Q0,526 0,0 z' fill='url(%23g)' /></svg>",
},
} satisfies Meta<PortfolioContentCardProps>;

export const WithCta: StoryObj<PortfolioContentCardProps> = {};

export const WithoutCta: StoryObj<PortfolioContentCardProps> = {
args: { cta: undefined },
};

export const WithTag: StoryObj<PortfolioContentCardProps> = {
args: { tag: "New" },
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { type ReactEventHandler } from "react";
import styled from "styled-components";

import { StyleProvider } from "../../../../styles";
import { Icons } from "../../../../assets";
import { Text } from "../../../asorted";
import { Button } from "../../../cta";
import Tag from "../../../Tag";

export type PortfolioContentCardProps = {
title: string;
cta?: string;
description?: string;
tag?: string;
image?: string;

onClick: ReactEventHandler;
onClose: ReactEventHandler;
};

export default function PortfolioContentCard({
title,
cta,
description,
tag,
image,
onClick,
onClose,
}: PortfolioContentCardProps) {
const handleClose: ReactEventHandler = event => {
event.stopPropagation();
onClose(event);
};

return (
<Wrapper image={image} tag={tag} onClick={onClick}>
{tag && <StyledTag>{tag}</StyledTag>}
<Title>{title}</Title>
{description && <Desc>{description}</Desc>}
{cta && (
<Button variant="main" outline={false} onClick={onClick}>
{cta}
</Button>
)}
<StyleProvider selectedPalette="dark">
<CloseButton onClick={handleClose} />
</StyleProvider>
</Wrapper>
);
}

const StyledTag = styled(Tag).attrs({ size: "medium", type: "plain", active: true })`
font-size: 11px;
background-color: ${p => p.theme.colors.primary.c80};
`;

const Title = styled(Text).attrs({ variant: "h4Inter" })`
font-family: Inter;
font-size: 24px;
font-weight: 600;
`;

const Desc = styled(Text).attrs({ variant: "small", color: "neutral.c70" })`
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 500;
padding-bottom: 8px;
`;

const Wrapper = styled.div<Pick<PortfolioContentCardProps, "image" | "tag" | "onClick">>`
background-color: ${p => p.theme.colors.background.card};
background-image: ${p => (p.image ? `url("${p.image}")` : "none")};
background-position: right center;
background-repeat: no-repeat;
background-size: 50% auto;
cursor: pointer;
padding: 16px;
padding-top: ${p => (p.tag ? "16px" : "24px")};
padding-right: 50%;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
gap: 4px;
`;

const CloseButton = styled(Button).attrs({
Icon: <Icons.Close size="XS" />,
iconButton: true,
outline: true,
})`
background-color: ${p => p.theme.colors.neutral.c30};
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
svg {
width: 12px;
height: 12px;
}
`;
1 change: 1 addition & 0 deletions libs/ui/packages/react/src/components/layout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { default as Drawer } from "./Drawer";
export { default as Carousel } from "./Carousel";
export { default as VerticalTimeline } from "./List/VerticalTimeline";
export { default as NumberedList } from "./List/NumberedList";
export { default as PortfolioContentCard } from "./ContentCard/PortfolioContentCard";

0 comments on commit b9cffce

Please sign in to comment.