aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/card
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules/card')
-rw-r--r--src/components/molecules/card/card-actions.test.tsx37
-rw-r--r--src/components/molecules/card/card-actions.tsx42
-rw-r--r--src/components/molecules/card/card-body.tsx18
-rw-r--r--src/components/molecules/card/card-cover.test.tsx23
-rw-r--r--src/components/molecules/card/card-cover.tsx29
-rw-r--r--src/components/molecules/card/card-footer.tsx32
-rw-r--r--src/components/molecules/card/card-header.tsx32
-rw-r--r--src/components/molecules/card/card-meta.tsx16
-rw-r--r--src/components/molecules/card/card-provider.tsx43
-rw-r--r--src/components/molecules/card/card-title.test.tsx24
-rw-r--r--src/components/molecules/card/card-title.tsx27
-rw-r--r--src/components/molecules/card/card.module.scss296
-rw-r--r--src/components/molecules/card/card.stories.tsx545
-rw-r--r--src/components/molecules/card/card.test.tsx129
-rw-r--r--src/components/molecules/card/card.tsx112
-rw-r--r--src/components/molecules/card/index.ts8
16 files changed, 1413 insertions, 0 deletions
diff --git a/src/components/molecules/card/card-actions.test.tsx b/src/components/molecules/card/card-actions.test.tsx
new file mode 100644
index 0000000..94c2677
--- /dev/null
+++ b/src/components/molecules/card/card-actions.test.tsx
@@ -0,0 +1,37 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { CardActions } from './card-actions';
+
+describe('CardActions', () => {
+ it('renders its children', () => {
+ const actions = 'animi et omnis';
+
+ render(<CardActions>{actions}</CardActions>);
+
+ expect(rtlScreen.getByText(actions)).toBeInTheDocument();
+ });
+
+ it('can render its children with start alignment', () => {
+ const actions = 'animi et omnis';
+
+ render(<CardActions alignment="start">{actions}</CardActions>);
+
+ expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: flex-start`);
+ });
+
+ it('can render its children with centered alignment', () => {
+ const actions = 'animi et omnis';
+
+ render(<CardActions alignment="center">{actions}</CardActions>);
+
+ expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: center`);
+ });
+
+ it('can render its children with end alignment', () => {
+ const actions = 'animi et omnis';
+
+ render(<CardActions alignment="end">{actions}</CardActions>);
+
+ expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: flex-end`);
+ });
+});
diff --git a/src/components/molecules/card/card-actions.tsx b/src/components/molecules/card/card-actions.tsx
new file mode 100644
index 0000000..9d3e09e
--- /dev/null
+++ b/src/components/molecules/card/card-actions.tsx
@@ -0,0 +1,42 @@
+import {
+ type ForwardRefRenderFunction,
+ type HTMLAttributes,
+ forwardRef,
+ type ReactNode,
+} from 'react';
+import styles from './card.module.scss';
+
+export type CardActionsProps = Omit<
+ HTMLAttributes<HTMLDivElement>,
+ 'children'
+> & {
+ /**
+ * The actions alignment.
+ *
+ * @default 'end'
+ */
+ alignment?: 'center' | 'end' | 'start';
+ /**
+ * The card actions (ie. buttons, links...).
+ */
+ children: ReactNode;
+};
+
+const CardActionsWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ CardActionsProps
+> = ({ alignment = 'end', children, className = '', style, ...props }, ref) => {
+ const actionsClass = `${styles.actions} ${className}`;
+ const actionsStyles = {
+ ...style,
+ '--alignment': alignment === 'center' ? alignment : `flex-${alignment}`,
+ };
+
+ return (
+ <div {...props} className={actionsClass} ref={ref} style={actionsStyles}>
+ {children}
+ </div>
+ );
+};
+
+export const CardActions = forwardRef(CardActionsWithRef);
diff --git a/src/components/molecules/card/card-body.tsx b/src/components/molecules/card/card-body.tsx
new file mode 100644
index 0000000..40cf515
--- /dev/null
+++ b/src/components/molecules/card/card-body.tsx
@@ -0,0 +1,18 @@
+import type { FC, HTMLAttributes } from 'react';
+import styles from './card.module.scss';
+
+export type CardBodyProps = HTMLAttributes<HTMLElement>;
+
+export const CardBody: FC<CardBodyProps> = ({
+ children,
+ className = '',
+ ...props
+}) => {
+ const bodyClass = `${styles.body} ${className}`;
+
+ return (
+ <div {...props} className={bodyClass}>
+ {children}
+ </div>
+ );
+};
diff --git a/src/components/molecules/card/card-cover.test.tsx b/src/components/molecules/card/card-cover.test.tsx
new file mode 100644
index 0000000..541da44
--- /dev/null
+++ b/src/components/molecules/card/card-cover.test.tsx
@@ -0,0 +1,23 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import NextImage from 'next/image';
+import { CardCover } from './card-cover';
+
+describe('CardCover', () => {
+ it('renders a cover', () => {
+ const altTxt = 'nam cupiditate ex';
+
+ render(
+ <CardCover>
+ <NextImage
+ alt={altTxt}
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ );
+
+ expect(rtlScreen.getByRole('img', { name: altTxt })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/card/card-cover.tsx b/src/components/molecules/card/card-cover.tsx
new file mode 100644
index 0000000..3d6bc7b
--- /dev/null
+++ b/src/components/molecules/card/card-cover.tsx
@@ -0,0 +1,29 @@
+import {
+ type ForwardRefRenderFunction,
+ type ReactElement,
+ forwardRef,
+} from 'react';
+import { Figure, type FigureProps } from '../../atoms';
+import styles from './card.module.scss';
+
+export type CardCoverProps = Omit<FigureProps, 'caption' | 'children'> & {
+ /**
+ * The cover.
+ */
+ children: ReactElement;
+};
+
+const CardCoverWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ CardCoverProps
+> = ({ className = '', children, ...props }, ref) => {
+ const coverClass = `${styles.cover} ${className}`;
+
+ return (
+ <Figure {...props} className={coverClass} ref={ref}>
+ {children}
+ </Figure>
+ );
+};
+
+export const CardCover = forwardRef(CardCoverWithRef);
diff --git a/src/components/molecules/card/card-footer.tsx b/src/components/molecules/card/card-footer.tsx
new file mode 100644
index 0000000..56a6513
--- /dev/null
+++ b/src/components/molecules/card/card-footer.tsx
@@ -0,0 +1,32 @@
+import {
+ forwardRef,
+ type ForwardRefRenderFunction,
+ type ReactNode,
+} from 'react';
+import { Footer, type FooterProps } from '../../atoms';
+import { useCardFooterMeta } from './card-provider';
+import styles from './card.module.scss';
+
+export type CardFooterProps = Omit<FooterProps, 'children'> & {
+ /**
+ * The card footer contents.
+ */
+ children?: ReactNode;
+};
+
+const CardFooterWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ CardFooterProps
+> = ({ children, className = '', ...props }, ref) => {
+ const footerClass = `${styles.footer} ${className}`;
+ const meta = useCardFooterMeta();
+
+ return (
+ <Footer {...props} className={footerClass} ref={ref}>
+ {children}
+ {meta}
+ </Footer>
+ );
+};
+
+export const CardFooter = forwardRef(CardFooterWithRef);
diff --git a/src/components/molecules/card/card-header.tsx b/src/components/molecules/card/card-header.tsx
new file mode 100644
index 0000000..213d1bc
--- /dev/null
+++ b/src/components/molecules/card/card-header.tsx
@@ -0,0 +1,32 @@
+import {
+ forwardRef,
+ type ForwardRefRenderFunction,
+ type ReactNode,
+} from 'react';
+import { Header, type HeaderProps } from '../../atoms';
+import { useCardCover } from './card-provider';
+import styles from './card.module.scss';
+
+export type CardHeaderProps = Omit<HeaderProps, 'children'> & {
+ /**
+ * The card header contents.
+ */
+ children?: ReactNode;
+};
+
+const CardHeaderWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ CardHeaderProps
+> = ({ children, className = '', ...props }, ref) => {
+ const cover = useCardCover();
+ const headerClass = `${styles.header} ${className}`;
+
+ return (
+ <Header {...props} className={headerClass} ref={ref}>
+ {cover}
+ {children}
+ </Header>
+ );
+};
+
+export const CardHeader = forwardRef(CardHeaderWithRef);
diff --git a/src/components/molecules/card/card-meta.tsx b/src/components/molecules/card/card-meta.tsx
new file mode 100644
index 0000000..403d543
--- /dev/null
+++ b/src/components/molecules/card/card-meta.tsx
@@ -0,0 +1,16 @@
+import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import { MetaList, type MetaListProps } from '../meta-list';
+import styles from './card.module.scss';
+
+export type CardMetaProps = MetaListProps;
+
+const CardMetaWithRef: ForwardRefRenderFunction<
+ HTMLDListElement,
+ CardMetaProps
+> = ({ className = '', ...props }, ref) => {
+ const metaClass = `${styles.meta} ${className}`;
+
+ return <MetaList {...props} className={metaClass} ref={ref} />;
+};
+
+export const CardMeta = forwardRef(CardMetaWithRef);
diff --git a/src/components/molecules/card/card-provider.tsx b/src/components/molecules/card/card-provider.tsx
new file mode 100644
index 0000000..5bcdb49
--- /dev/null
+++ b/src/components/molecules/card/card-provider.tsx
@@ -0,0 +1,43 @@
+import {
+ createContext,
+ type FC,
+ type ReactElement,
+ type ReactNode,
+ useContext,
+} from 'react';
+
+export type CardCoverProviderProps = {
+ children: ReactNode;
+ cover?: ReactElement;
+};
+
+export const CardCoverContext = createContext<ReactElement | null>(null);
+
+export const useCardCover = () => useContext(CardCoverContext);
+
+export const CardCoverProvider: FC<CardCoverProviderProps> = ({
+ children,
+ cover,
+}) => (
+ <CardCoverContext.Provider value={cover ?? null}>
+ {children}
+ </CardCoverContext.Provider>
+);
+
+export type CardFooterMetaProviderProps = {
+ children: ReactNode;
+ meta?: ReactElement;
+};
+
+export const CardFooterMetaContext = createContext<ReactElement | null>(null);
+
+export const useCardFooterMeta = () => useContext(CardFooterMetaContext);
+
+export const CardFooterMetaProvider: FC<CardFooterMetaProviderProps> = ({
+ children,
+ meta,
+}) => (
+ <CardFooterMetaContext.Provider value={meta ?? null}>
+ {children}
+ </CardFooterMetaContext.Provider>
+);
diff --git a/src/components/molecules/card/card-title.test.tsx b/src/components/molecules/card/card-title.test.tsx
new file mode 100644
index 0000000..9dbf6ac
--- /dev/null
+++ b/src/components/molecules/card/card-title.test.tsx
@@ -0,0 +1,24 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { CardTitle } from './card-title';
+
+describe('CardTitle', () => {
+ it('renders a title of level 2', () => {
+ const title = 'animi et omnis';
+
+ render(<CardTitle>{title}</CardTitle>);
+
+ expect(rtlScreen.getByRole('heading', { level: 2 })).toHaveTextContent(
+ title
+ );
+ });
+
+ it('can render a title with a custom level', () => {
+ const level = 4;
+ const title = 'animi et omnis';
+
+ render(<CardTitle level={level}>{title}</CardTitle>);
+
+ expect(rtlScreen.getByRole('heading', { level })).toHaveTextContent(title);
+ });
+});
diff --git a/src/components/molecules/card/card-title.tsx b/src/components/molecules/card/card-title.tsx
new file mode 100644
index 0000000..09d61ea
--- /dev/null
+++ b/src/components/molecules/card/card-title.tsx
@@ -0,0 +1,27 @@
+import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import { Heading, type HeadingProps } from '../../atoms';
+import styles from './card.module.scss';
+
+export type CardTitleProps = Omit<HeadingProps, 'level'> & {
+ /**
+ * The title level (between 1 and 6).
+ *
+ * @default 2
+ */
+ level?: HeadingProps['level'];
+};
+
+const CardTitleWithRef: ForwardRefRenderFunction<
+ HTMLHeadingElement,
+ CardTitleProps
+> = ({ children, className = '', level = 2, ...props }, ref) => {
+ const headingClass = `${styles.title} ${className}`;
+
+ return (
+ <Heading {...props} className={headingClass} level={level} ref={ref}>
+ {children}
+ </Heading>
+ );
+};
+
+export const CardTitle = forwardRef(CardTitleWithRef);
diff --git a/src/components/molecules/card/card.module.scss b/src/components/molecules/card/card.module.scss
new file mode 100644
index 0000000..65f92f6
--- /dev/null
+++ b/src/components/molecules/card/card.module.scss
@@ -0,0 +1,296 @@
+@use "../../../styles/abstracts/functions" as fun;
+
+$breakpoint: 50ch;
+
+.cover {
+ width: var(--cover-width, 100%);
+ height: var(--cover-height, fun.convert-px(150));
+ max-width: none;
+ position: relative;
+
+ > * {
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ position: absolute;
+ inset: 0;
+ object-position: center;
+ object-fit: cover;
+ }
+}
+
+.title {
+ background: none;
+ padding: 0;
+}
+
+.body {
+ max-width: 80ch;
+ color: var(--color-fg);
+ font-weight: 400;
+}
+
+.actions {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: var(--alignment);
+ column-gap: var(--spacing-md);
+ row-gap: var(--spacing-sm);
+ padding-block: var(--spacing-sm);
+}
+
+.meta {
+ font-size: var(--font-size-sm);
+}
+
+:where(.footer) .meta {
+ flex-flow: row wrap;
+}
+
+.header,
+.footer {
+ display: contents;
+}
+
+.card {
+ --card-padding: clamp(var(--spacing-sm), 3vw, var(--spacing-md));
+
+ column-gap: var(--spacing-md);
+ row-gap: var(--spacing-sm);
+ width: 100%;
+ height: 100%;
+ background: var(--color-bg);
+
+ &--variant-1 {
+ --cover-height: clamp(
+ #{fun.convert-px(100)},
+ calc(#{fun.convert-px(200)} - 10cqw),
+ #{fun.convert-px(200)}
+ );
+ }
+
+ &--variant-2 {
+ --cover-height: #{fun.convert-px(90)};
+ --cover-width: var(--cover-height);
+ }
+}
+
+:where(.card--variant-2) .cover {
+ border-radius: fun.convert-px(3);
+ box-shadow:
+ 0 0 0 fun.convert-px(1) var(--color-shadow-light),
+ fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(1) var(--color-shadow);
+}
+
+.wrapper {
+ container: card / inline-size;
+
+ &--centered {
+ .header,
+ .body,
+ .footer {
+ text-align: center;
+ }
+
+ .meta {
+ margin-inline: auto;
+ }
+ }
+
+ &--is-link {
+ --scale-up: 1.05;
+
+ &:not(:disabled):focus:not(:hover) {
+ text-decoration: none;
+
+ .title {
+ text-decoration: underline solid var(--color-primary) 0.3ex;
+ }
+ }
+ }
+}
+
+/* stylelint-disable no-descending-specificity -- Stylelint complains about
+ * specificity but I think it is clearer and DRY this way. */
+
+:where(.wrapper--is-block) .card--variant-2 {
+ border: fun.convert-px(1) solid var(--color-border);
+ box-shadow:
+ fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow-light),
+ fun.convert-px(4) fun.convert-px(4) fun.convert-px(3) fun.convert-px(-2)
+ var(--color-shadow);
+}
+
+@container card (width <= #{$breakpoint}) {
+ .card {
+ display: flex;
+ flex-flow: column wrap;
+ row-gap: var(--card-padding);
+
+ &:where(:not(&--has-cover)) {
+ padding: var(--card-padding);
+ }
+
+ &:where(&--has-cover) {
+ padding-block-end: var(--card-padding);
+
+ :where(.header, .footer) > *:not(.cover),
+ .body {
+ padding-inline: var(--card-padding);
+ }
+ }
+ }
+
+ :where(.card--variant-1) {
+ .cover {
+ --cover-width: 100%;
+ }
+
+ :where(.footer) .meta {
+ margin-block-start: auto;
+ justify-content: space-between;
+ }
+ }
+
+ :where(.card--variant-2) {
+ :where(.header) {
+ .cover,
+ .title {
+ margin-inline: auto;
+ }
+
+ .cover {
+ margin-block-start: var(--card-padding);
+ }
+ }
+ }
+}
+
+@container card (width > #{$breakpoint}) {
+ .card {
+ display: grid;
+ grid-auto-rows: max-content;
+ padding: var(--card-padding);
+ }
+
+ .cover,
+ .title,
+ .meta,
+ .body,
+ .actions {
+ grid-column: 1;
+ }
+
+ .card--variant-1 {
+ grid-auto-columns: minmax(30ch, 80ch) 1fr minmax(min-content, 25cqw);
+ }
+
+ :where(.wrapper--is-block) .card--variant-1 {
+ border: fun.convert-px(1) solid var(--color-primary-dark);
+ border-radius: fun.convert-px(3);
+ box-shadow:
+ fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+ var(--color-shadow),
+ fun.convert-px(3) fun.convert-px(3) fun.convert-px(3) fun.convert-px(-1)
+ var(--color-shadow-light),
+ fun.convert-px(5) fun.convert-px(5) fun.convert-px(7) fun.convert-px(-1)
+ var(--color-shadow-light);
+ }
+
+ .card--variant-2 {
+ grid-auto-columns: minmax(min-content, 15cqw) 1fr minmax(30ch, 80ch);
+ }
+
+ .cover {
+ grid-row-start: 1;
+ }
+
+ :where(.wrapper--is-link:not(:disabled, :hover):focus) .title {
+ text-decoration: underline solid var(--color-primary) 0.3ex;
+ }
+
+ :where(.card--has-cover .header:only-child) .cover:only-child {
+ --cover-width: calc(100% + (2 * var(--card-padding)));
+ --cover-height: calc(100% + (2 * var(--card-padding)));
+
+ margin-block-start: calc(var(--card-padding) * -1);
+ margin-inline-start: calc(var(--card-padding) * -1);
+ }
+
+ :where(.card--variant-1) {
+ :where(.header:only-child) > *:only-child,
+ .body:only-child,
+ :where(.footer:only-child) > *:only-child {
+ grid-column: 1 / span 2;
+ }
+
+ :where(.header:not(:only-child)) .cover,
+ :where(.header:only-child) .cover:not(:only-child),
+ :where(.footer:not(:only-child)) .meta,
+ :where(.footer:only-child) .meta:not(:only-child) {
+ grid-column: 3;
+ }
+
+ :where(.footer) .meta {
+ grid-row-start: 1;
+ flex-flow: column wrap;
+ }
+
+ :where(.body:first-child + .footer) .meta,
+ :where(.footer:only-child) .meta {
+ grid-row-end: 1;
+ }
+
+ :where(.header) .title,
+ :where(.header) .cover + .meta {
+ grid-row: 1;
+ align-self: center;
+ }
+ }
+
+ :where(.card--variant-1.card--has-cover .footer) .meta {
+ grid-row: 2 / span 3;
+ }
+
+ :where(.card--variant-1.card--no-footer-meta .header:not(:only-child)) .cover,
+ :where(.card--variant-1.card--no-footer-meta .header) .cover:not(:only-child),
+ :where(.card--variant-1.card--no-cover .footer) .meta {
+ --cover-height: 100%;
+
+ grid-row-end: span 4;
+ }
+
+ :where(.card--variant-2) {
+ .header:only-child > *,
+ .body:only-child,
+ .footer:only-child > * {
+ grid-column: 1 / span 2;
+ }
+
+ .header:only-child > .meta {
+ justify-self: center;
+ }
+
+ :where(.header:not(:only-child)) .meta,
+ .body,
+ :where(.footer:not(:only-child)) .actions,
+ :where(.footer:not(:only-child)) .meta {
+ grid-column: 3;
+ }
+
+ :where(.header) {
+ .cover,
+ .title {
+ grid-row: 1 / span 2;
+ justify-self: center;
+ }
+ }
+ }
+
+ :where(.card--variant-2.card--has-cover .header) .title {
+ margin-top: calc(var(--cover-height) + var(--spacing-sm));
+ }
+}
+
+/* stylelint-enable no-descending-specificity */
diff --git a/src/components/molecules/card/card.stories.tsx b/src/components/molecules/card/card.stories.tsx
new file mode 100644
index 0000000..74f426c
--- /dev/null
+++ b/src/components/molecules/card/card.stories.tsx
@@ -0,0 +1,545 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import NextImage from 'next/image';
+import { Button, ButtonLink, Link, Time } from '../../atoms';
+import { Card, type CardProps } from './card';
+import { CardActions } from './card-actions';
+import { CardBody } from './card-body';
+import { CardCover } from './card-cover';
+import { CardFooter } from './card-footer';
+import { CardHeader } from './card-header';
+import { CardMeta } from './card-meta';
+import { CardTitle } from './card-title';
+
+/**
+ * Card - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Card',
+ component: Card,
+ argTypes: {},
+} as ComponentMeta<typeof Card>;
+
+const Template: ComponentStory<typeof Card> = <T extends string | undefined>(
+ args: CardProps<T>
+) => <Card {...args} />;
+
+/**
+ * Card Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ children: 'The card contents.',
+};
+
+/**
+ * Card Stories - AsLink
+ */
+export const AsLink = Template.bind({});
+AsLink.args = {
+ 'aria-label': 'Learn more about this card',
+ children: 'The card contents.',
+ linkTo: '#card',
+};
+
+export const HeaderCover = Template.bind({});
+HeaderCover.args = {
+ children: <CardHeader />,
+ cover: (
+ <CardCover>
+ <NextImage
+ alt="A cover example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ ),
+};
+
+export const HeaderTitle = Template.bind({});
+HeaderTitle.args = {
+ children: (
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ ),
+};
+
+export const HeaderMeta = Template.bind({});
+HeaderMeta.args = {
+ children: (
+ <CardHeader>
+ <CardMeta
+ isInline
+ items={[
+ { id: 'author', label: 'Written by:', value: 'The author' },
+ {
+ id: 'publication-date',
+ label: 'Published on:',
+ value: <Time date={new Date().toISOString()} />,
+ },
+ ]}
+ />
+ </CardHeader>
+ ),
+};
+
+export const BodyContents = Template.bind({});
+BodyContents.args = {
+ children: <CardBody>The card contents</CardBody>,
+};
+
+export const FooterActions = Template.bind({});
+FooterActions.args = {
+ children: (
+ <CardFooter>
+ <CardActions>
+ <ButtonLink to="#post">Read more</ButtonLink>
+ <Button>Share</Button>
+ </CardActions>
+ </CardFooter>
+ ),
+};
+
+export const FooterMeta = Template.bind({});
+FooterMeta.args = {
+ children: <CardFooter />,
+ meta: (
+ <CardMeta
+ items={[
+ {
+ id: 'categories',
+ label: 'Categories:',
+ value: [
+ { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> },
+ { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> },
+ ],
+ },
+ {
+ id: 'tags',
+ label: 'Tags:',
+ value: [
+ { id: 'tag-1', value: 'Tag 1' },
+ { id: 'tag-2', value: 'Tag 2' },
+ { id: 'tag-3', value: 'Tag 3' },
+ ],
+ },
+ ]}
+ />
+ ),
+};
+
+export const CompositionCoverTitle = Template.bind({});
+CompositionCoverTitle.args = {
+ children: (
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ ),
+ cover: (
+ <CardCover>
+ <NextImage
+ alt="A cover example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ ),
+};
+
+export const CompositionTitleMeta = Template.bind({});
+CompositionTitleMeta.args = {
+ children: (
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ <CardMeta
+ isInline
+ items={[
+ { id: 'author', label: 'Written by:', value: 'The author' },
+ {
+ id: 'publication-date',
+ label: 'Published on:',
+ value: <Time date={new Date().toISOString()} />,
+ },
+ ]}
+ />
+ </CardHeader>
+ ),
+};
+
+export const CompositionCoverTitleMeta = Template.bind({});
+CompositionCoverTitleMeta.args = {
+ children: (
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ <CardMeta
+ isInline
+ items={[
+ { id: 'author', label: 'Written by:', value: 'The author' },
+ {
+ id: 'publication-date',
+ label: 'Published on:',
+ value: <Time date={new Date().toISOString()} />,
+ },
+ ]}
+ />
+ </CardHeader>
+ ),
+ cover: (
+ <CardCover>
+ <NextImage
+ alt="A cover example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ ),
+};
+
+export const CompositionTitleBody = Template.bind({});
+CompositionTitleBody.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et
+ necessitatibus esse laudantium sequi ad. Et est quas pariatur facere
+ velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur
+ dolores veniam. Nostrum est ullam aliquam aliquid expedita ea.
+ </CardBody>
+ </>
+ ),
+};
+
+export const CompositionCoverTitleBody = Template.bind({});
+CompositionCoverTitleBody.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et
+ necessitatibus esse laudantium sequi ad. Et est quas pariatur facere
+ velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur
+ dolores veniam. Nostrum est ullam aliquam aliquid expedita ea.
+ </CardBody>
+ </>
+ ),
+ cover: (
+ <CardCover>
+ <NextImage
+ alt="A cover example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ ),
+};
+
+export const CompositionTitleMetaBody = Template.bind({});
+CompositionTitleMetaBody.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ <CardMeta
+ isInline
+ items={[
+ { id: 'author', label: 'Written by:', value: 'The author' },
+ {
+ id: 'publication-date',
+ label: 'Published on:',
+ value: <Time date={new Date().toISOString()} />,
+ },
+ ]}
+ />
+ </CardHeader>
+ <CardBody>
+ Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et
+ necessitatibus esse laudantium sequi ad. Et est quas pariatur facere
+ velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur
+ dolores veniam. Nostrum est ullam aliquam aliquid expedita ea.
+ </CardBody>
+ </>
+ ),
+};
+
+export const CompositionCoverTitleMetaBody = Template.bind({});
+CompositionCoverTitleMetaBody.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ <CardMeta
+ isInline
+ items={[
+ { id: 'author', label: 'Written by:', value: 'The author' },
+ {
+ id: 'publication-date',
+ label: 'Published on:',
+ value: <Time date={new Date().toISOString()} />,
+ },
+ ]}
+ />
+ </CardHeader>
+ <CardBody>
+ Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et
+ necessitatibus esse laudantium sequi ad. Et est quas pariatur facere
+ velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur
+ dolores veniam. Nostrum est ullam aliquam aliquid expedita ea.
+ </CardBody>
+ </>
+ ),
+ cover: (
+ <CardCover>
+ <NextImage
+ alt="A cover example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ ),
+};
+
+export const CompositionTitleActions = Template.bind({});
+CompositionTitleActions.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ <CardFooter>
+ <CardActions>
+ <ButtonLink to="#post">Read more</ButtonLink>
+ <Button>Share</Button>
+ </CardActions>
+ </CardFooter>
+ </>
+ ),
+};
+
+export const CompositionCoverTitleActions = Template.bind({});
+CompositionCoverTitleActions.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ <CardFooter>
+ <CardActions>
+ <ButtonLink to="#post">Read more</ButtonLink>
+ <Button>Share</Button>
+ </CardActions>
+ </CardFooter>
+ </>
+ ),
+ cover: (
+ <CardCover>
+ <NextImage
+ alt="A cover example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ ),
+};
+
+export const CompositionTitleBodyActions = Template.bind({});
+CompositionTitleBodyActions.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et
+ necessitatibus esse laudantium sequi ad. Et est quas pariatur facere
+ velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur
+ dolores veniam. Nostrum est ullam aliquam aliquid expedita ea.
+ </CardBody>
+ <CardFooter>
+ <CardActions>
+ <ButtonLink to="#post">Read more</ButtonLink>
+ <Button>Share</Button>
+ </CardActions>
+ </CardFooter>
+ </>
+ ),
+};
+
+export const CompositionTitleBodyActionsMeta = Template.bind({});
+CompositionTitleBodyActionsMeta.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et
+ necessitatibus esse laudantium sequi ad. Et est quas pariatur facere
+ velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur
+ dolores veniam. Nostrum est ullam aliquam aliquid expedita ea.
+ </CardBody>
+ <CardFooter>
+ <CardActions>
+ <ButtonLink to="#post">Read more</ButtonLink>
+ <Button>Share</Button>
+ </CardActions>
+ </CardFooter>
+ </>
+ ),
+ meta: (
+ <CardMeta
+ items={[
+ {
+ id: 'categories',
+ label: 'Categories:',
+ value: [
+ { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> },
+ { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> },
+ ],
+ },
+ {
+ id: 'tags',
+ label: 'Tags:',
+ value: [
+ { id: 'tag-1', value: 'Tag 1' },
+ { id: 'tag-2', value: 'Tag 2' },
+ { id: 'tag-3', value: 'Tag 3' },
+ ],
+ },
+ ]}
+ />
+ ),
+};
+
+export const CompositionCoverTitleBodyActionsMeta = Template.bind({});
+CompositionCoverTitleBodyActionsMeta.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et
+ necessitatibus esse laudantium sequi ad. Et est quas pariatur facere
+ velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur
+ dolores veniam. Nostrum est ullam aliquam aliquid expedita ea.
+ </CardBody>
+ <CardFooter>
+ <CardActions>
+ <ButtonLink to="#post">Read more</ButtonLink>
+ <Button>Share</Button>
+ </CardActions>
+ </CardFooter>
+ </>
+ ),
+ cover: (
+ <CardCover>
+ <NextImage
+ alt="A cover example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ ),
+ meta: (
+ <CardMeta
+ items={[
+ {
+ id: 'categories',
+ label: 'Categories:',
+ value: [
+ { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> },
+ { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> },
+ ],
+ },
+ {
+ id: 'tags',
+ label: 'Tags:',
+ value: [
+ { id: 'tag-1', value: 'Tag 1' },
+ { id: 'tag-2', value: 'Tag 2' },
+ { id: 'tag-3', value: 'Tag 3' },
+ ],
+ },
+ ]}
+ />
+ ),
+};
+
+export const CompositionAllContents = Template.bind({});
+CompositionAllContents.args = {
+ children: (
+ <>
+ <CardHeader>
+ <CardTitle>The card title</CardTitle>
+ <CardMeta
+ isInline
+ items={[
+ { id: 'author', label: 'Written by:', value: 'The author' },
+ {
+ id: 'publication-date',
+ label: 'Published on:',
+ value: <Time date={new Date().toISOString()} />,
+ },
+ ]}
+ />
+ </CardHeader>
+ <CardBody>
+ Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et
+ necessitatibus esse laudantium sequi ad. Et est quas pariatur facere
+ velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur
+ dolores veniam. Nostrum est ullam aliquam aliquid expedita ea.
+ </CardBody>
+ <CardFooter>
+ <CardActions>
+ <ButtonLink to="#post">Read more</ButtonLink>
+ <Button>Share</Button>
+ </CardActions>
+ </CardFooter>
+ </>
+ ),
+ cover: (
+ <CardCover>
+ <NextImage
+ alt="A cover example"
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ </CardCover>
+ ),
+ meta: (
+ <CardMeta
+ items={[
+ {
+ id: 'categories',
+ label: 'Categories:',
+ value: [
+ { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> },
+ { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> },
+ ],
+ },
+ {
+ id: 'tags',
+ label: 'Tags:',
+ value: [
+ { id: 'tag-1', value: 'Tag 1' },
+ { id: 'tag-2', value: 'Tag 2' },
+ { id: 'tag-3', value: 'Tag 3' },
+ ],
+ },
+ ]}
+ />
+ ),
+};
diff --git a/src/components/molecules/card/card.test.tsx b/src/components/molecules/card/card.test.tsx
new file mode 100644
index 0000000..40a5830
--- /dev/null
+++ b/src/components/molecules/card/card.test.tsx
@@ -0,0 +1,129 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import NextImage from 'next/image';
+import { Card } from './card';
+import { CardFooter } from './card-footer';
+import { CardHeader } from './card-header';
+import { CardMeta } from './card-meta';
+
+describe('Card', () => {
+ it('renders its children', () => {
+ const body = 'eveniet error voluptas';
+
+ render(<Card>{body}</Card>);
+
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
+ });
+
+ it('can render a cover in the card header', () => {
+ const altTxt = 'quo expedita eveniet';
+
+ render(
+ <Card
+ cover={
+ <NextImage
+ alt={altTxt}
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ }
+ >
+ <CardHeader />
+ </Card>
+ );
+
+ expect(rtlScreen.getByRole('img', { name: altTxt })).toBeInTheDocument();
+ });
+
+ it('does not render a cover without card header', () => {
+ const body = 'necessitatibus maiores sed';
+ const altTxt = 'quo expedita eveniet';
+
+ render(
+ <Card
+ cover={
+ <NextImage
+ alt={altTxt}
+ height={480}
+ src="https://picsum.photos/640/480"
+ width={640}
+ />
+ }
+ >
+ {body}
+ </Card>
+ );
+
+ expect(
+ rtlScreen.queryByRole('img', { name: altTxt })
+ ).not.toBeInTheDocument();
+ });
+
+ it('can render some meta in the card footer', () => {
+ const term = 'ut';
+ const desc = 'repudiandae';
+
+ render(
+ <Card
+ meta={<CardMeta items={[{ id: 'any', label: term, value: desc }]} />}
+ >
+ <CardFooter />
+ </Card>
+ );
+
+ expect(rtlScreen.getByRole('term')).toHaveTextContent(term);
+ expect(rtlScreen.getByRole('definition')).toHaveTextContent(desc);
+ });
+
+ it('does not render the meta without card footer', () => {
+ const body = 'rerum dolore et';
+ const term = 'ut';
+ const desc = 'repudiandae';
+
+ render(
+ <Card
+ meta={<CardMeta items={[{ id: 'any', label: term, value: desc }]} />}
+ >
+ {body}
+ </Card>
+ );
+
+ expect(
+ rtlScreen.queryByRole('term', { name: term })
+ ).not.toBeInTheDocument();
+ expect(
+ rtlScreen.queryByRole('definition', { name: desc })
+ ).not.toBeInTheDocument();
+ });
+
+ it('can render a card as link to another page', () => {
+ const body = 'Et qui harum voluptas est quos qui.';
+ const cta = 'asperiores optio incidunt';
+ const target = '#molestiae';
+
+ render(
+ <Card aria-label={cta} linkTo={target}>
+ {body}
+ </Card>
+ );
+
+ expect(rtlScreen.getByRole('link', { name: cta })).toHaveAttribute(
+ 'href',
+ target
+ );
+ });
+
+ it('can render a card with centered text', () => {
+ const body = 'Et qui harum voluptas est quos qui.';
+ const label = 'asperiores optio incidunt';
+
+ render(
+ <Card aria-label={label} isCentered>
+ {body}
+ </Card>
+ );
+
+ expect(rtlScreen.getByLabelText(label)).toHaveClass('wrapper--centered');
+ });
+});
diff --git a/src/components/molecules/card/card.tsx b/src/components/molecules/card/card.tsx
new file mode 100644
index 0000000..788b040
--- /dev/null
+++ b/src/components/molecules/card/card.tsx
@@ -0,0 +1,112 @@
+import {
+ forwardRef,
+ type HTMLAttributes,
+ type ForwardedRef,
+ type ReactNode,
+ type ReactElement,
+} from 'react';
+import { Article, ButtonLink, type ButtonLinkProps } from '../../atoms';
+import { CardCoverProvider, CardFooterMetaProvider } from './card-provider';
+import styles from './card.module.scss';
+
+type CardBaseProps<T extends string | undefined> = T extends string
+ ? Omit<ButtonLinkProps, 'children' | 'isExternal' | 'kind' | 'shape' | 'to'>
+ : Omit<HTMLAttributes<HTMLDivElement>, 'children'>;
+
+export type CardProps<T extends string | undefined> = CardBaseProps<T> & {
+ /**
+ * The card contents.
+ */
+ children: ReactNode;
+ /**
+ * The card cover. You need to add a `<CardHeader />` as children to use it.
+ */
+ cover?: ReactElement;
+ /**
+ * Should the contents be centered?
+ *
+ * @default false
+ */
+ isCentered?: boolean;
+ /**
+ * Link the card to another page.
+ *
+ * @default undefined
+ */
+ linkTo?: T;
+ /**
+ * The meta to place in the card footer. You need to add a `<CardFooter />`
+ * as children to use it.
+ */
+ meta?: ReactElement;
+ /**
+ * The card variant.
+ *
+ * @default 1
+ */
+ variant?: 1 | 2;
+};
+
+const CardWrapper = <T extends string | undefined>(
+ {
+ children,
+ className = '',
+ cover,
+ isCentered = false,
+ linkTo,
+ meta,
+ variant = 1,
+ ...props
+ }: CardProps<T>,
+ ref: ForwardedRef<T extends string ? HTMLAnchorElement : HTMLDivElement>
+) => {
+ const wrapperClass = [
+ styles.wrapper,
+ styles[isCentered ? 'wrapper--centered' : 'wrapper--not-centered'],
+ styles[linkTo ? 'wrapper--is-link' : 'wrapper--is-block'],
+ className,
+ ].join(' ');
+ const cardClass = [
+ styles.card,
+ styles[cover ? 'card--has-cover' : 'card--no-cover'],
+ styles[meta ? 'card--has-footer-meta' : 'card--no-footer-meta'],
+ styles[`card--variant-${variant}`],
+ ].join(' ');
+
+ return (
+ <CardCoverProvider cover={cover}>
+ <CardFooterMetaProvider meta={meta}>
+ {linkTo ? (
+ <ButtonLink
+ {...(props as CardBaseProps<typeof linkTo>)}
+ className={wrapperClass}
+ ref={ref}
+ // eslint-disable-next-line react/jsx-no-literals -- Shape allowed
+ shape="auto"
+ to={linkTo}
+ >
+ <Article className={cardClass}>{children}</Article>
+ </ButtonLink>
+ ) : (
+ <div
+ {...(props as CardBaseProps<undefined>)}
+ className={wrapperClass}
+ ref={ref as ForwardedRef<HTMLDivElement>}
+ >
+ <Article className={cardClass}>{children}</Article>
+ </div>
+ )}
+ </CardFooterMetaProvider>
+ </CardCoverProvider>
+ );
+};
+
+/**
+ * Card component
+ *
+ * Render a card with title and some other optional data.
+ *
+ * TODO: remove `cover` and `meta` props (and adapt CSS) when support for CSS
+ * `:has()` pseudo-class will be good enough.
+ */
+export const Card = forwardRef(CardWrapper);
diff --git a/src/components/molecules/card/index.ts b/src/components/molecules/card/index.ts
new file mode 100644
index 0000000..2b55191
--- /dev/null
+++ b/src/components/molecules/card/index.ts
@@ -0,0 +1,8 @@
+export * from './card';
+export * from './card-actions';
+export * from './card-body';
+export * from './card-cover';
+export * from './card-footer';
+export * from './card-header';
+export * from './card-meta';
+export * from './card-title';