aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
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
parentff3a251e75fafce7d95177010401483127973313 (diff)
chore: add a Summary component
Diffstat (limited to 'src/components')
-rw-r--r--src/components/atoms/buttons/button-link.stories.tsx13
-rw-r--r--src/components/atoms/buttons/button-link.tsx14
-rw-r--r--src/components/molecules/images/responsive-image.tsx14
-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
7 files changed, 424 insertions, 5 deletions
diff --git a/src/components/atoms/buttons/button-link.stories.tsx b/src/components/atoms/buttons/button-link.stories.tsx
index 6fe786b..92b7521 100644
--- a/src/components/atoms/buttons/button-link.stories.tsx
+++ b/src/components/atoms/buttons/button-link.stories.tsx
@@ -28,6 +28,19 @@ export default {
required: true,
},
},
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the button link.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
kind: {
control: {
type: 'select',
diff --git a/src/components/atoms/buttons/button-link.tsx b/src/components/atoms/buttons/button-link.tsx
index 81229c8..77a7f7b 100644
--- a/src/components/atoms/buttons/button-link.tsx
+++ b/src/components/atoms/buttons/button-link.tsx
@@ -8,6 +8,10 @@ export type ButtonLinkProps = {
*/
'aria-label'?: string;
/**
+ * Set additional classnames to the button link.
+ */
+ className?: string;
+ /**
* True if it is an external link. Default: false.
*/
external?: boolean;
@@ -18,7 +22,7 @@ export type ButtonLinkProps = {
/**
* ButtonLink shape. Default: rectangle.
*/
- shape?: 'rectangle' | 'square';
+ shape?: 'circle' | 'rectangle' | 'square';
/**
* Define an URL as target.
*/
@@ -32,6 +36,7 @@ export type ButtonLinkProps = {
*/
const ButtonLink: FC<ButtonLinkProps> = ({
children,
+ className,
target,
kind = 'secondary',
shape = 'rectangle',
@@ -44,14 +49,17 @@ const ButtonLink: FC<ButtonLinkProps> = ({
return external ? (
<a
href={target}
- className={`${styles.btn} ${kindClass} ${shapeClass}`}
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
{...props}
>
{children}
</a>
) : (
<Link href={target}>
- <a className={`${styles.btn} ${kindClass} ${shapeClass}`} {...props}>
+ <a
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
+ {...props}
+ >
{children}
</a>
</Link>
diff --git a/src/components/molecules/images/responsive-image.tsx b/src/components/molecules/images/responsive-image.tsx
index 9f96f18..3d54e95 100644
--- a/src/components/molecules/images/responsive-image.tsx
+++ b/src/components/molecules/images/responsive-image.tsx
@@ -13,6 +13,10 @@ type ResponsiveImageProps = Omit<ImageProps, 'alt' | 'width' | 'height'> & {
*/
caption?: string;
/**
+ * Set additional classnames to the figure wrapper.
+ */
+ className?: string;
+ /**
* The image height.
*/
height: number | string;
@@ -34,16 +38,22 @@ type ResponsiveImageProps = Omit<ImageProps, 'alt' | 'width' | 'height'> & {
const ResponsiveImage: VFC<ResponsiveImageProps> = ({
alt,
caption,
+ className = '',
layout,
objectFit,
target,
...props
}) => {
return (
- <figure className={styles.wrapper}>
+ <figure className={`${styles.wrapper} ${className}`}>
{target ? (
<Link href={target} className={styles.link}>
- <Image alt={alt} layout={layout || 'intrinsic'} {...props} />
+ <Image
+ alt={alt}
+ layout={layout || 'intrinsic'}
+ objectFit={objectFit || 'contain'}
+ {...props}
+ />
{caption && (
<figcaption className={styles.caption}>{caption}</figcaption>
)}
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;