diff options
Diffstat (limited to 'src/components/organisms/layout')
| -rw-r--r-- | src/components/organisms/layout/cards-list.module.scss | 27 | ||||
| -rw-r--r-- | src/components/organisms/layout/cards-list.stories.tsx | 105 | ||||
| -rw-r--r-- | src/components/organisms/layout/cards-list.test.tsx | 61 | ||||
| -rw-r--r-- | src/components/organisms/layout/cards-list.tsx | 80 | ||||
| -rw-r--r-- | src/components/organisms/layout/footer.module.scss | 41 | ||||
| -rw-r--r-- | src/components/organisms/layout/footer.stories.tsx | 74 | ||||
| -rw-r--r-- | src/components/organisms/layout/footer.test.tsx | 33 | ||||
| -rw-r--r-- | src/components/organisms/layout/footer.tsx | 52 | ||||
| -rw-r--r-- | src/components/organisms/layout/overview.module.scss | 12 | ||||
| -rw-r--r-- | src/components/organisms/layout/overview.stories.tsx | 50 | ||||
| -rw-r--r-- | src/components/organisms/layout/overview.test.tsx | 29 | ||||
| -rw-r--r-- | src/components/organisms/layout/overview.tsx | 33 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.module.scss | 84 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.stories.tsx | 114 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.test.tsx | 85 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.tsx | 105 |
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; |
