aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms/layout
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/organisms/layout')
-rw-r--r--src/components/organisms/layout/cards-list.module.scss27
-rw-r--r--src/components/organisms/layout/cards-list.stories.tsx105
-rw-r--r--src/components/organisms/layout/cards-list.test.tsx61
-rw-r--r--src/components/organisms/layout/cards-list.tsx80
-rw-r--r--src/components/organisms/layout/footer.module.scss41
-rw-r--r--src/components/organisms/layout/footer.stories.tsx74
-rw-r--r--src/components/organisms/layout/footer.test.tsx33
-rw-r--r--src/components/organisms/layout/footer.tsx52
-rw-r--r--src/components/organisms/layout/overview.module.scss12
-rw-r--r--src/components/organisms/layout/overview.stories.tsx50
-rw-r--r--src/components/organisms/layout/overview.test.tsx29
-rw-r--r--src/components/organisms/layout/overview.tsx33
-rw-r--r--src/components/organisms/layout/summary.module.scss84
-rw-r--r--src/components/organisms/layout/summary.stories.tsx114
-rw-r--r--src/components/organisms/layout/summary.test.tsx85
-rw-r--r--src/components/organisms/layout/summary.tsx105
16 files changed, 985 insertions, 0 deletions
diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss
new file mode 100644
index 0000000..9fe428c
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.module.scss
@@ -0,0 +1,27 @@
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ --card-width: 30ch;
+
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width))
+ );
+ gap: var(--spacing-sm);
+ place-content: center;
+ align-items: stretch;
+ justify-items: stretch;
+
+ &--ordered {
+ @extend %reset-ordered-list;
+ }
+
+ &--unordered {
+ @extend %reset-list;
+ }
+}
+
+.card {
+ height: 100%;
+}
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
new file mode 100644
index 0000000..5182808
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -0,0 +1,105 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CardsListComponent, { type CardsListItem } from './cards-list';
+
+export default {
+ title: 'Organisms/Layout',
+ component: CardsListComponent,
+ args: {
+ kind: 'unordered',
+ },
+ argTypes: {
+ items: {
+ description: 'The cards data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level for each card.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CardsListComponent>;
+
+const Template: ComponentStory<typeof CardsListComponent> = (args) => (
+ <CardsListComponent {...args} />
+);
+
+const items: CardsListItem[] = [
+ {
+ id: 'card-1',
+ cover: {
+ alt: 'card 1 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] },
+ ],
+ tagline: 'Molestias ut error',
+ title: 'Et alias omnis',
+ url: '#',
+ },
+ {
+ id: 'card-2',
+ cover: {
+ alt: 'card 2 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }],
+ tagline: 'Quod vel accusamus',
+ title: 'Laboriosam doloremque mollitia',
+ url: '#',
+ },
+ {
+ id: 'card-3',
+ cover: {
+ alt: 'card 3 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ {
+ id: 'meta-1',
+ term: 'Omnis',
+ value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+ },
+ ],
+ tagline: 'Quo error eum',
+ title: 'Magni rem nulla',
+ url: '#',
+ },
+];
+
+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
new file mode 100644
index 0000000..2df3f59
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.test.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@test-utils';
+import CardsList from './cards-list';
+
+const items = [
+ {
+ id: 'card-1',
+ cover: {
+ alt: 'card 1 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] },
+ ],
+ tagline: 'Molestias ut error',
+ title: 'Et alias omnis',
+ url: '#',
+ },
+ {
+ id: 'card-2',
+ cover: {
+ alt: 'card 2 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }],
+ tagline: 'Quod vel accusamus',
+ title: 'Laboriosam doloremque mollitia',
+ url: '#',
+ },
+ {
+ id: 'card-3',
+ cover: {
+ alt: 'card 3 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ {
+ id: 'meta-1',
+ term: 'Omnis',
+ value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+ },
+ ],
+ tagline: 'Quo error eum',
+ title: 'Magni rem nulla',
+ url: '#',
+ },
+];
+
+describe('CardsList', () => {
+ it('renders a list of cards', () => {
+ render(<CardsList items={items} titleLevel={2} />);
+ expect(screen.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
new file mode 100644
index 0000000..a53df0d
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -0,0 +1,80 @@
+import List, {
+ type ListItem,
+ type ListProps,
+} from '@components/atoms/lists/list';
+import Card, { type CardProps } from '@components/molecules/layout/card';
+import { VFC } from 'react';
+import styles from './cards-list.module.scss';
+
+export type CardsListItem = Omit<
+ CardProps,
+ 'className' | 'coverFit' | 'titleLevel'
+> & {
+ id: string;
+};
+
+export type CardsListProps = {
+ /**
+ * The cover fit.
+ */
+ coverFit?: CardProps['coverFit'];
+ /**
+ * The cards data.
+ */
+ items: CardsListItem[];
+ /**
+ * The list kind. Either ordered or unordered.
+ */
+ kind?: ListProps['kind'];
+ /**
+ * The title level (hn).
+ */
+ titleLevel: CardProps['titleLevel'];
+};
+
+/**
+ * CardsList component
+ *
+ * Return a list of Card components.
+ */
+const CardsList: VFC<CardsListProps> = ({
+ coverFit,
+ items,
+ kind = 'unordered',
+ titleLevel,
+}) => {
+ const kindModifier = `wrapper--${kind}`;
+
+ /**
+ * Format the cards data to be used by the List component.
+ *
+ * @param {CardsListItem[]} cards - An array of card data.
+ * @returns {ListItem[]} The formatted cards data.
+ */
+ const getCards = (cards: CardsListItem[]): ListItem[] => {
+ return cards.map(({ id, ...card }) => {
+ return {
+ id,
+ value: (
+ <Card
+ key={id}
+ coverFit={coverFit}
+ titleLevel={titleLevel}
+ className={styles.card}
+ {...card}
+ />
+ ),
+ };
+ });
+ };
+
+ return (
+ <List
+ items={getCards(items)}
+ withMargin={false}
+ className={`${styles.wrapper} ${styles[kindModifier]}`}
+ />
+ );
+};
+
+export default CardsList;
diff --git a/src/components/organisms/layout/footer.module.scss b/src/components/organisms/layout/footer.module.scss
new file mode 100644
index 0000000..c180e86
--- /dev/null
+++ b/src/components/organisms/layout/footer.module.scss
@@ -0,0 +1,41 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-xs);
+ place-items: center;
+ place-content: center;
+ padding: var(--spacing-md) 0 calc(var(--toolbar-size) + var(--spacing-md));
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ --toolbar-size: 0px;
+
+ flex-flow: row wrap;
+ font-size: var(--font-size-sm);
+ }
+ }
+}
+
+.nav {
+ display: flex;
+ flex-flow: row wrap;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ &::before {
+ content: "\2022";
+ margin-right: var(--spacing-2xs);
+ }
+ }
+ }
+}
+
+.back-to-top {
+ position: fixed;
+ bottom: calc(var(--toolbar-size, 0px) + var(--spacing-md));
+ right: var(--spacing-md);
+ transition: all 0.4s ease-in 0s;
+}
diff --git a/src/components/organisms/layout/footer.stories.tsx b/src/components/organisms/layout/footer.stories.tsx
new file mode 100644
index 0000000..2ce7ee1
--- /dev/null
+++ b/src/components/organisms/layout/footer.stories.tsx
@@ -0,0 +1,74 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import FooterComponent from './footer';
+
+export default {
+ title: 'Organisms/Layout',
+ component: FooterComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the footer element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ copyright: {
+ description: 'The copyright information.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ navItems: {
+ description: 'The footer nav items.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ topId: {
+ control: {
+ type: 'text',
+ },
+ description:
+ 'An element id (without hashtag) used as target by back to top button.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FooterComponent>;
+
+const Template: ComponentStory<typeof FooterComponent> = (args) => (
+ <IntlProvider locale="en">
+ <FooterComponent {...args} />
+ </IntlProvider>
+);
+
+const copyright = {
+ dates: { start: '2017', end: '2022' },
+ owner: 'Lorem ipsum',
+ icon: 'CC',
+};
+
+const navItems = [{ id: 'legal-notice', href: '#', label: 'Legal notice' }];
+
+export const Footer = Template.bind({});
+Footer.args = {
+ copyright,
+ navItems,
+ topId: 'top',
+};
diff --git a/src/components/organisms/layout/footer.test.tsx b/src/components/organisms/layout/footer.test.tsx
new file mode 100644
index 0000000..bc23732
--- /dev/null
+++ b/src/components/organisms/layout/footer.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import Footer, { type FooterProps } from './footer';
+
+const copyright: FooterProps['copyright'] = {
+ dates: { start: '2017', end: '2022' },
+ owner: 'Lorem ipsum',
+ icon: 'CC',
+};
+
+const navItems: FooterProps['navItems'] = [
+ { id: 'legal-notice', href: '#', label: 'Legal notice' },
+];
+
+describe('Footer', () => {
+ it('renders the website copyright', () => {
+ render(<Footer copyright={copyright} topId="top" />);
+ expect(screen.getByText(copyright.owner)).toBeInTheDocument();
+ });
+
+ it('renders a back to top link', () => {
+ render(<Footer copyright={copyright} topId="top" />);
+ expect(
+ screen.getByRole('link', { name: 'Back to top' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders some nav items', () => {
+ render(<Footer copyright={copyright} navItems={navItems} topId="top" />);
+ expect(
+ screen.getByRole('link', { name: navItems[0].label })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
new file mode 100644
index 0000000..c9cb067
--- /dev/null
+++ b/src/components/organisms/layout/footer.tsx
@@ -0,0 +1,52 @@
+import Copyright, { CopyrightProps } from '@components/atoms/layout/copyright';
+import BackToTop from '@components/molecules/buttons/back-to-top';
+import Nav, { type NavItem } from '@components/molecules/nav/nav';
+import { VFC } from 'react';
+import styles from './footer.module.scss';
+
+export type FooterProps = {
+ /**
+ * Set additional classnames to the footer element.
+ */
+ className?: string;
+ /**
+ * Set the copyright information.
+ */
+ copyright: CopyrightProps;
+ /**
+ * The footer nav items.
+ */
+ navItems?: NavItem[];
+ /**
+ * An element id (without hashtag) used as anchor for back to top button.
+ */
+ topId: string;
+};
+
+/**
+ * Footer component
+ *
+ * Renders a footer with copyright and nav;
+ */
+const Footer: VFC<FooterProps> = ({
+ className,
+ copyright,
+ navItems,
+ topId,
+}) => {
+ return (
+ <footer className={`${styles.wrapper} ${className}`}>
+ <Copyright
+ dates={copyright.dates}
+ owner={copyright.owner}
+ icon={copyright.icon}
+ />
+ {navItems && (
+ <Nav kind="footer" items={navItems} className={styles.nav} />
+ )}
+ <BackToTop target={topId} className={styles['back-to-top']} />
+ </footer>
+ );
+};
+
+export default Footer;
diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss
new file mode 100644
index 0000000..4d50ad1
--- /dev/null
+++ b/src/components/organisms/layout/overview.module.scss
@@ -0,0 +1,12 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-border);
+}
+
+.cover {
+ max-height: fun.convert-px(150);
+ margin: 0 auto var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-border);
+}
diff --git a/src/components/organisms/layout/overview.stories.tsx b/src/components/organisms/layout/overview.stories.tsx
new file mode 100644
index 0000000..61d8b35
--- /dev/null
+++ b/src/components/organisms/layout/overview.stories.tsx
@@ -0,0 +1,50 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import OverviewComponent from './overview';
+
+export default {
+ title: 'Organisms/Layout',
+ component: OverviewComponent,
+ argTypes: {
+ cover: {
+ description: 'The overview cover.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ meta: {
+ description: 'The overview metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof OverviewComponent>;
+
+const Template: ComponentStory<typeof OverviewComponent> = (args) => (
+ <OverviewComponent {...args} />
+);
+
+const cover = {
+ alt: 'picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480/cats',
+ width: 640,
+};
+
+const meta = {
+ publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
+ update: {
+ name: 'Perspiciatis vel laudantium:',
+ value: 'Dignissimos ratione veritatis',
+ },
+};
+
+export const Overview = Template.bind({});
+Overview.args = {
+ cover,
+ meta,
+};
diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx
new file mode 100644
index 0000000..0738d3f
--- /dev/null
+++ b/src/components/organisms/layout/overview.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@test-utils';
+import Overview from './overview';
+
+const cover = {
+ alt: 'Incidunt unde quam',
+ height: 480,
+ src: 'http://placeimg.com/640/480/cats',
+ width: 640,
+};
+
+const meta = {
+ publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
+ update: {
+ name: 'Perspiciatis vel laudantium:',
+ value: 'Dignissimos ratione veritatis',
+ },
+};
+
+describe('Overview', () => {
+ it('renders some meta', () => {
+ render(<Overview meta={meta} />);
+ expect(screen.getByText(meta['publication'].name)).toBeInTheDocument();
+ });
+
+ it('renders a cover', () => {
+ render(<Overview meta={meta} cover={cover} />);
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx
new file mode 100644
index 0000000..3f83342
--- /dev/null
+++ b/src/components/organisms/layout/overview.tsx
@@ -0,0 +1,33 @@
+import ResponsiveImage, {
+ type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Meta, { type MetaMap } from '@components/molecules/layout/meta';
+import { VFC } from 'react';
+import styles from './overview.module.scss';
+
+export type OverviewProps = {
+ cover?: Pick<ResponsiveImageProps, 'alt' | 'src' | 'width' | 'height'>;
+ meta: MetaMap;
+};
+
+/**
+ * Overview component
+ *
+ * Render an overview.
+ */
+const Overview: VFC<OverviewProps> = ({ cover, meta }) => {
+ return (
+ <div className={styles.wrapper}>
+ {cover && (
+ <ResponsiveImage
+ objectFit="cover"
+ className={styles.cover}
+ {...cover}
+ />
+ )}
+ <Meta data={meta} layout="column" responsiveLayout={true} />
+ </div>
+ );
+};
+
+export default Overview;
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
new file mode 100644
index 0000000..5da0a18
--- /dev/null
+++ b/src/components/organisms/layout/summary.module.scss
@@ -0,0 +1,84 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ @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") {
+ display: grid;
+ grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
+ grid-template-rows: repeat(3, max-content);
+ column-gap: var(--spacing-md);
+ }
+ }
+}
+
+.cover {
+ width: auto;
+ max-height: fun.convert-px(100);
+ max-width: 100%;
+ border: fun.convert-px(1) solid var(--color-border);
+
+ @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;
+ }
+ }
+}
+
+.title {
+ background: none;
+ text-shadow: none;
+}
+
+.read-more {
+ display: flex;
+ flex-flow: row nowrap;
+ column-gap: var(--spacing-xs);
+ width: max-content;
+ margin: var(--spacing-sm) 0;
+}
+
+.meta {
+ font-size: var(--font-size-sm);
+}
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
new file mode 100644
index 0000000..5214d70
--- /dev/null
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -0,0 +1,114 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SummaryComponent from './summary';
+
+export default {
+ title: 'Organisms/Layout',
+ component: SummaryComponent,
+ args: {
+ titleLevel: 2,
+ },
+ argTypes: {
+ cover: {
+ description: 'The cover data.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ excerpt: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page excerpt.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ meta: {
+ description: 'The page metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page title',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The page title level (hn)',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 2 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SummaryComponent>;
+
+const Template: ComponentStory<typeof SummaryComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SummaryComponent {...args} />
+ </IntlProvider>
+);
+
+const meta = {
+ publication: { name: 'Published on:', value: 'April 11th 2022' },
+ readingTime: { name: 'Reading time:', value: '5 minutes' },
+ categories: {
+ name: 'Categories:',
+ value: [
+ <a key="cat-1" href="#">
+ Cat 1
+ </a>,
+ <a key="cat-2" href="#">
+ Cat 2
+ </a>,
+ ],
+ },
+ comments: { name: 'Comments:', value: '1 comment' },
+};
+
+export const Summary = Template.bind({});
+Summary.args = {
+ cover: {
+ alt: 'A cover',
+ height: 480,
+ url: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ excerpt:
+ 'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.',
+ meta,
+ title: 'Odio odit necessitatibus',
+ url: '#',
+};
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
new file mode 100644
index 0000000..ce87c0c
--- /dev/null
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen } from '@test-utils';
+import Summary from './summary';
+
+const cover = {
+ alt: 'A cover',
+ height: 480,
+ url: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+const excerpt =
+ 'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
+
+const meta = {
+ publication: { name: 'Published on:', value: 'April 11th 2022' },
+ readingTime: { name: 'Reading time:', value: '5 minutes' },
+ categories: {
+ name: 'Categories:',
+ value: [
+ <a key="cat-1" href="#">
+ Cat 1
+ </a>,
+ <a key="cat-2" href="#">
+ Cat 2
+ </a>,
+ ],
+ },
+ comments: { name: 'Comments:', value: '1 comment' },
+};
+
+const title = 'Odio odit necessitatibus';
+
+const url = '#';
+
+describe('Summary', () => {
+ it('renders a title wrapped in a h2 element', () => {
+ render(
+ <Summary
+ excerpt={excerpt}
+ meta={meta}
+ title={title}
+ titleLevel={2}
+ url={url}
+ />
+ );
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an excerpt', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByText(excerpt)).toBeInTheDocument();
+ });
+
+ it('renders a cover', () => {
+ render(
+ <Summary
+ cover={cover}
+ excerpt={excerpt}
+ meta={meta}
+ title={title}
+ url={url}
+ />
+ );
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+
+ it('renders a link to the full post', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByRole('link', { name: title })).toBeInTheDocument();
+ });
+
+ it('renders a read more link', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(
+ screen.getByRole('link', { name: `Read more about ${title}` })
+ ).toBeInTheDocument();
+ });
+
+ it('renders some meta', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByText(meta.publication.name)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
new file mode 100644
index 0000000..3624e5d
--- /dev/null
+++ b/src/components/organisms/layout/summary.tsx
@@ -0,0 +1,105 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Arrow from '@components/atoms/icons/arrow';
+import Link from '@components/atoms/links/link';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import Meta, { type MetaItem } from '@components/molecules/layout/meta';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './summary.module.scss';
+
+export type Cover = {
+ alt: string;
+ height: number;
+ url: string;
+ width: number;
+};
+
+export type RequiredMetaKey = 'publication';
+
+export type RequiredMeta = {
+ [key in RequiredMetaKey]: MetaItem;
+};
+
+export type OptionalMetaKey =
+ | 'author'
+ | 'categories'
+ | 'comments'
+ | 'readingTime'
+ | 'update';
+
+export type OptionalMeta = {
+ [key in OptionalMetaKey]?: MetaItem;
+};
+
+export type Meta = RequiredMeta & OptionalMeta;
+
+export type SummaryProps = {
+ cover?: Cover;
+ excerpt: string;
+ meta: Meta;
+ title: string;
+ titleLevel?: HeadingLevel;
+ url: string;
+};
+
+/**
+ * Summary component
+ *
+ * Render a page summary.
+ */
+const Summary: VFC<SummaryProps> = ({
+ cover,
+ excerpt,
+ meta,
+ title,
+ titleLevel = 2,
+ url,
+}) => {
+ const intl = useIntl();
+
+ return (
+ <article className={styles.wrapper}>
+ {cover && (
+ <ResponsiveImage
+ alt={cover.alt}
+ src={cover.url}
+ width={cover.width}
+ height={cover.height}
+ className={styles.cover}
+ />
+ )}
+ <header className={styles.header}>
+ <Link href={url}>
+ <Heading level={titleLevel} className={styles.title}>
+ {title}
+ </Heading>
+ </Link>
+ </header>
+ <div className={styles.body}>
+ {excerpt}
+ <ButtonLink target={url} className={styles['read-more']}>
+ {intl.formatMessage(
+ {
+ defaultMessage: 'Read more<a11y> about {title}</a11y>',
+ description: 'Summary: read more link',
+ id: 'Zpgv+f',
+ },
+ {
+ title,
+ a11y: (chunks: string) => (
+ <span className="screen-reader-text">{chunks}</span>
+ ),
+ }
+ )}
+ <Arrow direction="right" />
+ </ButtonLink>
+ </div>
+ <footer className={styles.footer}>
+ <Meta data={meta} layout="column" className={styles.meta} />
+ </footer>
+ </article>
+ );
+};
+
+export default Summary;