summaryrefslogtreecommitdiffstats
path: root/src/components/organisms
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-04-12 16:09:21 +0200
committerArmand Philippot <git@armandphilippot.com>2022-04-12 16:09:21 +0200
commit27ff3104aabed240470d351bda02095d8169501f (patch)
treee4b43947c1150201067d40622b52b65bd19f01a2 /src/components/organisms
parentff3a251e75fafce7d95177010401483127973313 (diff)
chore: add a Summary component
Diffstat (limited to 'src/components/organisms')
-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
4 files changed, 388 insertions, 0 deletions
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;