aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-10-17 19:46:08 +0200
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:14:41 +0100
commitc153f93dc8691a71dc76aad3dd618298da9d238a (patch)
tree9c116c1472bab5585f98bceee19cfeca5041360d
parent006b15b467a5cd835a6eab1b49023100bdc8f2e6 (diff)
refactor(components): rewrite Card component
* make the component more generic * merge `<Summary />` and `<Comment />` styles into card component to avoid repeating the same structure * remove most of the props to use composition However the CSS is a bit complex because of the two variants... Also, the component should be refactored when the CSS pseudo-class `:has` has enough support: the provider and the `cover` and `meta` props should be removed.
-rw-r--r--src/components/atoms/buttons/button-link/button-link.tsx2
-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
-rw-r--r--src/components/molecules/index.ts1
-rw-r--r--src/components/molecules/layout/card.module.scss90
-rw-r--r--src/components/molecules/layout/card.stories.tsx208
-rw-r--r--src/components/molecules/layout/card.test.tsx69
-rw-r--r--src/components/molecules/layout/card.tsx88
-rw-r--r--src/components/molecules/layout/index.ts1
-rw-r--r--src/components/organisms/layout/cards-list.module.scss2
-rw-r--r--src/components/organisms/layout/cards-list.stories.tsx145
-rw-r--r--src/components/organisms/layout/cards-list.test.tsx93
-rw-r--r--src/components/organisms/layout/cards-list.tsx25
-rw-r--r--src/components/organisms/layout/comment.module.scss150
-rw-r--r--src/components/organisms/layout/comment.tsx121
-rw-r--r--src/components/organisms/layout/summary.module.scss105
-rw-r--r--src/components/organisms/layout/summary.tsx92
-rw-r--r--src/i18n/en.json8
-rw-r--r--src/i18n/fr.json8
-rw-r--r--src/pages/index.tsx59
-rw-r--r--src/pages/projets/index.tsx72
-rw-r--r--src/styles/abstracts/placeholders/_buttons.scss2
36 files changed, 1796 insertions, 958 deletions
diff --git a/src/components/atoms/buttons/button-link/button-link.tsx b/src/components/atoms/buttons/button-link/button-link.tsx
index f8bbadc..96f5d3e 100644
--- a/src/components/atoms/buttons/button-link/button-link.tsx
+++ b/src/components/atoms/buttons/button-link/button-link.tsx
@@ -27,7 +27,7 @@ export type ButtonLinkProps = Omit<
*
* @default 'rectangle'
*/
- shape?: 'circle' | 'rectangle' | 'square';
+ shape?: 'auto' | 'circle' | 'rectangle' | 'square';
/**
* Define an URL or anchor as target.
*/
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';
diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts
index a1e2c7a..b042a96 100644
--- a/src/components/molecules/index.ts
+++ b/src/components/molecules/index.ts
@@ -1,5 +1,6 @@
export * from './branding';
export * from './buttons';
+export * from './card';
export * from './code';
export * from './collapsible';
export * from './forms';
diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss
deleted file mode 100644
index 14a5baf..0000000
--- a/src/components/molecules/layout/card.module.scss
+++ /dev/null
@@ -1,90 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-
-.footer {
- margin-top: auto;
-}
-
-.wrapper {
- --scale-up: 1.05;
- --scale-down: 0.95;
-
- display: flex;
- flex-flow: column wrap;
- max-width: var(--card-width, 40ch);
- padding: 0;
- text-align: center;
-
- .article {
- flex: 1;
- display: flex;
- flex-flow: column nowrap;
- justify-content: flex-start;
- }
-
- .cover {
- place-content: center;
- height: fun.convert-px(150);
- object-fit: scale-down;
- margin: auto;
- border-bottom: fun.convert-px(1) solid var(--color-border);
- }
-
- .title,
- .tagline,
- .footer {
- padding: 0 var(--spacing-md);
- }
-
- .title {
- width: fit-content;
- margin: var(--spacing-sm) auto;
- }
-
- h2.title {
- background: none;
- text-shadow: none;
- }
-
- .tagline {
- flex: 1;
- margin-bottom: var(--spacing-md);
- color: var(--color-fg);
- font-weight: 400;
- }
-
- .list {
- margin-bottom: var(--spacing-md);
- }
-
- .meta {
- &__item {
- flex-flow: row wrap;
- place-content: center;
- gap: var(--spacing-2xs);
- margin: auto;
- }
-
- &__label {
- flex: 0 0 100%;
- }
-
- &__value {
- padding: fun.convert-px(2) var(--spacing-xs);
- border: fun.convert-px(1) solid var(--color-primary-darker);
- color: var(--color-fg);
- font-weight: 400;
-
- &::before {
- display: none;
- }
- }
- }
-
- &:not(:disabled):focus {
- text-decoration: none;
-
- .title {
- text-decoration: underline solid var(--color-primary) 0.3ex;
- }
- }
-}
diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx
deleted file mode 100644
index 070c978..0000000
--- a/src/components/molecules/layout/card.stories.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import type { MetaItemData } from '../meta-list';
-import { Card } from './card';
-
-/**
- * Card - Storybook Meta
- */
-export default {
- title: 'Molecules/Layout/Card',
- component: Card,
- argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the card wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- cover: {
- description: 'The card cover data (src, dimensions, alternative text).',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- coverFit: {
- control: {
- type: 'select',
- },
- description: 'The cover fit.',
- options: ['contain', 'cover', 'fill', 'scale-down'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'cover' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- id: {
- control: {
- type: 'text',
- },
- description: 'The card id.',
- type: {
- name: 'string',
- required: true,
- },
- },
- meta: {
- description: 'The card metadata (a publication date for example).',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- tagline: {
- control: {
- type: 'text',
- },
- description: 'A few words about the card.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The card title.',
- type: {
- name: 'string',
- required: true,
- },
- },
- titleLevel: {
- control: {
- type: 'number',
- min: 1,
- max: 6,
- },
- description: 'The title level.',
- type: {
- name: 'number',
- required: true,
- },
- },
- url: {
- control: {
- type: 'text',
- },
- description: 'The card target.',
- type: {
- name: 'string',
- required: true,
- },
- },
- },
-} as ComponentMeta<typeof Card>;
-
-const Template: ComponentStory<typeof Card> = (args) => <Card {...args} />;
-
-const cover = {
- alt: 'A picture',
- height: 480,
- src: 'https://picsum.photos/640/480',
- width: 640,
-};
-
-const id = 'nam';
-
-const meta = [
- { id: 'author', label: 'Author', value: 'Possimus' },
- {
- id: 'categories',
- label: 'Categories',
- value: [
- { id: 'autem', value: 'Autem' },
- { id: 'eos', value: 'Eos' },
- ],
- },
-] satisfies MetaItemData[];
-
-const tagline = 'Ut rerum incidunt';
-
-const title = 'Alias qui porro';
-
-const url = '/an-existing-url';
-
-/**
- * Card Stories - Default
- */
-export const Default = Template.bind({});
-Default.args = {
- id,
- title,
- titleLevel: 2,
- url,
-};
-
-/**
- * Card Stories - With cover
- */
-export const WithCover = Template.bind({});
-WithCover.args = {
- cover,
- id,
- title,
- titleLevel: 2,
- url,
-};
-
-/**
- * Card Stories - With meta
- */
-export const WithMeta = Template.bind({});
-WithMeta.args = {
- id,
- meta,
- title,
- titleLevel: 2,
- url,
-};
-
-/**
- * Card Stories - With tagline
- */
-export const WithTagline = Template.bind({});
-WithTagline.args = {
- id,
- tagline,
- title,
- titleLevel: 2,
- url,
-};
-
-/**
- * Card Stories - With all data
- */
-export const WithAll = Template.bind({});
-WithAll.args = {
- cover,
- id,
- meta,
- tagline,
- title,
- titleLevel: 2,
- url,
-};
diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx
deleted file mode 100644
index b690d4c..0000000
--- a/src/components/molecules/layout/card.test.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { describe, expect, it } from '@jest/globals';
-import { render, screen as rtlScreen } from '../../../../tests/utils';
-import type { MetaItemData } from '../meta-list';
-import { Card } from './card';
-
-const cover = {
- alt: 'A picture',
- height: 480,
- src: 'https://picsum.photos/640/480',
- width: 640,
-};
-
-const id = 'nam';
-
-const meta = [
- { id: 'author', label: 'Author', value: 'Possimus' },
- {
- id: 'categories',
- label: 'Categories',
- value: [
- { id: 'autem', value: 'Autem' },
- { id: 'eos', value: 'Eos' },
- ],
- },
-] satisfies MetaItemData[];
-
-const tagline = 'Ut rerum incidunt';
-
-const title = 'Alias qui porro';
-
-const url = '/an-existing-url';
-
-describe('Card', () => {
- it('renders a title wrapped in h2 element', () => {
- render(<Card id={id} title={title} titleLevel={2} url={url} />);
- expect(
- rtlScreen.getByRole('heading', { level: 2, name: title })
- ).toBeInTheDocument();
- });
-
- it('renders a link to another page', () => {
- render(<Card id={id} title={title} titleLevel={2} url={url} />);
- expect(rtlScreen.getByRole('link')).toHaveAttribute('href', url);
- });
-
- it('renders a cover', () => {
- render(
- <Card id={id} title={title} titleLevel={2} url={url} cover={cover} />
- );
- expect(rtlScreen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
- });
-
- it('renders a tagline', () => {
- render(
- <Card id={id} title={title} titleLevel={2} url={url} tagline={tagline} />
- );
- expect(rtlScreen.getByText(tagline)).toBeInTheDocument();
- });
-
- it('renders some meta', () => {
- render(<Card id={id} title={title} titleLevel={2} url={url} meta={meta} />);
-
- const metaLabels = meta.map((item) => item.label);
-
- for (const label of metaLabels) {
- expect(rtlScreen.getByText(label)).toBeInTheDocument();
- }
- });
-});
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
deleted file mode 100644
index d90cba2..0000000
--- a/src/components/molecules/layout/card.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import NextImage, { type ImageProps as NextImageProps } from 'next/image';
-import type { FC } from 'react';
-import { ButtonLink, Figure, Heading, type HeadingLevel } from '../../atoms';
-import { MetaList, type MetaItemData } from '../meta-list';
-import styles from './card.module.scss';
-
-export type CardProps = {
- /**
- * Set additional classnames to the card wrapper.
- */
- className?: string;
- /**
- * The card cover.
- */
- cover?: Pick<NextImageProps, 'alt' | 'src' | 'title' | 'width' | 'height'>;
- /**
- * The card id.
- */
- id: string;
- /**
- * The card meta.
- */
- meta?: MetaItemData[];
- /**
- * The card tagline.
- */
- tagline?: string;
- /**
- * The card title.
- */
- title: string;
- /**
- * The title level (hn).
- */
- titleLevel: HeadingLevel;
- /**
- * The card target.
- */
- url: string;
-};
-
-/**
- * Card component
- *
- * Render a link with minimal information about its content.
- */
-export const Card: FC<CardProps> = ({
- className = '',
- cover,
- id,
- meta,
- tagline,
- title,
- titleLevel,
- url,
-}) => {
- const cardClass = `${styles.wrapper} ${className}`;
- const headingId = `${id}-heading`;
-
- return (
- <ButtonLink aria-labelledby={headingId} className={cardClass} to={url}>
- <article className={styles.article}>
- <header className={styles.header}>
- {cover ? (
- <Figure>
- <NextImage {...cover} className={styles.cover} />
- </Figure>
- ) : null}
- <Heading className={styles.title} id={headingId} level={titleLevel}>
- {title}
- </Heading>
- </header>
- {tagline ? <div className={styles.tagline}>{tagline}</div> : null}
- {meta ? (
- <footer className={styles.footer}>
- <MetaList
- className={styles.list}
- hasBorderedValues={meta.length < 2}
- hasInlinedValues={meta.length < 2}
- isCentered
- items={meta}
- />
- </footer>
- ) : null}
- </article>
- </ButtonLink>
- );
-};
diff --git a/src/components/molecules/layout/index.ts b/src/components/molecules/layout/index.ts
index 80db10a..75cbe28 100644
--- a/src/components/molecules/layout/index.ts
+++ b/src/components/molecules/layout/index.ts
@@ -1,4 +1,3 @@
-export * from './card';
export * from './columns';
export * from './page-footer';
export * from './page-header';
diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss
index 8b18c08..1665829 100644
--- a/src/components/organisms/layout/cards-list.module.scss
+++ b/src/components/organisms/layout/cards-list.module.scss
@@ -19,6 +19,6 @@
}
}
-.card {
+.item > * {
height: 100%;
}
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
index 03feee7..3f8e72a 100644
--- a/src/components/organisms/layout/cards-list.stories.tsx
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -1,4 +1,5 @@
import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Card, CardBody, CardHeader, CardTitle } from '../../molecules';
import {
CardsList as CardsListComponent,
type CardsListItem,
@@ -10,39 +11,7 @@ import {
export default {
title: 'Organisms/Layout',
component: CardsListComponent,
- args: {
- coverFit: 'cover',
- kind: 'unordered',
- },
argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the list wrapper.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- coverFit: {
- control: {
- type: 'select',
- },
- description: 'The cover fit.',
- options: ['fill', 'contain', 'cover', 'none', 'scale-down'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'cover' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
items: {
description: 'The cards data.',
type: {
@@ -51,33 +20,20 @@ export default {
value: {},
},
},
- kind: {
+ isOrdered: {
control: {
- type: 'select',
+ type: 'boolean',
},
- description: 'The list kind.',
- options: ['ordered', 'unordered'],
+ description: 'Should the list be ordered?',
table: {
category: 'Options',
- defaultValue: { summary: 'unordered' },
+ defaultValue: { summary: false },
},
type: {
- name: 'string',
+ name: 'boolean',
required: false,
},
},
- titleLevel: {
- control: {
- type: 'number',
- min: 1,
- max: 6,
- },
- description: 'The heading level for each card.',
- type: {
- name: 'number',
- required: true,
- },
- },
},
} as ComponentMeta<typeof CardsListComponent>;
@@ -88,63 +44,49 @@ const Template: ComponentStory<typeof CardsListComponent> = (args) => (
const items: CardsListItem[] = [
{
id: 'card-1',
- cover: {
- alt: 'card 1 picture',
- src: 'https://picsum.photos/640/480',
- width: 640,
- height: 480,
- },
- meta: [
- {
- id: 'categories',
- label: 'Categories',
- value: [
- { id: 'velit', value: 'Velit' },
- { id: 'ex', value: 'Ex' },
- { id: 'alias', value: 'Alias' },
- ],
- },
- ],
- tagline: 'Molestias ut error',
- title: 'Et alias omnis',
- url: '#',
+ card: (
+ <Card>
+ <CardHeader>
+ <CardTitle>Et alias omnis</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Rerum voluptatem sint sint sit dignissimos. Labore totam possimus
+ tempore atque veniam. Doloremque tenetur quidem beatae veritatis quo.
+ Quaerat voluptatem deleniti voluptas quia. Qui voluptatem iure iste
+ expedita et sed beatae.
+ </CardBody>
+ </Card>
+ ),
},
{
id: 'card-2',
- cover: {
- alt: 'card 2 picture',
- src: 'https://picsum.photos/640/480',
- width: 640,
- height: 480,
- },
- meta: [{ id: 'categories', label: 'Categories', value: 'Voluptas' }],
- tagline: 'Quod vel accusamus',
- title: 'Laboriosam doloremque mollitia',
- url: '#',
+ card: (
+ <Card>
+ <CardHeader>
+ <CardTitle>Fugiat magnam nesciunt</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Sit corporis animi ea. Earum asperiores error et. Aliquid quia et
+ consequatur. Magnam sit ut facere explicabo vel dolorem earum
+ assumenda. Aspernatur inventore quod libero est.
+ </CardBody>
+ </Card>
+ ),
},
{
id: 'card-3',
- cover: {
- alt: 'card 3 picture',
- src: 'https://picsum.photos/640/480',
- width: 640,
- height: 480,
- },
- meta: [
- {
- id: 'categories',
- label: 'Categories',
- value: [
- { id: 'quisquam', value: 'Quisquam' },
- { id: 'quia', value: 'Quia' },
- { id: 'sapiente', value: 'Sapiente' },
- { id: 'perspiciatis', value: 'Perspiciatis' },
- ],
- },
- ],
- tagline: 'Quo error eum',
- title: 'Magni rem nulla',
- url: '#',
+ card: (
+ <Card>
+ <CardHeader>
+ <CardTitle>Asperiores eum quas</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Doloremque ut cupiditate distinctio aperiam. Neque tempora unde
+ perferendis asperiores. Doloremque velit vel quam. Temporibus itaque
+ non non exercitationem.
+ </CardBody>
+ </Card>
+ ),
},
];
@@ -154,5 +96,4 @@ const items: CardsListItem[] = [
export const CardsList = Template.bind({});
CardsList.args = {
items,
- titleLevel: 2,
};
diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx
index c9d6ae7..b04e109 100644
--- a/src/components/organisms/layout/cards-list.test.tsx
+++ b/src/components/organisms/layout/cards-list.test.tsx
@@ -1,73 +1,60 @@
import { describe, expect, it } from '@jest/globals';
-import { render, screen as rtlScreen } from '../../../../tests/utils';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { Card, CardBody, CardHeader, CardTitle } from '../../molecules';
import { CardsList, type CardsListItem } from './cards-list';
const items: CardsListItem[] = [
{
id: 'card-1',
- cover: {
- alt: 'card 1 picture',
- src: 'https://picsum.photos/640/480',
- width: 640,
- height: 480,
- },
- meta: [
- {
- id: 'categories',
- label: 'Categories',
- value: [
- { id: 'velit', value: 'Velit' },
- { id: 'ex', value: 'Ex' },
- { id: 'alias', value: 'Alias' },
- ],
- },
- ],
- tagline: 'Molestias ut error',
- title: 'Et alias omnis',
- url: '#',
+ card: (
+ <Card>
+ <CardHeader>
+ <CardTitle>Et alias omnis</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Rerum voluptatem sint sint sit dignissimos. Labore totam possimus
+ tempore atque veniam. Doloremque tenetur quidem beatae veritatis quo.
+ Quaerat voluptatem deleniti voluptas quia. Qui voluptatem iure iste
+ expedita et sed beatae.
+ </CardBody>
+ </Card>
+ ),
},
{
id: 'card-2',
- cover: {
- alt: 'card 2 picture',
- src: 'https://picsum.photos/640/480',
- width: 640,
- height: 480,
- },
- meta: [{ id: 'categories', label: 'Categories', value: 'Voluptas' }],
- tagline: 'Quod vel accusamus',
- title: 'Laboriosam doloremque mollitia',
- url: '#',
+ card: (
+ <Card>
+ <CardHeader>
+ <CardTitle>Fugiat magnam nesciunt</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Sit corporis animi ea. Earum asperiores error et. Aliquid quia et
+ consequatur. Magnam sit ut facere explicabo vel dolorem earum
+ assumenda. Aspernatur inventore quod libero est.
+ </CardBody>
+ </Card>
+ ),
},
{
id: 'card-3',
- cover: {
- alt: 'card 3 picture',
- src: 'https://picsum.photos/640/480',
- width: 640,
- height: 480,
- },
- meta: [
- {
- id: 'categories',
- label: 'Categories',
- value: [
- { id: 'quisquam', value: 'Quisquam' },
- { id: 'quia', value: 'Quia' },
- { id: 'sapiente', value: 'Sapiente' },
- { id: 'perspiciatis', value: 'Perspiciatis' },
- ],
- },
- ],
- tagline: 'Quo error eum',
- title: 'Magni rem nulla',
- url: '#',
+ card: (
+ <Card>
+ <CardHeader>
+ <CardTitle>Asperiores eum quas</CardTitle>
+ </CardHeader>
+ <CardBody>
+ Doloremque ut cupiditate distinctio aperiam. Neque tempora unde
+ perferendis asperiores. Doloremque velit vel quam. Temporibus itaque
+ non non exercitationem.
+ </CardBody>
+ </Card>
+ ),
},
];
describe('CardsList', () => {
it('renders a list of cards', () => {
- render(<CardsList items={items} titleLevel={2} />);
+ render(<CardsList items={items} />);
expect(rtlScreen.getAllByRole('heading', { level: 2 })).toHaveLength(
items.length
);
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index 29add3b..4f920e8 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -1,16 +1,20 @@
-import type { FC } from 'react';
+import type { FC, ReactElement } from 'react';
import { List, ListItem } from '../../atoms';
-import { Card, type CardProps } from '../../molecules';
+import type { CardProps } from '../../molecules';
import styles from './cards-list.module.scss';
-export type CardsListItem = Omit<CardProps, 'className' | 'titleLevel'> & {
+export type CardsListItem = {
+ /**
+ * The card.
+ */
+ card: ReactElement<CardProps<string> | CardProps<undefined>>;
/**
* The card id.
*/
id: string;
};
-export type CardsListProps = Pick<CardProps, 'titleLevel'> & {
+export type CardsListProps = {
/**
* Set additional classnames to the list wrapper.
*/
@@ -36,7 +40,6 @@ export const CardsList: FC<CardsListProps> = ({
className = '',
isOrdered = false,
items,
- titleLevel,
}) => {
const kindModifier = `wrapper--${isOrdered ? 'ordered' : 'unordered'}`;
@@ -47,15 +50,9 @@ export const CardsList: FC<CardsListProps> = ({
isInline
isOrdered={isOrdered}
>
- {items.map(({ id, ...item }) => (
- <ListItem key={id}>
- <Card
- {...item}
- className={styles.card}
- key={id}
- id={id}
- titleLevel={titleLevel}
- />
+ {items.map(({ id, card }) => (
+ <ListItem className={styles.item} key={id}>
+ {card}
</ListItem>
))}
</List>
diff --git a/src/components/organisms/layout/comment.module.scss b/src/components/organisms/layout/comment.module.scss
index bf8aada..f26b3fe 100644
--- a/src/components/organisms/layout/comment.module.scss
+++ b/src/components/organisms/layout/comment.module.scss
@@ -2,132 +2,72 @@
@use "../../../styles/abstracts/mixins" as mix;
@use "../../../styles/abstracts/placeholders";
-.wrapper {
- padding: var(--spacing-md);
- background: var(--color-bg);
- 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);
-
- &--comment {
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- display: grid;
- grid-template-columns: minmax(0, #{fun.convert-px(150)}) minmax(0, 1fr);
- column-gap: var(--spacing-lg);
- }
- }
- }
-
- &--form {
- display: flex;
- flex-flow: column wrap;
- place-content: center;
- margin-top: var(--spacing-sm);
- }
-
- .header {
- display: flex;
- flex-flow: column wrap;
- align-items: center;
- row-gap: var(--spacing-sm);
-
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- grid-row: 1 / 4;
- }
- }
- }
-
- .author {
- color: var(--color-primary-darker);
- font-weight: 600;
- text-align: center;
- }
-
- .avatar {
- width: fun.convert-px(85);
- height: fun.convert-px(85);
- position: relative;
+.avatar {
+ img {
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);
-
- img {
- border-radius: fun.convert-px(3);
- }
}
+}
- .date {
- margin: var(--spacing-sm) 0;
- font-size: var(--font-size-sm);
+.author {
+ color: var(--color-primary-darker);
+ font-family: var(--font-family-regular);
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ text-align: center;
+ text-shadow: none;
+}
- &__item {
- justify-content: center;
- }
+.body {
+ overflow-wrap: break-word;
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- margin: 0 0 var(--spacing-sm);
+ :global {
+ a {
+ @extend %link;
- &__item {
- justify-content: left;
- }
+ &[hreflang],
+ &.download,
+ &.external {
+ @extend %link-with-icon;
}
- }
- }
-
- .body {
- overflow-wrap: break-word;
-
- :global {
- a {
- @extend %link;
- &[hreflang],
- &.download,
- &.external {
- @extend %link-with-icon;
- }
-
- &[hreflang] {
- @extend %link-with-lang;
- }
+ &[hreflang] {
+ @extend %link-with-lang;
+ }
- &[hreflang]:not(.download, .external) {
- --is-icon-hidden: "";
- }
+ &[hreflang]:not(.download, .external) {
+ --is-icon-hidden: "";
+ }
- &.download {
- @extend %download-link;
- }
+ &.download {
+ @extend %download-link;
+ }
- &.external {
- @extend %external-link;
- }
+ &.external {
+ @extend %external-link;
+ }
- &.download,
- &.external {
- &:not([hreflang]) {
- --is-lang-hidden: "";
- }
+ &.download,
+ &.external {
+ &:not([hreflang]) {
+ --is-lang-hidden: "";
}
+ }
- &.external.download {
- @extend %external-download-link;
- }
+ &.external.download {
+ @extend %external-download-link;
}
}
}
+}
+
+.form {
+ margin-top: var(--spacing-sm);
- .footer {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- padding: var(--spacing-md) 0 0;
+ form > * {
+ margin-inline: auto;
}
}
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
index cb2f16f..db7cb3a 100644
--- a/src/components/organisms/layout/comment.tsx
+++ b/src/components/organisms/layout/comment.tsx
@@ -7,7 +7,16 @@ import type { Comment as CommentSchema, WithContext } from 'schema-dts';
import type { SingleComment } from '../../../types';
import { useSettings } from '../../../utils/hooks';
import { Button, Link, Time } from '../../atoms';
-import { MetaList } from '../../molecules';
+import {
+ Card,
+ CardActions,
+ CardBody,
+ CardCover,
+ CardFooter,
+ CardHeader,
+ CardMeta,
+ CardTitle,
+} from '../../molecules';
import { CommentForm, type CommentFormProps } from '../forms';
import styles from './comment.module.scss';
@@ -103,9 +112,6 @@ export const UserComment: FC<UserCommentProps> = ({
text: content,
};
- const commentWrapperClass = `${styles.wrapper} ${styles['wrapper--comment']}`;
- const formWrapperClass = `${styles.wrapper} ${styles['wrapper--form']}`;
-
return (
<>
<Script
@@ -113,10 +119,10 @@ export const UserComment: FC<UserCommentProps> = ({
id="schema-comments"
type="application/ld+json"
/>
- <article className={commentWrapperClass} id={`comment-${id}`}>
- <header className={styles.header}>
- {author.avatar ? (
- <div className={styles.avatar}>
+ <Card
+ cover={
+ author.avatar ? (
+ <CardCover className={styles.avatar}>
<NextImage
{...props}
alt={author.avatar.alt}
@@ -124,55 +130,64 @@ export const UserComment: FC<UserCommentProps> = ({
src={author.avatar.src}
style={{ objectFit: 'cover' }}
/>
- </div>
- ) : null}
- {author.website ? (
- <Link href={author.website} className={styles.author}>
- {author.name}
- </Link>
- ) : (
- <span className={styles.author}>{author.name}</span>
- )}
- </header>
- <MetaList
- className={styles.date}
- isInline
- items={[
- {
- id: 'publication-date',
- label: intl.formatMessage({
- defaultMessage: 'Published on:',
- description: 'Comment: publication date label',
- id: 'soj7do',
- }),
- value: (
- <Link href={`#comment-${id}`}>
- <Time date={date} showTime />
- </Link>
- ),
- },
- ]}
- />
- <div
+ </CardCover>
+ ) : undefined
+ }
+ id={`comment-${id}`}
+ variant={2}
+ >
+ <CardHeader>
+ <CardTitle className={styles.author} isFake>
+ {author.website ? (
+ <Link href={author.website}>{author.name}</Link>
+ ) : (
+ author.name
+ )}
+ </CardTitle>
+ <CardMeta
+ hasInlinedItems
+ items={[
+ {
+ id: 'publication-date',
+ label: intl.formatMessage({
+ defaultMessage: 'Published on:',
+ description: 'Comment: publication date label',
+ id: 'soj7do',
+ }),
+ value: (
+ <Link href={`#comment-${id}`}>
+ <Time date={date} showTime />
+ </Link>
+ ),
+ },
+ ]}
+ />
+ </CardHeader>
+ <CardBody
className={styles.body}
dangerouslySetInnerHTML={{ __html: content }}
/>
- <footer className={styles.footer}>
- {canReply ? (
- <Button kind="tertiary" onClick={handleReply}>
- {buttonLabel}
- </Button>
- ) : null}
- </footer>
- </article>
+ {canReply ? (
+ <CardFooter>
+ <CardActions>
+ <Button kind="tertiary" onClick={handleReply}>
+ {buttonLabel}
+ </Button>
+ </CardActions>
+ </CardFooter>
+ ) : null}
+ </Card>
{isReplying ? (
- <CommentForm
- className={formWrapperClass}
- Notice={Notice}
- parentId={id}
- saveComment={saveComment}
- title={formTitle}
- />
+ <Card className={styles.form} variant={2}>
+ <CardBody>
+ <CommentForm
+ Notice={Notice}
+ parentId={id}
+ saveComment={saveComment}
+ title={formTitle}
+ />
+ </CardBody>
+ </Card>
) : null}
</>
);
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
index ffc30ac..6e0af6a 100644
--- a/src/components/organisms/layout/summary.module.scss
+++ b/src/components/organisms/layout/summary.module.scss
@@ -1,34 +1,6 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
@use "../../../styles/abstracts/placeholders";
.wrapper {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- column-gap: var(--spacing-md);
- row-gap: var(--spacing-sm);
- padding: var(--spacing-2xs) 0 var(--spacing-lg);
-
- @include mix.media("screen") {
- @include mix.dimensions("xs") {
- padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-md);
- 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);
- }
-
- @include mix.dimensions("sm") {
- grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
- grid-template-rows: repeat(3, max-content);
- }
- }
-
&:hover {
.icon {
:global {
@@ -38,86 +10,15 @@
}
}
-.cover {
- display: inline-flex;
- flex-flow: column nowrap;
- justify-content: center;
- width: auto;
- height: fun.convert-px(100);
- max-width: 100%;
- border: fun.convert-px(1) solid var(--color-border);
- object-fit: scale-down;
-
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- grid-column: 2;
- grid-row: 1;
- }
- }
-}
-
-.header {
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- grid-column: 1;
- grid-row: 1;
- align-self: center;
- }
- }
-}
-
-.body {
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- grid-column: 1;
- grid-row: 2;
- }
- }
-}
-
-.footer {
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- grid-column: 2;
- grid-row: 2 / 4;
- }
- }
-}
-
-.link {
- display: block;
- width: fit-content;
-}
-
.title {
- margin: 0;
- background: none;
- color: inherit;
font-size: var(--font-size-2xl);
- text-shadow: none;
-}
-
-.read-more {
- display: flex;
- flex-flow: row nowrap;
- column-gap: var(--spacing-xs);
- width: max-content;
- margin: var(--spacing-sm) 0 0;
}
-.meta {
- flex-flow: row wrap;
- font-size: var(--font-size-sm);
-
- @include mix.media("screen") {
- @include mix.dimensions("sm") {
- flex-flow: column wrap;
- margin-top: 0;
- }
+.intro {
+ > *:last-child {
+ margin-bottom: 0;
}
-}
-.intro {
:global {
a {
@extend %link;
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 4fe7632..0c95f90 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -3,16 +3,18 @@ import type { FC, ReactNode } from 'react';
import { useIntl } from 'react-intl';
import type { Article, Meta as MetaType } from '../../../types';
import { useReadingTime } from '../../../utils/hooks';
+import { ButtonLink, type HeadingLevel, Icon, Link, Time } from '../../atoms';
import {
- ButtonLink,
- Heading,
- type HeadingLevel,
- Icon,
- Link,
- Figure,
- Time,
-} from '../../atoms';
-import { MetaList, type MetaItemData } from '../../molecules';
+ Card,
+ CardActions,
+ CardBody,
+ CardCover,
+ CardFooter,
+ CardHeader,
+ CardMeta,
+ CardTitle,
+ type MetaItemData,
+} from '../../molecules';
import styles from './summary.module.scss';
export type Cover = Pick<NextImageProps, 'alt' | 'src' | 'width' | 'height'>;
@@ -56,6 +58,14 @@ export const Summary: FC<SummaryProps> = ({
url,
}) => {
const intl = useIntl();
+ const figureLabel = intl.formatMessage(
+ {
+ defaultMessage: '{title} cover',
+ description: 'Summary: figure (cover) accessible name',
+ id: 'RNVe1W',
+ },
+ { title }
+ );
const readMore = intl.formatMessage(
{
defaultMessage: 'Read more<a11y> about {title}</a11y>',
@@ -182,40 +192,44 @@ export const Summary: FC<SummaryProps> = ({
};
return (
- <article className={styles.wrapper}>
- {meta.cover ? (
- <Figure>
- <NextImage {...meta.cover} className={styles.cover} />
- </Figure>
- ) : null}
- <header className={styles.header}>
- <Link href={url} className={styles.link}>
- <Heading level={titleLevel} className={styles.title}>
- {title}
- </Heading>
- </Link>
- </header>
- <div className={styles.body}>
+ <Card
+ className={styles.wrapper}
+ cover={
+ meta.cover ? (
+ <CardCover aria-label={figureLabel} hasBorders>
+ <NextImage {...meta.cover} />
+ </CardCover>
+ ) : undefined
+ }
+ meta={<CardMeta items={getMetaItems()} />}
+ >
+ <CardHeader>
+ <CardTitle className={styles.title} level={titleLevel}>
+ <Link href={url}>{title}</Link>
+ </CardTitle>
+ </CardHeader>
+ <CardBody>
<div
className={styles.intro}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: intro }}
/>
- <ButtonLink className={styles['read-more']} to={url}>
- {readMore}
- <Icon
- aria-hidden={true}
- className={styles.icon}
- // eslint-disable-next-line react/jsx-no-literals -- Direction allowed
- orientation="right"
- // eslint-disable-next-line react/jsx-no-literals -- Shape allowed
- shape="arrow"
- />
- </ButtonLink>
- </div>
- <footer className={styles.footer}>
- <MetaList className={styles.meta} items={getMetaItems()} />
- </footer>
- </article>
+ </CardBody>
+ <CardFooter>
+ <CardActions>
+ <ButtonLink to={url}>
+ {readMore}
+ <Icon
+ aria-hidden={true}
+ className={styles.icon}
+ // eslint-disable-next-line react/jsx-no-literals -- Direction allowed
+ orientation="right"
+ // eslint-disable-next-line react/jsx-no-literals -- Shape allowed
+ shape="arrow"
+ />
+ </ButtonLink>
+ </CardActions>
+ </CardFooter>
+ </Card>
);
};
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 9bfe248..8f552e6 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -231,6 +231,10 @@
"defaultMessage": "Updated on:",
"description": "Page: update date label"
},
+ "FdF33B": {
+ "defaultMessage": "{title} cover",
+ "description": "ProjectsPage: figure (cover) accessible name"
+ },
"G+Twgm": {
"defaultMessage": "Search",
"description": "SearchModal: modal title"
@@ -371,6 +375,10 @@
"defaultMessage": "CV",
"description": "Layout: main nav - cv link"
},
+ "RNVe1W": {
+ "defaultMessage": "{title} cover",
+ "description": "Summary: figure (cover) accessible name"
+ },
"RecdwX": {
"defaultMessage": "Published on:",
"description": "ArticlePage: publication date label"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index a988729..b499a1f 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -231,6 +231,10 @@
"defaultMessage": "Mis à jour le :",
"description": "Page: update date label"
},
+ "FdF33B": {
+ "defaultMessage": "Illustration de {title}",
+ "description": "ProjectsPage: figure (cover) accessible name"
+ },
"G+Twgm": {
"defaultMessage": "Recherche",
"description": "SearchModal: modal title"
@@ -371,6 +375,10 @@
"defaultMessage": "CV",
"description": "Layout: main nav - cv link"
},
+ "RNVe1W": {
+ "defaultMessage": "Illustration de {title}",
+ "description": "Summary: figure (cover) accessible name"
+ },
"RecdwX": {
"defaultMessage": "Publié le :",
"description": "ArticlePage: publication date label"
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index fb6ba9a..e482fb4 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -8,19 +8,25 @@ import type { FC, HTMLAttributes } from 'react';
import { useIntl } from 'react-intl';
import {
ButtonLink,
+ Card,
+ CardCover,
+ CardFooter,
+ CardHeader,
+ CardMeta,
+ CardTitle,
CardsList,
type CardsListItem,
Column,
Columns,
type ColumnsProps,
+ Figure,
getLayout,
+ Heading,
Icon,
List,
ListItem,
Section,
type SectionProps,
- Heading,
- Figure,
Time,
} from '../components';
import HomePageContent from '../content/pages/homepage.mdx';
@@ -299,22 +305,47 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
const getRecentPosts = (): JSX.Element => {
const posts: CardsListItem[] = recentPosts.map((post) => {
return {
- cover: post.cover,
- id: post.slug,
- meta: [
- {
- id: 'publication-date',
- label: publicationDate,
- value: <Time date={post.dates.publication} />,
- },
- ],
- title: post.title,
- url: `${ROUTES.ARTICLE}/${post.slug}`,
+ card: (
+ <Card
+ cover={
+ post.cover ? (
+ <CardCover hasBorders>
+ <NextImage
+ {...post.cover}
+ style={{ objectFit: 'scale-down' }}
+ />
+ </CardCover>
+ ) : undefined
+ }
+ meta={
+ <CardMeta
+ hasBorderedValues
+ hasInlinedValues
+ isCentered
+ items={[
+ {
+ id: 'publication-date',
+ label: publicationDate,
+ value: <Time date={post.dates.publication} />,
+ },
+ ]}
+ />
+ }
+ isCentered
+ linkTo={`${ROUTES.ARTICLE}/${post.slug}`}
+ >
+ <CardHeader>
+ <CardTitle level={3}>{post.title}</CardTitle>
+ </CardHeader>
+ <CardFooter />
+ </Card>
+ ),
+ id: `${post.id}`,
};
});
const listClass = `${styles.list} ${styles['list--cards']}`;
- return <CardsList className={listClass} items={posts} titleLevel={3} />;
+ return <CardsList className={listClass} items={posts} />;
};
const components: MDXComponents = {
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 44354ce..abb6da0 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -2,14 +2,22 @@
import type { MDXComponents } from 'mdx/types';
import type { GetStaticProps } from 'next';
import Head from 'next/head';
+import NextImage from 'next/image';
import { useRouter } from 'next/router';
import Script from 'next/script';
import { useIntl } from 'react-intl';
import {
+ Card,
+ CardCover,
+ CardBody,
+ CardFooter,
+ CardHeader,
+ CardTitle,
CardsList,
type CardsListItem,
getLayout,
Link,
+ MetaList,
PageLayout,
} from '../../components';
import PageContent, { meta } from '../../content/pages/projects.mdx';
@@ -56,24 +64,54 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
const items: CardsListItem[] = projects.map(
({ id, meta: projectMeta, slug, title: projectTitle }) => {
const { cover, tagline, technologies } = projectMeta;
+ const figureLabel = intl.formatMessage(
+ {
+ defaultMessage: '{title} cover',
+ description: 'ProjectsPage: figure (cover) accessible name',
+ id: 'FdF33B',
+ },
+ { title: projectTitle }
+ );
return {
- cover,
- id: id as string,
- meta: technologies?.length
- ? [
- {
- id: 'technologies',
- label: metaLabel,
- value: technologies.map((techno) => {
- return { id: techno, value: techno };
- }),
- },
- ]
- : [],
- tagline,
- title: projectTitle,
- url: `${ROUTES.PROJECTS}/${slug}`,
+ card: (
+ <Card
+ cover={
+ cover ? (
+ <CardCover aria-label={figureLabel} hasBorders>
+ <NextImage {...cover} />
+ </CardCover>
+ ) : undefined
+ }
+ meta={
+ technologies ? (
+ <MetaList
+ hasBorderedValues
+ hasInlinedValues
+ isCentered
+ items={[
+ {
+ id: 'technologies',
+ label: metaLabel,
+ value: technologies.map((techno) => {
+ return { id: techno, value: techno };
+ }),
+ },
+ ]}
+ />
+ ) : undefined
+ }
+ isCentered
+ linkTo={`${ROUTES.PROJECTS}/${slug}`}
+ >
+ <CardHeader>
+ <CardTitle>{projectTitle}</CardTitle>
+ </CardHeader>
+ <CardBody>{tagline}</CardBody>
+ <CardFooter />
+ </Card>
+ ),
+ id: `${id}`,
};
}
);
@@ -127,7 +165,7 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
breadcrumb={breadcrumbItems}
breadcrumbSchema={breadcrumbSchema}
>
- <CardsList items={items} titleLevel={2} className={styles.list} />
+ <CardsList className={styles.list} items={items} />
</PageLayout>
</>
);
diff --git a/src/styles/abstracts/placeholders/_buttons.scss b/src/styles/abstracts/placeholders/_buttons.scss
index 38388a1..896c5a9 100644
--- a/src/styles/abstracts/placeholders/_buttons.scss
+++ b/src/styles/abstracts/placeholders/_buttons.scss
@@ -1,7 +1,7 @@
@use "../functions" as fun;
%button {
- display: inline-flex;
+ display: flex;
place-content: center;
align-items: center;
gap: var(--spacing-2xs);