aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules')
-rw-r--r--src/components/molecules/buttons/back-to-top.module.scss51
-rw-r--r--src/components/molecules/buttons/back-to-top.stories.tsx47
-rw-r--r--src/components/molecules/buttons/back-to-top.test.tsx10
-rw-r--r--src/components/molecules/buttons/back-to-top.tsx43
-rw-r--r--src/components/molecules/buttons/heading-button.module.scss44
-rw-r--r--src/components/molecules/buttons/heading-button.stories.tsx105
-rw-r--r--src/components/molecules/buttons/heading-button.test.tsx32
-rw-r--r--src/components/molecules/buttons/heading-button.tsx67
-rw-r--r--src/components/molecules/buttons/help-button.module.scss21
-rw-r--r--src/components/molecules/buttons/help-button.stories.tsx47
-rw-r--r--src/components/molecules/buttons/help-button.test.tsx9
-rw-r--r--src/components/molecules/buttons/help-button.tsx37
-rw-r--r--src/components/molecules/forms/ackee-select.module.scss11
-rw-r--r--src/components/molecules/forms/ackee-select.stories.tsx71
-rw-r--r--src/components/molecules/forms/ackee-select.test.tsx25
-rw-r--r--src/components/molecules/forms/ackee-select.tsx103
-rw-r--r--src/components/molecules/forms/flipping-label.module.scss63
-rw-r--r--src/components/molecules/forms/flipping-label.stories.tsx96
-rw-r--r--src/components/molecules/forms/flipping-label.test.tsx14
-rw-r--r--src/components/molecules/forms/flipping-label.tsx40
-rw-r--r--src/components/molecules/forms/labelled-field.module.scss9
-rw-r--r--src/components/molecules/forms/labelled-field.stories.tsx293
-rw-r--r--src/components/molecules/forms/labelled-field.test.tsx19
-rw-r--r--src/components/molecules/forms/labelled-field.tsx50
-rw-r--r--src/components/molecules/forms/labelled-select.module.scss9
-rw-r--r--src/components/molecules/forms/labelled-select.stories.tsx236
-rw-r--r--src/components/molecules/forms/labelled-select.test.tsx25
-rw-r--r--src/components/molecules/forms/labelled-select.tsx69
-rw-r--r--src/components/molecules/forms/motion-toggle.stories.tsx57
-rw-r--r--src/components/molecules/forms/motion-toggle.test.tsx13
-rw-r--r--src/components/molecules/forms/motion-toggle.tsx75
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.stories.tsx34
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.test.tsx13
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.tsx73
-rw-r--r--src/components/molecules/forms/select-with-tooltip.module.scss48
-rw-r--r--src/components/molecules/forms/select-with-tooltip.stories.tsx210
-rw-r--r--src/components/molecules/forms/select-with-tooltip.test.tsx32
-rw-r--r--src/components/molecules/forms/select-with-tooltip.tsx73
-rw-r--r--src/components/molecules/forms/theme-toggle.stories.tsx34
-rw-r--r--src/components/molecules/forms/theme-toggle.test.tsx13
-rw-r--r--src/components/molecules/forms/theme-toggle.tsx64
-rw-r--r--src/components/molecules/forms/toggle.module.scss75
-rw-r--r--src/components/molecules/forms/toggle.stories.tsx121
-rw-r--r--src/components/molecules/forms/toggle.test.tsx29
-rw-r--r--src/components/molecules/forms/toggle.tsx78
-rw-r--r--src/components/molecules/images/flipping-logo.module.scss59
-rw-r--r--src/components/molecules/images/flipping-logo.stories.tsx72
-rw-r--r--src/components/molecules/images/flipping-logo.test.tsx25
-rw-r--r--src/components/molecules/images/flipping-logo.tsx55
-rw-r--r--src/components/molecules/images/responsive-image.module.scss79
-rw-r--r--src/components/molecules/images/responsive-image.stories.tsx212
-rw-r--r--src/components/molecules/images/responsive-image.test.tsx18
-rw-r--r--src/components/molecules/images/responsive-image.tsx95
-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
-rw-r--r--src/components/molecules/modals/modal.module.scss38
-rw-r--r--src/components/molecules/modals/modal.stories.tsx96
-rw-r--r--src/components/molecules/modals/modal.test.tsx18
-rw-r--r--src/components/molecules/modals/modal.tsx81
-rw-r--r--src/components/molecules/modals/tooltip.module.scss46
-rw-r--r--src/components/molecules/modals/tooltip.stories.tsx70
-rw-r--r--src/components/molecules/modals/tooltip.test.tsx24
-rw-r--r--src/components/molecules/modals/tooltip.tsx60
-rw-r--r--src/components/molecules/nav/breadcrumb.module.scss19
-rw-r--r--src/components/molecules/nav/breadcrumb.stories.tsx81
-rw-r--r--src/components/molecules/nav/breadcrumb.test.tsx15
-rw-r--r--src/components/molecules/nav/breadcrumb.tsx127
-rw-r--r--src/components/molecules/nav/nav.module.scss22
-rw-r--r--src/components/molecules/nav/nav.stories.tsx107
-rw-r--r--src/components/molecules/nav/nav.test.tsx28
-rw-r--r--src/components/molecules/nav/nav.tsx85
-rw-r--r--src/components/molecules/nav/pagination.module.scss51
-rw-r--r--src/components/molecules/nav/pagination.stories.tsx171
-rw-r--r--src/components/molecules/nav/pagination.test.tsx26
-rw-r--r--src/components/molecules/nav/pagination.tsx220
104 files changed, 7325 insertions, 0 deletions
diff --git a/src/components/molecules/buttons/back-to-top.module.scss b/src/components/molecules/buttons/back-to-top.module.scss
new file mode 100644
index 0000000..77ee97b
--- /dev/null
+++ b/src/components/molecules/buttons/back-to-top.module.scss
@@ -0,0 +1,51 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ .link {
+ width: clamp(#{fun.convert-px(48)}, 8vw, #{fun.convert-px(55)});
+ height: clamp(#{fun.convert-px(48)}, 8vw, #{fun.convert-px(55)});
+
+ svg {
+ width: 100%;
+ }
+
+ :global {
+ .arrow-head {
+ transform: translateY(30%) scale(1.2);
+ transition: all 0.45s ease-in-out 0s;
+ }
+
+ .arrow-bar {
+ opacity: 0;
+ transform: translateY(30%) scaleY(0);
+ transition: transform 0.45s ease-in-out 0s, opacity 0.1s linear 0.2s;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ :global {
+ .arrow-head {
+ transform: translateY(0) scale(1);
+ }
+
+ .arrow-bar {
+ opacity: 1;
+ transform: translateY(0) scaleY(1);
+ }
+ }
+
+ svg {
+ :global {
+ animation: pulse 1.2s ease-in-out 0.6s infinite;
+ }
+ }
+ }
+
+ &:active {
+ svg {
+ animation-play-state: paused;
+ }
+ }
+ }
+}
diff --git a/src/components/molecules/buttons/back-to-top.stories.tsx b/src/components/molecules/buttons/back-to-top.stories.tsx
new file mode 100644
index 0000000..a338b8b
--- /dev/null
+++ b/src/components/molecules/buttons/back-to-top.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import BackToTopComponent from './back-to-top';
+
+/**
+ * BackToTop - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Buttons',
+ component: BackToTopComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the button wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ target: {
+ control: {
+ type: 'text',
+ },
+ description: 'An element id (without hashtag) to use as anchor.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof BackToTopComponent>;
+
+const Template: ComponentStory<typeof BackToTopComponent> = (args) => (
+ <BackToTopComponent {...args} />
+);
+
+/**
+ * Buttons Stories - Back to top
+ */
+export const BackToTop = Template.bind({});
+BackToTop.args = {
+ target: 'top',
+};
diff --git a/src/components/molecules/buttons/back-to-top.test.tsx b/src/components/molecules/buttons/back-to-top.test.tsx
new file mode 100644
index 0000000..2b3a0a9
--- /dev/null
+++ b/src/components/molecules/buttons/back-to-top.test.tsx
@@ -0,0 +1,10 @@
+import { render, screen } from '@test-utils';
+import BackToTop from './back-to-top';
+
+describe('BackToTop', () => {
+ it('renders a BackToTop link', () => {
+ render(<BackToTop target="top" />);
+ expect(screen.getByRole('link')).toHaveAccessibleName('Back to top');
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/#top');
+ });
+});
diff --git a/src/components/molecules/buttons/back-to-top.tsx b/src/components/molecules/buttons/back-to-top.tsx
new file mode 100644
index 0000000..bd1925a
--- /dev/null
+++ b/src/components/molecules/buttons/back-to-top.tsx
@@ -0,0 +1,43 @@
+import ButtonLink, {
+ type ButtonLinkProps,
+} from '@components/atoms/buttons/button-link';
+import Arrow from '@components/atoms/icons/arrow';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './back-to-top.module.scss';
+
+export type BackToTopProps = Pick<ButtonLinkProps, 'target'> & {
+ /**
+ * Set additional classnames to the button wrapper.
+ */
+ className?: string;
+};
+
+/**
+ * BackToTop component
+ *
+ * Render a back to top link.
+ */
+const BackToTop: FC<BackToTopProps> = ({ className = '', target }) => {
+ const intl = useIntl();
+ const linkName = intl.formatMessage({
+ defaultMessage: 'Back to top',
+ description: 'BackToTop: link text',
+ id: 'm+SUSR',
+ });
+
+ return (
+ <div className={`${styles.wrapper} ${className}`}>
+ <ButtonLink
+ shape="square"
+ target={`#${target}`}
+ aria-label={linkName}
+ className={styles.link}
+ >
+ <Arrow direction="top" />
+ </ButtonLink>
+ </div>
+ );
+};
+
+export default BackToTop;
diff --git a/src/components/molecules/buttons/heading-button.module.scss b/src/components/molecules/buttons/heading-button.module.scss
new file mode 100644
index 0000000..3c69221
--- /dev/null
+++ b/src/components/molecules/buttons/heading-button.module.scss
@@ -0,0 +1,44 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ transition: all 0.25s ease-in-out 0s;
+}
+
+.wrapper {
+ display: flex;
+ flex-flow: row nowrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md);
+ width: 100%;
+ padding: 0 var(--spacing-2xs);
+ position: sticky;
+ top: 0;
+ background: inherit;
+ border: none;
+ border-top: fun.convert-px(2) solid var(--color-primary-dark);
+ border-bottom: fun.convert-px(2) solid var(--color-primary-dark);
+ cursor: pointer;
+
+ .heading {
+ padding: var(--spacing-2xs) 0;
+ background: none;
+ font-size: var(--font-size-xl);
+ font-weight: 500;
+ text-align: left;
+ }
+
+ &:hover,
+ &:focus {
+ .icon {
+ background: var(--color-primary-light);
+ color: var(--color-fg-inverted);
+ transform: scale(1.25);
+
+ &::before,
+ &::after {
+ background: var(--color-bg);
+ }
+ }
+ }
+}
diff --git a/src/components/molecules/buttons/heading-button.stories.tsx b/src/components/molecules/buttons/heading-button.stories.tsx
new file mode 100644
index 0000000..59f7be9
--- /dev/null
+++ b/src/components/molecules/buttons/heading-button.stories.tsx
@@ -0,0 +1,105 @@
+import headingStories from '@components/atoms/headings/heading.stories';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import HeadingButtonComponent from './heading-button';
+
+/**
+ * HeadingButton - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Buttons/HeadingButton',
+ component: HeadingButtonComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the button.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ expanded: {
+ control: {
+ type: null,
+ },
+ description: 'Heading button state (plus or minus).',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'Heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ setExpanded: {
+ control: {
+ type: null,
+ },
+ description: 'Callback function to set heading button state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'Heading title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof HeadingButtonComponent>;
+
+const Template: ComponentStory<typeof HeadingButtonComponent> = ({
+ expanded,
+ setExpanded: _setExpanded,
+ ...args
+}) => {
+ const [isExpanded, setIsExpanded] = useState<boolean>(expanded);
+
+ return (
+ <HeadingButtonComponent
+ expanded={isExpanded}
+ setExpanded={setIsExpanded}
+ {...args}
+ />
+ );
+};
+
+/**
+ * Heading Button Stories - Expanded
+ */
+export const Expanded = Template.bind({});
+Expanded.args = {
+ expanded: true,
+ level: 2,
+ title: 'Your title',
+};
+
+/**
+ * Heading Button Stories - Collapsed
+ */
+export const Collapsed = Template.bind({});
+Collapsed.args = {
+ expanded: false,
+ level: 2,
+ title: 'Your title',
+};
diff --git a/src/components/molecules/buttons/heading-button.test.tsx b/src/components/molecules/buttons/heading-button.test.tsx
new file mode 100644
index 0000000..be3865a
--- /dev/null
+++ b/src/components/molecules/buttons/heading-button.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@test-utils';
+import HeadingButton from './heading-button';
+
+describe('HeadingButton', () => {
+ it('renders a button to collapse.', () => {
+ render(
+ <HeadingButton
+ level={2}
+ title="The accordion title"
+ expanded={true}
+ setExpanded={() => null}
+ />
+ );
+ expect(
+ screen.getByRole('button', { name: 'Collapse The accordion title' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a button to expand.', () => {
+ render(
+ <HeadingButton
+ level={2}
+ title="The accordion title"
+ expanded={false}
+ setExpanded={() => null}
+ />
+ );
+ expect(
+ screen.getByRole('button', { name: 'Expand The accordion title' })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/buttons/heading-button.tsx b/src/components/molecules/buttons/heading-button.tsx
new file mode 100644
index 0000000..0ed9a76
--- /dev/null
+++ b/src/components/molecules/buttons/heading-button.tsx
@@ -0,0 +1,67 @@
+import Heading, { type HeadingProps } from '@components/atoms/headings/heading';
+import PlusMinus from '@components/atoms/icons/plus-minus';
+import { FC, SetStateAction } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './heading-button.module.scss';
+
+export type HeadingButtonProps = Pick<HeadingProps, 'level'> & {
+ /**
+ * Set additional classnames to the button.
+ */
+ className?: string;
+ /**
+ * Accordion state.
+ */
+ expanded: boolean;
+ /**
+ * Callback function to set accordion state on click.
+ */
+ setExpanded: (value: SetStateAction<boolean>) => void;
+ /**
+ * Accordion title.
+ */
+ title: string;
+};
+
+/**
+ * HeadingButton component
+ *
+ * Render a button as accordion title to toggle body.
+ */
+const HeadingButton: FC<HeadingButtonProps> = ({
+ className = '',
+ expanded,
+ level,
+ setExpanded,
+ title,
+}) => {
+ const intl = useIntl();
+ const iconState = expanded ? 'minus' : 'plus';
+ const titlePrefix = expanded
+ ? intl.formatMessage({
+ defaultMessage: 'Collapse',
+ description: 'HeadingButton: title prefix (expanded state)',
+ id: 'UX9Bu8',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Expand',
+ description: 'HeadingButton: title prefix (collapsed state)',
+ id: 'bcyOgC',
+ });
+
+ return (
+ <button
+ type="button"
+ className={`${styles.wrapper} ${className}`}
+ onClick={() => setExpanded(!expanded)}
+ >
+ <Heading level={level} withMargin={false} className={styles.heading}>
+ <span className="screen-reader-text">{titlePrefix} </span>
+ {title}
+ </Heading>
+ <PlusMinus state={iconState} className={styles.icon} />
+ </button>
+ );
+};
+
+export default HeadingButton;
diff --git a/src/components/molecules/buttons/help-button.module.scss b/src/components/molecules/buttons/help-button.module.scss
new file mode 100644
index 0000000..42d49f6
--- /dev/null
+++ b/src/components/molecules/buttons/help-button.module.scss
@@ -0,0 +1,21 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.btn {
+ padding: var(--spacing-xs);
+
+ &:not(:disabled) {
+ &:focus {
+ text-decoration: none;
+ }
+ }
+
+ @include mix.pointer("fine") {
+ padding: var(--spacing-2xs);
+ }
+}
+
+.icon {
+ color: var(--color-primary-dark);
+ font-weight: 600;
+}
diff --git a/src/components/molecules/buttons/help-button.stories.tsx b/src/components/molecules/buttons/help-button.stories.tsx
new file mode 100644
index 0000000..4968b27
--- /dev/null
+++ b/src/components/molecules/buttons/help-button.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HelpButtonComponent from './help-button';
+
+/**
+ * HelpButton - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Buttons',
+ component: HelpButtonComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the button wrapper.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ onClick: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle click on button.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof HelpButtonComponent>;
+
+const Template: ComponentStory<typeof HelpButtonComponent> = (args) => (
+ <HelpButtonComponent {...args} />
+);
+
+/**
+ * Help Button Stories - Level 1
+ */
+export const HelpButton = Template.bind({});
diff --git a/src/components/molecules/buttons/help-button.test.tsx b/src/components/molecules/buttons/help-button.test.tsx
new file mode 100644
index 0000000..78987ef
--- /dev/null
+++ b/src/components/molecules/buttons/help-button.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import HelpButton from './help-button';
+
+describe('Help', () => {
+ it('renders a help button', () => {
+ render(<HelpButton />);
+ expect(screen.getByRole('button', { name: 'Help ?' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/buttons/help-button.tsx b/src/components/molecules/buttons/help-button.tsx
new file mode 100644
index 0000000..ccf1ebd
--- /dev/null
+++ b/src/components/molecules/buttons/help-button.tsx
@@ -0,0 +1,37 @@
+import Button, { type ButtonProps } from '@components/atoms/buttons/button';
+import { forwardRef, ForwardRefRenderFunction } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './help-button.module.scss';
+
+export type HelpButtonProps = Pick<ButtonProps, 'className' | 'onClick'>;
+
+/**
+ * HelpButton component
+ *
+ * Render a button with an interrogation mark icon.
+ */
+const HelpButton: ForwardRefRenderFunction<
+ HTMLButtonElement,
+ HelpButtonProps
+> = ({ className = '', ...props }, ref) => {
+ const intl = useIntl();
+ const text = intl.formatMessage({
+ defaultMessage: 'Help',
+ id: 'i+/ckF',
+ description: 'HelpButton: screen reader text',
+ });
+
+ return (
+ <Button
+ className={`${styles.btn} ${className}`}
+ ref={ref}
+ shape="circle"
+ {...props}
+ >
+ <span className="screen-reader-text">{text}</span>
+ <span className={styles.icon}>?</span>
+ </Button>
+ );
+};
+
+export default forwardRef(HelpButton);
diff --git a/src/components/molecules/forms/ackee-select.module.scss b/src/components/molecules/forms/ackee-select.module.scss
new file mode 100644
index 0000000..87cd9ee
--- /dev/null
+++ b/src/components/molecules/forms/ackee-select.module.scss
@@ -0,0 +1,11 @@
+.wrapper {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ position: relative;
+}
+
+.tooltip {
+ position: absolute;
+ bottom: -100%;
+}
diff --git a/src/components/molecules/forms/ackee-select.stories.tsx b/src/components/molecules/forms/ackee-select.stories.tsx
new file mode 100644
index 0000000..6b42b71
--- /dev/null
+++ b/src/components/molecules/forms/ackee-select.stories.tsx
@@ -0,0 +1,71 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import AckeeSelect from './ackee-select';
+
+/**
+ * AckeeSelect - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Select',
+ component: AckeeSelect,
+ argTypes: {
+ initialValue: {
+ control: {
+ type: 'select',
+ },
+ description: 'Initial selected option.',
+ options: ['full', 'partial'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ labelClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ storageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set Ackee settings local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ tooltipClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof AckeeSelect>;
+
+const Template: ComponentStory<typeof AckeeSelect> = (args) => (
+ <AckeeSelect {...args} />
+);
+
+/**
+ * Select Stories - Ackee select
+ */
+export const Ackee = Template.bind({});
+Ackee.args = {
+ initialValue: 'full',
+};
diff --git a/src/components/molecules/forms/ackee-select.test.tsx b/src/components/molecules/forms/ackee-select.test.tsx
new file mode 100644
index 0000000..0089c06
--- /dev/null
+++ b/src/components/molecules/forms/ackee-select.test.tsx
@@ -0,0 +1,25 @@
+import user from '@testing-library/user-event';
+import { act, render, screen } from '@test-utils';
+import AckeeSelect from './ackee-select';
+
+describe('Select', () => {
+ it('should correctly set default option', () => {
+ render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />);
+ expect(screen.getByRole('combobox')).toHaveValue('full');
+ expect(screen.queryByRole('combobox')).not.toHaveValue('partial');
+ });
+
+ it('should correctly change value when user choose another option', async () => {
+ render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />);
+
+ await act(async () => {
+ await user.selectOptions(
+ screen.getByRole('combobox'),
+ screen.getByRole('option', { name: 'Partial' })
+ );
+ });
+
+ expect(screen.getByRole('combobox')).toHaveValue('partial');
+ expect(screen.queryByRole('combobox')).not.toHaveValue('full');
+ });
+});
diff --git a/src/components/molecules/forms/ackee-select.tsx b/src/components/molecules/forms/ackee-select.tsx
new file mode 100644
index 0000000..34850fb
--- /dev/null
+++ b/src/components/molecules/forms/ackee-select.tsx
@@ -0,0 +1,103 @@
+import { type SelectOptions } from '@components/atoms/forms/select';
+import useLocalStorage from '@utils/hooks/use-local-storage';
+import useUpdateAckeeOptions, {
+ type AckeeOptions,
+} from '@utils/hooks/use-update-ackee-options';
+import { Dispatch, FC, SetStateAction } from 'react';
+import { useIntl } from 'react-intl';
+import SelectWithTooltip, {
+ type SelectWithTooltipProps,
+} from './select-with-tooltip';
+
+export type AckeeSelectProps = Pick<
+ SelectWithTooltipProps,
+ 'labelClassName' | 'tooltipClassName'
+> & {
+ /**
+ * A default value for Ackee settings.
+ */
+ initialValue: AckeeOptions;
+ /**
+ * The local storage key to save preference.
+ */
+ storageKey: string;
+};
+
+/**
+ * AckeeSelect component
+ *
+ * Render a select to set Ackee settings.
+ */
+const AckeeSelect: FC<AckeeSelectProps> = ({
+ initialValue,
+ storageKey,
+ ...props
+}) => {
+ const intl = useIntl();
+ const { value, setValue } = useLocalStorage<AckeeOptions>(
+ storageKey,
+ initialValue
+ );
+ useUpdateAckeeOptions(value);
+
+ const ackeeLabel = intl.formatMessage({
+ defaultMessage: 'Tracking:',
+ description: 'AckeeSelect: select label',
+ id: '2pmylc',
+ });
+ const tooltipTitle = intl.formatMessage({
+ defaultMessage: 'Ackee tracking (analytics)',
+ description: 'AckeeSelect: tooltip title',
+ id: 'F1EQX3',
+ });
+ const tooltipContent = [
+ intl.formatMessage({
+ defaultMessage: 'Partial includes only page url, views and duration.',
+ description: 'AckeeSelect: tooltip message',
+ id: 'skb4W5',
+ }),
+ intl.formatMessage({
+ defaultMessage:
+ 'Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.',
+ description: 'AckeeSelect: tooltip message',
+ id: 'Ogccx6',
+ }),
+ ];
+ const options: SelectOptions[] = [
+ {
+ id: 'partial',
+ name: intl.formatMessage({
+ defaultMessage: 'Partial',
+ description: 'AckeeSelect: partial option name',
+ id: 'e/8Kyj',
+ }),
+ value: 'partial',
+ },
+ {
+ id: 'full',
+ name: intl.formatMessage({
+ defaultMessage: 'Full',
+ description: 'AckeeSelect: full option name',
+ id: 'PzRpPw',
+ }),
+ value: 'full',
+ },
+ ];
+
+ return (
+ <SelectWithTooltip
+ id="ackee-settings"
+ name="ackee-settings"
+ label={ackeeLabel}
+ labelSize="medium"
+ options={options}
+ title={tooltipTitle}
+ content={tooltipContent}
+ value={value}
+ setValue={setValue as Dispatch<SetStateAction<string>>}
+ {...props}
+ />
+ );
+};
+
+export default AckeeSelect;
diff --git a/src/components/molecules/forms/flipping-label.module.scss b/src/components/molecules/forms/flipping-label.module.scss
new file mode 100644
index 0000000..e650ebe
--- /dev/null
+++ b/src/components/molecules/forms/flipping-label.module.scss
@@ -0,0 +1,63 @@
+@use "@styles/abstracts/functions" as fun;
+
+.label {
+ display: block;
+ width: var(--btn-size, #{fun.convert-px(60)});
+ height: var(--btn-size, #{fun.convert-px(60)});
+}
+
+.front,
+.back {
+ display: flex;
+ place-content: center;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ right: 0;
+ backface-visibility: hidden;
+ transition: all 0.6s ease-in 0s;
+}
+
+.front {
+ z-index: 20;
+}
+
+.back {
+ z-index: 10;
+}
+
+.wrapper {
+ --icon-size: 60%;
+
+ display: flex;
+ place-content: center;
+ place-items: center;
+ width: 100%;
+ height: 100%;
+ position: relative;
+ transition: all 0.5s ease-in-out 0s;
+ transform-style: preserve-3d;
+
+ &--active {
+ transform: rotateY(180deg);
+
+ .front {
+ transform: scale(0.2);
+ }
+
+ .back {
+ transform: scale(1) rotateY(180deg);
+ }
+ }
+
+ &--inactive {
+ .front {
+ transform: scale(1);
+ }
+
+ .back {
+ transform: scale(0.2) rotateY(180deg);
+ }
+ }
+}
diff --git a/src/components/molecules/forms/flipping-label.stories.tsx b/src/components/molecules/forms/flipping-label.stories.tsx
new file mode 100644
index 0000000..b8d17ec
--- /dev/null
+++ b/src/components/molecules/forms/flipping-label.stories.tsx
@@ -0,0 +1,96 @@
+import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import FlippingLabel from './flipping-label';
+
+export default {
+ title: 'Organisms/Forms/FlippingLabel',
+ component: FlippingLabel,
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible name for the label.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: null,
+ },
+ description: 'An icon for the label front face.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ htmlFor: {
+ control: {
+ type: null,
+ },
+ description: 'Bind the label to a field by id.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: 'boolean',
+ },
+ description:
+ 'Which side of the label should be displayed? True for the close icon.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FlippingLabel>;
+
+const Template: ComponentStory<typeof FlippingLabel> = ({
+ isActive,
+ ...args
+}) => {
+ const [active, setActive] = useState<boolean>(isActive);
+
+ return (
+ <div onClick={() => setActive(!active)}>
+ <FlippingLabel isActive={active} {...args} />
+ </div>
+ );
+};
+
+export const Active = Template.bind({});
+Active.args = {
+ children: <MagnifyingGlass />,
+ isActive: true,
+};
+
+export const Inactive = Template.bind({});
+Inactive.args = {
+ children: <MagnifyingGlass />,
+ isActive: false,
+};
diff --git a/src/components/molecules/forms/flipping-label.test.tsx b/src/components/molecules/forms/flipping-label.test.tsx
new file mode 100644
index 0000000..9a7aa22
--- /dev/null
+++ b/src/components/molecules/forms/flipping-label.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@test-utils';
+import FlippingLabel from './flipping-label';
+
+describe('FlippingLabel', () => {
+ it('renders a label', () => {
+ const ariaLabel = 'vero quo inventore';
+ render(
+ <FlippingLabel aria-label={ariaLabel} isActive={false}>
+ <>Test</>
+ </FlippingLabel>
+ );
+ expect(screen.getByLabelText(ariaLabel)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/forms/flipping-label.tsx b/src/components/molecules/forms/flipping-label.tsx
new file mode 100644
index 0000000..c874369
--- /dev/null
+++ b/src/components/molecules/forms/flipping-label.tsx
@@ -0,0 +1,40 @@
+import Label, { LabelProps } from '@components/atoms/forms/label';
+import Close from '@components/atoms/icons/close';
+import { FC } from 'react';
+import styles from './flipping-label.module.scss';
+
+export type FlippingLabelProps = Pick<
+ LabelProps,
+ 'aria-label' | 'className' | 'htmlFor'
+> & {
+ /**
+ * The front icon.
+ */
+ children: JSX.Element;
+ /**
+ * Which side of the label should be displayed? True for the close icon.
+ */
+ isActive: boolean;
+};
+
+const FlippingLabel: FC<FlippingLabelProps> = ({
+ children,
+ className = '',
+ isActive,
+ ...props
+}) => {
+ const wrapperModifier = isActive ? 'wrapper--active' : 'wrapper--inactive';
+
+ return (
+ <Label className={`${styles.label} ${className}`} {...props}>
+ <span className={`${styles.wrapper} ${styles[wrapperModifier]}`}>
+ <span className={styles.front}>{children}</span>
+ <span className={styles.back}>
+ <Close />
+ </span>
+ </span>
+ </Label>
+ );
+};
+
+export default FlippingLabel;
diff --git a/src/components/molecules/forms/labelled-field.module.scss b/src/components/molecules/forms/labelled-field.module.scss
new file mode 100644
index 0000000..64ef3d0
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field.module.scss
@@ -0,0 +1,9 @@
+.label {
+ &--left {
+ margin-right: var(--spacing-2xs);
+ }
+
+ &--top {
+ display: block;
+ }
+}
diff --git a/src/components/molecules/forms/labelled-field.stories.tsx b/src/components/molecules/forms/labelled-field.stories.tsx
new file mode 100644
index 0000000..795e785
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field.stories.tsx
@@ -0,0 +1,293 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import LabelledField from './labelled-field';
+
+/**
+ * LabelledField - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Field',
+ component: LabelledField,
+ args: {
+ disabled: false,
+ hideLabel: false,
+ labelPosition: 'top',
+ required: false,
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Field state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ hideLabel: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Visually hide the field label.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ labelPosition: {
+ control: {
+ type: 'select',
+ },
+ description: 'The label position.',
+ options: ['left', 'top'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'top' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ max: {
+ control: {
+ type: 'number',
+ },
+ description: 'Maximum value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ min: {
+ control: {
+ type: 'number',
+ },
+ description: 'Minimum value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ placeholder: {
+ control: {
+ type: 'text',
+ },
+ description: 'A placeholder value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'Callback function to set field value.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ step: {
+ control: {
+ type: 'number',
+ },
+ description: 'Field incremental values that are valid.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ type: {
+ control: {
+ type: 'select',
+ },
+ description: 'Field type: input type or textarea.',
+ options: [
+ 'datetime-local',
+ 'email',
+ 'number',
+ 'search',
+ 'tel',
+ 'text',
+ 'textarea',
+ 'time',
+ 'url',
+ ],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'Field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof LabelledField>;
+
+const Template: ComponentStory<typeof LabelledField> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [value, setValue] = useState<string>('');
+
+ return <LabelledField value={value} setValue={setValue} {...args} />;
+};
+
+/**
+ * Labelled Field Stories - Left
+ */
+export const Left = Template.bind({});
+Left.args = {
+ id: 'labelled-field-storybook',
+ label: 'Labelled field',
+ labelPosition: 'left',
+ name: 'labelled-field-storybook',
+};
+
+/**
+ * Labelled Field Stories - Top
+ */
+export const Top = Template.bind({});
+Top.args = {
+ id: 'labelled-field-storybook',
+ label: 'Labelled field',
+ labelPosition: 'top',
+ name: 'labelled-field-storybook',
+};
+
+/**
+ * Labelled Field Stories - Required
+ */
+export const Required = Template.bind({});
+Required.args = {
+ id: 'labelled-field-storybook',
+ label: 'Labelled field',
+ name: 'labelled-field-storybook',
+ required: true,
+};
+
+/**
+ * Labelled Field Stories - Hidden label
+ */
+export const HiddenLabel = Template.bind({});
+HiddenLabel.args = {
+ hideLabel: true,
+ id: 'labelled-field-storybook',
+ label: 'Labelled field',
+ name: 'labelled-field-storybook',
+};
+
+/**
+ * Labelled Field Stories - Disabled
+ */
+export const Disabled = Template.bind({});
+Disabled.args = {
+ disabled: true,
+ id: 'labelled-field-storybook',
+ label: 'Labelled field',
+ name: 'labelled-field-storybook',
+};
diff --git a/src/components/molecules/forms/labelled-field.test.tsx b/src/components/molecules/forms/labelled-field.test.tsx
new file mode 100644
index 0000000..6fabe19
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@test-utils';
+import LabelledField from './labelled-field';
+
+describe('LabelledField', () => {
+ it('renders a labelled field', () => {
+ render(
+ <LabelledField
+ type="text"
+ id="jest-text-field"
+ name="jest-text-field"
+ label="Jest text field"
+ value="test"
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByLabelText('Jest text field')).toBeInTheDocument();
+ expect(screen.getByRole('textbox')).toHaveValue('test');
+ });
+});
diff --git a/src/components/molecules/forms/labelled-field.tsx b/src/components/molecules/forms/labelled-field.tsx
new file mode 100644
index 0000000..6a00a3e
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field.tsx
@@ -0,0 +1,50 @@
+import Field, { type FieldProps } from '@components/atoms/forms/field';
+import Label from '@components/atoms/forms/label';
+import { forwardRef, ForwardRefRenderFunction } from 'react';
+import styles from './labelled-field.module.scss';
+
+export type LabelledFieldProps = FieldProps & {
+ /**
+ * Visually hide the field label. Default: false.
+ */
+ hideLabel?: boolean;
+ /**
+ * The field label.
+ */
+ label: string;
+ /**
+ * The label position. Default: top.
+ */
+ labelPosition?: 'left' | 'top';
+};
+
+/**
+ * LabelledField component
+ *
+ * Render a field tied to a label.
+ */
+const LabelledField: ForwardRefRenderFunction<
+ HTMLInputElement,
+ LabelledFieldProps
+> = (
+ { hideLabel = false, id, label, labelPosition = 'top', required, ...props },
+ ref
+) => {
+ const positionModifier = `label--${labelPosition}`;
+ const visibilityClass = hideLabel ? 'screen-reader-text' : '';
+
+ return (
+ <>
+ <Label
+ htmlFor={id}
+ required={required}
+ className={`${visibilityClass} ${styles[positionModifier]}`}
+ >
+ {label}
+ </Label>
+ <Field id={id} ref={ref} required={required} {...props} />
+ </>
+ );
+};
+
+export default forwardRef(LabelledField);
diff --git a/src/components/molecules/forms/labelled-select.module.scss b/src/components/molecules/forms/labelled-select.module.scss
new file mode 100644
index 0000000..64ef3d0
--- /dev/null
+++ b/src/components/molecules/forms/labelled-select.module.scss
@@ -0,0 +1,9 @@
+.label {
+ &--left {
+ margin-right: var(--spacing-2xs);
+ }
+
+ &--top {
+ display: block;
+ }
+}
diff --git a/src/components/molecules/forms/labelled-select.stories.tsx b/src/components/molecules/forms/labelled-select.stories.tsx
new file mode 100644
index 0000000..d02732c
--- /dev/null
+++ b/src/components/molecules/forms/labelled-select.stories.tsx
@@ -0,0 +1,236 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import LabelledSelect from './labelled-select';
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+
+/**
+ * LabelledSelect - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Select',
+ component: LabelledSelect,
+ args: {
+ disabled: false,
+ labelPosition: 'top',
+ required: false,
+ },
+ argTypes: {
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Field state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ labelClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ labelPosition: {
+ control: {
+ type: 'select',
+ },
+ description: 'The label position.',
+ options: ['left', 'top'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'top' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ labelSize: {
+ control: {
+ type: 'select',
+ },
+ description: 'The label size.',
+ options: ['medium', 'small'],
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ options: {
+ control: {
+ type: null,
+ },
+ description: 'Select options.',
+ type: {
+ name: 'array',
+ required: true,
+ value: {
+ name: 'string',
+ },
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ selectClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the select field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'Callback function to set field value.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'Field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof LabelledSelect>;
+
+const Template: ComponentStory<typeof LabelledSelect> = ({
+ value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [selected, setSelected] = useState<string>(value);
+
+ return <LabelledSelect value={selected} setValue={setSelected} {...args} />;
+};
+
+/**
+ * Labelled Select Stories - Left
+ */
+export const Left = Template.bind({});
+Left.args = {
+ id: 'labelled-select-storybook',
+ label: 'Labelled select',
+ labelPosition: 'left',
+ name: 'labelled-select-storybook',
+ options: selectOptions,
+ value: 'option1',
+};
+
+/**
+ * Labelled Select Stories - Top
+ */
+export const Top = Template.bind({});
+Top.args = {
+ id: 'labelled-select-storybook',
+ label: 'Labelled select',
+ labelPosition: 'top',
+ name: 'labelled-select-storybook',
+ options: selectOptions,
+ value: 'option1',
+};
+
+/**
+ * Labelled Select Stories - Disabled
+ */
+export const Disabled = Template.bind({});
+Disabled.args = {
+ disabled: true,
+ id: 'labelled-select-storybook',
+ label: 'Labelled select',
+ name: 'labelled-select-storybook',
+ options: selectOptions,
+ value: 'option1',
+};
+
+/**
+ * Labelled Select Stories - Required
+ */
+export const Required = Template.bind({});
+Required.args = {
+ id: 'labelled-select-storybook',
+ label: 'Labelled select',
+ labelPosition: 'top',
+ name: 'labelled-select-storybook',
+ options: selectOptions,
+ required: true,
+ value: 'option1',
+};
diff --git a/src/components/molecules/forms/labelled-select.test.tsx b/src/components/molecules/forms/labelled-select.test.tsx
new file mode 100644
index 0000000..9a50d6e
--- /dev/null
+++ b/src/components/molecules/forms/labelled-select.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@test-utils';
+import LabelledSelect from './labelled-select';
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+
+describe('LabelledSelect', () => {
+ it('renders a labelled select', () => {
+ render(
+ <LabelledSelect
+ id="jest-select-field"
+ name="jest-select-field"
+ label="Jest select field"
+ options={selectOptions}
+ value="option1"
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByLabelText('Jest select field')).toBeInTheDocument();
+ expect(screen.getByRole('combobox')).toHaveValue('option1');
+ });
+});
diff --git a/src/components/molecules/forms/labelled-select.tsx b/src/components/molecules/forms/labelled-select.tsx
new file mode 100644
index 0000000..23057d0
--- /dev/null
+++ b/src/components/molecules/forms/labelled-select.tsx
@@ -0,0 +1,69 @@
+import Label, { type LabelProps } from '@components/atoms/forms/label';
+import Select, { type SelectProps } from '@components/atoms/forms/select';
+import { FC } from 'react';
+import styles from './labelled-select.module.scss';
+
+export type LabelledSelectProps = Omit<
+ SelectProps,
+ 'aria-labelledby' | 'className'
+> & {
+ /**
+ * The field label.
+ */
+ label: string;
+ /**
+ * Set additional classnames to the label.
+ */
+ labelClassName?: LabelProps['className'];
+ /**
+ * The label position. Default: top.
+ */
+ labelPosition?: 'left' | 'top';
+ /**
+ * The label size.
+ */
+ labelSize?: LabelProps['size'];
+ /**
+ * Set additional classnames to the select field.
+ */
+ selectClassName?: SelectProps['className'];
+};
+
+/**
+ * LabelledSelect component
+ *
+ * Render a select with a label.
+ */
+const LabelledSelect: FC<LabelledSelectProps> = ({
+ id,
+ label,
+ labelClassName = '',
+ labelPosition = 'top',
+ labelSize,
+ required,
+ selectClassName = '',
+ ...props
+}) => {
+ const positionModifier = `label--${labelPosition}`;
+
+ return (
+ <>
+ <Label
+ htmlFor={id}
+ required={required}
+ size={labelSize}
+ className={`${styles[positionModifier]} ${labelClassName}`}
+ >
+ {label}
+ </Label>
+ <Select
+ id={id}
+ required={required}
+ {...props}
+ className={selectClassName}
+ />
+ </>
+ );
+};
+
+export default LabelledSelect;
diff --git a/src/components/molecules/forms/motion-toggle.stories.tsx b/src/components/molecules/forms/motion-toggle.stories.tsx
new file mode 100644
index 0000000..60430d5
--- /dev/null
+++ b/src/components/molecules/forms/motion-toggle.stories.tsx
@@ -0,0 +1,57 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MotionToggleComponent from './motion-toggle';
+
+/**
+ * MotionToggle - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Toggle',
+ component: MotionToggleComponent,
+ argTypes: {
+ labelClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ storageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'The reduce motion value.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MotionToggleComponent>;
+
+const Template: ComponentStory<typeof MotionToggleComponent> = (args) => (
+ <MotionToggleComponent {...args} />
+);
+
+/**
+ * Toggle Stories - Motion
+ */
+export const Motion = Template.bind({});
+Motion.args = {
+ value: false,
+};
diff --git a/src/components/molecules/forms/motion-toggle.test.tsx b/src/components/molecules/forms/motion-toggle.test.tsx
new file mode 100644
index 0000000..4fd6b31
--- /dev/null
+++ b/src/components/molecules/forms/motion-toggle.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@test-utils';
+import MotionToggle from './motion-toggle';
+
+describe('MotionToggle', () => {
+ it('renders a checked toggle (deactivate animations choice)', () => {
+ render(<MotionToggle storageKey="reduced-motion" value={true} />);
+ expect(
+ screen.getByRole('checkbox', {
+ name: `Animations: On Off`,
+ })
+ ).toBeChecked();
+ });
+});
diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx
new file mode 100644
index 0000000..cbe38fe
--- /dev/null
+++ b/src/components/molecules/forms/motion-toggle.tsx
@@ -0,0 +1,75 @@
+import Toggle, {
+ type ToggleChoices,
+ type ToggleProps,
+} from '@components/molecules/forms/toggle';
+import useAttributes from '@utils/hooks/use-attributes';
+import useLocalStorage from '@utils/hooks/use-local-storage';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type MotionToggleProps = Pick<
+ ToggleProps,
+ 'labelClassName' | 'value'
+> & {
+ /**
+ * The local storage key to save preference.
+ */
+ storageKey: string;
+};
+
+/**
+ * MotionToggle component
+ *
+ * Render a Toggle component to set reduce motion.
+ */
+const MotionToggle: FC<MotionToggleProps> = ({
+ storageKey,
+ value,
+ ...props
+}) => {
+ const intl = useIntl();
+ const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>(
+ storageKey,
+ value
+ );
+ useAttributes({
+ element: document.documentElement || undefined,
+ attribute: 'reduced-motion',
+ value: `${isReduced}`,
+ });
+
+ const reduceMotionLabel = intl.formatMessage({
+ defaultMessage: 'Animations:',
+ description: 'MotionToggle: reduce motion label',
+ id: '/q5csZ',
+ });
+ const onLabel = intl.formatMessage({
+ defaultMessage: 'On',
+ description: 'MotionToggle: activate reduce motion label',
+ id: 'va65iw',
+ });
+ const offLabel = intl.formatMessage({
+ defaultMessage: 'Off',
+ description: 'MotionToggle: deactivate reduce motion label',
+ id: 'pWKyyR',
+ });
+ const reduceMotionChoices: ToggleChoices = {
+ left: onLabel,
+ right: offLabel,
+ };
+
+ return (
+ <Toggle
+ id="reduce-motion-settings"
+ name="reduce-motion-settings"
+ label={reduceMotionLabel}
+ labelSize="medium"
+ choices={reduceMotionChoices}
+ value={isReduced}
+ setValue={setIsReduced}
+ {...props}
+ />
+ );
+};
+
+export default MotionToggle;
diff --git a/src/components/molecules/forms/prism-theme-toggle.stories.tsx b/src/components/molecules/forms/prism-theme-toggle.stories.tsx
new file mode 100644
index 0000000..ef4ed6e
--- /dev/null
+++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PrismThemeToggle from './prism-theme-toggle';
+
+/**
+ * PrismThemeToggle - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Toggle',
+ component: PrismThemeToggle,
+ argTypes: {
+ labelClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof PrismThemeToggle>;
+
+const Template: ComponentStory<typeof PrismThemeToggle> = (args) => (
+ <PrismThemeToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Prism theme
+ */
+export const PrismTheme = Template.bind({});
diff --git a/src/components/molecules/forms/prism-theme-toggle.test.tsx b/src/components/molecules/forms/prism-theme-toggle.test.tsx
new file mode 100644
index 0000000..c9d7894
--- /dev/null
+++ b/src/components/molecules/forms/prism-theme-toggle.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@test-utils';
+import PrismThemeToggle from './prism-theme-toggle';
+
+describe('PrismThemeToggle', () => {
+ it('renders a toggle component', () => {
+ render(<PrismThemeToggle />);
+ expect(
+ screen.getByRole('checkbox', {
+ name: `Code blocks: Light theme Dark theme`,
+ })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx
new file mode 100644
index 0000000..3320722
--- /dev/null
+++ b/src/components/molecules/forms/prism-theme-toggle.tsx
@@ -0,0 +1,73 @@
+import Moon from '@components/atoms/icons/moon';
+import Sun from '@components/atoms/icons/sun';
+import Toggle, {
+ type ToggleChoices,
+ type ToggleProps,
+} from '@components/molecules/forms/toggle';
+import { usePrismTheme } from '@utils/providers/prism-theme';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type PrismThemeToggleProps = Pick<ToggleProps, 'labelClassName'>;
+
+/**
+ * PrismThemeToggle component
+ *
+ * Render a Toggle component to set code blocks theme.
+ */
+const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => {
+ const intl = useIntl();
+ const { theme, setTheme, resolvedTheme } = usePrismTheme();
+
+ /**
+ * Check if the resolved or chosen theme is dark theme.
+ *
+ * @returns {boolean} True if it is dark theme.
+ */
+ const isDarkTheme = (): boolean => {
+ if (theme === 'system') return resolvedTheme === 'dark';
+ return theme === 'dark';
+ };
+
+ /**
+ * Update the theme.
+ */
+ const updateTheme = () => {
+ setTheme(isDarkTheme() ? 'light' : 'dark');
+ };
+
+ const themeLabel = intl.formatMessage({
+ defaultMessage: 'Code blocks:',
+ description: 'PrismThemeToggle: theme label',
+ id: 'ftXN+0',
+ });
+ const lightThemeLabel = intl.formatMessage({
+ defaultMessage: 'Light theme',
+ description: 'PrismThemeToggle: light theme label',
+ id: 'tsWh8x',
+ });
+ const darkThemeLabel = intl.formatMessage({
+ defaultMessage: 'Dark theme',
+ description: 'PrismThemeToggle: dark theme label',
+ id: 'og/zWL',
+ });
+ const themeChoices: ToggleChoices = {
+ left: <Sun title={lightThemeLabel} />,
+ right: <Moon title={darkThemeLabel} />,
+ };
+
+ return (
+ <Toggle
+ id="prism-theme-settings"
+ name="prism-theme-settings"
+ label={themeLabel}
+ labelSize="medium"
+ choices={themeChoices}
+ value={isDarkTheme()}
+ setValue={updateTheme}
+ {...props}
+ />
+ );
+};
+
+export default PrismThemeToggle;
diff --git a/src/components/molecules/forms/select-with-tooltip.module.scss b/src/components/molecules/forms/select-with-tooltip.module.scss
new file mode 100644
index 0000000..bfadece
--- /dev/null
+++ b/src/components/molecules/forms/select-with-tooltip.module.scss
@@ -0,0 +1,48 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ position: relative;
+}
+
+.select {
+ width: auto;
+
+ @include mix.pointer("fine") {
+ padding: fun.convert-px(3) var(--spacing-xs);
+ }
+}
+
+.btn {
+ margin-left: var(--spacing-xs);
+
+ &--activated {
+ background: var(--color-primary);
+
+ * {
+ color: var(--color-fg-inverted);
+ }
+ }
+}
+
+.tooltip {
+ position: absolute;
+ top: calc(100% + var(--spacing-xs));
+ transform-origin: top;
+ transition: all 0.75s ease-in-out 0s;
+
+ &--hidden {
+ opacity: 0;
+ visibility: hidden;
+ transform: scale(0);
+ }
+
+ &--visible {
+ opacity: 1;
+ visibility: visible;
+ transform: scale(1);
+ }
+}
diff --git a/src/components/molecules/forms/select-with-tooltip.stories.tsx b/src/components/molecules/forms/select-with-tooltip.stories.tsx
new file mode 100644
index 0000000..ddf5d4c
--- /dev/null
+++ b/src/components/molecules/forms/select-with-tooltip.stories.tsx
@@ -0,0 +1,210 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import SelectWithTooltip from './select-with-tooltip';
+
+/**
+ * SelectWithTooltip - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Select',
+ component: SelectWithTooltip,
+ argTypes: {
+ content: {
+ control: {
+ type: 'text',
+ },
+ description: 'The tooltip body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Field state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'The select label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ labelClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ labelSize: {
+ control: {
+ type: 'select',
+ },
+ description: 'The label size.',
+ options: ['medium', 'small'],
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ options: {
+ control: {
+ type: null,
+ },
+ description: 'Select options.',
+ type: {
+ name: 'array',
+ required: true,
+ value: {
+ name: 'string',
+ },
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ selectClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the select field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'Callback function to set field value.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The tooltip title',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ tooltipClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ value: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SelectWithTooltip>;
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+
+const Template: ComponentStory<typeof SelectWithTooltip> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [selected, setSelected] = useState<string>('option1');
+ return (
+ <SelectWithTooltip value={selected} setValue={setSelected} {...args} />
+ );
+};
+
+/**
+ * Select Stories - With tooltip
+ */
+export const WithTooltip = Template.bind({});
+WithTooltip.args = {
+ content: 'Illo voluptatibus quia minima placeat sit nostrum excepturi.',
+ title: 'Possimus quidem dolor',
+ id: 'storybook-select',
+ label: 'Officiis:',
+ name: 'storybook-select',
+ options: selectOptions,
+};
diff --git a/src/components/molecules/forms/select-with-tooltip.test.tsx b/src/components/molecules/forms/select-with-tooltip.test.tsx
new file mode 100644
index 0000000..7a423f5
--- /dev/null
+++ b/src/components/molecules/forms/select-with-tooltip.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@test-utils';
+import SelectWithTooltip from './select-with-tooltip';
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+const selectLabel = 'Jest select';
+const selectValue = selectOptions[0].value;
+const tooltipTitle = 'Jest tooltip';
+const tooltipContent = 'Nesciunt voluptatibus voluptatem omnis at quia libero.';
+
+describe('SelectWithTooltip', () => {
+ it('renders a select', () => {
+ render(
+ <SelectWithTooltip
+ id="jest-select"
+ name="jest-select"
+ label={selectLabel}
+ options={selectOptions}
+ value={selectValue}
+ setValue={() => null}
+ title={tooltipTitle}
+ content={tooltipContent}
+ />
+ );
+ expect(screen.getByRole('combobox', { name: selectLabel })).toHaveValue(
+ selectValue
+ );
+ });
+});
diff --git a/src/components/molecules/forms/select-with-tooltip.tsx b/src/components/molecules/forms/select-with-tooltip.tsx
new file mode 100644
index 0000000..29e2563
--- /dev/null
+++ b/src/components/molecules/forms/select-with-tooltip.tsx
@@ -0,0 +1,73 @@
+import useClickOutside from '@utils/hooks/use-click-outside';
+import { FC, useRef, useState } from 'react';
+import HelpButton from '../buttons/help-button';
+import Tooltip, { type TooltipProps } from '../modals/tooltip';
+import LabelledSelect, { type LabelledSelectProps } from './labelled-select';
+import styles from './select-with-tooltip.module.scss';
+
+export type SelectWithTooltipProps = Omit<
+ LabelledSelectProps,
+ 'labelPosition'
+> &
+ Pick<TooltipProps, 'title' | 'content'> & {
+ /**
+ * Set additional classnames to the tooltip wrapper.
+ */
+ tooltipClassName?: TooltipProps['className'];
+ };
+
+/**
+ * SelectWithTooltip component
+ *
+ * Render a select with a button to display a tooltip about options.
+ */
+const SelectWithTooltip: FC<SelectWithTooltipProps> = ({
+ title,
+ content,
+ id,
+ tooltipClassName = '',
+ ...props
+}) => {
+ const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false);
+ const buttonRef = useRef<HTMLButtonElement>(null);
+ const tooltipRef = useRef<HTMLDivElement>(null);
+ const buttonModifier = isTooltipOpened ? styles['btn--activated'] : '';
+ const tooltipModifier = isTooltipOpened
+ ? styles['tooltip--visible']
+ : styles['tooltip--hidden'];
+
+ const closeTooltip = (target: EventTarget) => {
+ if (buttonRef.current && !buttonRef.current.contains(target as Node))
+ setIsTooltipOpened(false);
+ };
+
+ useClickOutside(
+ tooltipRef,
+ (target) => isTooltipOpened && closeTooltip(target)
+ );
+
+ return (
+ <div className={styles.wrapper}>
+ <LabelledSelect
+ labelPosition="left"
+ id={id}
+ labelClassName={styles.label}
+ {...props}
+ />
+ <HelpButton
+ className={`${styles.btn} ${buttonModifier}`}
+ onClick={() => setIsTooltipOpened(!isTooltipOpened)}
+ ref={buttonRef}
+ />
+ <Tooltip
+ title={title}
+ content={content}
+ icon="?"
+ className={`${styles.tooltip} ${tooltipModifier} ${tooltipClassName}`}
+ ref={tooltipRef}
+ />
+ </div>
+ );
+};
+
+export default SelectWithTooltip;
diff --git a/src/components/molecules/forms/theme-toggle.stories.tsx b/src/components/molecules/forms/theme-toggle.stories.tsx
new file mode 100644
index 0000000..5ebf5a2
--- /dev/null
+++ b/src/components/molecules/forms/theme-toggle.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ThemeToggle from './theme-toggle';
+
+/**
+ * ThemeToggle - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Toggle',
+ component: ThemeToggle,
+ argTypes: {
+ labelClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ThemeToggle>;
+
+const Template: ComponentStory<typeof ThemeToggle> = (args) => (
+ <ThemeToggle {...args} />
+);
+
+/**
+ * Toggle Stories - Theme
+ */
+export const Theme = Template.bind({});
diff --git a/src/components/molecules/forms/theme-toggle.test.tsx b/src/components/molecules/forms/theme-toggle.test.tsx
new file mode 100644
index 0000000..0600c5e
--- /dev/null
+++ b/src/components/molecules/forms/theme-toggle.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@test-utils';
+import ThemeToggle from './theme-toggle';
+
+describe('ThemeToggle', () => {
+ it('renders a toggle component', () => {
+ render(<ThemeToggle />);
+ expect(
+ screen.getByRole('checkbox', {
+ name: `Theme: Light theme Dark theme`,
+ })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx
new file mode 100644
index 0000000..61ee4c6
--- /dev/null
+++ b/src/components/molecules/forms/theme-toggle.tsx
@@ -0,0 +1,64 @@
+import Moon from '@components/atoms/icons/moon';
+import Sun from '@components/atoms/icons/sun';
+import Toggle, {
+ type ToggleChoices,
+ type ToggleProps,
+} from '@components/molecules/forms/toggle';
+import { useTheme } from 'next-themes';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName'>;
+
+/**
+ * ThemeToggle component
+ *
+ * Render a Toggle component to set theme.
+ */
+const ThemeToggle: FC<ThemeToggleProps> = ({ ...props }) => {
+ const intl = useIntl();
+ const { resolvedTheme, setTheme } = useTheme();
+ const isDarkTheme = resolvedTheme === 'dark';
+
+ /**
+ * Update the theme.
+ */
+ const updateTheme = () => {
+ setTheme(isDarkTheme ? 'light' : 'dark');
+ };
+
+ const themeLabel = intl.formatMessage({
+ defaultMessage: 'Theme:',
+ description: 'ThemeToggle: theme label',
+ id: 'suXOBu',
+ });
+ const lightThemeLabel = intl.formatMessage({
+ defaultMessage: 'Light theme',
+ description: 'ThemeToggle: light theme label',
+ id: 'Ygea7s',
+ });
+ const darkThemeLabel = intl.formatMessage({
+ defaultMessage: 'Dark theme',
+ description: 'ThemeToggle: dark theme label',
+ id: '2QwvtS',
+ });
+ const themeChoices: ToggleChoices = {
+ left: <Sun title={lightThemeLabel} />,
+ right: <Moon title={darkThemeLabel} />,
+ };
+
+ return (
+ <Toggle
+ id="theme-settings"
+ name="theme-settings"
+ label={themeLabel}
+ labelSize="medium"
+ choices={themeChoices}
+ value={isDarkTheme}
+ setValue={updateTheme}
+ {...props}
+ />
+ );
+};
+
+export default ThemeToggle;
diff --git a/src/components/molecules/forms/toggle.module.scss b/src/components/molecules/forms/toggle.module.scss
new file mode 100644
index 0000000..2e8a49f
--- /dev/null
+++ b/src/components/molecules/forms/toggle.module.scss
@@ -0,0 +1,75 @@
+@use "@styles/abstracts/functions" as fun;
+
+.label {
+ --toggle-width: #{fun.convert-px(45)};
+ --toggle-height: calc(var(--toggle-width) / 2);
+
+ display: inline-flex;
+ align-items: center;
+ width: 100%;
+}
+
+.title {
+ margin-right: var(--spacing-2xs);
+}
+
+.toggle {
+ display: inline-flex;
+ align-items: center;
+ width: var(--toggle-width);
+ height: var(--toggle-height);
+ background: var(--color-shadow-light);
+ border: fun.convert-px(1) solid var(--color-primary);
+ border-radius: fun.convert-px(32);
+ box-shadow: inset 0 0 fun.convert-px(3) 0 var(--color-shadow-dark);
+ margin: 0 var(--spacing-2xs);
+ position: relative;
+
+ &::after {
+ content: "";
+ display: block;
+ width: calc((var(--toggle-width) / 2) - 1px);
+ height: calc((var(--toggle-width) / 2) - 1px);
+ background: var(--color-primary-light);
+ border: fun.convert-px(1) solid var(--color-primary);
+ border-radius: 50%;
+ box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow),
+ 0 0 fun.convert-px(2) fun.convert-px(1) var(--color-shadow-light);
+ position: absolute;
+ left: fun.convert-px(-2);
+ transition: all 0.3s ease-in-out 0s;
+ }
+}
+
+.checkbox {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+
+ &:checked ~ .label {
+ .toggle::after {
+ position: absolute;
+ left: calc(100% - (var(--toggle-width) / 2) + #{fun.convert-px(2)});
+ }
+ }
+
+ &:hover,
+ &:focus {
+ ~ .label {
+ .toggle::after {
+ background: var(--color-primary-lighter);
+ }
+ }
+ }
+
+ &:focus ~ .label {
+ .title {
+ text-decoration: underline solid var(--color-primary) fun.convert-px(2);
+ }
+
+ .toggle {
+ outline: var(--color-border) solid fun.convert-px(5);
+ }
+ }
+}
diff --git a/src/components/molecules/forms/toggle.stories.tsx b/src/components/molecules/forms/toggle.stories.tsx
new file mode 100644
index 0000000..0351ab7
--- /dev/null
+++ b/src/components/molecules/forms/toggle.stories.tsx
@@ -0,0 +1,121 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import Toggle from './toggle';
+
+/**
+ * ThemeToggle - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Forms/Toggle',
+ component: Toggle,
+ argTypes: {
+ choices: {
+ description: 'The toggle choices.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'The input id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'The toggle label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ labelClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ labelSize: {
+ control: {
+ type: 'select',
+ },
+ description: 'The label size.',
+ options: ['medium', 'small'],
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'The input name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to update the toggle value.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'The toggle value. True if checked.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof Toggle>;
+
+const Template: ComponentStory<typeof Toggle> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [isChecked, setIsChecked] = useState<boolean>(false);
+ return <Toggle value={isChecked} setValue={setIsChecked} {...args} />;
+};
+
+/**
+ * Toggle Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ choices: {
+ left: 'On',
+ right: 'Off',
+ },
+ id: 'toggle-example',
+ label: 'Activate setting:',
+ name: 'toggle-example',
+};
diff --git a/src/components/molecules/forms/toggle.test.tsx b/src/components/molecules/forms/toggle.test.tsx
new file mode 100644
index 0000000..fb97adc
--- /dev/null
+++ b/src/components/molecules/forms/toggle.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@test-utils';
+import Toggle from './toggle';
+
+const choices = {
+ left: 'On',
+ right: 'Off',
+};
+
+const label = 'Activate this setting:';
+
+describe('Toggle', () => {
+ it('renders a checked toggle', () => {
+ render(
+ <Toggle
+ id="toggle-example"
+ name="toggle-example"
+ choices={choices}
+ label={label}
+ value={true}
+ setValue={(__value) => null}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', {
+ name: `${label} ${choices.left} ${choices.right}`,
+ })
+ ).toBeChecked();
+ });
+});
diff --git a/src/components/molecules/forms/toggle.tsx b/src/components/molecules/forms/toggle.tsx
new file mode 100644
index 0000000..288062d
--- /dev/null
+++ b/src/components/molecules/forms/toggle.tsx
@@ -0,0 +1,78 @@
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
+import Label, { type LabelProps } from '@components/atoms/forms/label';
+import { FC, ReactNode } from 'react';
+import styles from './toggle.module.scss';
+
+export type ToggleChoices = {
+ /**
+ * The left part of the toggle field (unchecked).
+ */
+ left: ReactNode;
+ /**
+ * The right part of the toggle field (checked).
+ */
+ right: ReactNode;
+};
+
+export type ToggleProps = Pick<CheckboxProps, 'id' | 'name'> & {
+ /**
+ * The toggle choices.
+ */
+ choices: ToggleChoices;
+ /**
+ * The toggle label.
+ */
+ label: string;
+ /**
+ * Set additional classnames to the label.
+ */
+ labelClassName?: LabelProps['className'];
+ /**
+ * The label size.
+ */
+ labelSize?: LabelProps['size'];
+ /**
+ * The toggle value. True if checked.
+ */
+ value: boolean;
+ /**
+ * A callback function to update the toggle value.
+ */
+ setValue: (value: boolean) => void;
+};
+
+/**
+ * Toggle component
+ *
+ * Render a toggle with a label and two choices.
+ */
+const Toggle: FC<ToggleProps> = ({
+ choices,
+ id,
+ label,
+ labelClassName = '',
+ labelSize,
+ name,
+ setValue,
+ value,
+}) => {
+ return (
+ <>
+ <Checkbox
+ name={name}
+ id={id}
+ value={value}
+ setValue={() => setValue(!value)}
+ className={styles.checkbox}
+ />
+ <Label size={labelSize} htmlFor={id} className={styles.label}>
+ <span className={`${styles.title} ${labelClassName}`}>{label}</span>
+ {choices.left}
+ <span className={styles.toggle}></span>
+ {choices.right}
+ </Label>
+ </>
+ );
+};
+
+export default Toggle;
diff --git a/src/components/molecules/images/flipping-logo.module.scss b/src/components/molecules/images/flipping-logo.module.scss
new file mode 100644
index 0000000..89b9499
--- /dev/null
+++ b/src/components/molecules/images/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/images/flipping-logo.stories.tsx b/src/components/molecules/images/flipping-logo.stories.tsx
new file mode 100644
index 0000000..9d09293
--- /dev/null
+++ b/src/components/molecules/images/flipping-logo.stories.tsx
@@ -0,0 +1,72 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import FlippingLogoComponent from './flipping-logo';
+
+/**
+ * FlippingLogo - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Images',
+ 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} />
+);
+
+/**
+ * Images Stories - Flipping Logo
+ */
+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/images/flipping-logo.test.tsx b/src/components/molecules/images/flipping-logo.test.tsx
new file mode 100644
index 0000000..806fdbe
--- /dev/null
+++ b/src/components/molecules/images/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/images/flipping-logo.tsx b/src/components/molecules/images/flipping-logo.tsx
new file mode 100644
index 0000000..1099d53
--- /dev/null
+++ b/src/components/molecules/images/flipping-logo.tsx
@@ -0,0 +1,55 @@
+import Logo, { type LogoProps } from '@components/atoms/images/logo';
+import Image, { type ImageProps } from 'next/image';
+import { ForwardedRef, forwardRef, ForwardRefRenderFunction } from 'react';
+import styles from './flipping-logo.module.scss';
+
+export type FlippingLogoProps = {
+ /**
+ * Set additional classnames to the logo wrapper.
+ */
+ className?: string;
+ /**
+ * Photo alternative text.
+ */
+ altText: string;
+ /**
+ * Logo image title.
+ */
+ logoTitle?: LogoProps['title'];
+ /**
+ * Photo url.
+ */
+ photo: ImageProps['src'];
+};
+
+/**
+ * FlippingLogo component
+ *
+ * Render a logo and a photo with a flipping effect.
+ */
+const FlippingLogo: ForwardRefRenderFunction<
+ HTMLDivElement,
+ FlippingLogoProps
+> = (
+ { className = '', altText, logoTitle, photo, ...props },
+ ref: ForwardedRef<HTMLDivElement>
+) => {
+ return (
+ <div className={`${styles.logo} ${className}`} ref={ref}>
+ <div className={styles.logo__front}>
+ <Image
+ src={photo}
+ alt={altText}
+ layout="fill"
+ objectFit="cover"
+ {...props}
+ />
+ </div>
+ <div className={styles.logo__back}>
+ <Logo title={logoTitle} />
+ </div>
+ </div>
+ );
+};
+
+export default forwardRef(FlippingLogo);
diff --git a/src/components/molecules/images/responsive-image.module.scss b/src/components/molecules/images/responsive-image.module.scss
new file mode 100644
index 0000000..8a1d51f
--- /dev/null
+++ b/src/components/molecules/images/responsive-image.module.scss
@@ -0,0 +1,79 @@
+@use "@styles/abstracts/functions" as fun;
+
+.caption {
+ margin: 0;
+ padding: fun.convert-px(4) var(--spacing-2xs);
+ background: var(--color-bg-secondary);
+ border: fun.convert-px(1) solid var(--color-border-light);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+}
+
+.wrapper {
+ display: flex;
+ flex-flow: column;
+ width: fit-content;
+ margin: 0 auto;
+ position: relative;
+ text-align: center;
+
+ &--has-borders {
+ .caption {
+ margin-top: fun.convert-px(4);
+ }
+ }
+
+ &--has-borders#{&}--has-link {
+ .link {
+ padding: fun.convert-px(4);
+ }
+ }
+
+ &--has-borders#{&}--no-link {
+ padding: fun.convert-px(4);
+ border: fun.convert-px(1) solid var(--color-border);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+ var(--color-shadow);
+ }
+}
+
+.link {
+ display: flex;
+ flex-flow: column;
+ background: none;
+ border: fun.convert-px(1) solid var(--color-border);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+ var(--color-shadow);
+ text-decoration: none;
+
+ .caption {
+ color: var(--color-primary-darker);
+ }
+
+ &:hover,
+ &:focus {
+ box-shadow: 0 0 fun.convert-px(2) 0 var(--color-shadow-light),
+ fun.convert-px(2) fun.convert-px(2) fun.convert-px(4) fun.convert-px(1)
+ var(--color-shadow-light),
+ fun.convert-px(4) fun.convert-px(4) fun.convert-px(8) fun.convert-px(2)
+ var(--color-shadow-light);
+ transform: scale(var(--scale-up, 1.05));
+ }
+
+ &:focus {
+ .caption {
+ text-decoration: underline solid var(--color-primary-darker)
+ fun.convert-px(3);
+ }
+ }
+
+ &:active {
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+ fun.convert-px(1) var(--color-shadow-light);
+ transform: scale(var(--scale-down, 0.95));
+
+ .caption {
+ text-decoration: none;
+ }
+ }
+}
diff --git a/src/components/molecules/images/responsive-image.stories.tsx b/src/components/molecules/images/responsive-image.stories.tsx
new file mode 100644
index 0000000..4917cde
--- /dev/null
+++ b/src/components/molecules/images/responsive-image.stories.tsx
@@ -0,0 +1,212 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ResponsiveImage from './responsive-image';
+
+/**
+ * ResponsiveImage - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Images/ResponsiveImage',
+ component: ResponsiveImage,
+ args: {
+ withBorders: false,
+ },
+ argTypes: {
+ alt: {
+ control: {
+ type: 'text',
+ },
+ description: 'An alternative text.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ caption: {
+ control: {
+ type: 'text',
+ },
+ description: 'A figure caption.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the image wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ height: {
+ control: {
+ type: 'number',
+ },
+ description: 'The image height.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ src: {
+ control: {
+ type: 'text',
+ },
+ description: 'The image source.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ target: {
+ control: {
+ type: 'text',
+ },
+ description: 'A link target.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ width: {
+ control: {
+ type: 'number',
+ },
+ description: 'The image width.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ withBorders: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Add borders around the image.',
+ table: {
+ category: 'Styles',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ResponsiveImage>;
+
+const Template: ComponentStory<typeof ResponsiveImage> = (args) => (
+ <ResponsiveImage {...args} />
+);
+
+/**
+ * Responsive Image Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+};
+
+/**
+ * Responsive Image Stories - With borders
+ */
+export const WithBorders = Template.bind({});
+WithBorders.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+ withBorders: true,
+};
+
+/**
+ * Responsive Image Stories - With link
+ */
+export const WithLink = Template.bind({});
+WithLink.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+ target: '#',
+};
+
+/**
+ * Responsive Image Stories - With link and borders
+ */
+export const WithLinkAndBorders = Template.bind({});
+WithLinkAndBorders.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+ target: '#',
+ withBorders: true,
+};
+
+/**
+ * Responsive Image Stories - With caption
+ */
+export const WithCaption = Template.bind({});
+WithCaption.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+ caption: 'Omnis nulla labore',
+};
+
+/**
+ * Responsive Image Stories - With caption and borders
+ */
+export const WithCaptionAndBorders = Template.bind({});
+WithCaptionAndBorders.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+ caption: 'Omnis nulla labore',
+ withBorders: true,
+};
+
+/**
+ * Responsive Image Stories - With caption and link
+ */
+export const WithCaptionAndLink = Template.bind({});
+WithCaptionAndLink.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+ caption: 'Omnis nulla labore',
+ target: '#',
+};
+
+/**
+ * Responsive Image Stories - With caption, link and borders
+ */
+export const WithCaptionLinkAndBorders = Template.bind({});
+WithCaptionLinkAndBorders.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+ caption: 'Omnis nulla labore',
+ target: '#',
+ withBorders: true,
+};
diff --git a/src/components/molecules/images/responsive-image.test.tsx b/src/components/molecules/images/responsive-image.test.tsx
new file mode 100644
index 0000000..5452d28
--- /dev/null
+++ b/src/components/molecules/images/responsive-image.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@test-utils';
+import ResponsiveImage from './responsive-image';
+
+describe('ResponsiveImage', () => {
+ it('renders a responsive image', () => {
+ render(
+ <ResponsiveImage
+ src="http://placeimg.com/640/480"
+ alt="An alternative text"
+ width={640}
+ height={480}
+ />
+ );
+ expect(
+ screen.getByRole('img', { name: 'An alternative text' })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/images/responsive-image.tsx b/src/components/molecules/images/responsive-image.tsx
new file mode 100644
index 0000000..4541df8
--- /dev/null
+++ b/src/components/molecules/images/responsive-image.tsx
@@ -0,0 +1,95 @@
+import Link, { type LinkProps } from '@components/atoms/links/link';
+import Image, { type ImageProps } from 'next/image';
+import { FC, ReactNode } from 'react';
+import styles from './responsive-image.module.scss';
+
+export type ResponsiveImageProps = Omit<
+ ImageProps,
+ 'alt' | 'width' | 'height'
+> & {
+ /**
+ * An alternative text.
+ */
+ alt: string;
+ /**
+ * A figure caption.
+ */
+ caption?: ReactNode;
+ /**
+ * Set additional classnames to the figure wrapper.
+ */
+ className?: string;
+ /**
+ * The image height.
+ */
+ height: number | string;
+ /**
+ * A link target.
+ */
+ target?: LinkProps['href'];
+ /**
+ * The image width.
+ */
+ width: number | string;
+ /**
+ * Wrap the image with borders.
+ */
+ withBorders?: boolean;
+};
+
+/**
+ * ResponsiveImage component
+ *
+ * Render a responsive image wrapped in a figure element.
+ */
+const ResponsiveImage: FC<ResponsiveImageProps> = ({
+ alt,
+ caption,
+ className = '',
+ layout,
+ objectFit,
+ target,
+ withBorders,
+ ...props
+}) => {
+ const bordersModifier = withBorders
+ ? 'wrapper--has-borders'
+ : 'wrapper--no-borders';
+ const linkModifier = target ? 'wrapper--has-link' : 'wrapper--no-link';
+
+ return (
+ <figure
+ className={`${styles.wrapper} ${styles[bordersModifier]} ${styles[linkModifier]} ${className}`}
+ >
+ {target ? (
+ <Link href={target} className={styles.link}>
+ <Image
+ alt={alt}
+ layout={layout || 'intrinsic'}
+ objectFit={objectFit || 'contain'}
+ className={styles.img}
+ {...props}
+ />
+ {caption && (
+ <figcaption className={styles.caption}>{caption}</figcaption>
+ )}
+ </Link>
+ ) : (
+ <>
+ <Image
+ alt={alt}
+ layout={layout || 'intrinsic'}
+ objectFit={objectFit || 'contain'}
+ className={styles.img}
+ {...props}
+ />
+ {caption && (
+ <figcaption className={styles.caption}>{caption}</figcaption>
+ )}
+ </>
+ )}
+ </figure>
+ );
+};
+
+export default ResponsiveImage;
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;
diff --git a/src/components/molecules/modals/modal.module.scss b/src/components/molecules/modals/modal.module.scss
new file mode 100644
index 0000000..8866834
--- /dev/null
+++ b/src/components/molecules/modals/modal.module.scss
@@ -0,0 +1,38 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ padding: var(--spacing-md);
+ background: var(--color-bg-secondary);
+ border: fun.convert-px(4) solid;
+ border-image: radial-gradient(
+ ellipse at top,
+ var(--color-primary-lighter) 20%,
+ var(--color-primary) 100%
+ )
+ 1;
+ box-shadow: fun.convert-px(2) fun.convert-px(-2) fun.convert-px(3)
+ fun.convert-px(-1) var(--color-shadow-dark);
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ padding: var(--spacing-xs);
+ border-left: none;
+ border-right: none;
+
+ .title {
+ margin-bottom: var(--spacing-2xs);
+ }
+ }
+
+ @include mix.dimensions("sm") {
+ max-width: 35ch;
+ }
+ }
+}
+
+.icon {
+ --icon-size: #{fun.convert-px(30)};
+
+ margin-right: var(--spacing-2xs);
+}
diff --git a/src/components/molecules/modals/modal.stories.tsx b/src/components/molecules/modals/modal.stories.tsx
new file mode 100644
index 0000000..f6dd364
--- /dev/null
+++ b/src/components/molecules/modals/modal.stories.tsx
@@ -0,0 +1,96 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Modal from './modal';
+
+/**
+ * Widget - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Modals/Modal',
+ component: Modal,
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The modal body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ headingClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal heading.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ icon: {
+ control: {
+ type: 'select',
+ },
+ description: 'The title icon.',
+ options: ['', 'cogs', 'search'],
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The modal title.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof Modal>;
+
+const Template: ComponentStory<typeof Modal> = (args) => <Modal {...args} />;
+
+/**
+ * Modal Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+};
+
+/**
+ * Modal Stories - With title
+ */
+export const WithTitle = Template.bind({});
+WithTitle.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+ title: 'Alias praesentium corporis',
+};
diff --git a/src/components/molecules/modals/modal.test.tsx b/src/components/molecules/modals/modal.test.tsx
new file mode 100644
index 0000000..9a0e237
--- /dev/null
+++ b/src/components/molecules/modals/modal.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@test-utils';
+import Modal from './modal';
+
+const title = 'A custom title';
+const children =
+ 'Labore ullam delectus sit modi quam dolores. Ratione id sint aliquid facilis ipsum. Unde necessitatibus provident minus.';
+
+describe('Modal', () => {
+ it('renders a title', () => {
+ render(<Modal title={title}>{children}</Modal>);
+ expect(screen.getByText(title)).toBeInTheDocument();
+ });
+
+ it('renders the modal body', () => {
+ render(<Modal title={title}>{children}</Modal>);
+ expect(screen.getByText(children)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/modals/modal.tsx b/src/components/molecules/modals/modal.tsx
new file mode 100644
index 0000000..58f5fa0
--- /dev/null
+++ b/src/components/molecules/modals/modal.tsx
@@ -0,0 +1,81 @@
+import Heading, { type HeadingProps } from '@components/atoms/headings/heading';
+import { type CogProps } from '@components/atoms/icons/cog';
+import { type MagnifyingGlassProps } from '@components/atoms/icons/magnifying-glass';
+import dynamic from 'next/dynamic';
+import { FC, ReactNode } from 'react';
+import styles from './modal.module.scss';
+
+export type Icons = 'cogs' | 'search';
+
+export type ModalProps = {
+ /**
+ * The modal body.
+ */
+ children: ReactNode;
+ /**
+ * Set additional classnames.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the heading.
+ */
+ headingClassName?: HeadingProps['className'];
+ /**
+ * A icon to illustrate the modal.
+ */
+ icon?: Icons;
+ /**
+ * The modal title.
+ */
+ title?: string;
+};
+
+const CogIcon = dynamic<CogProps>(() => import('@components/atoms/icons/cog'), {
+ ssr: false,
+});
+const SearchIcon = dynamic<MagnifyingGlassProps>(
+ () => import('@components/atoms/icons/magnifying-glass'),
+ { ssr: false }
+);
+
+/**
+ * Modal component
+ *
+ * Render a modal component with an optional title and icon.
+ */
+const Modal: FC<ModalProps> = ({
+ children,
+ className = '',
+ headingClassName = '',
+ icon,
+ title,
+}) => {
+ const getIcon = (id: Icons) => {
+ switch (id) {
+ case 'cogs':
+ return <CogIcon />;
+ case 'search':
+ return <SearchIcon />;
+ default:
+ return <></>;
+ }
+ };
+
+ return (
+ <div className={`${styles.wrapper} ${className}`}>
+ {title && (
+ <Heading
+ isFake={true}
+ level={3}
+ className={`${styles.title} ${headingClassName}`}
+ >
+ {icon && <span className={styles.icon}>{getIcon(icon)}</span>}
+ {title}
+ </Heading>
+ )}
+ {children}
+ </div>
+ );
+};
+
+export default Modal;
diff --git a/src/components/molecules/modals/tooltip.module.scss b/src/components/molecules/modals/tooltip.module.scss
new file mode 100644
index 0000000..94aa3dd
--- /dev/null
+++ b/src/components/molecules/modals/tooltip.module.scss
@@ -0,0 +1,46 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ --title-height: #{fun.convert-px(40)};
+
+ margin-top: calc(var(--title-height) / 2);
+ padding: calc((var(--title-height) / 2) + var(--spacing-sm)) var(--spacing-sm)
+ var(--spacing-sm);
+ position: relative;
+ background: var(--color-bg);
+ border: fun.convert-px(2) solid var(--color-primary-dark);
+ border-radius: fun.convert-px(3);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow),
+ fun.convert-px(2) fun.convert-px(2) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow-light);
+}
+
+.title {
+ display: flex;
+ align-items: center;
+ height: var(--title-height);
+ padding-right: var(--spacing-xs);
+ position: absolute;
+ top: calc(var(--title-height) / -2);
+ left: var(--spacing-xs);
+ background: var(--color-bg);
+ border: fun.convert-px(1) solid var(--color-primary-dark);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow);
+ color: var(--color-primary-darker);
+ font-size: var(--font-size-sm);
+ font-variant: small-caps;
+ font-weight: 500;
+}
+
+.icon {
+ display: flex;
+ align-items: center;
+ height: var(--title-height);
+ margin-right: var(--spacing-xs);
+ padding: 0 var(--spacing-2xs);
+ background: var(--color-primary-dark);
+ border: fun.convert-px(1) solid var(--color-primary-dark);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow);
+ color: var(--color-fg-inverted);
+ font-weight: 600;
+}
diff --git a/src/components/molecules/modals/tooltip.stories.tsx b/src/components/molecules/modals/tooltip.stories.tsx
new file mode 100644
index 0000000..06a4855
--- /dev/null
+++ b/src/components/molecules/modals/tooltip.stories.tsx
@@ -0,0 +1,70 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Tooltip from './tooltip';
+
+/**
+ * Tooltip - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Modals/Tooltip',
+ component: Tooltip,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ content: {
+ control: {
+ type: 'text',
+ },
+ description: 'The tooltip body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ icon: {
+ control: {
+ type: 'text',
+ },
+ description: 'The tooltip icon.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The tooltip title',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof Tooltip>;
+
+const Template: ComponentStory<typeof Tooltip> = (args) => (
+ <Tooltip {...args} />
+);
+
+/**
+ * Tooltip Stories - Help
+ */
+export const Help = Template.bind({});
+Help.args = {
+ content:
+ 'Minima tempora fuga omnis ratione doloribus ut. Totam ea vitae consequatur. Fuga hic ipsum. In non debitis ex assumenda ut dicta. Sit ut maxime eligendi est.',
+ icon: '?',
+ title: 'Laborum enim vero',
+};
diff --git a/src/components/molecules/modals/tooltip.test.tsx b/src/components/molecules/modals/tooltip.test.tsx
new file mode 100644
index 0000000..24f20d8
--- /dev/null
+++ b/src/components/molecules/modals/tooltip.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from '@test-utils';
+import Tooltip from './tooltip';
+
+const title = 'Illum eum at';
+const content =
+ 'Non accusantium ad. Est et impedit iste animi voluptas cum accusamus accusantium. Repellat ut sint pariatur cumque cupiditate. Animi occaecati odio ut debitis ipsam similique. Repudiandae aut earum occaecati consequatur laborum ut nobis iusto. Adipisci laboriosam id.';
+const icon = '?';
+
+describe('Tooltip', () => {
+ it('renders a title', () => {
+ render(<Tooltip title={title} content={content} icon={icon} />);
+ expect(screen.getByText(title)).toBeInTheDocument();
+ });
+
+ it('renders an explanation', () => {
+ render(<Tooltip title={title} content={content} icon={icon} />);
+ expect(screen.getByText(content)).toBeInTheDocument();
+ });
+
+ it('renders an icon', () => {
+ render(<Tooltip title={title} content={content} icon={icon} />);
+ expect(screen.getByText(icon)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/modals/tooltip.tsx b/src/components/molecules/modals/tooltip.tsx
new file mode 100644
index 0000000..efb3009
--- /dev/null
+++ b/src/components/molecules/modals/tooltip.tsx
@@ -0,0 +1,60 @@
+import List, { type ListItem } from '@components/atoms/lists/list';
+import { forwardRef, ForwardRefRenderFunction, ReactNode } from 'react';
+import styles from './tooltip.module.scss';
+
+export type TooltipProps = {
+ /**
+ * Set additional classnames to the tooltip wrapper.
+ */
+ className?: string;
+ /**
+ * The tooltip body.
+ */
+ content: string | string[];
+ /**
+ * An icon to illustrate tooltip content.
+ */
+ icon: ReactNode;
+ /**
+ * The tooltip title.
+ */
+ title: string;
+};
+
+/**
+ * Tooltip component
+ *
+ * Render a tooltip modal.
+ */
+const Tooltip: ForwardRefRenderFunction<HTMLDivElement, TooltipProps> = (
+ { className = '', content, icon, title },
+ ref
+) => {
+ /**
+ * Format an array of strings to an array of object with id and value.
+ *
+ * @param {string[]} array - An array of strings.
+ * @returns {ListItem[]} The array formatted to be used as list items.
+ */
+ const getListItems = (array: string[]): ListItem[] => {
+ return array.map((string, index) => {
+ return { id: `item-${index}`, value: string };
+ });
+ };
+
+ return (
+ <div className={`${styles.wrapper} ${className}`} ref={ref}>
+ <div className={styles.title}>
+ <span className={styles.icon}>{icon}</span>
+ {title}
+ </div>
+ {Array.isArray(content) ? (
+ <List items={getListItems(content)} />
+ ) : (
+ content
+ )}
+ </div>
+ );
+};
+
+export default forwardRef(Tooltip);
diff --git a/src/components/molecules/nav/breadcrumb.module.scss b/src/components/molecules/nav/breadcrumb.module.scss
new file mode 100644
index 0000000..c26f60a
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.module.scss
@@ -0,0 +1,19 @@
+@use "@styles/abstracts/placeholders";
+
+.list {
+ @extend %reset-ordered-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ gap: var(--spacing-2xs);
+}
+
+.item {
+ &:not(:last-of-type) {
+ &::after {
+ content: ">";
+ margin-left: var(--spacing-2xs);
+ }
+ }
+}
diff --git a/src/components/molecules/nav/breadcrumb.stories.tsx b/src/components/molecules/nav/breadcrumb.stories.tsx
new file mode 100644
index 0000000..cf67e60
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.stories.tsx
@@ -0,0 +1,81 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Breadcrumb from './breadcrumb';
+
+/**
+ * Breadcrumb - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Navigation/Breadcrumb',
+ component: Breadcrumb,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ table: {
+ category: 'Styles',
+ },
+ description: 'Set additional classnames to the nav element.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ itemClassName: {
+ control: {
+ type: 'text',
+ },
+ table: {
+ category: 'Styles',
+ },
+ description: 'Set additional classnames to the breadcrumb items.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ description: 'The breadcrumb items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof Breadcrumb>;
+
+const Template: ComponentStory<typeof Breadcrumb> = (args) => (
+ <Breadcrumb {...args} />
+);
+
+/**
+ * Breadcrumb Stories - One item
+ */
+export const OneItem = Template.bind({});
+OneItem.args = {
+ items: [{ id: 'home', url: '#', name: 'Home' }],
+};
+
+/**
+ * Breadcrumb Stories - Two items
+ */
+export const TwoItems = Template.bind({});
+TwoItems.args = {
+ items: [
+ { id: 'home', url: '#', name: 'Home' },
+ { id: 'blog', url: '#', name: 'Blog' },
+ ],
+};
+
+/**
+ * Breadcrumb Stories - Three items
+ */
+export const ThreeItems = Template.bind({});
+ThreeItems.args = {
+ items: [
+ { id: 'home', url: '#', name: 'Home' },
+ { id: 'blog', url: '#', name: 'Blog' },
+ { id: 'post1', url: '#', name: 'A Post' },
+ ],
+};
diff --git a/src/components/molecules/nav/breadcrumb.test.tsx b/src/components/molecules/nav/breadcrumb.test.tsx
new file mode 100644
index 0000000..43220c9
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@test-utils';
+import Breadcrumb, { type BreadcrumbItem } from './breadcrumb';
+
+const items: BreadcrumbItem[] = [
+ { id: 'home', url: '#', name: 'Home' },
+ { id: 'blog', url: '#', name: 'Blog' },
+ { id: 'post1', url: '#', name: 'A Post' },
+];
+
+describe('Breadcrumb', () => {
+ it('renders a navigation', () => {
+ render(<Breadcrumb items={items} />);
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/nav/breadcrumb.tsx b/src/components/molecules/nav/breadcrumb.tsx
new file mode 100644
index 0000000..d184d65
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.tsx
@@ -0,0 +1,127 @@
+import Link from '@components/atoms/links/link';
+import { settings } from '@utils/config';
+import Script from 'next/script';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import { BreadcrumbList, ListItem, WithContext } from 'schema-dts';
+import styles from './breadcrumb.module.scss';
+
+export type BreadcrumbItem = {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * The item URL.
+ */
+ url: string;
+ /**
+ * The item name.
+ */
+ name: string;
+};
+
+export type BreadcrumbProps = {
+ /**
+ * Set additional classnames to the nav element.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the breadcrumb items.
+ */
+ itemClassName?: string;
+ /**
+ * The breadcrumb items
+ */
+ items: BreadcrumbItem[];
+};
+
+/**
+ * Breadcrumb component
+ *
+ * Render a breadcrumb navigation.
+ */
+const Breadcrumb: FC<BreadcrumbProps> = ({
+ itemClassName = '',
+ items,
+ ...props
+}) => {
+ const intl = useIntl();
+
+ const ariaLabel = intl.formatMessage({
+ defaultMessage: 'Breadcrumb',
+ description: 'Breadcrumb: an accessible name for the breadcrumb nav.',
+ id: '28nnDY',
+ });
+
+ /**
+ * Retrieve the breadcrumb list items.
+ *
+ * @param {BreadcrumbItem[]} list - The breadcrumb items.
+ * @returns {JSX.Element[]} The list items.
+ */
+ const getListItems = (list: BreadcrumbItem[]): JSX.Element[] => {
+ return list.map((item, index) => {
+ const isLastItem = index === list.length - 1;
+ const itemStyles = isLastItem
+ ? `${styles.item} screen-reader-text`
+ : styles.item;
+
+ return (
+ <li key={item.id} className={`${itemStyles} ${itemClassName}`}>
+ {isLastItem ? item.name : <Link href={item.url}>{item.name}</Link>}
+ </li>
+ );
+ });
+ };
+
+ /**
+ * Retrieve the breadcrumb list items with Schema.org format.
+ *
+ * @param {BreadcrumbItem[]} list - The breadcrumb items.
+ * @returns {ListItem[]} An array of list items using Schema.org format.
+ */
+ const getSchemaItems = (list: BreadcrumbItem[]): ListItem[] => {
+ const schemaItems: ListItem[] = [];
+
+ list.forEach((item, index) => {
+ schemaItems.push({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.name,
+ item: item.url,
+ });
+ });
+
+ return schemaItems;
+ };
+
+ const schemaJsonLd: WithContext<BreadcrumbList> = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ '@id': `${settings.url}/#breadcrumb`,
+ itemListElement: getSchemaItems(items),
+ };
+
+ return (
+ <>
+ <Script
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+ />
+ <nav aria-label={ariaLabel} {...props}>
+ <span className="screen-reader-text">
+ {intl.formatMessage({
+ defaultMessage: 'You are here:',
+ description: 'Breadcrumb: You are here prefix',
+ id: '16zl9Z',
+ })}
+ </span>
+ <ol className={styles.list}>{getListItems(items)}</ol>
+ </nav>
+ </>
+ );
+};
+
+export default Breadcrumb;
diff --git a/src/components/molecules/nav/nav.module.scss b/src/components/molecules/nav/nav.module.scss
new file mode 100644
index 0000000..9c0f6de
--- /dev/null
+++ b/src/components/molecules/nav/nav.module.scss
@@ -0,0 +1,22 @@
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.nav {
+ &__list {
+ @extend %reset-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-2xs);
+ align-items: center;
+ }
+
+ &--footer & {
+ &__item:not(:first-child) {
+ &::before {
+ content: "\2022";
+ margin-right: var(--spacing-2xs);
+ }
+ }
+ }
+}
diff --git a/src/components/molecules/nav/nav.stories.tsx b/src/components/molecules/nav/nav.stories.tsx
new file mode 100644
index 0000000..f3a29a6
--- /dev/null
+++ b/src/components/molecules/nav/nav.stories.tsx
@@ -0,0 +1,107 @@
+import Envelop from '@components/atoms/icons/envelop';
+import Home from '@components/atoms/icons/home';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NavComponent, { type NavItem } from './nav';
+
+/**
+ * Nav - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Navigation/Nav',
+ component: NavComponent,
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible name for the navigation.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the navigation wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ control: {
+ type: null,
+ },
+ description: 'The nav items.',
+ type: {
+ name: 'other',
+ required: true,
+ value: '',
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The navigation kind.',
+ options: ['main', 'footer'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ listClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the navigation list.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof NavComponent>;
+
+const Template: ComponentStory<typeof NavComponent> = (args) => (
+ <NavComponent {...args} />
+);
+
+const MainNavItems: NavItem[] = [
+ { id: 'homeLink', href: '/', label: 'Home', logo: <Home /> },
+ { id: 'contactLink', href: '/contact', label: 'Contact', logo: <Envelop /> },
+];
+
+const FooterNavItems: NavItem[] = [
+ { id: 'contactLink', href: '/contact', label: 'Contact' },
+ { id: 'legalLink', href: '/legal-notice', label: 'Legal notice' },
+];
+
+/**
+ * Nav Stories - Main navigation
+ */
+export const MainNav = Template.bind({});
+MainNav.args = {
+ items: MainNavItems,
+ kind: 'main',
+};
+
+/**
+ * Nav Stories - Footer navigation
+ */
+export const FooterNav = Template.bind({});
+FooterNav.args = {
+ items: FooterNavItems,
+ kind: 'footer',
+};
diff --git a/src/components/molecules/nav/nav.test.tsx b/src/components/molecules/nav/nav.test.tsx
new file mode 100644
index 0000000..183ca0b
--- /dev/null
+++ b/src/components/molecules/nav/nav.test.tsx
@@ -0,0 +1,28 @@
+import Envelop from '@components/atoms/icons/envelop';
+import Home from '@components/atoms/icons/home';
+import { render, screen } from '@test-utils';
+import Nav, { type NavItem } from './nav';
+
+const navItems: NavItem[] = [
+ { id: 'homeLink', href: '/', label: 'Home', logo: <Home /> },
+ { id: 'contactLink', href: '/contact', label: 'Contact', logo: <Envelop /> },
+];
+
+describe('Nav', () => {
+ it('renders a main navigation', () => {
+ render(<Nav kind="main" items={navItems} />);
+ expect(screen.getByRole('navigation')).toHaveClass('nav--main');
+ });
+
+ it('renders a footer navigation', () => {
+ render(<Nav kind="footer" items={navItems} />);
+ expect(screen.getByRole('navigation')).toHaveClass('nav--footer');
+ });
+
+ it('renders navigation links', () => {
+ render(<Nav kind="main" items={navItems} />);
+ expect(
+ screen.getByRole('link', { name: navItems[0].label })
+ ).toHaveAttribute('href', navItems[0].href);
+ });
+});
diff --git a/src/components/molecules/nav/nav.tsx b/src/components/molecules/nav/nav.tsx
new file mode 100644
index 0000000..581f813
--- /dev/null
+++ b/src/components/molecules/nav/nav.tsx
@@ -0,0 +1,85 @@
+import Link from '@components/atoms/links/link';
+import NavLink from '@components/atoms/links/nav-link';
+import { FC, ReactNode } from 'react';
+import styles from './nav.module.scss';
+
+export type NavItem = {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * The item link.
+ */
+ href: string;
+ /**
+ * The item name.
+ */
+ label: string;
+ /**
+ * The item logo.
+ */
+ logo?: ReactNode;
+};
+
+export type NavProps = {
+ /**
+ * An accessible name.
+ */
+ 'aria-label'?: string;
+ /**
+ * Set additional classnames to the navigation wrapper.
+ */
+ className?: string;
+ /**
+ * The navigation items.
+ */
+ items: NavItem[];
+ /**
+ * The navigation kind.
+ */
+ kind: 'main' | 'footer';
+ /**
+ * Set additional classnames to the navigation list.
+ */
+ listClassName?: string;
+};
+
+/**
+ * Nav component
+ *
+ * Render the nav links.
+ */
+const Nav: FC<NavProps> = ({
+ className = '',
+ items,
+ kind,
+ listClassName = '',
+ ...props
+}) => {
+ const kindClass = `nav--${kind}`;
+
+ /**
+ * Get the nav items.
+ * @returns {JSX.Element[]} An array of nav items.
+ */
+ const getItems = (): JSX.Element[] => {
+ return items.map(({ id, href, label, logo }) => (
+ <li key={id} className={styles.nav__item}>
+ {kind === 'main' ? (
+ <NavLink href={href} label={label} logo={logo} />
+ ) : (
+ <Link href={href}>{label}</Link>
+ )}
+ </li>
+ ));
+ };
+
+ return (
+ <nav className={`${styles[kindClass]} ${className}`} {...props}>
+ <ul className={`${styles.nav__list} ${listClassName}`}>{getItems()}</ul>
+ </nav>
+ );
+};
+
+export default Nav;
diff --git a/src/components/molecules/nav/pagination.module.scss b/src/components/molecules/nav/pagination.module.scss
new file mode 100644
index 0000000..56c5bfc
--- /dev/null
+++ b/src/components/molecules/nav/pagination.module.scss
@@ -0,0 +1,51 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ .list {
+ @extend %flex-list;
+
+ align-items: stretch;
+ justify-content: center;
+ position: relative;
+ row-gap: var(--spacing-xs);
+ column-gap: var(--spacing-sm);
+
+ &--pages {
+ column-gap: var(--spacing-2xs);
+ margin-bottom: var(--spacing-sm);
+ }
+ }
+
+ .link {
+ height: 100%;
+ min-width: 5ch;
+ min-height: 6ex;
+ position: relative;
+
+ &:not(&--disabled) {
+ &:hover,
+ &:focus {
+ z-index: 3;
+ }
+ }
+
+ &--number {
+ padding: 0;
+ }
+
+ &--disabled {
+ display: flex;
+ place-content: center;
+ align-items: center;
+ background: var(--color-bg);
+ border: fun.convert-px(3) solid var(--color-primary-darker);
+ border-radius: fun.convert-px(5);
+ color: var(--color-primary-darker);
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ text-decoration: underline transparent 0;
+ transform: scale(var(--scale-down, 0.94));
+ }
+ }
+}
diff --git a/src/components/molecules/nav/pagination.stories.tsx b/src/components/molecules/nav/pagination.stories.tsx
new file mode 100644
index 0000000..2e86db4
--- /dev/null
+++ b/src/components/molecules/nav/pagination.stories.tsx
@@ -0,0 +1,171 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PaginationComponent from './pagination';
+
+/**
+ * Pagination - Storybook Meta
+ */
+export default {
+ title: 'Molecules/Navigation/Pagination',
+ component: PaginationComponent,
+ args: {
+ baseUrl: '/page/',
+ siblings: 1,
+ },
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible name for the pagination.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ baseUrl: {
+ control: {
+ type: 'text',
+ },
+ description: 'The url prefix.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: '/page/' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the pagination wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ current: {
+ control: {
+ type: 'number',
+ },
+ description: 'The current page number.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ perPage: {
+ control: {
+ type: 'number',
+ },
+ description: 'The number of items per page.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ siblings: {
+ control: {
+ type: 'number',
+ },
+ description:
+ 'The number of pages to show next to the current page for one side.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 1 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ total: {
+ control: {
+ type: 'number',
+ },
+ description: 'The total number of items.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof PaginationComponent>;
+
+const Template: ComponentStory<typeof PaginationComponent> = (args) => (
+ <PaginationComponent {...args} />
+);
+
+/**
+ * Pagination Stories - Less than 5 pages
+ */
+export const WithoutDots = Template.bind({});
+WithoutDots.args = {
+ current: 2,
+ perPage: 10,
+ siblings: 2,
+ total: 50,
+};
+
+/**
+ * Pagination Stories - Truncated to the right.
+ */
+export const RightDots = Template.bind({});
+RightDots.args = {
+ current: 2,
+ perPage: 10,
+ siblings: 2,
+ total: 80,
+};
+
+/**
+ * Pagination Stories - Truncated to the left.
+ */
+export const LeftDots = Template.bind({});
+LeftDots.args = {
+ current: 7,
+ perPage: 10,
+ siblings: 2,
+ total: 80,
+};
+
+/**
+ * Pagination Stories - Truncated both sides.
+ */
+export const LeftAndRightDots = Template.bind({});
+LeftAndRightDots.args = {
+ current: 6,
+ perPage: 10,
+ siblings: 2,
+ total: 150,
+};
+
+/**
+ * Pagination Stories - Without previous link
+ */
+export const WithoutPreviousLink = Template.bind({});
+WithoutPreviousLink.args = {
+ current: 1,
+ perPage: 10,
+ siblings: 2,
+ total: 50,
+};
+
+/**
+ * Pagination Stories - Without next link
+ */
+export const WithoutNextLink = Template.bind({});
+WithoutNextLink.args = {
+ current: 5,
+ perPage: 10,
+ siblings: 2,
+ total: 50,
+};
diff --git a/src/components/molecules/nav/pagination.test.tsx b/src/components/molecules/nav/pagination.test.tsx
new file mode 100644
index 0000000..2c4a063
--- /dev/null
+++ b/src/components/molecules/nav/pagination.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@test-utils';
+import Pagination from './pagination';
+
+const total = 50;
+const perPage = 10;
+
+describe('Pagination', () => {
+ it('renders previous and next page links', () => {
+ render(<Pagination current={2} total={total} perPage={perPage} />);
+ expect(
+ screen.getByRole('link', { name: /Previous page/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: /Next page/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the page links except for the current one', () => {
+ render(
+ <Pagination current={2} siblings={2} total={total} perPage={perPage} />
+ );
+ expect(screen.getAllByRole('link', { name: /Page / })).toHaveLength(
+ total / perPage - 1
+ );
+ });
+});
diff --git a/src/components/molecules/nav/pagination.tsx b/src/components/molecules/nav/pagination.tsx
new file mode 100644
index 0000000..934b50a
--- /dev/null
+++ b/src/components/molecules/nav/pagination.tsx
@@ -0,0 +1,220 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import { FC, Fragment, ReactNode } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './pagination.module.scss';
+
+export type PaginationProps = {
+ /**
+ * An accessible name for the pagination.
+ */
+ 'aria-label'?: string;
+ /**
+ * The url part before page number. Default: /page/
+ */
+ baseUrl?: string;
+ /**
+ * Set additional classnames to the pagination wrapper.
+ */
+ className?: string;
+ /**
+ * The current page number.
+ */
+ current: number;
+ /**
+ * The number of items per page.
+ */
+ perPage: number;
+ /**
+ * The number of siblings on one side of the current page. Default: 1.
+ */
+ siblings?: number;
+ /**
+ * The total number of items.
+ */
+ total: number;
+};
+
+/**
+ * Pagination component
+ *
+ * Render a page-based navigation.
+ */
+const Pagination: FC<PaginationProps> = ({
+ baseUrl = '/page/',
+ className = '',
+ current,
+ perPage,
+ siblings = 2,
+ total,
+ ...props
+}) => {
+ const intl = useIntl();
+ const totalPages = Math.round(total / perPage);
+ const hasPreviousPage = current > 1;
+ const previousPageName = intl.formatMessage(
+ {
+ defaultMessage: '{icon} Previous page',
+ description: 'Pagination: previous page link',
+ id: 'aMFqPH',
+ },
+ { icon: '←' }
+ );
+ const previousPageUrl = `${baseUrl}${current - 1}`;
+ const hasNextPage = current < totalPages;
+ const nextPageName = intl.formatMessage(
+ {
+ defaultMessage: 'Next page {icon}',
+ description: 'Pagination: Next page link',
+ id: 'R4yaW6',
+ },
+ { icon: '→' }
+ );
+ const nextPageUrl = `${baseUrl}${current + 1}`;
+
+ /**
+ * Create an array with a range of values from start value to end value.
+ *
+ * @param {number} start - The first value.
+ * @param {number} end - The last value.
+ * @returns {number[]} An array from start value to end value.
+ */
+ const range = (start: number, end: number): number[] => {
+ const length = end - start + 1;
+
+ return Array.from({ length }, (_, index) => index + start);
+ };
+
+ /**
+ * Get the pagination range.
+ *
+ * @param currentPage - The current page number.
+ * @param maxPages - The total pages number.
+ * @returns {(number|string)[]} An array of page numbers with or without dots.
+ */
+ const getPaginationRange = (
+ currentPage: number,
+ maxPages: number
+ ): (number | string)[] => {
+ const dots = '\u2026';
+
+ /**
+ * Show left dots if current page less left siblings is greater than the
+ * first two pages.
+ */
+ const hasLeftDots = currentPage - siblings > 2;
+
+ /**
+ * Show right dots if current page plus right siblings is lower than the
+ * total of pages less the last page.
+ */
+ const hasRightDots = currentPage + siblings < maxPages - 1;
+
+ if (hasLeftDots && hasRightDots) {
+ const middleItems = range(currentPage - siblings, currentPage + siblings);
+ return [1, dots, ...middleItems, dots, maxPages];
+ }
+
+ if (hasLeftDots) {
+ const rightItems = range(currentPage - siblings, maxPages);
+ return [1, dots, ...rightItems];
+ }
+
+ if (hasRightDots) {
+ const leftItems = range(1, currentPage + siblings);
+ return [...leftItems, dots, maxPages];
+ }
+
+ return range(1, maxPages);
+ };
+
+ /**
+ * Get a link or a span wrapped in a list item.
+ *
+ * @param {string} id - The item id.
+ * @param {ReactNode} body - The link body.
+ * @param {string} [link] - An URL.
+ * @returns {JSX.Element} The list item.
+ */
+ const getItem = (id: string, body: ReactNode, link?: string): JSX.Element => {
+ const linkModifier = id.startsWith('page') ? 'link--number' : '';
+ const kind = id === 'previous' || id === 'next' ? 'tertiary' : 'secondary';
+
+ return (
+ <li className={styles.item}>
+ {link ? (
+ <ButtonLink
+ kind={kind}
+ target={link}
+ className={`${styles.link} ${styles[linkModifier]}`}
+ >
+ {body}
+ </ButtonLink>
+ ) : (
+ <span className={`${styles.link} ${styles['link--disabled']}`}>
+ {body}
+ </span>
+ )}
+ </li>
+ );
+ };
+
+ /**
+ * Get the list of pages.
+ *
+ * @param {number} currentPage - The current page number.
+ * @param {number} maxPages - The total of pages.
+ * @returns {JSX.Element[]} The list items.
+ */
+ const getPages = (currentPage: number, maxPages: number): JSX.Element[] => {
+ const pagesRange = getPaginationRange(currentPage, maxPages);
+
+ return pagesRange.map((page, index) => {
+ const id = typeof page === 'string' ? `dots-${index}` : `page-${page}`;
+ const currentPagePrefix = intl.formatMessage({
+ defaultMessage: 'You are here:',
+ description: 'Pagination: current page indication',
+ id: 'yE/Jdz',
+ });
+ const body =
+ typeof page === 'string'
+ ? '\u2026'
+ : intl.formatMessage(
+ {
+ defaultMessage: '<a11y>Page </a11y>{number}',
+ description: 'Pagination: page number',
+ id: 'TSXPzr',
+ },
+ {
+ number: page,
+ a11y: (chunks: ReactNode) => (
+ <span className="screen-reader-text">
+ {page === currentPage && currentPagePrefix}
+ {chunks}
+ </span>
+ ),
+ }
+ );
+ const url =
+ page === currentPage || typeof page === 'string'
+ ? undefined
+ : `${baseUrl}${page}`;
+
+ return <Fragment key={`item-${id}`}>{getItem(id, body, url)}</Fragment>;
+ });
+ };
+
+ return (
+ <nav className={`${styles.wrapper} ${className}`} {...props}>
+ <ul className={`${styles.list} ${styles['list--pages']}`}>
+ {getPages(current, totalPages)}
+ </ul>
+ <ul className={styles.list}>
+ {hasPreviousPage &&
+ getItem('previous', previousPageName, previousPageUrl)}
+ {hasNextPage && getItem('next', nextPageName, nextPageUrl)}
+ </ul>
+ </nav>
+ );
+};
+
+export default Pagination;