aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/layout
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules/layout')
-rw-r--r--src/components/molecules/layout/branding.module.scss48
-rw-r--r--src/components/molecules/layout/branding.stories.tsx83
-rw-r--r--src/components/molecules/layout/branding.test.tsx61
-rw-r--r--src/components/molecules/layout/branding.tsx97
-rw-r--r--src/components/molecules/layout/card.module.scss77
-rw-r--r--src/components/molecules/layout/card.stories.tsx102
-rw-r--r--src/components/molecules/layout/card.test.tsx52
-rw-r--r--src/components/molecules/layout/card.tsx114
-rw-r--r--src/components/molecules/layout/flipping-logo.module.scss59
-rw-r--r--src/components/molecules/layout/flipping-logo.stories.tsx66
-rw-r--r--src/components/molecules/layout/flipping-logo.test.tsx25
-rw-r--r--src/components/molecules/layout/flipping-logo.tsx48
-rw-r--r--src/components/molecules/layout/meta.stories.tsx57
-rw-r--r--src/components/molecules/layout/meta.test.tsx8
-rw-r--r--src/components/molecules/layout/meta.tsx74
-rw-r--r--src/components/molecules/layout/widget.module.scss40
-rw-r--r--src/components/molecules/layout/widget.stories.tsx85
-rw-r--r--src/components/molecules/layout/widget.test.tsx19
-rw-r--r--src/components/molecules/layout/widget.tsx54
19 files changed, 1169 insertions, 0 deletions
diff --git a/src/components/molecules/layout/branding.module.scss b/src/components/molecules/layout/branding.module.scss
new file mode 100644
index 0000000..aa18002
--- /dev/null
+++ b/src/components/molecules/layout/branding.module.scss
@@ -0,0 +1,48 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ display: grid;
+ grid-template-columns:
+ var(--logo-size, fun.convert-px(100))
+ minmax(0, 1fr);
+ grid-template-rows: 1fr min-content;
+ align-items: center;
+ column-gap: var(--spacing-sm);
+}
+
+.logo {
+ grid-row: span 2;
+}
+
+.title {
+ font-size: var(--font-size-2xl);
+}
+
+.baseline {
+ color: var(--color-fg-light);
+}
+
+.link {
+ background: linear-gradient(
+ to top,
+ var(--color-primary-light) fun.convert-px(5),
+ transparent fun.convert-px(5)
+ )
+ left / 0 100% no-repeat;
+ text-decoration: none;
+ transition: all 0.6s ease-out 0s;
+
+ &:hover,
+ &:focus {
+ background-size: 100% 100%;
+ }
+
+ &:focus {
+ color: var(--color-primary-light);
+ }
+
+ &:active {
+ background-size: 0 100%;
+ color: var(--color-primary-dark);
+ }
+}
diff --git a/src/components/molecules/layout/branding.stories.tsx b/src/components/molecules/layout/branding.stories.tsx
new file mode 100644
index 0000000..726ba26
--- /dev/null
+++ b/src/components/molecules/layout/branding.stories.tsx
@@ -0,0 +1,83 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import BrandingComponent from './branding';
+
+export default {
+ title: 'Molecules/Layout',
+ component: BrandingComponent,
+ args: {
+ isHome: false,
+ },
+ argTypes: {
+ baseline: {
+ control: {
+ type: 'text',
+ },
+ description: 'The Branding baseline.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isHome: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Use H1 if the current page is homepage.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ photo: {
+ control: {
+ type: 'text',
+ },
+ description: 'The Branding photo.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The Branding title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ withLink: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Wraps the title with a link to homepage.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof BrandingComponent>;
+
+const Template: ComponentStory<typeof BrandingComponent> = (args) => (
+ <IntlProvider locale="en">
+ <BrandingComponent {...args} />
+ </IntlProvider>
+);
+
+export const Branding = Template.bind({});
+Branding.args = {
+ title: 'Website title',
+ photo: 'http://placeimg.com/640/480',
+};
diff --git a/src/components/molecules/layout/branding.test.tsx b/src/components/molecules/layout/branding.test.tsx
new file mode 100644
index 0000000..4fe1e9a
--- /dev/null
+++ b/src/components/molecules/layout/branding.test.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@test-utils';
+import Branding from './branding';
+
+describe('Branding', () => {
+ it('renders a photo', () => {
+ render(
+ <Branding
+ photo="http://placeimg.com/640/480/city"
+ title="Website title"
+ />
+ );
+ expect(
+ screen.getByRole('img', { name: 'Website title picture' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a logo', () => {
+ render(
+ <Branding photo="http://placeimg.com/640/480/city" title="Website name" />
+ );
+ expect(screen.getByTitle('Website name logo')).toBeInTheDocument();
+ });
+
+ it('renders a baseline', () => {
+ render(
+ <Branding
+ photo="http://placeimg.com/640/480"
+ title="Website title"
+ baseline="Website baseline"
+ />
+ );
+ expect(screen.getByText('Website baseline')).toBeInTheDocument();
+ });
+
+ it('renders a title wrapped with h1 element', () => {
+ render(
+ <Branding
+ photo="http://placeimg.com/640/480"
+ title="Website title"
+ isHome={true}
+ />
+ );
+ expect(
+ screen.getByRole('heading', { level: 1, name: 'Website title' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a title with h1 styles', () => {
+ render(
+ <Branding
+ photo="http://placeimg.com/640/480"
+ title="Website title"
+ isHome={false}
+ />
+ );
+ expect(
+ screen.queryByRole('heading', { level: 1, name: 'Website title' })
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('Website title')).toHaveClass('heading--1');
+ });
+});
diff --git a/src/components/molecules/layout/branding.tsx b/src/components/molecules/layout/branding.tsx
new file mode 100644
index 0000000..9f564bf
--- /dev/null
+++ b/src/components/molecules/layout/branding.tsx
@@ -0,0 +1,97 @@
+import Heading from '@components/atoms/headings/heading';
+import Link from 'next/link';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './branding.module.scss';
+import FlippingLogo from './flipping-logo';
+
+type BrandingProps = {
+ /**
+ * The Branding baseline.
+ */
+ baseline?: string;
+ /**
+ * Use H1 if the current page is homepage. Default: false.
+ */
+ isHome?: boolean;
+ /**
+ * A photography URL.
+ */
+ photo: string;
+ /**
+ * The Branding title;
+ */
+ title: string;
+ /**
+ * Wraps the title with a link to homepage. Default: false.
+ */
+ withLink?: boolean;
+};
+
+/**
+ * Branding component
+ *
+ * Render the branding logo, title and optional baseline.
+ */
+const Branding: VFC<BrandingProps> = ({
+ baseline,
+ isHome = false,
+ photo,
+ title,
+ withLink = false,
+}) => {
+ const intl = useIntl();
+ const altText = intl.formatMessage(
+ {
+ defaultMessage: '{website} picture',
+ description: 'Branding: photo alternative text',
+ id: 'dDK5oc',
+ },
+ { website: title }
+ );
+ const logoTitle = intl.formatMessage(
+ {
+ defaultMessage: '{website} logo',
+ description: 'Branding: logo title',
+ id: 'x55qsD',
+ },
+ { website: title }
+ );
+
+ return (
+ <div className={styles.wrapper}>
+ <FlippingLogo
+ className={styles.logo}
+ altText={altText}
+ logoTitle={logoTitle}
+ photo={photo}
+ />
+ <Heading
+ isFake={!isHome}
+ level={1}
+ withMargin={false}
+ className={styles.title}
+ >
+ {withLink ? (
+ <Link href="/">
+ <a className={styles.link}>{title}</a>
+ </Link>
+ ) : (
+ title
+ )}
+ </Heading>
+ {baseline && (
+ <Heading
+ isFake={true}
+ level={4}
+ withMargin={false}
+ className={styles.baseline}
+ >
+ {baseline}
+ </Heading>
+ )}
+ </div>
+ );
+};
+
+export default Branding;
diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss
new file mode 100644
index 0000000..2b1b7dc
--- /dev/null
+++ b/src/components/molecules/layout/card.module.scss
@@ -0,0 +1,77 @@
+@use "@styles/abstracts/functions" as fun;
+
+.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;
+ }
+
+ .footer {
+ margin-top: var(--spacing-md);
+ }
+
+ .cover {
+ align-self: flex-start;
+ max-height: fun.convert-px(150);
+ margin: auto;
+ border-bottom: fun.convert-px(1) solid var(--color-border);
+ }
+
+ .title,
+ .tagline,
+ .footer {
+ padding: 0 var(--spacing-md);
+ }
+
+ .title {
+ flex: 1;
+ margin: var(--spacing-sm) 0;
+ }
+
+ h2.title {
+ background: none;
+ text-shadow: none;
+ }
+
+ .tagline {
+ flex: 1;
+ color: var(--color-fg);
+ font-weight: 400;
+ }
+
+ .list {
+ margin-bottom: var(--spacing-md);
+ }
+
+ .items {
+ flex-flow: row wrap;
+ place-content: center;
+ gap: var(--spacing-2xs);
+ }
+
+ .term {
+ flex: 0 0 100%;
+ }
+
+ .description {
+ 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;
+ }
+ }
+}
diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx
new file mode 100644
index 0000000..a07f8dc
--- /dev/null
+++ b/src/components/molecules/layout/card.stories.tsx
@@ -0,0 +1,102 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CardComponent from './card';
+
+export default {
+ title: 'Molecules/Layout',
+ component: CardComponent,
+ argTypes: {
+ cover: {
+ description: 'The card cover data (src, dimensions, alternative text).',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ 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',
+ },
+ 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 CardComponent>;
+
+const Template: ComponentStory<typeof CardComponent> = (args) => (
+ <CardComponent {...args} />
+);
+
+const cover = {
+ alt: 'A picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+const meta = [
+ {
+ id: 'an-id',
+ term: 'Voluptates',
+ value: ['Autem', 'Eos'],
+ },
+];
+
+export const Card = Template.bind({});
+Card.args = {
+ cover,
+ meta,
+ title: 'Veritatis dicta quod',
+ titleLevel: 2,
+ url: '#',
+};
diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx
new file mode 100644
index 0000000..404bc7a
--- /dev/null
+++ b/src/components/molecules/layout/card.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from '@test-utils';
+import Card from './card';
+
+const cover = {
+ alt: 'A picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+const meta = [
+ {
+ id: 'an-id',
+ term: 'Voluptates',
+ value: ['Autem', 'Eos'],
+ },
+];
+
+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 title={title} titleLevel={2} url={url} />);
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a link to another page', () => {
+ render(<Card title={title} titleLevel={2} url={url} />);
+ expect(screen.getByRole('link')).toHaveAttribute('href', url);
+ });
+
+ it('renders a cover', () => {
+ render(<Card title={title} titleLevel={2} url={url} cover={cover} />);
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+
+ it('renders a tagline', () => {
+ render(<Card title={title} titleLevel={2} url={url} tagline={tagline} />);
+ expect(screen.getByText(tagline)).toBeInTheDocument();
+ });
+
+ it('renders some meta', () => {
+ render(<Card title={title} titleLevel={2} url={url} meta={meta} />);
+ expect(screen.getByText(meta[0].term)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
new file mode 100644
index 0000000..23a0e54
--- /dev/null
+++ b/src/components/molecules/layout/card.tsx
@@ -0,0 +1,114 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import DescriptionList, {
+ DescriptionListItem,
+} from '@components/atoms/lists/description-list';
+import { VFC } from 'react';
+import ResponsiveImage, {
+ ResponsiveImageProps,
+} from '../images/responsive-image';
+import styles from './card.module.scss';
+
+export type Cover = {
+ /**
+ * The cover alternative text.
+ */
+ alt: string;
+ /**
+ * The cover height.
+ */
+ height: number;
+ /**
+ * The cover source.
+ */
+ src: string;
+ /**
+ * The cover width.
+ */
+ width: number;
+};
+
+export type CardProps = {
+ /**
+ * Set additional classnames to the card wrapper.
+ */
+ className?: string;
+ /**
+ * The card cover.
+ */
+ cover?: Cover;
+ /**
+ * The cover fit. Default: cover.
+ */
+ coverFit?: ResponsiveImageProps['objectFit'];
+ /**
+ * The card meta.
+ */
+ meta?: DescriptionListItem[];
+ /**
+ * 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.
+ */
+const Card: VFC<CardProps> = ({
+ className = '',
+ cover,
+ coverFit = 'cover',
+ meta,
+ tagline,
+ title,
+ titleLevel,
+ url,
+}) => {
+ return (
+ <ButtonLink target={url} className={`${styles.wrapper} ${className}`}>
+ <article className={styles.article}>
+ <header className={styles.header}>
+ {cover && (
+ <ResponsiveImage
+ {...cover}
+ objectFit={coverFit}
+ className={styles.cover}
+ />
+ )}
+ <Heading level={titleLevel} className={styles.title}>
+ {title}
+ </Heading>
+ </header>
+ <div className={styles.tagline}>{tagline}</div>
+ {meta && (
+ <footer className={styles.footer}>
+ <DescriptionList
+ items={meta}
+ layout="inline"
+ className={styles.list}
+ groupClassName={styles.items}
+ termClassName={styles.term}
+ descriptionClassName={styles.description}
+ />
+ </footer>
+ )}
+ </article>
+ </ButtonLink>
+ );
+};
+
+export default Card;
diff --git a/src/components/molecules/layout/flipping-logo.module.scss b/src/components/molecules/layout/flipping-logo.module.scss
new file mode 100644
index 0000000..89b9499
--- /dev/null
+++ b/src/components/molecules/layout/flipping-logo.module.scss
@@ -0,0 +1,59 @@
+@use "@styles/abstracts/functions" as fun;
+
+.logo {
+ width: var(--logo-size, fun.convert-px(100));
+ height: var(--logo-size, fun.convert-px(100));
+ position: relative;
+ border-radius: 50%;
+ transform-style: preserve-3d;
+ transition: all 0.6s linear 0s;
+
+ &__front,
+ &__back {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ backface-visibility: hidden;
+ background: var(--color-bg);
+ border: fun.convert-px(2) solid var(--color-primary-dark);
+ border-radius: 50%;
+ transition: all 0.6s linear 0s;
+
+ svg,
+ img {
+ // !important is required to override next/image styles...
+ padding: fun.convert-px(2) !important;
+ border-radius: 50%;
+ }
+ }
+
+ &__front {
+ box-shadow: fun.convert-px(1) fun.convert-px(2) fun.convert-px(1) 0
+ var(--color-shadow-light),
+ fun.convert-px(2) fun.convert-px(3) fun.convert-px(3) 0
+ var(--color-shadow-light);
+ }
+
+ &__back {
+ transform: rotateY(180deg);
+ }
+
+ &:hover {
+ transform: rotateY(180deg);
+ }
+
+ &:hover & {
+ &__front {
+ box-shadow: none;
+ }
+
+ &__back {
+ box-shadow: fun.convert-px(1) fun.convert-px(2) fun.convert-px(1) 0
+ var(--color-shadow-light),
+ fun.convert-px(2) fun.convert-px(3) fun.convert-px(3) 0
+ var(--color-shadow-light);
+ }
+ }
+}
diff --git a/src/components/molecules/layout/flipping-logo.stories.tsx b/src/components/molecules/layout/flipping-logo.stories.tsx
new file mode 100644
index 0000000..1ac8de8
--- /dev/null
+++ b/src/components/molecules/layout/flipping-logo.stories.tsx
@@ -0,0 +1,66 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import FlippingLogoComponent from './flipping-logo';
+
+export default {
+ title: 'Molecules/Layout',
+ component: FlippingLogoComponent,
+ argTypes: {
+ altText: {
+ control: {
+ type: 'text',
+ },
+ description: 'Photo alternative text.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the logo wrapper.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ logoTitle: {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible name for the logo.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ photo: {
+ control: {
+ type: 'text',
+ },
+ description: 'Photo url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FlippingLogoComponent>;
+
+const Template: ComponentStory<typeof FlippingLogoComponent> = (args) => (
+ <FlippingLogoComponent {...args} />
+);
+
+export const FlippingLogo = Template.bind({});
+FlippingLogo.args = {
+ altText: 'Website picture',
+ logoTitle: 'Website logo',
+ photo: 'http://placeimg.com/640/480',
+};
diff --git a/src/components/molecules/layout/flipping-logo.test.tsx b/src/components/molecules/layout/flipping-logo.test.tsx
new file mode 100644
index 0000000..806fdbe
--- /dev/null
+++ b/src/components/molecules/layout/flipping-logo.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@test-utils';
+import FlippingLogo from './flipping-logo';
+
+describe('FlippingLogo', () => {
+ it('renders a photo', () => {
+ render(
+ <FlippingLogo
+ altText="Alternative text"
+ photo="http://placeimg.com/640/480"
+ />
+ );
+ expect(screen.getByAltText('Alternative text')).toBeInTheDocument();
+ });
+
+ it('renders a logo', () => {
+ render(
+ <FlippingLogo
+ altText="Alternative text"
+ logoTitle="A logo title"
+ photo="http://placeimg.com/640/480"
+ />
+ );
+ expect(screen.getByTitle('A logo title')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/layout/flipping-logo.tsx b/src/components/molecules/layout/flipping-logo.tsx
new file mode 100644
index 0000000..6f7645f
--- /dev/null
+++ b/src/components/molecules/layout/flipping-logo.tsx
@@ -0,0 +1,48 @@
+import Logo from '@components/atoms/images/logo';
+import Image from 'next/image';
+import { VFC } from 'react';
+import styles from './flipping-logo.module.scss';
+
+type FlippingLogoProps = {
+ /**
+ * Set additional classnames to the logo wrapper.
+ */
+ className?: string;
+ /**
+ * Photo alternative text.
+ */
+ altText: string;
+ /**
+ * Logo image title.
+ */
+ logoTitle?: string;
+ /**
+ * Photo url.
+ */
+ photo: string;
+};
+
+/**
+ * FlippingLogo component
+ *
+ * Render a logo and a photo with a flipping effect.
+ */
+const FlippingLogo: VFC<FlippingLogoProps> = ({
+ className = '',
+ altText,
+ logoTitle,
+ photo,
+}) => {
+ return (
+ <div className={`${styles.logo} ${className}`}>
+ <div className={styles.logo__front}>
+ <Image src={photo} alt={altText} layout="fill" objectFit="cover" />
+ </div>
+ <div className={styles.logo__back}>
+ <Logo title={logoTitle} />
+ </div>
+ </div>
+ );
+};
+
+export default FlippingLogo;
diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx
new file mode 100644
index 0000000..e7a932d
--- /dev/null
+++ b/src/components/molecules/layout/meta.stories.tsx
@@ -0,0 +1,57 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MetaComponent from './meta';
+
+export default {
+ title: 'Molecules/Layout',
+ component: MetaComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the meta wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ meta: {
+ control: {
+ type: null,
+ },
+ description: 'The page metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof MetaComponent>;
+
+const Template: ComponentStory<typeof MetaComponent> = (args) => (
+ <MetaComponent {...args} />
+);
+
+const data = {
+ publication: { name: 'Published on:', value: 'April 9th 2022' },
+ categories: {
+ name: 'Categories:',
+ value: [
+ <a key="category1" href="#">
+ Category 1
+ </a>,
+ <a key="category2" href="#">
+ Category 2
+ </a>,
+ ],
+ },
+};
+
+export const Meta = Template.bind({});
+Meta.args = {
+ data,
+};
diff --git a/src/components/molecules/layout/meta.test.tsx b/src/components/molecules/layout/meta.test.tsx
new file mode 100644
index 0000000..a738bdb
--- /dev/null
+++ b/src/components/molecules/layout/meta.test.tsx
@@ -0,0 +1,8 @@
+import { render } from '@test-utils';
+import Meta from './meta';
+
+describe('Meta', () => {
+ it('renders a Meta component', () => {
+ render(<Meta data={{}} />);
+ });
+});
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
new file mode 100644
index 0000000..218ebd9
--- /dev/null
+++ b/src/components/molecules/layout/meta.tsx
@@ -0,0 +1,74 @@
+import DescriptionList, {
+ type DescriptionListProps,
+ type DescriptionListItem,
+} from '@components/atoms/lists/description-list';
+import { ReactNode, VFC } from 'react';
+
+export type MetaItem = {
+ /**
+ * The meta name.
+ */
+ name: string;
+ /**
+ * The meta value.
+ */
+ value: ReactNode | ReactNode[];
+};
+
+export type MetaMap = {
+ [key: string]: MetaItem | undefined;
+};
+
+export type MetaProps = {
+ /**
+ * Set additional classnames to the meta wrapper.
+ */
+ className?: string;
+ /**
+ * The meta data.
+ */
+ data: MetaMap;
+ /**
+ * The meta layout.
+ */
+ layout?: DescriptionListProps['layout'];
+ /**
+ * Determine if the layout should be responsive.
+ */
+ responsiveLayout?: DescriptionListProps['responsiveLayout'];
+};
+
+/**
+ * Meta component
+ *
+ * Renders the page metadata.
+ */
+const Meta: VFC<MetaProps> = ({ data, ...props }) => {
+ /**
+ * Transform the metadata to description list item format.
+ *
+ * @param {MetaMap} items - The meta.
+ * @returns {DescriptionListItem[]} The formatted description list items.
+ */
+ const getItems = (items: MetaMap): DescriptionListItem[] => {
+ const listItems: DescriptionListItem[] = Object.entries(items)
+ .map(([key, item]) => {
+ if (!item) return;
+
+ const { name, value } = item;
+
+ return {
+ id: key,
+ term: name,
+ value: Array.isArray(value) ? value : [value],
+ } as DescriptionListItem;
+ })
+ .filter((item): item is DescriptionListItem => !!item);
+
+ return listItems;
+ };
+
+ return <DescriptionList items={getItems(data)} {...props} />;
+};
+
+export default Meta;
diff --git a/src/components/molecules/layout/widget.module.scss b/src/components/molecules/layout/widget.module.scss
new file mode 100644
index 0000000..727ffb7
--- /dev/null
+++ b/src/components/molecules/layout/widget.module.scss
@@ -0,0 +1,40 @@
+@use "@styles/abstracts/functions" as fun;
+
+.widget {
+ display: flex;
+ flex-flow: column;
+
+ &__header {
+ background: var(--color-bg);
+ }
+
+ &--has-borders & {
+ &__body {
+ border: fun.convert-px(2) solid var(--color-primary-dark);
+ }
+ }
+
+ &--collapsed & {
+ &__body {
+ max-height: 0;
+ margin: 0;
+ visibility: hidden;
+ opacity: 0;
+ overflow: hidden;
+ border: 0 solid transparent;
+ transition: all 0.1s linear 0.3s,
+ max-height 0.5s cubic-bezier(0, 1, 0, 1) 0s, margin 0.3s ease-in-out 0s;
+ }
+ }
+
+ &--expanded & {
+ &__body {
+ max-height: 10000px; // needs a fixed value for transition.
+ margin: var(--spacing-sm) 0;
+ opacity: 1;
+ visibility: visible;
+ transition: all 0.5s ease-in-out 0s, border 0s linear 0s,
+ max-height 0.6s ease-in-out 0s;
+ }
+ }
+}
diff --git a/src/components/molecules/layout/widget.stories.tsx b/src/components/molecules/layout/widget.stories.tsx
new file mode 100644
index 0000000..d79f66e
--- /dev/null
+++ b/src/components/molecules/layout/widget.stories.tsx
@@ -0,0 +1,85 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import WidgetComponent from './widget';
+
+export default {
+ title: 'Molecules/Layout',
+ component: WidgetComponent,
+ args: {
+ expanded: true,
+ withBorders: false,
+ },
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget body',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ expanded: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'The widget state (expanded or collapsed)',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ withBorders: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Define if the content should have borders.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof WidgetComponent>;
+
+const Template: ComponentStory<typeof WidgetComponent> = (args) => (
+ <IntlProvider locale="en">
+ <WidgetComponent {...args} />
+ </IntlProvider>
+);
+
+export const Widget = Template.bind({});
+Widget.args = {
+ children: 'Widget body',
+ level: 2,
+ title: 'Widget title',
+};
diff --git a/src/components/molecules/layout/widget.test.tsx b/src/components/molecules/layout/widget.test.tsx
new file mode 100644
index 0000000..af561ea
--- /dev/null
+++ b/src/components/molecules/layout/widget.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@test-utils';
+import Widget from './widget';
+
+const children = 'Widget body';
+const title = 'Widget title';
+const titleLevel = 2;
+
+describe('Widget', () => {
+ it('renders the widget title', () => {
+ render(
+ <Widget expanded={true} title={title} level={titleLevel}>
+ {children}
+ </Widget>
+ );
+ expect(
+ screen.getByRole('heading', { level: titleLevel })
+ ).toHaveTextContent(title);
+ });
+});
diff --git a/src/components/molecules/layout/widget.tsx b/src/components/molecules/layout/widget.tsx
new file mode 100644
index 0000000..c04362a
--- /dev/null
+++ b/src/components/molecules/layout/widget.tsx
@@ -0,0 +1,54 @@
+import { FC, useState } from 'react';
+import HeadingButton, { HeadingButtonProps } from '../buttons/heading-button';
+import styles from './widget.module.scss';
+
+export type WidgetProps = Pick<
+ HeadingButtonProps,
+ 'expanded' | 'level' | 'title'
+> & {
+ /**
+ * Set additional classnames to the widget wrapper.
+ */
+ className?: string;
+ /**
+ * Determine if the widget body should have borders. Default: false.
+ */
+ withBorders?: boolean;
+};
+
+/**
+ * Widget component
+ *
+ * Render an expandable widget.
+ */
+const Widget: FC<WidgetProps> = ({
+ children,
+ className = '',
+ expanded = true,
+ level,
+ title,
+ withBorders = false,
+}) => {
+ const [isExpanded, setIsExpanded] = useState<boolean>(expanded);
+ const stateClass = isExpanded ? 'widget--expanded' : 'widget--collapsed';
+ const bordersClass = withBorders
+ ? 'widget--has-borders'
+ : 'widget--no-borders';
+
+ return (
+ <div
+ className={`${styles.widget} ${styles[bordersClass]} ${styles[stateClass]} ${className}`}
+ >
+ <HeadingButton
+ level={level}
+ title={title}
+ expanded={isExpanded}
+ setExpanded={setIsExpanded}
+ className={styles.widget__header}
+ />
+ <div className={styles.widget__body}>{children}</div>
+ </div>
+ );
+};
+
+export default Widget;