aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/layout
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-24 19:35:12 +0200
committerGitHub <noreply@github.com>2022-05-24 19:35:12 +0200
commitc85ab5ad43ccf52881ee224672c41ec30021cf48 (patch)
tree8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/molecules/layout
parent52404177c07a2aab7fc894362fb3060dff2431a0 (diff)
parent11b9de44a4b2f305a6a484187805e429b2767118 (diff)
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/components/molecules/layout')
-rw-r--r--src/components/molecules/layout/branding.module.scss105
-rw-r--r--src/components/molecules/layout/branding.stories.tsx97
-rw-r--r--src/components/molecules/layout/branding.test.tsx61
-rw-r--r--src/components/molecules/layout/branding.tsx119
-rw-r--r--src/components/molecules/layout/card.module.scss87
-rw-r--r--src/components/molecules/layout/card.stories.tsx176
-rw-r--r--src/components/molecules/layout/card.test.tsx49
-rw-r--r--src/components/molecules/layout/card.tsx98
-rw-r--r--src/components/molecules/layout/code.module.scss305
-rw-r--r--src/components/molecules/layout/code.stories.tsx110
-rw-r--r--src/components/molecules/layout/code.test.tsx16
-rw-r--r--src/components/molecules/layout/code.tsx64
-rw-r--r--src/components/molecules/layout/columns.module.scss30
-rw-r--r--src/components/molecules/layout/columns.stories.tsx108
-rw-r--r--src/components/molecules/layout/columns.test.tsx48
-rw-r--r--src/components/molecules/layout/columns.tsx49
-rw-r--r--src/components/molecules/layout/meta.module.scss5
-rw-r--r--src/components/molecules/layout/meta.stories.tsx69
-rw-r--r--src/components/molecules/layout/meta.test.tsx24
-rw-r--r--src/components/molecules/layout/meta.tsx391
-rw-r--r--src/components/molecules/layout/page-footer.stories.tsx60
-rw-r--r--src/components/molecules/layout/page-footer.test.tsx9
-rw-r--r--src/components/molecules/layout/page-footer.tsx28
-rw-r--r--src/components/molecules/layout/page-header.module.scss64
-rw-r--r--src/components/molecules/layout/page-header.stories.tsx113
-rw-r--r--src/components/molecules/layout/page-header.test.tsx18
-rw-r--r--src/components/molecules/layout/page-header.tsx67
-rw-r--r--src/components/molecules/layout/widget.module.scss65
-rw-r--r--src/components/molecules/layout/widget.stories.tsx117
-rw-r--r--src/components/molecules/layout/widget.test.tsx19
-rw-r--r--src/components/molecules/layout/widget.tsx66
31 files changed, 2637 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..6121fa1
--- /dev/null
+++ b/src/components/molecules/layout/branding.module.scss
@@ -0,0 +1,105 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+@mixin typing-animation {
+ --typing-animation: none;
+
+ width: fit-content;
+ position: relative;
+ overflow: hidden;
+
+ &::after {
+ content: "|";
+ display: block;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: var(--color-bg);
+ color: var(--color-primary-darker);
+ font-weight: 400;
+ text-align: left;
+ visibility: hidden;
+ transform: translateX(100%);
+ transform-origin: right;
+ animation: var(--typing-animation);
+
+ :global {
+ animation: var(--typing-animation);
+ }
+ }
+}
+
+.wrapper {
+ --logo-size: #{clamp(fun.convert-px(90), 12vw, fun.convert-px(100))};
+
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ justify-items: center;
+ width: 100%;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("2xs") {
+ grid-template-columns:
+ var(--logo-size, fun.convert-px(100))
+ minmax(0, 1fr);
+ grid-template-rows: 1fr min-content;
+ align-items: center;
+ justify-items: left;
+ column-gap: var(--spacing-sm);
+ width: unset;
+ }
+ }
+
+ .logo {
+ grid-row: span 2;
+ margin-bottom: var(--spacing-sm);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("2xs") {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .title {
+ font-size: clamp(var(--font-size-xl), 8vw, var(--font-size-2xl));
+ text-align: center;
+
+ @include typing-animation;
+ }
+
+ .baseline {
+ color: var(--color-fg-light);
+ font-size: var(--font-size-lg);
+ text-align: center;
+
+ @include typing-animation;
+ }
+
+ .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..94bb166
--- /dev/null
+++ b/src/components/molecules/layout/branding.stories.tsx
@@ -0,0 +1,97 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Branding from './branding';
+
+/**
+ * Branding - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Layout/Branding',
+ component: Branding,
+ args: {
+ isHome: false,
+ withLink: 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 Branding>;
+
+const Template: ComponentStory<typeof Branding> = (args) => (
+ <Branding {...args} />
+);
+
+/**
+ * Branding Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ title: 'Website title',
+ photo: 'http://placeimg.com/640/480',
+};
+
+/**
+ * Branding Stories - With baseline
+ */
+export const WithBaseline = Template.bind({});
+WithBaseline.args = {
+ title: 'Website title',
+ baseline: 'Maiores corporis qui',
+ 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..9a82a74
--- /dev/null
+++ b/src/components/molecules/layout/branding.tsx
@@ -0,0 +1,119 @@
+import Heading from '@components/atoms/headings/heading';
+import useStyles from '@utils/hooks/use-styles';
+import Link from 'next/link';
+import { FC, useRef } from 'react';
+import { useIntl } from 'react-intl';
+import FlippingLogo, { type FlippingLogoProps } from '../images/flipping-logo';
+import styles from './branding.module.scss';
+
+export type BrandingProps = Pick<FlippingLogoProps, 'photo'> & {
+ /**
+ * The Branding baseline.
+ */
+ baseline?: string;
+ /**
+ * Use H1 if the current page is homepage. Default: false.
+ */
+ isHome?: boolean;
+ /**
+ * 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: FC<BrandingProps> = ({
+ baseline,
+ isHome = false,
+ photo,
+ title,
+ withLink = false,
+ ...props
+}) => {
+ const baselineRef = useRef<HTMLParagraphElement>(null);
+ const logoRef = useRef<HTMLDivElement>(null);
+ const titleRef = useRef<HTMLHeadingElement | HTMLParagraphElement>(null);
+ 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 }
+ );
+
+ useStyles({
+ property: '--typing-animation',
+ styles: 'blink 0.7s ease-in-out 0s 2, typing 4.3s linear 0s 1',
+ target: titleRef,
+ });
+ useStyles({
+ property: '--typing-animation',
+ styles:
+ 'hide-text 4.25s linear 0s 1, blink 0.8s ease-in-out 4.25s 2, typing 3.8s linear 4.25s 1',
+ target: baselineRef,
+ });
+ useStyles({
+ property: 'animation',
+ styles: 'flip-logo 9s ease-in 0s 1',
+ target: logoRef,
+ });
+
+ return (
+ <div className={styles.wrapper}>
+ <FlippingLogo
+ className={styles.logo}
+ altText={altText}
+ logoTitle={logoTitle}
+ photo={photo}
+ ref={logoRef}
+ {...props}
+ />
+ <Heading
+ isFake={!isHome}
+ level={1}
+ withMargin={false}
+ className={styles.title}
+ ref={titleRef}
+ >
+ {withLink ? (
+ <Link href="/">
+ <a className={styles.link}>{title}</a>
+ </Link>
+ ) : (
+ title
+ )}
+ </Heading>
+ {baseline && (
+ <Heading
+ isFake={true}
+ level={4}
+ withMargin={false}
+ className={styles.baseline}
+ ref={baselineRef}
+ >
+ {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..6065642
--- /dev/null
+++ b/src/components/molecules/layout/card.module.scss
@@ -0,0 +1,87 @@
+@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;
+ }
+
+ .cover {
+ align-self: flex-start;
+ place-content: center;
+ 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-top: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+ }
+
+ h2.title {
+ background: none;
+ text-shadow: none;
+ }
+
+ .tagline {
+ flex: 1;
+ margin-bottom: var(--spacing-md);
+ color: var(--color-fg);
+ font-weight: 400;
+ }
+
+ .list {
+ margin-bottom: var(--spacing-md);
+ }
+
+ .meta {
+ &__item {
+ flex-flow: row wrap;
+ place-content: center;
+ gap: var(--spacing-2xs);
+ margin: auto;
+ }
+
+ &__label {
+ flex: 0 0 100%;
+ }
+
+ &__value {
+ 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;
+ }
+ }
+ }
+
+ &:not(:disabled):focus {
+ text-decoration: none;
+
+ .title {
+ text-decoration: underline solid var(--color-primary) 0.3ex;
+ }
+ }
+}
diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx
new file mode 100644
index 0000000..0ad42c0
--- /dev/null
+++ b/src/components/molecules/layout/card.stories.tsx
@@ -0,0 +1,176 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Card from './card';
+
+/**
+ * Card - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Layout/Card',
+ component: Card,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the card wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ cover: {
+ description: 'The card cover data (src, dimensions, alternative text).',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ coverFit: {
+ control: {
+ type: 'select',
+ },
+ description: 'The cover fit.',
+ options: ['contain', 'cover', 'fill', 'scale-down'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'cover' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ 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',
+ min: 1,
+ max: 6,
+ },
+ 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 Card>;
+
+const Template: ComponentStory<typeof Card> = (args) => <Card {...args} />;
+
+const cover = {
+ alt: 'A picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+const meta = {
+ thematics: ['Autem', 'Eos'],
+};
+
+/**
+ * Card Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ title: 'Veritatis dicta quod',
+ titleLevel: 2,
+ url: '#',
+};
+
+/**
+ * Card Stories - With cover
+ */
+export const WithCover = Template.bind({});
+WithCover.args = {
+ cover,
+ title: 'Veritatis dicta quod',
+ titleLevel: 2,
+ url: '#',
+};
+
+/**
+ * Card Stories - With meta
+ */
+export const WithMeta = Template.bind({});
+WithMeta.args = {
+ meta,
+ title: 'Veritatis dicta quod',
+ titleLevel: 2,
+ url: '#',
+};
+
+/**
+ * Card Stories - With tagline
+ */
+export const WithTagline = Template.bind({});
+WithTagline.args = {
+ tagline: 'Ullam accusantium ipsa',
+ title: 'Veritatis dicta quod',
+ titleLevel: 2,
+ url: '#',
+};
+
+/**
+ * Card Stories - With all data
+ */
+export const WithAll = Template.bind({});
+WithAll.args = {
+ cover,
+ meta,
+ tagline: 'Ullam accusantium ipsa',
+ 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..07c01e9
--- /dev/null
+++ b/src/components/molecules/layout/card.test.tsx
@@ -0,0 +1,49 @@
+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 = {
+ author: 'Possimus',
+ thematics: ['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.author)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
new file mode 100644
index 0000000..7bbd040
--- /dev/null
+++ b/src/components/molecules/layout/card.tsx
@@ -0,0 +1,98 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import { type Image } from '@ts/types/app';
+import { FC } from 'react';
+import ResponsiveImage, {
+ type ResponsiveImageProps,
+} from '../images/responsive-image';
+import styles from './card.module.scss';
+import Meta, { type MetaData } from './meta';
+
+export type CardProps = {
+ /**
+ * Set additional classnames to the card wrapper.
+ */
+ className?: string;
+ /**
+ * The card cover.
+ */
+ cover?: Image;
+ /**
+ * The cover fit. Default: cover.
+ */
+ coverFit?: ResponsiveImageProps['objectFit'];
+ /**
+ * The card meta.
+ */
+ meta?: MetaData;
+ /**
+ * 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: FC<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
+ alignment="center"
+ level={titleLevel}
+ className={styles.title}
+ >
+ {title}
+ </Heading>
+ </header>
+ <div className={styles.tagline}>{tagline}</div>
+ {meta && (
+ <footer className={styles.footer}>
+ <Meta
+ data={meta}
+ layout="inline"
+ className={styles.list}
+ groupClassName={styles.meta__item}
+ labelClassName={styles.meta__label}
+ valueClassName={styles.meta__value}
+ />
+ </footer>
+ )}
+ </article>
+ </ButtonLink>
+ );
+};
+
+export default Card;
diff --git a/src/components/molecules/layout/code.module.scss b/src/components/molecules/layout/code.module.scss
new file mode 100644
index 0000000..1feeccc
--- /dev/null
+++ b/src/components/molecules/layout/code.module.scss
@@ -0,0 +1,305 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ :global {
+ .code-toolbar {
+ --toolbar-height: #{fun.convert-px(100)};
+
+ position: relative;
+ margin-top: calc(var(--toolbar-height) + var(--spacing-sm));
+
+ @include mix.media("screen") {
+ @include mix.dimensions("2xs") {
+ --toolbar-height: #{fun.convert-px(60)};
+ }
+ }
+
+ .toolbar {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: center;
+ width: 100%;
+ height: var(--toolbar-height);
+ position: absolute;
+ top: calc(var(--toolbar-height) * -1);
+ left: 0;
+ right: 0;
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(1) solid var(--color-border);
+ }
+
+ .toolbar-item {
+ display: flex;
+ align-items: center;
+ margin: 0 var(--spacing-2xs);
+ }
+
+ .toolbar-item:nth-child(1) {
+ flex: 0 0 100%;
+ justify-content: center;
+ margin: 0 auto 0 0;
+ padding: 0 var(--spacing-sm);
+ background: var(--color-bg-code);
+ border-bottom: fun.convert-px(1) solid var(--color-border);
+ color: var(--color-primary-darker);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("2xs") {
+ flex: 0 0 auto;
+ justify-content: left;
+ border-bottom: none;
+ border-right: fun.convert-px(1) solid var(--color-border);
+ }
+ }
+ }
+ }
+
+ .copy-to-clipboard-button,
+ .prism-color-scheme-button {
+ display: block;
+ padding: fun.convert-px(3) var(--spacing-xs);
+ background: var(--color-bg);
+ border: 0.4ex solid var(--color-primary);
+ border-radius: fun.convert-px(30);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow),
+ fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2)
+ var(--color-shadow),
+ fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4)
+ var(--color-shadow);
+ color: var(--color-primary);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ transition: all 0.35s ease-in-out 0s;
+
+ &:hover,
+ &:focus {
+ transform: translateX(#{fun.convert-px(-2)})
+ translateY(#{fun.convert-px(-2)});
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow-light),
+ fun.convert-px(1) fun.convert-px(2) fun.convert-px(2)
+ fun.convert-px(-2) var(--color-shadow-light),
+ fun.convert-px(3) fun.convert-px(4) fun.convert-px(5)
+ fun.convert-px(-4) var(--color-shadow-light),
+ fun.convert-px(4) fun.convert-px(7) fun.convert-px(8)
+ fun.convert-px(-3) var(--color-shadow-light);
+ }
+
+ &:focus {
+ text-decoration: underline var(--color-primary) fun.convert-px(3);
+ }
+
+ &:active {
+ text-decoration: none;
+ transform: translateY(#{fun.convert-px(2)});
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ }
+ }
+
+ pre[class*="language-"] {
+ --gutter-size-with-spacing: calc(var(--gutter-size) + var(--spacing-xs));
+
+ position: relative;
+ overflow: auto;
+ background: var(--color-bg-secondary);
+ border: fun.convert-px(1) solid var(--color-border-light);
+ color: var(--color-fg);
+ hyphens: none;
+ tab-size: 4;
+ text-align: left;
+ white-space: pre;
+ word-spacing: normal;
+ word-break: normal;
+ word-wrap: normal;
+
+ &.command-line {
+ --gutter-size: 19ch;
+ padding-left: var(--gutter-size-with-spacing);
+ }
+
+ &.line-numbers {
+ --gutter-size: 6ch;
+
+ counter-reset: lineNumber;
+ padding-left: var(--gutter-size-with-spacing);
+ }
+
+ code {
+ display: block;
+ padding: var(--spacing-xs) 0;
+ position: relative;
+ }
+
+ .line-numbers-rows,
+ .command-line-prompt {
+ display: block;
+ width: var(--gutter-size);
+ padding: var(--spacing-xs) 0;
+ position: absolute;
+ top: 0;
+ left: calc(var(--gutter-size-with-spacing) * -1);
+ background: var(--color-bg);
+ border-right: fun.convert-px(1) solid var(--color-border);
+ font-size: 100%;
+ letter-spacing: -1px;
+ text-align: right;
+ pointer-events: none;
+ user-select: none;
+
+ > span {
+ &::before {
+ display: block;
+ padding-right: var(--spacing-xs);
+ color: var(--color-fg-light);
+ }
+ }
+ }
+
+ .command-line-prompt {
+ > span {
+ &::before {
+ content: " ";
+ }
+
+ &[data-user]::before {
+ content: "[" attr(data-user) "@" attr(data-host) "] $";
+ }
+
+ &[data-user="root"]::before {
+ content: "[" attr(data-user) "@" attr(data-host) "] #";
+ }
+
+ &[data-prompt]::before {
+ content: attr(data-prompt);
+ }
+
+ &[data-continuation-prompt]::before {
+ content: attr(data-continuation-prompt);
+ }
+ }
+ }
+
+ .line-numbers-rows {
+ > span {
+ counter-increment: lineNumber;
+
+ &::before {
+ content: counter(lineNumber);
+ }
+ }
+ }
+
+ .token {
+ &.comment,
+ &.doc-comment {
+ color: var(--color-fg-light);
+ }
+
+ &.punctuation {
+ color: var(--color-fg);
+ }
+
+ &.attr-name,
+ &.hexcode,
+ &.inserted,
+ &.string {
+ color: var(--color-token-green);
+ }
+
+ &.class,
+ &.coord,
+ &.id,
+ &.function {
+ color: var(--color-token-purple);
+ }
+
+ &.builtin,
+ &.builtin.class-name,
+ &.property-access,
+ &.regex,
+ &.scope {
+ color: var(--color-token-magenta);
+ }
+
+ &.class-name,
+ &.constant,
+ &.global,
+ &.interpolation,
+ &.key,
+ &.package,
+ &.this,
+ &.title,
+ &.variable {
+ color: var(--color-token-blue);
+ }
+
+ &.combinator,
+ &.keyword,
+ &.operator,
+ &.pseudo-class,
+ &.pseudo-element,
+ &.rule,
+ &.selector,
+ &.unit {
+ color: var(--color-token-orange);
+ }
+
+ &.attr-value,
+ &.boolean,
+ &.number {
+ color: var(--color-token-yellow);
+ }
+
+ &.delimiter,
+ &.doctype,
+ &.parameter,
+ &.parent,
+ &.property,
+ &.shebang,
+ &.tag {
+ color: var(--color-token-cyan);
+ }
+
+ &.deleted {
+ color: var(--color-token-red);
+ }
+
+ &.punctuation.brace-hover,
+ &.punctuation.brace-selected {
+ background: var(--color-bg);
+ outline: solid fun.convert-px(1) var(--color-primary-light);
+ }
+ }
+
+ span.inline-color-wrapper {
+ background: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><path fill="gray" d="M0 0h2v2H0z"/><path fill="white" d="M0 0h1v1H0zM1 1h1v1H1z"/></svg>'
+ ));
+
+ // Prevent repeating pattern to be seen.
+ background-position: center;
+ background-size: 110%;
+
+ display: inline-block;
+ height: 1.1ch;
+ width: 1.1ch;
+ margin: 0 0.5ch 0 0;
+ border: fun.convert-px(1) solid var(--color-bg);
+ outline: fun.convert-px(1) solid var(--color-border-dark);
+ overflow: hidden;
+ }
+
+ span.inline-color {
+ display: block;
+
+ /* To prevent visual glitches again */
+ height: 120%;
+ width: 120%;
+ }
+ }
+ }
+}
diff --git a/src/components/molecules/layout/code.stories.tsx b/src/components/molecules/layout/code.stories.tsx
new file mode 100644
index 0000000..ac0e98f
--- /dev/null
+++ b/src/components/molecules/layout/code.stories.tsx
@@ -0,0 +1,110 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CodeComponent from './code';
+
+/**
+ * Code - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Layout/Code',
+ component: CodeComponent,
+ args: {
+ filterOutput: false,
+ outputPattern: '#output#',
+ },
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The code sample.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ filterOutput: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Filter the command line output.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ language: {
+ control: {
+ type: 'text',
+ },
+ description: 'The code sample language.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ plugins: {
+ description: 'An array of Prism plugins to activate.',
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ outputPattern: {
+ control: {
+ type: 'text',
+ },
+ description: 'The command line output pattern.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: '#output#' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CodeComponent>;
+
+const Template: ComponentStory<typeof CodeComponent> = (args) => (
+ <CodeComponent {...args} />
+);
+
+const javascriptCodeSample = `
+const foo = () => {
+ return 'bar';
+}
+`;
+
+/**
+ * Code Stories - Code sample
+ */
+export const CodeSample = Template.bind({});
+CodeSample.args = {
+ children: javascriptCodeSample,
+ language: 'javascript',
+ plugins: ['line-numbers'],
+};
+
+const commandLineCode = `
+ls -lah
+#output#drwxr-x---+ 42 armand armand 4,0K 17 avril 11:15 .
+#output#drwxr-xr-x 4 root root 4,0K 30 mai 2021 ..
+#output#-rw-r--r-- 1 armand armand 2,0K 21 juil. 2021 .xinitrc
+`;
+
+/**
+ * Code Stories - Command Line
+ */
+export const CommandLine = Template.bind({});
+CommandLine.args = {
+ children: commandLineCode,
+ filterOutput: true,
+ language: 'bash',
+ plugins: ['command-line'],
+};
diff --git a/src/components/molecules/layout/code.test.tsx b/src/components/molecules/layout/code.test.tsx
new file mode 100644
index 0000000..ebcfae5
--- /dev/null
+++ b/src/components/molecules/layout/code.test.tsx
@@ -0,0 +1,16 @@
+import { render } from '@test-utils';
+import Code from './code';
+
+const code = `
+function foo() {
+ return 'bar';
+}
+`;
+
+const language = 'javascript';
+
+describe('Code', () => {
+ it('renders a code block', () => {
+ render(<Code language={language}>{code}</Code>);
+ });
+});
diff --git a/src/components/molecules/layout/code.tsx b/src/components/molecules/layout/code.tsx
new file mode 100644
index 0000000..30351b9
--- /dev/null
+++ b/src/components/molecules/layout/code.tsx
@@ -0,0 +1,64 @@
+import usePrism, {
+ type OptionalPrismPlugin,
+ type PrismLanguage,
+} from '@utils/hooks/use-prism';
+import { FC, useRef } from 'react';
+import styles from './code.module.scss';
+
+export type CodeProps = {
+ /**
+ * The code to highlight.
+ */
+ children: string;
+ /**
+ * Filter command line output. Default: false.
+ */
+ filterOutput?: boolean;
+ /**
+ * The code language.
+ */
+ language: PrismLanguage;
+ /**
+ * The optional Prism plugins.
+ */
+ plugins?: OptionalPrismPlugin[];
+ /**
+ * Filter command line output using the given string. Default: #output#
+ */
+ outputPattern?: string;
+};
+
+/**
+ * Code component
+ *
+ * Render a code block with syntax highlighting.
+ */
+const Code: FC<CodeProps> = ({
+ children,
+ filterOutput = false,
+ language,
+ plugins = [],
+ outputPattern = '#output#',
+}) => {
+ const wrapperRef = useRef<HTMLDivElement>(null);
+ const { attributes, className } = usePrism({ language, plugins });
+
+ const outputAttribute = filterOutput
+ ? { 'data-filter-output': outputPattern }
+ : {};
+
+ return (
+ <div className={styles.wrapper} ref={wrapperRef}>
+ <pre
+ className={className}
+ tabIndex={0}
+ {...attributes}
+ {...outputAttribute}
+ >
+ <code className={`language-${language}`}>{children}</code>
+ </pre>
+ </div>
+ );
+};
+
+export default Code;
diff --git a/src/components/molecules/layout/columns.module.scss b/src/components/molecules/layout/columns.module.scss
new file mode 100644
index 0000000..b449c45
--- /dev/null
+++ b/src/components/molecules/layout/columns.module.scss
@@ -0,0 +1,30 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: grid;
+ gap: var(--spacing-md);
+
+ &--responsive#{&} {
+ @for $i from 2 through 4 {
+ &--#{$i}-columns {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ @include mix.dimensions("md") {
+ grid-template-columns: repeat($i, minmax(0, 1fr));
+ }
+ }
+ }
+ }
+ }
+
+ &--no-responsive#{&} {
+ @for $i from 2 through 4 {
+ &--#{$i}-columns {
+ grid-template-columns: repeat($i, minmax(0, 1fr));
+ }
+ }
+ }
+}
diff --git a/src/components/molecules/layout/columns.stories.tsx b/src/components/molecules/layout/columns.stories.tsx
new file mode 100644
index 0000000..2022fa4
--- /dev/null
+++ b/src/components/molecules/layout/columns.stories.tsx
@@ -0,0 +1,108 @@
+import Column from '@components/atoms/layout/column';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Columns from './columns';
+
+export default {
+ title: 'Molecules/Layout/Columns',
+ args: {
+ responsive: true,
+ },
+ component: Columns,
+ argTypes: {
+ children: {
+ description: 'The columns.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the columns wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ count: {
+ control: {
+ type: 'number',
+ min: 2,
+ max: 4,
+ },
+ description: 'The number of columns.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ responsive: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Should the columns be stacked on small devices?',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof Columns>;
+
+const Template: ComponentStory<typeof Columns> = (args) => (
+ <Columns {...args} />
+);
+
+const column1 =
+ 'Non praesentium voluptas quisquam ex est. Distinctio accusamus facilis libero in aut. Et veritatis quo impedit fugit amet sit accusantium. Ut est rerum asperiores sint libero eveniet. Molestias placeat recusandae suscipit eligendi sunt hic.';
+
+const column2 =
+ 'Occaecati consectetur ad similique itaque rem doloremque commodi voluptate porro. Nam quo voluptas commodi qui rerum qui. Explicabo quis adipisci rerum. Culpa alias laboriosam temporibus iusto harum at placeat.';
+
+const column3 =
+ 'Libero aut ab neque voluptatem commodi. Quam quia voluptatem iusto dolorum. Enim ipsa totam corrupti qui cum quidem ea. Eos sed aliquam porro consequatur officia sed.';
+
+const column4 =
+ 'Ratione placeat ea ea. Explicabo rem eaque voluptatibus. Nihil nulla culpa et dolor numquam omnis est. Quis quas excepturi est dignissimos ducimus et ad quis quis. Eos enim et nam delectus.';
+
+export const TwoColumns = Template.bind({});
+TwoColumns.args = {
+ children: [
+ <Column key="column-1">{column1}</Column>,
+ <Column key="column-2">{column2}</Column>,
+ <Column key="column-3">{column3}</Column>,
+ <Column key="column-4">{column4}</Column>,
+ ],
+ count: 2,
+};
+
+export const ThreeColumns = Template.bind({});
+ThreeColumns.args = {
+ children: [
+ <Column key="column-1">{column1}</Column>,
+ <Column key="column-2">{column2}</Column>,
+ <Column key="column-3">{column3}</Column>,
+ <Column key="column-4">{column4}</Column>,
+ ],
+ count: 3,
+};
+
+export const FourColumns = Template.bind({});
+FourColumns.args = {
+ children: [
+ <Column key="column-1">{column1}</Column>,
+ <Column key="column-2">{column2}</Column>,
+ <Column key="column-3">{column3}</Column>,
+ <Column key="column-4">{column4}</Column>,
+ ],
+ count: 4,
+};
diff --git a/src/components/molecules/layout/columns.test.tsx b/src/components/molecules/layout/columns.test.tsx
new file mode 100644
index 0000000..4b55bbb
--- /dev/null
+++ b/src/components/molecules/layout/columns.test.tsx
@@ -0,0 +1,48 @@
+import Column from '@components/atoms/layout/column';
+import { render, screen } from '@test-utils';
+import Columns from './columns';
+
+const column1 =
+ 'Non praesentium voluptas quisquam ex est. Distinctio accusamus facilis libero in aut. Et veritatis quo impedit fugit amet sit accusantium. Ut est rerum asperiores sint libero eveniet. Molestias placeat recusandae suscipit eligendi sunt hic.';
+
+const column2 =
+ 'Occaecati consectetur ad similique itaque rem doloremque commodi voluptate porro. Nam quo voluptas commodi qui rerum qui. Explicabo quis adipisci rerum. Culpa alias laboriosam temporibus iusto harum at placeat.';
+
+const column3 =
+ 'Libero aut ab neque voluptatem commodi. Quam quia voluptatem iusto dolorum. Enim ipsa totam corrupti qui cum quidem ea. Eos sed aliquam porro consequatur officia sed.';
+
+const column4 =
+ 'Ratione placeat ea ea. Explicabo rem eaque voluptatibus. Nihil nulla culpa et dolor numquam omnis est. Quis quas excepturi est dignissimos ducimus et ad quis quis. Eos enim et nam delectus.';
+
+describe('Columns', () => {
+ it('renders all the children', () => {
+ render(
+ <Columns count={2}>
+ <Column key="column-1">{column1}</Column>
+ <Column key="column-2">{column2}</Column>
+ <Column key="column-3">{column3}</Column>
+ <Column key="column-4">{column4}</Column>
+ </Columns>
+ );
+
+ expect(screen.getByText(column1)).toBeInTheDocument();
+ expect(screen.getByText(column2)).toBeInTheDocument();
+ expect(screen.getByText(column3)).toBeInTheDocument();
+ expect(screen.getByText(column4)).toBeInTheDocument();
+ });
+
+ it('renders the right number of columns', () => {
+ render(
+ <Columns count={3}>
+ <Column key="column-1">{column1}</Column>
+ <Column key="column-2">{column2}</Column>
+ <Column key="column-3">{column3}</Column>
+ <Column key="column-4">{column4}</Column>
+ </Columns>
+ );
+
+ const container = screen.getByText(column1).parentElement;
+
+ expect(container).toHaveClass('wrapper--3-columns');
+ });
+});
diff --git a/src/components/molecules/layout/columns.tsx b/src/components/molecules/layout/columns.tsx
new file mode 100644
index 0000000..c196457
--- /dev/null
+++ b/src/components/molecules/layout/columns.tsx
@@ -0,0 +1,49 @@
+import Column from '@components/atoms/layout/column';
+import { FC, ReactComponentElement } from 'react';
+import styles from './columns.module.scss';
+
+export type ColumnsProps = {
+ /**
+ * The columns.
+ */
+ children: ReactComponentElement<typeof Column>[];
+ /**
+ * Set additional classnames to the columns wrapper.
+ */
+ className?: string;
+ /**
+ * The number of columns.
+ */
+ count: 2 | 3 | 4;
+ /**
+ * Should the columns be stacked on small devices? Default: true.
+ */
+ responsive?: boolean;
+};
+
+/**
+ * Columns component.
+ *
+ * Render some Column components as columns.
+ */
+const Columns: FC<ColumnsProps> = ({
+ children,
+ className = '',
+ count,
+ responsive = true,
+}) => {
+ const countClass = `wrapper--${count}-columns`;
+ const responsiveClass = responsive
+ ? `wrapper--responsive`
+ : 'wrapper--no-responsive';
+
+ return (
+ <div
+ className={`${styles.wrapper} ${styles[countClass]} ${styles[responsiveClass]} ${className}`}
+ >
+ {children}
+ </div>
+ );
+};
+
+export default Columns;
diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss
new file mode 100644
index 0000000..4194a6e
--- /dev/null
+++ b/src/components/molecules/layout/meta.module.scss
@@ -0,0 +1,5 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.value {
+ word-break: break-all;
+}
diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx
new file mode 100644
index 0000000..c33680f
--- /dev/null
+++ b/src/components/molecules/layout/meta.stories.tsx
@@ -0,0 +1,69 @@
+import descriptionListItemStories from '@components/atoms/lists/description-list-item.stories';
+import descriptionListStories from '@components/atoms/lists/description-list.stories';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MetaComponent, { MetaData } from './meta';
+
+/**
+ * Meta - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Layout',
+ component: MetaComponent,
+ args: {
+ itemsLayout: 'inline-values',
+ withSeparator: false,
+ },
+ argTypes: {
+ className: descriptionListStories.argTypes?.className,
+ data: {
+ description: 'The page metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ groupClassName: descriptionListStories.argTypes?.groupClassName,
+ itemsLayout: {
+ ...descriptionListItemStories.argTypes?.layout,
+ table: {
+ ...descriptionListItemStories.argTypes?.layout?.table,
+ defaultValue: { summary: 'inline-values' },
+ },
+ },
+ labelClassName: descriptionListStories.argTypes?.labelClassName,
+ layout: descriptionListStories.argTypes?.layout,
+ valueClassName: descriptionListStories.argTypes?.valueClassName,
+ withSeparator: {
+ ...descriptionListStories.argTypes?.withSeparator,
+ table: {
+ ...descriptionListStories.argTypes?.withSeparator?.table,
+ defaultValue: { summary: true },
+ },
+ },
+ },
+} as ComponentMeta<typeof MetaComponent>;
+
+const Template: ComponentStory<typeof MetaComponent> = (args) => (
+ <MetaComponent {...args} />
+);
+
+const data: MetaData = {
+ publication: { date: '2022-04-09', time: '01:04:00' },
+ thematics: [
+ <a key="category1" href="#">
+ Category 1
+ </a>,
+ <a key="category2" href="#">
+ Category 2
+ </a>,
+ ],
+};
+
+/**
+ * Layout Stories - Meta
+ */
+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..fe66d97
--- /dev/null
+++ b/src/components/molecules/layout/meta.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from '@test-utils';
+import { getFormattedDate } from '@utils/helpers/dates';
+import Meta from './meta';
+
+const data = {
+ publication: { date: '2022-04-09' },
+ thematics: [
+ <a key="category1" href="#">
+ Category 1
+ </a>,
+ <a key="category2" href="#">
+ Category 2
+ </a>,
+ ],
+};
+
+describe('Meta', () => {
+ it('format a date string', () => {
+ render(<Meta data={data} />);
+ expect(
+ screen.getByText(getFormattedDate(data.publication.date))
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
new file mode 100644
index 0000000..74bd4ff
--- /dev/null
+++ b/src/components/molecules/layout/meta.tsx
@@ -0,0 +1,391 @@
+import Link from '@components/atoms/links/link';
+import DescriptionList, {
+ type DescriptionListProps,
+ type DescriptionListItem,
+} from '@components/atoms/lists/description-list';
+import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
+import { FC, ReactNode } from 'react';
+import { useIntl } from 'react-intl';
+
+export type CustomMeta = {
+ label: string;
+ value: ReactNode | ReactNode[];
+};
+
+export type MetaComments = {
+ /**
+ * A page title.
+ */
+ about: string;
+ /**
+ * The comments count.
+ */
+ count: number;
+ /**
+ * Wrap the comments count with a link to the given target.
+ */
+ target?: string;
+};
+
+export type MetaDate = {
+ /**
+ * A date string. Ex: `2022-04-30`.
+ */
+ date: string;
+ /**
+ * A time string. Ex: `10:25:59`.
+ */
+ time?: string;
+ /**
+ * Wrap the date with a link to the given target.
+ */
+ target?: string;
+};
+
+export type MetaData = {
+ /**
+ * The author name.
+ */
+ author?: string;
+ /**
+ * The comments count.
+ */
+ comments?: MetaComments;
+ /**
+ * The creation date.
+ */
+ creation?: MetaDate;
+ /**
+ * A custom label/value metadata.
+ */
+ custom?: CustomMeta;
+ /**
+ * The license name.
+ */
+ license?: string;
+ /**
+ * The popularity.
+ */
+ popularity?: string | JSX.Element;
+ /**
+ * The publication date.
+ */
+ publication?: MetaDate;
+ /**
+ * The estimated reading time.
+ */
+ readingTime?: string | JSX.Element;
+ /**
+ * An array of repositories.
+ */
+ repositories?: string[] | JSX.Element[];
+ /**
+ * An array of technologies.
+ */
+ technologies?: string[];
+ /**
+ * An array of thematics.
+ */
+ thematics?: string[] | JSX.Element[];
+ /**
+ * An array of thematics.
+ */
+ topics?: string[] | JSX.Element[];
+ /**
+ * A total number of posts.
+ */
+ total?: number;
+ /**
+ * The update date.
+ */
+ update?: MetaDate;
+ /**
+ * An url.
+ */
+ website?: string;
+};
+
+export type MetaKey = keyof MetaData;
+
+export type MetaProps = Omit<
+ DescriptionListProps,
+ 'items' | 'withSeparator'
+> & {
+ /**
+ * The meta data.
+ */
+ data: MetaData;
+ /**
+ * The items layout.
+ */
+ itemsLayout?: DescriptionListItem['layout'];
+ /**
+ * If true, use a slash to delimitate multiple values. Default: true.
+ */
+ withSeparator?: DescriptionListProps['withSeparator'];
+};
+
+/**
+ * Meta component
+ *
+ * Renders the given metadata.
+ */
+const Meta: FC<MetaProps> = ({
+ data,
+ itemsLayout = 'inline-values',
+ withSeparator = true,
+ ...props
+}) => {
+ const intl = useIntl();
+
+ /**
+ * Retrieve the item label based on its key.
+ *
+ * @param {keyof MetaData} key - The meta key.
+ * @returns {string} The item label.
+ */
+ const getLabel = (key: keyof MetaData): string => {
+ switch (key) {
+ case 'author':
+ return intl.formatMessage({
+ defaultMessage: 'Written by:',
+ description: 'Meta: author label',
+ id: 'OI0N37',
+ });
+ case 'comments':
+ return intl.formatMessage({
+ defaultMessage: 'Comments:',
+ description: 'Meta: comments label',
+ id: 'jTVIh8',
+ });
+ case 'creation':
+ return intl.formatMessage({
+ defaultMessage: 'Created on:',
+ description: 'Meta: creation date label',
+ id: 'b4fdYE',
+ });
+ case 'license':
+ return intl.formatMessage({
+ defaultMessage: 'License:',
+ description: 'Meta: license label',
+ id: 'AuGklx',
+ });
+ case 'popularity':
+ return intl.formatMessage({
+ defaultMessage: 'Popularity:',
+ description: 'Meta: popularity label',
+ id: 'pWTj2W',
+ });
+ case 'publication':
+ return intl.formatMessage({
+ defaultMessage: 'Published on:',
+ description: 'Meta: publication date label',
+ id: 'QGi5uD',
+ });
+ case 'readingTime':
+ return intl.formatMessage({
+ defaultMessage: 'Reading time:',
+ description: 'Meta: reading time label',
+ id: 'EbFvsM',
+ });
+ case 'repositories':
+ return intl.formatMessage({
+ defaultMessage: 'Repositories:',
+ description: 'Meta: repositories label',
+ id: 'DssFG1',
+ });
+ case 'technologies':
+ return intl.formatMessage({
+ defaultMessage: 'Technologies:',
+ description: 'Meta: technologies label',
+ id: 'ADQmDF',
+ });
+ case 'thematics':
+ return intl.formatMessage({
+ defaultMessage: 'Thematics:',
+ description: 'Meta: thematics label',
+ id: 'bz53Us',
+ });
+ case 'topics':
+ return intl.formatMessage({
+ defaultMessage: 'Topics:',
+ description: 'Meta: topics label',
+ id: 'gJNaBD',
+ });
+ case 'total':
+ return intl.formatMessage({
+ defaultMessage: 'Total:',
+ description: 'Meta: total label',
+ id: '92zgdp',
+ });
+ case 'update':
+ return intl.formatMessage({
+ defaultMessage: 'Updated on:',
+ description: 'Meta: update date label',
+ id: 'tLC7bh',
+ });
+ case 'website':
+ return intl.formatMessage({
+ defaultMessage: 'Official website:',
+ description: 'Meta: official website label',
+ id: 'GRyyfy',
+ });
+ default:
+ return '';
+ }
+ };
+
+ /**
+ * Retrieve a formatted date (and time).
+ *
+ * @param {MetaDate} dateTime - A date object.
+ * @returns {JSX.Element} The formatted date wrapped in a time element.
+ */
+ const getDate = (dateTime: MetaDate): JSX.Element => {
+ const { date, time, target } = dateTime;
+
+ if (!dateTime.time) {
+ const isoDate = new Date(`${date}`).toISOString();
+ return target ? (
+ <Link href={target}>
+ <time dateTime={isoDate}>{getFormattedDate(dateTime.date)}</time>
+ </Link>
+ ) : (
+ <time dateTime={isoDate}>{getFormattedDate(dateTime.date)}</time>
+ );
+ }
+
+ const isoDateTime = new Date(`${date}T${time}`).toISOString();
+ const dateString = intl.formatMessage(
+ {
+ defaultMessage: '{date} at {time}',
+ description: 'Meta: publication date and time',
+ id: 'fcHeyC',
+ },
+ {
+ date: getFormattedDate(dateTime.date),
+ time: getFormattedTime(`${dateTime.date}T${dateTime.time}`),
+ }
+ );
+
+ return target ? (
+ <Link href={target}>
+ <time dateTime={isoDateTime}>{dateString}</time>
+ </Link>
+ ) : (
+ <time dateTime={isoDateTime}>{dateString}</time>
+ );
+ };
+
+ /**
+ * Retrieve the formatted comments count.
+ *
+ * @param comments - The comments object.
+ * @returns {string | JSX.Element} - The comments count.
+ */
+ const getCommentsCount = (comments: MetaComments): string | JSX.Element => {
+ const { about, count, target } = comments;
+ const commentsCount = intl.formatMessage(
+ {
+ defaultMessage:
+ '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>',
+ description: 'Meta: comments count',
+ id: '02rgLO',
+ },
+ {
+ a11y: (chunks: ReactNode) => (
+ <span className="screen-reader-text">{chunks}</span>
+ ),
+ commentsCount: count,
+ title: about,
+ }
+ );
+
+ return target ? (
+ <Link href={target}>{commentsCount as JSX.Element}</Link>
+ ) : (
+ (commentsCount as JSX.Element)
+ );
+ };
+
+ /**
+ * Retrieve the formatted item value.
+ *
+ * @param {keyof MetaData} key - The meta key.
+ * @param {ValueOf<MetaData>} value - The meta value.
+ * @returns {string|ReactNode|ReactNode[]} - The formatted value.
+ */
+ const getValue = <T extends MetaKey>(
+ key: T,
+ value: MetaData[T]
+ ): string | ReactNode | ReactNode[] => {
+ switch (key) {
+ case 'comments':
+ return getCommentsCount(value as MetaComments);
+ case 'creation':
+ case 'publication':
+ case 'update':
+ return getDate(value as MetaDate);
+ case 'total':
+ return intl.formatMessage(
+ {
+ defaultMessage:
+ '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
+ description: 'BlogPage: posts count meta',
+ id: 'OF5cPz',
+ },
+ { postsCount: value as number }
+ );
+ case 'website':
+ const url = value as string;
+ return (
+ <Link href={url} external={true}>
+ {url}
+ </Link>
+ );
+ default:
+ return value as string | ReactNode | ReactNode[];
+ }
+ };
+
+ /**
+ * Transform the metadata to description list item format.
+ *
+ * @param {MetaData} items - The meta.
+ * @returns {DescriptionListItem[]} The formatted description list items.
+ */
+ const getItems = (items: MetaData): DescriptionListItem[] => {
+ const listItems: DescriptionListItem[] = Object.entries(items)
+ .map(([key, value]) => {
+ if (!key || !value) return;
+
+ const metaKey = key as MetaKey;
+
+ return {
+ id: metaKey,
+ label:
+ metaKey === 'custom'
+ ? (value as CustomMeta).label
+ : getLabel(metaKey),
+ layout: itemsLayout,
+ value:
+ metaKey === 'custom' && (value as CustomMeta)
+ ? (value as CustomMeta).value
+ : getValue(metaKey, value),
+ } as DescriptionListItem;
+ })
+ .filter((item): item is DescriptionListItem => !!item);
+
+ return listItems;
+ };
+
+ return (
+ <DescriptionList
+ items={getItems(data)}
+ withSeparator={withSeparator}
+ {...props}
+ />
+ );
+};
+
+export default Meta;
diff --git a/src/components/molecules/layout/page-footer.stories.tsx b/src/components/molecules/layout/page-footer.stories.tsx
new file mode 100644
index 0000000..31b7a49
--- /dev/null
+++ b/src/components/molecules/layout/page-footer.stories.tsx
@@ -0,0 +1,60 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { MetaData } from './meta';
+import PageFooterComponent from './page-footer';
+
+/**
+ * Page Footer - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Layout',
+ component: PageFooterComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the footer element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ meta: {
+ description: 'The page meta.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof PageFooterComponent>;
+
+const Template: ComponentStory<typeof PageFooterComponent> = (args) => (
+ <PageFooterComponent {...args} />
+);
+
+const meta: MetaData = {
+ custom: {
+ label: 'More posts about:',
+ value: [
+ <a key="topic-1" href="#">
+ Topic name
+ </a>,
+ ],
+ },
+};
+
+/**
+ * Page Footer Stories - With meta
+ */
+export const PageFooter = Template.bind({});
+PageFooter.args = {
+ meta,
+};
diff --git a/src/components/molecules/layout/page-footer.test.tsx b/src/components/molecules/layout/page-footer.test.tsx
new file mode 100644
index 0000000..2e95625
--- /dev/null
+++ b/src/components/molecules/layout/page-footer.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import PageFooter from './page-footer';
+
+describe('PageFooter', () => {
+ it('renders a footer element', () => {
+ render(<PageFooter />);
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx
new file mode 100644
index 0000000..97e449f
--- /dev/null
+++ b/src/components/molecules/layout/page-footer.tsx
@@ -0,0 +1,28 @@
+import { FC } from 'react';
+import Meta, { MetaData } from './meta';
+
+export type PageFooterProps = {
+ /**
+ * Set additional classnames to the footer element.
+ */
+ className?: string;
+ /**
+ * The footer metadata.
+ */
+ meta?: MetaData;
+};
+
+/**
+ * PageFooter component
+ *
+ * Render a footer element to display page meta.
+ */
+const PageFooter: FC<PageFooterProps> = ({ meta, ...props }) => {
+ return (
+ <footer {...props}>
+ {meta && <Meta data={meta} withSeparator={false} />}
+ </footer>
+ );
+};
+
+export default PageFooter;
diff --git a/src/components/molecules/layout/page-header.module.scss b/src/components/molecules/layout/page-header.module.scss
new file mode 100644
index 0000000..232023a
--- /dev/null
+++ b/src/components/molecules/layout/page-header.module.scss
@@ -0,0 +1,64 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ @extend %grid;
+
+ &::before,
+ &::after {
+ content: "";
+ width: 100%;
+ height: 100%;
+ background: var(--color-bg-secondary);
+ border-top: fun.convert-px(3) solid var(--color-border-light);
+ border-bottom: fun.convert-px(3) solid var(--color-border-light);
+ }
+
+ &::before {
+ grid-column: 1;
+ justify-self: start;
+ border-right: fun.convert-px(3) solid var(--color-border-light);
+ }
+
+ &::after {
+ grid-column: 3;
+ justify-self: end;
+ border-left: fun.convert-px(3) solid var(--color-border-light);
+ }
+}
+
+.body {
+ grid-column: 2;
+ display: flex;
+ flex-flow: column wrap;
+ row-gap: var(--spacing-sm);
+}
+
+.title {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ position: relative;
+
+ &::before,
+ &::after {
+ content: "";
+ width: 100%;
+ height: fun.convert-px(4);
+ background: radial-gradient(
+ ellipse at center,
+ var(--color-primary-light),
+ var(--color-primary-dark)
+ );
+ }
+}
+
+.meta {
+ font-size: var(--font-size-sm);
+}
+
+.intro {
+ > *:last-child {
+ margin-bottom: 0;
+ }
+}
diff --git a/src/components/molecules/layout/page-header.stories.tsx b/src/components/molecules/layout/page-header.stories.tsx
new file mode 100644
index 0000000..d58f8b5
--- /dev/null
+++ b/src/components/molecules/layout/page-header.stories.tsx
@@ -0,0 +1,113 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PageHeader from './page-header';
+
+/**
+ * Page Header - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Layout/PageHeader',
+ component: PageHeader,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the header element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ intro: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page introduction.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ meta: {
+ description: 'The page metadata.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof PageHeader>;
+
+const Template: ComponentStory<typeof PageHeader> = (args) => (
+ <PageHeader {...args} />
+);
+
+const meta = {
+ publication: { date: '2022-04-09' },
+ thematics: [
+ <a key="category1" href="#">
+ Category 1
+ </a>,
+ <a key="category2" href="#">
+ Category 2
+ </a>,
+ ],
+};
+
+/**
+ * Page Header Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ title: 'Excepturi nesciunt illum',
+};
+
+/**
+ * Page Header Stories - With introduction
+ */
+export const WithIntro = Template.bind({});
+WithIntro.args = {
+ intro:
+ 'Minima dolor nihil. Velit atque odit totam enim. Quisquam reprehenderit ut et inventore et nihil libero exercitationem. Cumque similique magni placeat et. Et sed est cumque labore. Et quia similique.',
+ title: 'Excepturi nesciunt illum',
+};
+
+/**
+ * Page Header Stories - With meta
+ */
+export const WithMeta = Template.bind({});
+WithMeta.args = {
+ meta,
+ title: 'Excepturi nesciunt illum',
+};
+
+/**
+ * Page Header Stories - With introduction and meta
+ */
+export const WithIntroAndMeta = Template.bind({});
+WithIntroAndMeta.args = {
+ intro:
+ 'Minima dolor nihil. Velit atque odit totam enim. Quisquam reprehenderit ut et inventore et nihil libero exercitationem. Cumque similique magni placeat et. Et sed est cumque labore. Et quia similique.',
+ meta,
+ title: 'Excepturi nesciunt illum',
+};
diff --git a/src/components/molecules/layout/page-header.test.tsx b/src/components/molecules/layout/page-header.test.tsx
new file mode 100644
index 0000000..329b54c
--- /dev/null
+++ b/src/components/molecules/layout/page-header.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@test-utils';
+import PageHeader from './page-header';
+
+const title = 'Non nemo amet';
+const intro =
+ 'Suscipit omnis minima doloribus commodi. Laudantium similique ut enim voluptatem soluta maxime autem et.';
+
+describe('PageHeader', () => {
+ it('renders a title', () => {
+ render(<PageHeader title={title} intro={intro} />);
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(title);
+ });
+
+ it('renders an introduction', () => {
+ render(<PageHeader title={title} intro={intro} />);
+ expect(screen.getByText(intro)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
new file mode 100644
index 0000000..6759c7f
--- /dev/null
+++ b/src/components/molecules/layout/page-header.tsx
@@ -0,0 +1,67 @@
+import Heading from '@components/atoms/headings/heading';
+import { FC, ReactNode } from 'react';
+import Meta, { type MetaData } from './meta';
+import styles from './page-header.module.scss';
+
+export type PageHeaderProps = {
+ /**
+ * Set additional classnames to the header element.
+ */
+ className?: string;
+ /**
+ * The page introduction.
+ */
+ intro?: string | JSX.Element;
+ /**
+ * The page metadata.
+ */
+ meta?: MetaData;
+ /**
+ * The page title.
+ */
+ title: ReactNode;
+};
+
+/**
+ * PageHeader component
+ *
+ * Render a header element with page title, meta and intro.
+ */
+const PageHeader: FC<PageHeaderProps> = ({
+ className = '',
+ intro,
+ meta,
+ title,
+}) => {
+ const getIntro = () => {
+ return typeof intro === 'string' ? (
+ <div
+ className={styles.intro}
+ dangerouslySetInnerHTML={{ __html: intro }}
+ />
+ ) : (
+ <div className={styles.intro}>{intro}</div>
+ );
+ };
+
+ return (
+ <header className={`${styles.wrapper} ${className}`}>
+ <div className={styles.body}>
+ <Heading level={1} className={styles.title} withMargin={false}>
+ {title}
+ </Heading>
+ {meta && (
+ <Meta
+ data={meta}
+ className={styles.meta}
+ layout="column"
+ itemsLayout="inline"
+ />
+ )}
+ {intro && getIntro()}
+ </div>
+ </header>
+ );
+};
+
+export default PageHeader;
diff --git a/src/components/molecules/layout/widget.module.scss b/src/components/molecules/layout/widget.module.scss
new file mode 100644
index 0000000..27d7ffd
--- /dev/null
+++ b/src/components/molecules/layout/widget.module.scss
@@ -0,0 +1,65 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.widget {
+ display: flex;
+ flex-flow: column;
+
+ &__header {
+ z-index: 2;
+ background: var(--color-bg);
+ }
+
+ &__body {
+ position: relative;
+ }
+
+ &--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#{&}--has-scroll {
+ @include mix.media("screen") {
+ @include mix.dimensions("lg") {
+ max-height: 95vh;
+
+ .widget__body {
+ overflow: hidden;
+ }
+
+ &:hover,
+ &:focus-within {
+ .widget__body {
+ overflow-y: auto;
+ }
+ }
+ }
+ }
+ }
+
+ &--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..dd5a30b
--- /dev/null
+++ b/src/components/molecules/layout/widget.stories.tsx
@@ -0,0 +1,117 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import headingButtonStories from '../buttons/heading-button.stories';
+import Widget from './widget';
+
+/**
+ * Widget - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Layout/Widget',
+ component: Widget,
+ args: {
+ withBorders: false,
+ withScroll: false,
+ },
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget body',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the widget wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ expanded: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'The widget state (expanded or collapsed)',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ level: headingButtonStories.argTypes?.level,
+ 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,
+ },
+ },
+ withScroll: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Define if the widget should be scrollable',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof Widget>;
+
+const Template: ComponentStory<typeof Widget> = (args) => <Widget {...args} />;
+
+/**
+ * Widget Stories - Expanded
+ */
+export const Expanded = Template.bind({});
+Expanded.args = {
+ children: 'Widget body',
+ expanded: true,
+ level: 2,
+ title: 'Widget title',
+};
+
+/**
+ * Widget Stories - Collapsed
+ */
+export const Collapsed = Template.bind({});
+Collapsed.args = {
+ children: 'Widget body',
+ expanded: false,
+ 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..f50fe80
--- /dev/null
+++ b/src/components/molecules/layout/widget.tsx
@@ -0,0 +1,66 @@
+import { FC, ReactNode, useState } from 'react';
+import HeadingButton, {
+ type HeadingButtonProps,
+} from '../buttons/heading-button';
+import styles from './widget.module.scss';
+
+export type WidgetProps = Pick<
+ HeadingButtonProps,
+ 'expanded' | 'level' | 'title'
+> & {
+ /**
+ * The widget body.
+ */
+ children: ReactNode;
+ /**
+ * Set additional classnames to the widget wrapper.
+ */
+ className?: string;
+ /**
+ * Determine if the widget body should have borders. Default: false.
+ */
+ withBorders?: boolean;
+ /**
+ * Determine if a vertical scrollbar should be displayed. Default: false.
+ */
+ withScroll?: boolean;
+};
+
+/**
+ * Widget component
+ *
+ * Render an expandable widget.
+ */
+const Widget: FC<WidgetProps> = ({
+ children,
+ className = '',
+ expanded = true,
+ level,
+ title,
+ withBorders = false,
+ withScroll = false,
+}) => {
+ const [isExpanded, setIsExpanded] = useState<boolean>(expanded);
+ const stateClass = isExpanded ? 'widget--expanded' : 'widget--collapsed';
+ const bordersClass = withBorders
+ ? 'widget--has-borders'
+ : 'widget--no-borders';
+ const scrollClass = withScroll ? 'widget--has-scroll' : 'widget--no-scroll';
+
+ return (
+ <div
+ className={`${styles.widget} ${styles[bordersClass]} ${styles[stateClass]} ${styles[scrollClass]} ${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;