summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/atoms/buttons/button-link.stories.tsx95
-rw-r--r--src/components/atoms/buttons/button-link.test.tsx9
-rw-r--r--src/components/atoms/buttons/button-link.tsx69
-rw-r--r--src/components/atoms/buttons/button.stories.tsx148
-rw-r--r--src/components/atoms/buttons/button.test.tsx18
-rw-r--r--src/components/atoms/buttons/button.tsx64
-rw-r--r--src/components/atoms/buttons/buttons.module.scss178
-rw-r--r--src/components/atoms/forms/checkbox.stories.tsx96
-rw-r--r--src/components/atoms/forms/checkbox.test.tsx28
-rw-r--r--src/components/atoms/forms/checkbox.tsx46
-rw-r--r--src/components/atoms/forms/field.stories.tsx201
-rw-r--r--src/components/atoms/forms/field.test.tsx30
-rw-r--r--src/components/atoms/forms/field.tsx107
-rw-r--r--src/components/atoms/forms/form.test.tsx9
-rw-r--r--src/components/atoms/forms/form.tsx73
-rw-r--r--src/components/atoms/forms/forms.module.scss53
-rw-r--r--src/components/atoms/forms/label.module.scss17
-rw-r--r--src/components/atoms/forms/label.stories.tsx85
-rw-r--r--src/components/atoms/forms/label.test.tsx9
-rw-r--r--src/components/atoms/forms/label.tsx45
-rw-r--r--src/components/atoms/forms/select.stories.tsx145
-rw-r--r--src/components/atoms/forms/select.test.tsx30
-rw-r--r--src/components/atoms/forms/select.tsx99
-rw-r--r--src/components/atoms/headings/heading.module.scss57
-rw-r--r--src/components/atoms/headings/heading.stories.tsx82
-rw-r--r--src/components/atoms/headings/heading.test.tsx56
-rw-r--r--src/components/atoms/headings/heading.tsx56
-rw-r--r--src/components/atoms/icons/arrow.module.scss16
-rw-r--r--src/components/atoms/icons/arrow.stories.tsx42
-rw-r--r--src/components/atoms/icons/arrow.test.tsx9
-rw-r--r--src/components/atoms/icons/arrow.tsx101
-rw-r--r--src/components/atoms/icons/career.module.scss53
-rw-r--r--src/components/atoms/icons/career.stories.tsx28
-rw-r--r--src/components/atoms/icons/career.test.tsx9
-rw-r--r--src/components/atoms/icons/career.tsx71
-rw-r--r--src/components/atoms/icons/cc-by-sa.module.scss7
-rw-r--r--src/components/atoms/icons/cc-by-sa.stories.tsx31
-rw-r--r--src/components/atoms/icons/cc-by-sa.test.tsx9
-rw-r--r--src/components/atoms/icons/cc-by-sa.tsx45
-rw-r--r--src/components/atoms/icons/close.module.scss12
-rw-r--r--src/components/atoms/icons/close.stories.tsx28
-rw-r--r--src/components/atoms/icons/close.test.tsx9
-rw-r--r--src/components/atoms/icons/close.tsx35
-rw-r--r--src/components/atoms/icons/cog.module.scss8
-rw-r--r--src/components/atoms/icons/cog.stories.tsx28
-rw-r--r--src/components/atoms/icons/cog.test.tsx9
-rw-r--r--src/components/atoms/icons/cog.tsx29
-rw-r--r--src/components/atoms/icons/computer-screen.module.scss39
-rw-r--r--src/components/atoms/icons/computer-screen.stories.tsx28
-rw-r--r--src/components/atoms/icons/computer-screen.test.tsx9
-rw-r--r--src/components/atoms/icons/computer-screen.tsx79
-rw-r--r--src/components/atoms/icons/envelop.module.scss28
-rw-r--r--src/components/atoms/icons/envelop.stories.tsx28
-rw-r--r--src/components/atoms/icons/envelop.test.tsx9
-rw-r--r--src/components/atoms/icons/envelop.tsx67
-rw-r--r--src/components/atoms/icons/hamburger.module.scss42
-rw-r--r--src/components/atoms/icons/hamburger.stories.tsx41
-rw-r--r--src/components/atoms/icons/hamburger.test.tsx9
-rw-r--r--src/components/atoms/icons/hamburger.tsx32
-rw-r--r--src/components/atoms/icons/home.module.scss41
-rw-r--r--src/components/atoms/icons/home.stories.tsx28
-rw-r--r--src/components/atoms/icons/home.test.tsx9
-rw-r--r--src/components/atoms/icons/home.tsx55
-rw-r--r--src/components/atoms/icons/magnifying-glass.module.scss29
-rw-r--r--src/components/atoms/icons/magnifying-glass.stories.tsx28
-rw-r--r--src/components/atoms/icons/magnifying-glass.test.tsx9
-rw-r--r--src/components/atoms/icons/magnifying-glass.tsx43
-rw-r--r--src/components/atoms/icons/moon.module.scss8
-rw-r--r--src/components/atoms/icons/moon.stories.tsx41
-rw-r--r--src/components/atoms/icons/moon.test.tsx9
-rw-r--r--src/components/atoms/icons/moon.tsx28
-rw-r--r--src/components/atoms/icons/plus-minus.module.scss39
-rw-r--r--src/components/atoms/icons/plus-minus.stories.tsx43
-rw-r--r--src/components/atoms/icons/plus-minus.test.tsx9
-rw-r--r--src/components/atoms/icons/plus-minus.tsx31
-rw-r--r--src/components/atoms/icons/posts-stack.module.scss22
-rw-r--r--src/components/atoms/icons/posts-stack.stories.tsx28
-rw-r--r--src/components/atoms/icons/posts-stack.test.tsx9
-rw-r--r--src/components/atoms/icons/posts-stack.tsx75
-rw-r--r--src/components/atoms/icons/sun.module.scss8
-rw-r--r--src/components/atoms/icons/sun.stories.tsx41
-rw-r--r--src/components/atoms/icons/sun.test.tsx9
-rw-r--r--src/components/atoms/icons/sun.tsx33
-rw-r--r--src/components/atoms/images/logo.module.scss28
-rw-r--r--src/components/atoms/images/logo.stories.tsx28
-rw-r--r--src/components/atoms/images/logo.test.tsx9
-rw-r--r--src/components/atoms/images/logo.tsx46
-rw-r--r--src/components/atoms/layout/copyright.module.scss32
-rw-r--r--src/components/atoms/layout/copyright.stories.tsx55
-rw-r--r--src/components/atoms/layout/copyright.test.tsx32
-rw-r--r--src/components/atoms/layout/copyright.tsx59
-rw-r--r--src/components/atoms/layout/main.stories.tsx52
-rw-r--r--src/components/atoms/layout/main.test.tsx12
-rw-r--r--src/components/atoms/layout/main.tsx23
-rw-r--r--src/components/atoms/layout/no-script.module.scss19
-rw-r--r--src/components/atoms/layout/no-script.stories.tsx46
-rw-r--r--src/components/atoms/layout/no-script.test.tsx11
-rw-r--r--src/components/atoms/layout/no-script.tsx21
-rw-r--r--src/components/atoms/layout/notice.module.scss28
-rw-r--r--src/components/atoms/layout/notice.stories.tsx40
-rw-r--r--src/components/atoms/layout/notice.test.tsx11
-rw-r--r--src/components/atoms/layout/notice.tsx30
-rw-r--r--src/components/atoms/layout/section.module.scss25
-rw-r--r--src/components/atoms/layout/section.stories.tsx85
-rw-r--r--src/components/atoms/layout/section.test.tsx17
-rw-r--r--src/components/atoms/layout/section.tsx57
-rw-r--r--src/components/atoms/links/link.module.scss37
-rw-r--r--src/components/atoms/links/link.stories.tsx79
-rw-r--r--src/components/atoms/links/link.test.tsx9
-rw-r--r--src/components/atoms/links/link.tsx51
-rw-r--r--src/components/atoms/links/nav-link.module.scss46
-rw-r--r--src/components/atoms/links/nav-link.stories.tsx49
-rw-r--r--src/components/atoms/links/nav-link.test.tsx12
-rw-r--r--src/components/atoms/links/nav-link.tsx36
-rw-r--r--src/components/atoms/links/sharing-link.module.scss157
-rw-r--r--src/components/atoms/links/sharing-link.stories.tsx50
-rw-r--r--src/components/atoms/links/sharing-link.test.tsx46
-rw-r--r--src/components/atoms/links/sharing-link.tsx48
-rw-r--r--src/components/atoms/links/social-link.module.scss43
-rw-r--r--src/components/atoms/links/social-link.stories.tsx40
-rw-r--r--src/components/atoms/links/social-link.test.tsx15
-rw-r--r--src/components/atoms/links/social-link.tsx53
-rw-r--r--src/components/atoms/lists/description-list.module.scss54
-rw-r--r--src/components/atoms/lists/description-list.stories.tsx73
-rw-r--r--src/components/atoms/lists/description-list.test.tsx20
-rw-r--r--src/components/atoms/lists/description-list.tsx100
-rw-r--r--src/components/atoms/lists/list.module.scss39
-rw-r--r--src/components/atoms/lists/list.stories.tsx80
-rw-r--r--src/components/atoms/lists/list.test.tsx26
-rw-r--r--src/components/atoms/lists/list.tsx87
-rw-r--r--src/components/atoms/loaders/progress-bar.module.scss43
-rw-r--r--src/components/atoms/loaders/progress-bar.stories.tsx76
-rw-r--r--src/components/atoms/loaders/progress-bar.test.tsx9
-rw-r--r--src/components/atoms/loaders/progress-bar.tsx55
-rw-r--r--src/components/atoms/loaders/spinner.module.scss48
-rw-r--r--src/components/atoms/loaders/spinner.stories.tsx31
-rw-r--r--src/components/atoms/loaders/spinner.test.tsx14
-rw-r--r--src/components/atoms/loaders/spinner.tsx37
-rw-r--r--src/components/molecules/buttons/back-to-top.module.scss51
-rw-r--r--src/components/molecules/buttons/back-to-top.stories.tsx44
-rw-r--r--src/components/molecules/buttons/back-to-top.test.tsx10
-rw-r--r--src/components/molecules/buttons/back-to-top.tsx45
-rw-r--r--src/components/molecules/buttons/heading-button.module.scss41
-rw-r--r--src/components/molecules/buttons/heading-button.stories.tsx75
-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.tsx44
-rw-r--r--src/components/molecules/buttons/help-button.test.tsx9
-rw-r--r--src/components/molecules/buttons/help-button.tsx38
-rw-r--r--src/components/molecules/forms/ackee-select.module.scss11
-rw-r--r--src/components/molecules/forms/ackee-select.stories.tsx32
-rw-r--r--src/components/molecules/forms/ackee-select.test.tsx23
-rw-r--r--src/components/molecules/forms/ackee-select.tsx89
-rw-r--r--src/components/molecules/forms/labelled-field.module.scss9
-rw-r--r--src/components/molecules/forms/labelled-field.stories.tsx201
-rw-r--r--src/components/molecules/forms/labelled-field.test.tsx19
-rw-r--r--src/components/molecules/forms/labelled-field.tsx51
-rw-r--r--src/components/molecules/forms/labelled-select.module.scss9
-rw-r--r--src/components/molecules/forms/labelled-select.stories.tsx195
-rw-r--r--src/components/molecules/forms/labelled-select.test.tsx25
-rw-r--r--src/components/molecules/forms/labelled-select.tsx64
-rw-r--r--src/components/molecules/forms/motion-toggle.stories.tsx31
-rw-r--r--src/components/molecules/forms/motion-toggle.test.tsx13
-rw-r--r--src/components/molecules/forms/motion-toggle.tsx52
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.stories.tsx31
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.test.tsx13
-rw-r--r--src/components/molecules/forms/prism-theme-toggle.tsx57
-rw-r--r--src/components/molecules/forms/select-with-tooltip.module.scss48
-rw-r--r--src/components/molecules/forms/select-with-tooltip.stories.tsx211
-rw-r--r--src/components/molecules/forms/select-with-tooltip.test.tsx32
-rw-r--r--src/components/molecules/forms/select-with-tooltip.tsx62
-rw-r--r--src/components/molecules/forms/theme-toggle.stories.tsx31
-rw-r--r--src/components/molecules/forms/theme-toggle.test.tsx13
-rw-r--r--src/components/molecules/forms/theme-toggle.tsx54
-rw-r--r--src/components/molecules/forms/toggle.module.scss75
-rw-r--r--src/components/molecules/forms/toggle.stories.tsx117
-rw-r--r--src/components/molecules/forms/toggle.test.tsx29
-rw-r--r--src/components/molecules/forms/toggle.tsx86
-rw-r--r--src/components/molecules/images/responsive-image.module.scss52
-rw-r--r--src/components/molecules/images/responsive-image.stories.tsx87
-rw-r--r--src/components/molecules/images/responsive-image.test.tsx18
-rw-r--r--src/components/molecules/images/responsive-image.tsx81
-rw-r--r--src/components/molecules/layout/branding.module.scss48
-rw-r--r--src/components/molecules/layout/branding.stories.tsx83
-rw-r--r--src/components/molecules/layout/branding.test.tsx61
-rw-r--r--src/components/molecules/layout/branding.tsx97
-rw-r--r--src/components/molecules/layout/card.module.scss77
-rw-r--r--src/components/molecules/layout/card.stories.tsx102
-rw-r--r--src/components/molecules/layout/card.test.tsx52
-rw-r--r--src/components/molecules/layout/card.tsx114
-rw-r--r--src/components/molecules/layout/flipping-logo.module.scss59
-rw-r--r--src/components/molecules/layout/flipping-logo.stories.tsx66
-rw-r--r--src/components/molecules/layout/flipping-logo.test.tsx25
-rw-r--r--src/components/molecules/layout/flipping-logo.tsx48
-rw-r--r--src/components/molecules/layout/meta.stories.tsx57
-rw-r--r--src/components/molecules/layout/meta.test.tsx8
-rw-r--r--src/components/molecules/layout/meta.tsx74
-rw-r--r--src/components/molecules/layout/widget.module.scss40
-rw-r--r--src/components/molecules/layout/widget.stories.tsx85
-rw-r--r--src/components/molecules/layout/widget.test.tsx19
-rw-r--r--src/components/molecules/layout/widget.tsx54
-rw-r--r--src/components/molecules/modals/modal.module.scss38
-rw-r--r--src/components/molecules/modals/modal.stories.tsx69
-rw-r--r--src/components/molecules/modals/modal.test.tsx9
-rw-r--r--src/components/molecules/modals/modal.tsx77
-rw-r--r--src/components/molecules/modals/tooltip.module.scss46
-rw-r--r--src/components/molecules/modals/tooltip.stories.tsx64
-rw-r--r--src/components/molecules/modals/tooltip.test.tsx24
-rw-r--r--src/components/molecules/modals/tooltip.tsx62
-rw-r--r--src/components/molecules/nav/breadcrumb.module.scss19
-rw-r--r--src/components/molecules/nav/breadcrumb.stories.tsx48
-rw-r--r--src/components/molecules/nav/breadcrumb.test.tsx15
-rw-r--r--src/components/molecules/nav/breadcrumb.tsx113
-rw-r--r--src/components/molecules/nav/nav.module.scss22
-rw-r--r--src/components/molecules/nav/nav.stories.tsx75
-rw-r--r--src/components/molecules/nav/nav.test.tsx28
-rw-r--r--src/components/molecules/nav/nav.tsx80
-rw-r--r--src/components/organisms/forms/comment-form.module.scss8
-rw-r--r--src/components/organisms/forms/comment-form.stories.tsx86
-rw-r--r--src/components/organisms/forms/comment-form.test.tsx20
-rw-r--r--src/components/organisms/forms/comment-form.tsx174
-rw-r--r--src/components/organisms/forms/contact-form.module.scss8
-rw-r--r--src/components/organisms/forms/contact-form.stories.tsx59
-rw-r--r--src/components/organisms/forms/contact-form.test.tsx46
-rw-r--r--src/components/organisms/forms/contact-form.tsx150
-rw-r--r--src/components/organisms/forms/search-form.module.scss58
-rw-r--r--src/components/organisms/forms/search-form.stories.tsx34
-rw-r--r--src/components/organisms/forms/search-form.test.tsx16
-rw-r--r--src/components/organisms/forms/search-form.tsx57
-rw-r--r--src/components/organisms/layout/cards-list.module.scss27
-rw-r--r--src/components/organisms/layout/cards-list.stories.tsx105
-rw-r--r--src/components/organisms/layout/cards-list.test.tsx61
-rw-r--r--src/components/organisms/layout/cards-list.tsx80
-rw-r--r--src/components/organisms/layout/footer.module.scss41
-rw-r--r--src/components/organisms/layout/footer.stories.tsx74
-rw-r--r--src/components/organisms/layout/footer.test.tsx33
-rw-r--r--src/components/organisms/layout/footer.tsx52
-rw-r--r--src/components/organisms/layout/overview.module.scss12
-rw-r--r--src/components/organisms/layout/overview.stories.tsx50
-rw-r--r--src/components/organisms/layout/overview.test.tsx29
-rw-r--r--src/components/organisms/layout/overview.tsx33
-rw-r--r--src/components/organisms/layout/summary.module.scss84
-rw-r--r--src/components/organisms/layout/summary.stories.tsx114
-rw-r--r--src/components/organisms/layout/summary.test.tsx85
-rw-r--r--src/components/organisms/layout/summary.tsx105
-rw-r--r--src/components/organisms/modals/search-modal.module.scss11
-rw-r--r--src/components/organisms/modals/search-modal.stories.tsx16
-rw-r--r--src/components/organisms/modals/search-modal.test.tsx9
-rw-r--r--src/components/organisms/modals/search-modal.tsx34
-rw-r--r--src/components/organisms/modals/settings-modal.module.scss21
-rw-r--r--src/components/organisms/modals/settings-modal.stories.tsx31
-rw-r--r--src/components/organisms/modals/settings-modal.test.tsx32
-rw-r--r--src/components/organisms/modals/settings-modal.tsx59
-rw-r--r--src/components/organisms/toolbar/main-nav.module.scss88
-rw-r--r--src/components/organisms/toolbar/main-nav.stories.tsx75
-rw-r--r--src/components/organisms/toolbar/main-nav.test.tsx33
-rw-r--r--src/components/organisms/toolbar/main-nav.tsx74
-rw-r--r--src/components/organisms/toolbar/search.module.scss3
-rw-r--r--src/components/organisms/toolbar/search.stories.tsx63
-rw-r--r--src/components/organisms/toolbar/search.test.tsx19
-rw-r--r--src/components/organisms/toolbar/search.tsx66
-rw-r--r--src/components/organisms/toolbar/settings.module.scss10
-rw-r--r--src/components/organisms/toolbar/settings.stories.tsx76
-rw-r--r--src/components/organisms/toolbar/settings.test.tsx18
-rw-r--r--src/components/organisms/toolbar/settings.tsx72
-rw-r--r--src/components/organisms/toolbar/toolbar-items.module.scss96
-rw-r--r--src/components/organisms/toolbar/toolbar.module.scss80
-rw-r--r--src/components/organisms/toolbar/toolbar.stories.tsx26
-rw-r--r--src/components/organisms/toolbar/toolbar.test.tsx16
-rw-r--r--src/components/organisms/toolbar/toolbar.tsx51
-rw-r--r--src/components/organisms/widgets/image-widget.module.scss43
-rw-r--r--src/components/organisms/widgets/image-widget.stories.tsx113
-rw-r--r--src/components/organisms/widgets/image-widget.test.tsx54
-rw-r--r--src/components/organisms/widgets/image-widget.tsx75
-rw-r--r--src/components/organisms/widgets/links-list-widget.module.scss71
-rw-r--r--src/components/organisms/widgets/links-list-widget.stories.tsx92
-rw-r--r--src/components/organisms/widgets/links-list-widget.test.tsx32
-rw-r--r--src/components/organisms/widgets/links-list-widget.tsx81
-rw-r--r--src/components/organisms/widgets/sharing.module.scss10
-rw-r--r--src/components/organisms/widgets/sharing.stories.tsx78
-rw-r--r--src/components/organisms/widgets/sharing.test.tsx31
-rw-r--r--src/components/organisms/widgets/sharing.tsx190
-rw-r--r--src/components/organisms/widgets/social-media.module.scss10
-rw-r--r--src/components/organisms/widgets/social-media.stories.tsx56
-rw-r--r--src/components/organisms/widgets/social-media.test.tsx33
-rw-r--r--src/components/organisms/widgets/social-media.tsx41
287 files changed, 14045 insertions, 0 deletions
diff --git a/src/components/atoms/buttons/button-link.stories.tsx b/src/components/atoms/buttons/button-link.stories.tsx
new file mode 100644
index 0000000..92b7521
--- /dev/null
+++ b/src/components/atoms/buttons/button-link.stories.tsx
@@ -0,0 +1,95 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ButtonLinkComponent from './button-link';
+
+export default {
+ title: 'Atoms/Buttons',
+ component: ButtonLinkComponent,
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible label.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the button link.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The link kind.',
+ options: ['primary', 'secondary'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'secondary' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ shape: {
+ control: {
+ type: 'select',
+ },
+ description: 'The link shape.',
+ options: ['rectangle', 'square'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'rectangle' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ target: {
+ control: {
+ type: null,
+ },
+ description: 'The link target.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ButtonLinkComponent>;
+
+const Template: ComponentStory<typeof ButtonLinkComponent> = (args) => (
+ <ButtonLinkComponent {...args} />
+);
+
+export const ButtonLink = Template.bind({});
+ButtonLink.args = {
+ children: 'Link',
+ target: '#',
+};
diff --git a/src/components/atoms/buttons/button-link.test.tsx b/src/components/atoms/buttons/button-link.test.tsx
new file mode 100644
index 0000000..52ccdc7
--- /dev/null
+++ b/src/components/atoms/buttons/button-link.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import ButtonLink from './button-link';
+
+describe('ButtonLink', () => {
+ it('renders a ButtonLink component', () => {
+ render(<ButtonLink target="#">Button Link</ButtonLink>);
+ expect(screen.getByRole('link')).toHaveTextContent('Button Link');
+ });
+});
diff --git a/src/components/atoms/buttons/button-link.tsx b/src/components/atoms/buttons/button-link.tsx
new file mode 100644
index 0000000..77a7f7b
--- /dev/null
+++ b/src/components/atoms/buttons/button-link.tsx
@@ -0,0 +1,69 @@
+import Link from 'next/link';
+import { FC } from 'react';
+import styles from './buttons.module.scss';
+
+export type ButtonLinkProps = {
+ /**
+ * ButtonLink accessible label.
+ */
+ 'aria-label'?: string;
+ /**
+ * Set additional classnames to the button link.
+ */
+ className?: string;
+ /**
+ * True if it is an external link. Default: false.
+ */
+ external?: boolean;
+ /**
+ * ButtonLink kind. Default: secondary.
+ */
+ kind?: 'primary' | 'secondary';
+ /**
+ * ButtonLink shape. Default: rectangle.
+ */
+ shape?: 'circle' | 'rectangle' | 'square';
+ /**
+ * Define an URL as target.
+ */
+ target: string;
+};
+
+/**
+ * ButtonLink component
+ *
+ * Use a button-like link as call to action.
+ */
+const ButtonLink: FC<ButtonLinkProps> = ({
+ children,
+ className,
+ target,
+ kind = 'secondary',
+ shape = 'rectangle',
+ external = false,
+ ...props
+}) => {
+ const kindClass = styles[`btn--${kind}`];
+ const shapeClass = styles[`btn--${shape}`];
+
+ return external ? (
+ <a
+ href={target}
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
+ {...props}
+ >
+ {children}
+ </a>
+ ) : (
+ <Link href={target}>
+ <a
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
+ {...props}
+ >
+ {children}
+ </a>
+ </Link>
+ );
+};
+
+export default ButtonLink;
diff --git a/src/components/atoms/buttons/button.stories.tsx b/src/components/atoms/buttons/button.stories.tsx
new file mode 100644
index 0000000..d47a1ea
--- /dev/null
+++ b/src/components/atoms/buttons/button.stories.tsx
@@ -0,0 +1,148 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ButtonComponent from './button';
+
+export default {
+ title: 'Atoms/Buttons',
+ component: ButtonComponent,
+ args: {
+ disabled: false,
+ kind: 'secondary',
+ type: 'button',
+ },
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible label.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The button body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the button wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Render button as disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'Button kind.',
+ options: ['primary', 'secondary', 'tertiary', 'neutral'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'secondary' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ onClick: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle click.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ shape: {
+ control: {
+ type: 'select',
+ },
+ description: 'The link shape.',
+ options: ['circle', 'rectangle', 'square', 'initial'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'rectangle' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ type: {
+ control: {
+ type: 'select',
+ },
+ description: 'Button type attribute.',
+ options: ['button', 'reset', 'submit'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'button' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ButtonComponent>;
+
+const Template: ComponentStory<typeof ButtonComponent> = (args) => {
+ const { children, type, ...props } = args;
+
+ const getBody = () => {
+ if (children) return children;
+
+ switch (type) {
+ case 'reset':
+ return 'Reset';
+ case 'submit':
+ return 'Submit';
+ case 'button':
+ default:
+ return 'Button';
+ }
+ };
+
+ return (
+ <ButtonComponent type={type} {...props}>
+ {getBody()}
+ </ButtonComponent>
+ );
+};
+
+export const Button = Template.bind({});
diff --git a/src/components/atoms/buttons/button.test.tsx b/src/components/atoms/buttons/button.test.tsx
new file mode 100644
index 0000000..57c79c6
--- /dev/null
+++ b/src/components/atoms/buttons/button.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@test-utils';
+import Button from './button';
+
+describe('Button', () => {
+ it('renders the Button component', () => {
+ render(<Button onClick={() => null}>Button</Button>);
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('renders the Button component with disabled state', () => {
+ render(
+ <Button onClick={() => null} disabled={true}>
+ Disabled Button
+ </Button>
+ );
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+});
diff --git a/src/components/atoms/buttons/button.tsx b/src/components/atoms/buttons/button.tsx
new file mode 100644
index 0000000..545c5c5
--- /dev/null
+++ b/src/components/atoms/buttons/button.tsx
@@ -0,0 +1,64 @@
+import { FC, MouseEventHandler } from 'react';
+import styles from './buttons.module.scss';
+
+export type ButtonProps = {
+ /**
+ * Set additional classnames to the button wrapper.
+ */
+ className?: string;
+ /**
+ * Button accessible label.
+ */
+ 'aria-label'?: string;
+ /**
+ * Button state. Default: false.
+ */
+ disabled?: boolean;
+ /**
+ * Button kind. Default: secondary.
+ */
+ kind?: 'primary' | 'secondary' | 'tertiary' | 'neutral';
+ /**
+ * A callback function to handle click.
+ */
+ onClick?: MouseEventHandler<HTMLButtonElement>;
+ /**
+ * Button shape. Default: rectangle.
+ */
+ shape?: 'circle' | 'rectangle' | 'square' | 'initial';
+ /**
+ * Button type attribute. Default: button.
+ */
+ type?: 'button' | 'reset' | 'submit';
+};
+
+/**
+ * Button component
+ *
+ * Use a button as call to action.
+ */
+const Button: FC<ButtonProps> = ({
+ className = '',
+ children,
+ disabled = false,
+ kind = 'secondary',
+ shape = 'rectangle',
+ type = 'button',
+ ...props
+}) => {
+ const kindClass = styles[`btn--${kind}`];
+ const shapeClass = styles[`btn--${shape}`];
+
+ return (
+ <button
+ type={type}
+ disabled={disabled}
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
+ {...props}
+ >
+ {children}
+ </button>
+ );
+};
+
+export default Button;
diff --git a/src/components/atoms/buttons/buttons.module.scss b/src/components/atoms/buttons/buttons.module.scss
new file mode 100644
index 0000000..8e3e196
--- /dev/null
+++ b/src/components/atoms/buttons/buttons.module.scss
@@ -0,0 +1,178 @@
+@use "@styles/abstracts/functions" as fun;
+
+.btn {
+ display: inline-flex;
+ place-content: center;
+ align-items: center;
+ border: none;
+ border-radius: fun.convert-px(5);
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ transition: all 0.3s ease-in-out 0s;
+
+ &--initial {
+ border-radius: 0;
+ }
+
+ &--rectangle {
+ padding: var(--spacing-2xs) var(--spacing-md);
+ }
+
+ &--square,
+ &--circle {
+ padding: var(--spacing-xs);
+ aspect-ratio: 1 / 1;
+ }
+
+ &--circle {
+ border-radius: 50%;
+ }
+
+ &:disabled {
+ cursor: wait;
+ }
+
+ &--neutral {
+ background: inherit;
+ }
+
+ &--primary {
+ background: var(--color-primary);
+ border: fun.convert-px(2) solid var(--color-bg);
+ box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary),
+ 0 0 0 fun.convert-px(3) var(--color-primary-darker),
+ fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(3)
+ var(--color-primary-dark);
+ color: var(--color-fg-inverted);
+ text-decoration: none;
+ text-shadow: fun.convert-px(2) fun.convert-px(2) 0 var(--color-shadow);
+
+ &:disabled {
+ background: var(--color-primary-darker);
+ }
+
+ &:not(:disabled) {
+ &:hover,
+ &:focus {
+ background: var(--color-primary-light);
+ box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary-light),
+ 0 0 0 fun.convert-px(3) var(--color-primary-darker),
+ fun.convert-px(7) fun.convert-px(7) 0 fun.convert-px(2)
+ var(--color-primary-dark);
+ color: var(--color-fg-inverted);
+ transform: translateX(#{fun.convert-px(-4)})
+ translateY(#{fun.convert-px(-4)});
+ }
+
+ &:focus {
+ text-decoration: underline solid var(--color-fg-inverted)
+ fun.convert-px(2);
+ }
+
+ &:active {
+ background: var(--color-primary-dark);
+ box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary),
+ 0 0 0 fun.convert-px(3) var(--color-primary-darker),
+ 0 0 0 0 var(--color-primary-dark);
+ text-decoration: none;
+ transform: translateX(#{fun.convert-px(4)})
+ translateY(#{fun.convert-px(4)});
+ }
+ }
+ }
+
+ &--secondary {
+ background: var(--color-bg);
+ border: fun.convert-px(3) solid var(--color-primary);
+ 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);
+ text-decoration: underline transparent 0;
+
+ &:disabled {
+ border-color: var(--color-border-dark);
+ color: var(--color-fg-light);
+ }
+
+ &:not(:disabled) {
+ &:hover,
+ &:focus {
+ border-color: var(--color-primary-light);
+ color: var(--color-primary-light);
+ 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(7) fun.convert-px(10) fun.convert-px(12)
+ fun.convert-px(-3) var(--color-shadow-light);
+ transform: scale(var(--scale-up, 1.1));
+ }
+
+ &:focus {
+ text-decoration: underline var(--color-primary-light) fun.convert-px(3);
+ }
+
+ &:active {
+ border-color: var(--color-primary-dark);
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ color: var(--color-primary-dark);
+ text-decoration: underline transparent 0;
+ transform: scale(var(--scale-down, 0.94));
+ }
+ }
+ }
+
+ &--tertiary {
+ background: var(--color-bg);
+ border: fun.convert-px(3) solid var(--color-primary);
+ box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg),
+ fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-dark),
+ fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg),
+ fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-dark);
+ color: var(--color-primary);
+
+ &:disabled {
+ color: var(--color-fg-light);
+ border-color: var(--color-border-dark);
+ box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg),
+ fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-darker),
+ fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg),
+ fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-darker);
+ }
+
+ &:not(:disabled) {
+ &:hover,
+ &:focus {
+ border-color: var(--color-primary-light);
+ box-shadow: fun.convert-px(2) fun.convert-px(3) 0 0 var(--color-bg),
+ fun.convert-px(4) fun.convert-px(5) 0 0 var(--color-primary),
+ fun.convert-px(6) fun.convert-px(8) 0 0 var(--color-bg),
+ fun.convert-px(8) fun.convert-px(10) 0 0 var(--color-primary),
+ fun.convert-px(10) fun.convert-px(12) fun.convert-px(1) 0
+ var(--color-shadow-light),
+ fun.convert-px(10) fun.convert-px(12) fun.convert-px(5)
+ fun.convert-px(1) var(--color-shadow-light);
+ color: var(--color-primary-light);
+ transform: translateX(#{fun.convert-px(-3)})
+ translateY(#{fun.convert-px(-5)});
+ }
+
+ &:focus {
+ text-decoration: underline var(--color-primary) fun.convert-px(2);
+ }
+
+ &:active {
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ text-decoration: none;
+ transform: translateX(#{fun.convert-px(5)})
+ translateY(#{fun.convert-px(6)});
+ }
+ }
+ }
+}
diff --git a/src/components/atoms/forms/checkbox.stories.tsx b/src/components/atoms/forms/checkbox.stories.tsx
new file mode 100644
index 0000000..7faf343
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.stories.tsx
@@ -0,0 +1,96 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import CheckboxComponent from './checkbox';
+
+export default {
+ title: 'Atoms/Forms',
+ component: CheckboxComponent,
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the checkbox name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the checkbox.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'The checkbox id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'The checkbox name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle checkbox state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description:
+ 'The checkbox state: either checked (true) or unchecked (false).',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CheckboxComponent>;
+
+const Template: ComponentStory<typeof CheckboxComponent> = ({
+ value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [isChecked, setIsChecked] = useState<boolean>(value);
+
+ return (
+ <CheckboxComponent value={isChecked} setValue={setIsChecked} {...args} />
+ );
+};
+
+export const Checkbox = Template.bind({});
+Checkbox.args = {
+ id: 'storybook-checkbox',
+ name: 'storybook-checkbox',
+ value: false,
+};
diff --git a/src/components/atoms/forms/checkbox.test.tsx b/src/components/atoms/forms/checkbox.test.tsx
new file mode 100644
index 0000000..3b54549
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from '@test-utils';
+import Checkbox from './checkbox';
+
+describe('Checkbox', () => {
+ it('renders an unchecked checkbox', () => {
+ render(
+ <Checkbox
+ id="jest-checkbox"
+ name="jest-checkbox"
+ value={false}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('checkbox')).not.toBeChecked();
+ });
+
+ it('renders a checked checkbox', () => {
+ render(
+ <Checkbox
+ id="jest-checkbox"
+ name="jest-checkbox"
+ value={true}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('checkbox')).toBeChecked();
+ });
+});
diff --git a/src/components/atoms/forms/checkbox.tsx b/src/components/atoms/forms/checkbox.tsx
new file mode 100644
index 0000000..8babcc8
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.tsx
@@ -0,0 +1,46 @@
+import { SetStateAction, VFC } from 'react';
+
+export type CheckboxProps = {
+ /**
+ * One or more ids that refers to the checkbox name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the checkbox.
+ */
+ className?: string;
+ /**
+ * Checkbox id attribute.
+ */
+ id: string;
+ /**
+ * Checkbox name attribute.
+ */
+ name: string;
+ /**
+ * Callback function to set checkbox value.
+ */
+ setValue: (value: SetStateAction<boolean>) => void;
+ /**
+ * Checkbox value.
+ */
+ value: boolean;
+};
+
+/**
+ * Checkbox component
+ *
+ * Render a checkbox type input.
+ */
+const Checkbox: VFC<CheckboxProps> = ({ value, setValue, ...props }) => {
+ return (
+ <input
+ type="checkbox"
+ checked={value}
+ onChange={() => setValue(!value)}
+ {...props}
+ />
+ );
+};
+
+export default Checkbox;
diff --git a/src/components/atoms/forms/field.stories.tsx b/src/components/atoms/forms/field.stories.tsx
new file mode 100644
index 0000000..ec81922
--- /dev/null
+++ b/src/components/atoms/forms/field.stories.tsx
@@ -0,0 +1,201 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import FieldComponent from './field';
+
+export default {
+ title: 'Atoms/Forms',
+ component: FieldComponent,
+ args: {
+ disabled: false,
+ required: false,
+ type: 'text',
+ },
+ 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: 'Add 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,
+ },
+ },
+ 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 FieldComponent>;
+
+const Template: ComponentStory<typeof FieldComponent> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [value, setValue] = useState<string>('');
+
+ return <FieldComponent value={value} setValue={setValue} {...args} />;
+};
+
+export const Field = Template.bind({});
+Field.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+};
diff --git a/src/components/atoms/forms/field.test.tsx b/src/components/atoms/forms/field.test.tsx
new file mode 100644
index 0000000..a04a976
--- /dev/null
+++ b/src/components/atoms/forms/field.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@test-utils';
+import Field from './field';
+
+describe('Field', () => {
+ it('renders a text input', () => {
+ render(
+ <Field
+ id="text-field"
+ name="text-field"
+ type="text"
+ value=""
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
+ });
+
+ it('renders a search input', () => {
+ render(
+ <Field
+ id="search-field"
+ name="search-field"
+ type="search"
+ value=""
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('searchbox')).toHaveAttribute('type', 'search');
+ });
+});
diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx
new file mode 100644
index 0000000..2e75d0f
--- /dev/null
+++ b/src/components/atoms/forms/field.tsx
@@ -0,0 +1,107 @@
+import { ChangeEvent, SetStateAction, VFC } from 'react';
+import styles from './forms.module.scss';
+
+export type FieldType =
+ | 'datetime-local'
+ | 'email'
+ | 'number'
+ | 'search'
+ | 'tel'
+ | 'text'
+ | 'textarea'
+ | 'time'
+ | 'url';
+
+export type FieldProps = {
+ /**
+ * One or more ids that refers to the field name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the field.
+ */
+ className?: string;
+ /**
+ * Field state. Either enabled (false) or disabled (true).
+ */
+ disabled?: boolean;
+ /**
+ * Field id attribute.
+ */
+ id: string;
+ /**
+ * Field maximum value.
+ */
+ max?: number | string;
+ /**
+ * Field minimum value.
+ */
+ min?: number | string;
+ /**
+ * Field name attribute.
+ */
+ name: string;
+ /**
+ * Placeholder value.
+ */
+ placeholder?: string;
+ /**
+ * True if the field is required. Default: false.
+ */
+ required?: boolean;
+ /**
+ * Callback function to set field value.
+ */
+ setValue: (value: SetStateAction<string>) => void;
+ /**
+ * Field incremental values that are valid.
+ */
+ step?: number | string;
+ /**
+ * Field type. Default: text.
+ */
+ type: FieldType;
+ /**
+ * Field value.
+ */
+ value: string;
+};
+
+/**
+ * Field component.
+ *
+ * Render either an input or a textarea.
+ */
+const Field: VFC<FieldProps> = ({
+ className = '',
+ setValue,
+ type,
+ ...props
+}) => {
+ /**
+ * Update select value when an option is selected.
+ * @param e - The option change event.
+ */
+ const updateValue = (
+ e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+ ) => {
+ setValue(e.target.value);
+ };
+
+ return type === 'textarea' ? (
+ <textarea
+ onChange={updateValue}
+ className={`${styles.field} ${styles['field--textarea']} ${className}`}
+ {...props}
+ />
+ ) : (
+ <input
+ type={type}
+ onChange={updateValue}
+ className={`${styles.field} ${className}`}
+ {...props}
+ />
+ );
+};
+
+export default Field;
diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx
new file mode 100644
index 0000000..9cd3c58
--- /dev/null
+++ b/src/components/atoms/forms/form.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Form from './form';
+
+describe('Form', () => {
+ it('renders a form', () => {
+ render(<Form aria-label="Jest form" onSubmit={() => null}></Form>);
+ expect(screen.getByRole('form', { name: 'Jest form' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx
new file mode 100644
index 0000000..8e80930
--- /dev/null
+++ b/src/components/atoms/forms/form.tsx
@@ -0,0 +1,73 @@
+import { Children, FC, FormEvent, Fragment } from 'react';
+import styles from './forms.module.scss';
+
+export type FormProps = {
+ /**
+ * An accessible name.
+ */
+ 'aria-label'?: string;
+ /**
+ * One or more ids that refers to the form name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Wrap each items with a div. Default: true.
+ */
+ grouped?: boolean;
+ /**
+ * A callback function to execute on submit.
+ */
+ onSubmit: () => void;
+};
+
+/**
+ * Form component.
+ *
+ * Render children wrapped in a form element.
+ */
+const Form: FC<FormProps> = ({
+ children,
+ className = '',
+ grouped = true,
+ onSubmit,
+ ...props
+}) => {
+ const arrayChildren = Children.toArray(children);
+
+ /**
+ * Get the form items.
+ * @returns {JSX.Element[]} An array of child elements wrapped in a div.
+ */
+ const getFormItems = (): JSX.Element[] => {
+ return arrayChildren.map((child, index) =>
+ grouped ? (
+ <div key={`item-${index}`} className={styles.item}>
+ {child}
+ </div>
+ ) : (
+ <Fragment key={`item-${index}`}>{child}</Fragment>
+ )
+ );
+ };
+
+ /**
+ * Handle form submit.
+ * @param {FormEvent} e - The form event.
+ */
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ onSubmit();
+ };
+
+ return (
+ <form onSubmit={handleSubmit} className={className} {...props}>
+ {getFormItems()}
+ </form>
+ );
+};
+
+export default Form;
diff --git a/src/components/atoms/forms/forms.module.scss b/src/components/atoms/forms/forms.module.scss
new file mode 100644
index 0000000..19c7aee
--- /dev/null
+++ b/src/components/atoms/forms/forms.module.scss
@@ -0,0 +1,53 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.item {
+ margin: var(--spacing-xs) 0;
+ width: 100%;
+ max-width: 45ch;
+}
+
+.field {
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(2) solid var(--color-border);
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow);
+ transition: all 0.25s linear 0s;
+
+ &--select {
+ cursor: pointer;
+
+ @include mix.pointer("fine") {
+ padding: fun.convert-px(3) var(--spacing-xs);
+ }
+ }
+
+ &--textarea {
+ min-height: fun.convert-px(200);
+ }
+
+ &:disabled {
+ background: var(--color-bg-secondary);
+ border: fun.convert-px(2) solid var(--color-border-light);
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0
+ var(--color-shadow-light);
+ cursor: not-allowed;
+ }
+
+ &:not(:disabled) {
+ &:hover {
+ box-shadow: fun.convert-px(5) fun.convert-px(5) 0 fun.convert-px(1)
+ var(--color-shadow);
+ transform: translate(#{fun.convert-px(-3)}, #{fun.convert-px(-3)});
+ }
+
+ &:focus {
+ background: var(--color-bg);
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ transform: translate(#{fun.convert-px(3)}, #{fun.convert-px(3)});
+ outline: none;
+ transition: all 0.2s ease-in-out 0s, transform 0.3s ease-out 0s;
+ }
+ }
+}
diff --git a/src/components/atoms/forms/label.module.scss b/src/components/atoms/forms/label.module.scss
new file mode 100644
index 0000000..f900925
--- /dev/null
+++ b/src/components/atoms/forms/label.module.scss
@@ -0,0 +1,17 @@
+.label {
+ color: var(--color-primary-darker);
+ font-weight: 600;
+
+ &--small {
+ font-size: var(--font-size-sm);
+ font-variant: small-caps;
+ }
+
+ &--medium {
+ font-size: var(--font-size-md);
+ }
+}
+
+.required {
+ color: var(--color-secondary);
+}
diff --git a/src/components/atoms/forms/label.stories.tsx b/src/components/atoms/forms/label.stories.tsx
new file mode 100644
index 0000000..463e8ac
--- /dev/null
+++ b/src/components/atoms/forms/label.stories.tsx
@@ -0,0 +1,85 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LabelComponent from './label';
+
+export default {
+ title: 'Atoms/Forms',
+ component: LabelComponent,
+ args: {
+ required: false,
+ size: 'small',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the label.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The label body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ htmlFor: {
+ control: {
+ type: 'text',
+ },
+ description: 'The field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Set to true if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ size: {
+ control: {
+ type: 'select',
+ },
+ description: 'The label size.',
+ options: ['medium', 'small'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'small' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof LabelComponent>;
+
+const Template: ComponentStory<typeof LabelComponent> = ({
+ children,
+ ...args
+}) => <LabelComponent {...args}>{children}</LabelComponent>;
+
+export const Label = Template.bind({});
+Label.args = {
+ children: 'A label',
+};
diff --git a/src/components/atoms/forms/label.test.tsx b/src/components/atoms/forms/label.test.tsx
new file mode 100644
index 0000000..14257c3
--- /dev/null
+++ b/src/components/atoms/forms/label.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Label from './label';
+
+describe('Label', () => {
+ it('renders a field label', () => {
+ render(<Label>A label</Label>);
+ expect(screen.getByText('A label')).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx
new file mode 100644
index 0000000..8d57ee2
--- /dev/null
+++ b/src/components/atoms/forms/label.tsx
@@ -0,0 +1,45 @@
+import { FC } from 'react';
+import styles from './label.module.scss';
+
+export type LabelProps = {
+ /**
+ * Add classnames to the label.
+ */
+ className?: string;
+ /**
+ * The field id.
+ */
+ htmlFor?: string;
+ /**
+ * Is the field required? Default: false.
+ */
+ required?: boolean;
+ /**
+ * The label size. Default: small.
+ */
+ size?: 'medium' | 'small';
+};
+
+/**
+ * Label Component
+ *
+ * Render a HTML label element.
+ */
+const Label: FC<LabelProps> = ({
+ children,
+ className = '',
+ required = false,
+ size = 'small',
+ ...props
+}) => {
+ const sizeClass = styles[`label--${size}`];
+
+ return (
+ <label className={`${styles.label} ${sizeClass} ${className}`} {...props}>
+ {children}
+ {required && <span className={styles.required}> *</span>}
+ </label>
+ );
+};
+
+export default Label;
diff --git a/src/components/atoms/forms/select.stories.tsx b/src/components/atoms/forms/select.stories.tsx
new file mode 100644
index 0000000..c2fb8c6
--- /dev/null
+++ b/src/components/atoms/forms/select.stories.tsx
@@ -0,0 +1,145 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import SelectComponent from './select';
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+
+export default {
+ title: 'Atoms/Forms',
+ component: SelectComponent,
+ args: {
+ disabled: false,
+ required: false,
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the select field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the select 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,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ options: {
+ 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,
+ },
+ },
+ 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 SelectComponent>;
+
+const Template: ComponentStory<typeof SelectComponent> = ({
+ value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [selected, setSelected] = useState<string>(value);
+
+ return <SelectComponent value={selected} setValue={setSelected} {...args} />;
+};
+
+export const Select = Template.bind({});
+Select.args = {
+ id: 'storybook-select',
+ name: 'storybook-select',
+ options: selectOptions,
+ value: 'option2',
+};
diff --git a/src/components/atoms/forms/select.test.tsx b/src/components/atoms/forms/select.test.tsx
new file mode 100644
index 0000000..22efb86
--- /dev/null
+++ b/src/components/atoms/forms/select.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@test-utils';
+import Select from './select';
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+const selected = selectOptions[0];
+
+describe('Select', () => {
+ it('should correctly set default option', () => {
+ render(
+ <Select
+ id="jest-select"
+ name="jest-select"
+ options={selectOptions}
+ value={selected.value}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('combobox')).toHaveValue(selected.value);
+ expect(screen.queryByRole('combobox')).not.toHaveValue(
+ selectOptions[1].value
+ );
+ expect(screen.queryByRole('combobox')).not.toHaveValue(
+ selectOptions[2].value
+ );
+ });
+});
diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx
new file mode 100644
index 0000000..25e86e0
--- /dev/null
+++ b/src/components/atoms/forms/select.tsx
@@ -0,0 +1,99 @@
+import { ChangeEvent, SetStateAction, VFC } from 'react';
+import styles from './forms.module.scss';
+
+export type SelectOptions = {
+ /**
+ * The option id.
+ */
+ id: string;
+ /**
+ * The option name.
+ */
+ name: string;
+ /**
+ * The option value.
+ */
+ value: string;
+};
+
+export type SelectProps = {
+ /**
+ * One or more ids that refers to the select field name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the select field.
+ */
+ className?: string;
+ /**
+ * Field state. Either enabled (false) or disabled (true).
+ */
+ disabled?: boolean;
+ /**
+ * Field id attribute.
+ */
+ id: string;
+ /**
+ * Field name attribute.
+ */
+ name: string;
+ /**
+ * True if the field is required. Default: false.
+ */
+ options: SelectOptions[];
+ /**
+ * True if the field is required. Default: false.
+ */
+ required?: boolean;
+ /**
+ * Callback function to set field value.
+ */
+ setValue: (value: SetStateAction<string>) => void;
+ /**
+ * Field value.
+ */
+ value: string;
+};
+
+/**
+ * Select component
+ *
+ * Render a HTML select element.
+ */
+const Select: VFC<SelectProps> = ({
+ className = '',
+ options,
+ setValue,
+ ...props
+}) => {
+ /**
+ * Update select value when an option is selected.
+ * @param e - The option change event.
+ */
+ const updateValue = (e: ChangeEvent<HTMLSelectElement>) => {
+ setValue(e.target.value);
+ };
+
+ /**
+ * Get the option elements.
+ * @returns {JSX.Element[]} An array of HTML option elements.
+ */
+ const getOptions = (): JSX.Element[] =>
+ options.map((option) => (
+ <option key={option.id} value={option.value}>
+ {option.name}
+ </option>
+ ));
+
+ return (
+ <select
+ className={`${styles.field} ${styles['field--select']} ${className}`}
+ onChange={updateValue}
+ {...props}
+ >
+ {getOptions()}
+ </select>
+ );
+};
+
+export default Select;
diff --git a/src/components/atoms/headings/heading.module.scss b/src/components/atoms/headings/heading.module.scss
new file mode 100644
index 0000000..8620f6f
--- /dev/null
+++ b/src/components/atoms/headings/heading.module.scss
@@ -0,0 +1,57 @@
+@use "@styles/abstracts/functions" as fun;
+
+.heading {
+ color: var(--color-primary-dark);
+ font-family: var(--font-family-secondary);
+ letter-spacing: 0.01ex;
+
+ &--regular {
+ margin: 0;
+ }
+
+ &--margin {
+ margin: 0 0 var(--spacing-sm);
+
+ & + & {
+ margin-top: var(--spacing-md);
+ }
+ }
+
+ &--1 {
+ font-size: var(--font-size-3xl);
+ font-weight: 500;
+ }
+
+ &--2 {
+ padding-bottom: fun.convert-px(3);
+ background: linear-gradient(
+ to top,
+ var(--color-primary-dark) 0.3rem,
+ transparent 0.3rem
+ )
+ 0 0 / 3rem 100% no-repeat;
+ font-size: var(--font-size-2xl);
+ font-weight: 500;
+ text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light);
+ }
+
+ &--3 {
+ font-size: var(--font-size-xl);
+ font-weight: 500;
+ }
+
+ &--4 {
+ font-size: var(--font-size-lg);
+ font-weight: 500;
+ }
+
+ &--5 {
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ }
+
+ &--6 {
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ }
+}
diff --git a/src/components/atoms/headings/heading.stories.tsx b/src/components/atoms/headings/heading.stories.tsx
new file mode 100644
index 0000000..66a84dc
--- /dev/null
+++ b/src/components/atoms/headings/heading.stories.tsx
@@ -0,0 +1,82 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HeadingComponent from './heading';
+
+export default {
+ title: 'Atoms/Headings',
+ component: HeadingComponent,
+ args: {
+ isFake: false,
+ withMargin: true,
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ description: 'Heading body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ isFake: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Use an heading element or only its styles.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'select',
+ },
+ description: 'Heading level.',
+ options: [1, 2, 3, 4, 5, 6],
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ withMargin: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Adds margin.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof HeadingComponent>;
+
+const Template: ComponentStory<typeof HeadingComponent> = (args) => (
+ <HeadingComponent {...args} />
+);
+
+export const Heading = Template.bind({});
+Heading.args = {
+ children: 'Your title',
+ level: 1,
+};
diff --git a/src/components/atoms/headings/heading.test.tsx b/src/components/atoms/headings/heading.test.tsx
new file mode 100644
index 0000000..6b6789a
--- /dev/null
+++ b/src/components/atoms/headings/heading.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from '@test-utils';
+import Heading from './heading';
+
+describe('Heading', () => {
+ it('renders a h1', () => {
+ render(<Heading level={1}>Level 1</Heading>);
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ 'Level 1'
+ );
+ });
+
+ it('renders a h2', () => {
+ render(<Heading level={2}>Level 2</Heading>);
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
+ 'Level 2'
+ );
+ });
+
+ it('renders a h3', () => {
+ render(<Heading level={3}>Level 3</Heading>);
+ expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent(
+ 'Level 3'
+ );
+ });
+
+ it('renders a h4', () => {
+ render(<Heading level={4}>Level 4</Heading>);
+ expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent(
+ 'Level 4'
+ );
+ });
+
+ it('renders a h5', () => {
+ render(<Heading level={5}>Level 5</Heading>);
+ expect(screen.getByRole('heading', { level: 5 })).toHaveTextContent(
+ 'Level 5'
+ );
+ });
+
+ it('renders a h6', () => {
+ render(<Heading level={6}>Level 6</Heading>);
+ expect(screen.getByRole('heading', { level: 6 })).toHaveTextContent(
+ 'Level 6'
+ );
+ });
+
+ it('renders a text with heading styles', () => {
+ render(
+ <Heading isFake={true} level={2}>
+ Fake heading
+ </Heading>
+ );
+ expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument();
+ expect(screen.getByText('Fake heading')).toHaveClass('heading');
+ });
+});
diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx
new file mode 100644
index 0000000..4703b5d
--- /dev/null
+++ b/src/components/atoms/headings/heading.tsx
@@ -0,0 +1,56 @@
+import { FC } from 'react';
+import styles from './heading.module.scss';
+
+export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
+
+export type HeadingProps = {
+ /**
+ * Set additional classnames.
+ */
+ className?: string;
+ /**
+ * The heading id.
+ */
+ id?: string;
+ /**
+ * Use an heading element or only its styles. Default: false.
+ */
+ isFake?: boolean;
+ /**
+ * HTML heading level.
+ */
+ level: HeadingLevel;
+ /**
+ * Adds margin. Default: true.
+ */
+ withMargin?: boolean;
+};
+
+/**
+ * Heading component.
+ *
+ * Render an HTML heading element or a paragraph with heading styles.
+ */
+const Heading: FC<HeadingProps> = ({
+ children,
+ className,
+ id,
+ isFake = false,
+ level,
+ withMargin = true,
+}) => {
+ const TitleTag = isFake ? `p` : (`h${level}` as keyof JSX.IntrinsicElements);
+ const levelClass = `heading--${level}`;
+ const marginClass = withMargin ? 'heading--margin' : 'heading--regular';
+
+ return (
+ <TitleTag
+ className={`${styles.heading} ${styles[levelClass]} ${styles[marginClass]} ${className}`}
+ id={id}
+ >
+ {children}
+ </TitleTag>
+ );
+};
+
+export default Heading;
diff --git a/src/components/atoms/icons/arrow.module.scss b/src/components/atoms/icons/arrow.module.scss
new file mode 100644
index 0000000..76e6aea
--- /dev/null
+++ b/src/components/atoms/icons/arrow.module.scss
@@ -0,0 +1,16 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ fill: var(--color-primary);
+ transition: all 0.25s ease-in-out 0s;
+
+ &--left,
+ &--right {
+ width: var(--icon-size, #{fun.convert-px(30)});
+ }
+
+ &--bottom,
+ &--top {
+ height: var(--icon-size, #{fun.convert-px(30)});
+ }
+}
diff --git a/src/components/atoms/icons/arrow.stories.tsx b/src/components/atoms/icons/arrow.stories.tsx
new file mode 100644
index 0000000..96ce1d8
--- /dev/null
+++ b/src/components/atoms/icons/arrow.stories.tsx
@@ -0,0 +1,42 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ArrowIcon from './arrow';
+
+export default {
+ title: 'Atoms/Icons',
+ component: ArrowIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ direction: {
+ control: {
+ type: 'select',
+ },
+ description: 'An arrow icon.',
+ options: ['bottom', 'left', 'right', 'top'],
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ArrowIcon>;
+
+const Template: ComponentStory<typeof ArrowIcon> = (args) => (
+ <ArrowIcon {...args} />
+);
+
+export const Arrow = Template.bind({});
+Arrow.args = {
+ direction: 'right',
+};
diff --git a/src/components/atoms/icons/arrow.test.tsx b/src/components/atoms/icons/arrow.test.tsx
new file mode 100644
index 0000000..502dcc1
--- /dev/null
+++ b/src/components/atoms/icons/arrow.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Arrow from './arrow';
+
+describe('Arrow', () => {
+ it('renders an arrow icon oriented to the right', () => {
+ const { container } = render(<Arrow direction="right" />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/arrow.tsx b/src/components/atoms/icons/arrow.tsx
new file mode 100644
index 0000000..5f3c460
--- /dev/null
+++ b/src/components/atoms/icons/arrow.tsx
@@ -0,0 +1,101 @@
+import { VFC } from 'react';
+import styles from './arrow.module.scss';
+
+export type ArrowDirection = 'top' | 'right' | 'bottom' | 'left';
+
+export type ArrowProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * The arrow direction. Default: right.
+ */
+ direction: ArrowDirection;
+};
+
+/**
+ * Arrow component
+ *
+ * Render a svg arrow icon.
+ */
+const Arrow: VFC<ArrowProps> = ({ className = '', direction }) => {
+ const directionClass = styles[`icon--${direction}`];
+ const classes = `${styles.icon} ${directionClass} ${className}`;
+
+ if (direction === 'top') {
+ return (
+ <svg
+ className={classes}
+ viewBox="0 0 23.476 64.644995"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ className="arrow-head"
+ d="M 23.476001,24.637 11.715001,0 0,24.800001 Z"
+ />
+ <path
+ className="arrow-bar"
+ d="m 15.441001,64.644997 -0.018,-40.007999 H 8.035 l 0.142,40.007999 z"
+ />
+ </svg>
+ );
+ }
+
+ if (direction === 'bottom') {
+ return (
+ <svg
+ className={classes}
+ viewBox="0 0 23.476 64.644995"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ className="arrow-head"
+ d="m 23.476001,40.007997 -11.761,24.637 L 0,39.844996 Z"
+ />
+ <path
+ className="arrow-bar"
+ d="m 15.441001,0 -0.018,40.007999 H 8.035 L 8.177,0 Z"
+ />
+ </svg>
+ );
+ }
+
+ if (direction === 'left') {
+ return (
+ <svg
+ className={classes}
+ viewBox="0 0 64.644997 23.476001"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ className="arrow-head"
+ d="M 24.637,23.476 0,11.715 24.8,-8.3923343e-8 Z"
+ />
+ <path
+ className="arrow-bar"
+ d="m 64.644997,15.441 -40.008,-0.018 V 8.0349999 l 40.008,0.142 z"
+ />
+ </svg>
+ );
+ }
+
+ return (
+ <svg
+ className={classes}
+ viewBox="0 0 64.644997 23.476001"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ className="arrow-head"
+ d="M 40.007997,23.476 64.644997,11.715 39.844997,-8.3923343e-8 Z"
+ />
+ <path
+ className="arrow-bar"
+ d="M 0,15.441 40.008,15.423 V 8.0349999 L 0,8.1769999 Z"
+ />
+ </svg>
+ );
+};
+
+export default Arrow;
diff --git a/src/components/atoms/icons/career.module.scss b/src/components/atoms/icons/career.module.scss
new file mode 100644
index 0000000..c5d65eb
--- /dev/null
+++ b/src/components/atoms/icons/career.module.scss
@@ -0,0 +1,53 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.lock {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
+
+.lines {
+ fill: var(--color-fg);
+ stroke-width: 4;
+}
+
+.seal-top {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 2;
+}
+
+.seal-bottom {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 2;
+}
+
+.diploma {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.top {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.handle {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
+
+.bottom {
+ fill: var(--color-primary);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/career.stories.tsx b/src/components/atoms/icons/career.stories.tsx
new file mode 100644
index 0000000..8575cb9
--- /dev/null
+++ b/src/components/atoms/icons/career.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CareerIcon from './career';
+
+export default {
+ title: 'Atoms/Icons',
+ component: CareerIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CareerIcon>;
+
+const Template: ComponentStory<typeof CareerIcon> = (args) => (
+ <CareerIcon {...args} />
+);
+
+export const Career = Template.bind({});
diff --git a/src/components/atoms/icons/career.test.tsx b/src/components/atoms/icons/career.test.tsx
new file mode 100644
index 0000000..62ffc14
--- /dev/null
+++ b/src/components/atoms/icons/career.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Career from './career';
+
+describe('Career', () => {
+ it('renders a Career icon', () => {
+ const { container } = render(<Career />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/career.tsx b/src/components/atoms/icons/career.tsx
new file mode 100644
index 0000000..28edcc7
--- /dev/null
+++ b/src/components/atoms/icons/career.tsx
@@ -0,0 +1,71 @@
+import { VFC } from 'react';
+import styles from './career.module.scss';
+
+export type CareerProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Career Component
+ *
+ * Render a career svg icon.
+ */
+const Career: VFC<CareerProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.bottom}
+ d="M 0.72670447,19.813041 H 77.467597 v 54.36591 H 0.72670447 Z"
+ />
+ <path
+ className={styles.handle}
+ d="m 22.263958,10.17849 c 12.6493,-1.81512 21.613185,-1.732794 33.666442,0 l 1.683339,10.99517 h -5.891624 v -5.474639 c -7.949741,-2.722434 -16.311959,-2.706359 -25.249837,0 v 5.474639 h -5.891625 z"
+ />
+ <path
+ className={styles.top}
+ d="M 0.72670447,19.813041 H 77.467597 V 51.17622 H 0.72670447 Z"
+ />
+ <path
+ className={styles.diploma}
+ d="M 44.217117,47.159906 H 98.921356 V 82.664122 H 44.217117 Z"
+ />
+ <path
+ className={styles['seal-bottom']}
+ d="m 84.933665,80.775336 h 6.957554 V 90.992144 L 88.412426,87.2244 84.933665,90.992144 Z"
+ />
+ <path
+ className={styles['seal-top']}
+ d="m 93.326919,76.83334 a 4.914472,4.9188584 0 0 1 -4.914493,4.918858 4.914472,4.9188584 0 0 1 -4.914461,-4.918858 4.914472,4.9188584 0 0 1 4.914461,-4.918858 4.914472,4.9188584 0 0 1 4.914493,4.918858 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 54.53557,60.491974 h 34.067282 v 1.515453 H 54.53557 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 54.53557,67.437763 h 34.067282 v 1.515453 H 54.53557 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 54.53557,74.383628 h 17.563315 v 1.515454 H 54.53557 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 63.495911,53.546123 h 16.146628 v 1.515452 H 63.495911 Z"
+ />
+ <path
+ className={styles.lock}
+ d="M 34.048314,42.893007 H 44.145988 V 57.849688 H 34.048314 Z"
+ />
+ </svg>
+ );
+};
+
+export default Career;
diff --git a/src/components/atoms/icons/cc-by-sa.module.scss b/src/components/atoms/icons/cc-by-sa.module.scss
new file mode 100644
index 0000000..e1b2100
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.module.scss
@@ -0,0 +1,7 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(60)});
+ fill: var(--color-fg);
+}
diff --git a/src/components/atoms/icons/cc-by-sa.stories.tsx b/src/components/atoms/icons/cc-by-sa.stories.tsx
new file mode 100644
index 0000000..21d6cd5
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import CCBySAIcon from './cc-by-sa';
+
+export default {
+ title: 'Atoms/Icons',
+ component: CCBySAIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CCBySAIcon>;
+
+const Template: ComponentStory<typeof CCBySAIcon> = (args) => (
+ <IntlProvider locale="en">
+ <CCBySAIcon {...args} />
+ </IntlProvider>
+);
+
+export const CCBySA = Template.bind({});
diff --git a/src/components/atoms/icons/cc-by-sa.test.tsx b/src/components/atoms/icons/cc-by-sa.test.tsx
new file mode 100644
index 0000000..adb03e4
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import CCBySA from './cc-by-sa';
+
+describe('CCBySA', () => {
+ it('renders a CC BY SA icon', () => {
+ render(<CCBySA />);
+ expect(screen.getByTitle('CC BY SA')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/icons/cc-by-sa.tsx b/src/components/atoms/icons/cc-by-sa.tsx
new file mode 100644
index 0000000..552504e
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.tsx
@@ -0,0 +1,45 @@
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './cc-by-sa.module.scss';
+
+export type CCBySAProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * CCBySA component
+ *
+ * Render a CC BY SA svg icon.
+ */
+const CCBySA: VFC<CCBySAProps> = ({ className = '' }) => {
+ const intl = useIntl();
+
+ return (
+ <svg
+ className={`${styles.icon} ${className}`}
+ viewBox="0 0 211.99811 63.999996"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>
+ {intl.formatMessage({
+ defaultMessage: 'CC BY SA',
+ description: 'CCBySA: icon title',
+ id: 'cl7YNU',
+ })}
+ </title>
+ <path d="m 175.53911,15.829498 c 0,-3.008 1.485,-4.514 4.458,-4.514 2.973,0 4.457,1.504 4.457,4.514 0,2.971 -1.486,4.457 -4.457,4.457 -2.971,0 -4.458,-1.486 -4.458,-4.457 z" />
+ <path d="m 188.62611,24.057498 v 13.085 h -3.656 v 15.542 h -9.944 v -15.541 h -3.656 v -13.086 c 0,-0.572 0.2,-1.057 0.599,-1.457 0.401,-0.399 0.887,-0.6 1.457,-0.6 h 13.144 c 0.533,0 1.01,0.2 1.428,0.6 0.417,0.4 0.628,0.886 0.628,1.457 z" />
+ <path d="m 179.94147,-1.9073486e-6 c -8.839,0 -16.34167,3.0848125073486 -22.51367,9.2578125073486 -6.285,6.4000004 -9.42969,13.9811874 -9.42969,22.7421874 0,8.762 3.14469,16.284312 9.42969,22.570312 6.361,6.286 13.86467,9.429688 22.51367,9.429688 8.799,0 16.43611,-3.181922 22.91211,-9.544922 6.096,-5.98 9.14453,-13.464078 9.14453,-22.455078 0,-8.952 -3.10646,-16.532188 -9.31446,-22.7421874 -6.172,-6.172 -13.75418,-9.2578125073486 -22.74218,-9.2578125073486 z M 180.05475,5.7714825 c 7.238,0 13.40967,2.55225 18.51367,7.6562495 5.103,5.106 7.65625,11.294313 7.65625,18.570313 0,7.391 -2.51397,13.50575 -7.54297,18.34375 -5.295,5.221 -11.50591,7.828125 -18.6289,7.828125 -7.162,0 -13.33268,-2.589484 -18.51368,-7.771484 -5.18,-5.178001 -7.76953,-11.310485 -7.76953,-18.396485 0,-7.047 2.60813,-13.238266 7.82813,-18.572265 5.029,-5.1040004 11.18103,-7.6582035 18.45703,-7.6582035 z" />
+ <path d="m 91.998554,27.114498 c 0.609,-3.924 2.189,-6.962 4.742,-9.114 2.552,-2.152 5.655996,-3.228 9.313996,-3.228 5.027,0 9.029,1.62 12,4.856 2.971,3.238 4.457,7.391 4.457,12.457 0,4.915 -1.543,9 -4.627,12.256 -3.088,3.256 -7.086,4.886 -12.002,4.886 -3.619,0 -6.742996,-1.085 -9.370996,-3.257 -2.629,-2.172 -4.209,-5.257 -4.743,-9.257 h 8.059 c 0.189996,3.886 2.532996,5.829 7.028996,5.829 2.246,0 4.057,-0.972 5.428,-2.914 1.373,-1.942 2.059,-4.534 2.059,-7.771 0,-3.391 -0.629,-5.971 -1.885,-7.743 -1.258,-1.771 -3.066,-2.657 -5.43,-2.657 -4.268,0 -6.667,1.885 -7.199996,5.656 h 2.342996 l -6.341996,6.343 -6.343,-6.343 z" />
+ <path d="m 105.94241,-1.8610229e-6 c -8.799996,0 -16.304676,3.1054062610229 -22.513666,9.3164061610229 -6.285,6.3999997 -9.42969,13.9625467 -9.42969,22.6855467 0,8.763 3.14469,16.28336 9.42969,22.568359 6.361,6.286001 13.86467,9.429688 22.513666,9.429688 8.836,0 16.47511,-3.162328 22.91211,-9.486328 6.096,-6.057 9.14453,-13.559672 9.14453,-22.513672 0,-8.952 -3.10646,-16.513547 -9.31446,-22.6855468 -6.211,-6.21 -13.79118,-9.3144530610229 -22.74218,-9.3144530610229 z M 106.05569,5.7714825 c 7.275,0 13.44667,2.5698437 18.51367,7.7148435 5.103,5.028 7.65625,11.200672 7.65625,18.513672 0,7.353 -2.51397,13.46775 -7.54297,18.34375 -5.295,5.219 -11.50591,7.828125 -18.6289,7.828125 -7.161996,0 -13.332676,-2.589484 -18.513676,-7.771484 -5.18,-5.143 -7.76953,-11.275391 -7.76953,-18.400391 0,-7.046 2.60813,-13.217672 7.82813,-18.513672 5.029,-5.1429998 11.18103,-7.7148435 18.457026,-7.7148435 z" />
+ <path d="M 31.942383,5.9265138e-7 C 23.066111,5.9265138e-7 15.579851,3.1065496 9.484666,9.3147376 6.399571,12.400832 4.046856,15.896269 2.427808,19.801386 0.80876,23.706506 0,27.771846 0,32.000976 c 0,4.26713 0.800415,8.32413 2.400463,12.17225 1.600051,3.84811 3.933123,7.30532 7.000216,10.37141 3.067093,3.06609 6.534587,5.40951 10.400708,7.02756 3.867116,1.62105 7.914819,2.4278 12.142946,2.4278 4.22813,0 8.32441,-0.8171 12.28553,-2.45515 3.96313,-1.63805 7.50614,-4.00301 10.62923,-7.0881 3.0081,-2.93309 5.28529,-6.31477 6.82834,-10.14289 1.54104,-3.82712 2.31257,-7.93174 2.31257,-12.31288 0,-4.34313 -0.78277,-8.44771 -2.34382,-12.31483 C 60.094133,15.82003 57.808593,12.380471 54.800503,9.3713796 48.515313,3.1241896 40.893653,5.9265136e-7 31.942383,5.9265138e-7 Z M 32.057623,5.7716626 c 7.23822,0 13.42863,2.571923 18.57478,7.7150794 2.47408,2.478074 4.35948,5.297144 5.65252,8.459244 1.29504,3.16209 1.94342,6.51384 1.94342,10.05694 0,7.35423 -2.49445,13.46816 -7.4846,18.34432 -2.59208,2.51407 -5.49406,4.43661 -8.71316,5.77166 -3.2231,1.33404 -6.54486,1.9981 -9.97296,1.9981 -3.467107,0 -6.782568,-0.65672 -9.943661,-1.97076 -3.164098,-1.31604 -5.999858,-3.21894 -8.513933,-5.71502 -2.515077,-2.49507 -4.447918,-5.33279 -5.800959,-8.51588 -1.354042,-3.1791 -2.029358,-6.48331 -2.029358,-9.91242 0,-3.4671 0.675316,-6.79186 2.029358,-9.97295 1.352043,-3.1811 3.285882,-6.046798 5.800959,-8.599875 4.991151,-5.1041594 11.14337,-7.6584384 18.457594,-7.6584384 z" />
+ <path d="m 50.114533,26.687816 -4.22913,2.22907 c -0.45702,-0.95103 -1.02003,-1.61905 -1.68605,-2.00006 -0.66802,-0.38001 -1.30704,-0.57102 -1.91406,-0.57102 -2.85709,0 -4.28713,1.88506 -4.28713,5.65717 0,1.71406 0.363,3.0841 1.08603,4.11313 0.72302,1.02903 1.78906,1.54405 3.2011,1.54405 1.86506,0 3.1801,-0.91503 3.94112,-2.74309 l 4.00012,2.00007 c -0.87502,1.56304 -2.05706,2.79108 -3.54111,3.68611 -1.48604,0.89602 -3.10509,1.34304 -4.85715,1.34304 -2.89608,0 -5.20915,-0.87503 -6.94121,-2.62908 -1.73605,-1.75205 -2.60207,-4.19013 -2.60207,-7.31323 0,-3.04809 0.88502,-5.46616 2.65808,-7.25722 1.77005,-1.79005 4.00812,-2.68608 6.7132,-2.68608 3.96212,-0.002 6.78321,1.54105 8.45826,4.62714 z" />
+ <path d="m 31.656963,26.687816 -4.287128,2.22907 c -0.458013,-0.95103 -1.019029,-1.61905 -1.685048,-2.00006 -0.667024,-0.38001 -1.286042,-0.57102 -1.858057,-0.57102 -2.856087,0 -4.28613,1.88506 -4.28613,5.65717 0,1.71406 0.362014,3.0841 1.085029,4.11313 0.724025,1.02903 1.791056,1.54405 3.201101,1.54405 1.867057,0 3.181095,-0.91503 3.944118,-2.74309 l 3.942125,2.00007 c -0.83803,1.56304 -2.000065,2.79108 -3.486111,3.68611 -1.484043,0.89602 -3.123093,1.34304 -4.914149,1.34304 -2.857088,0 -5.163158,-0.87503 -6.915212,-2.62908 -1.752053,-1.75205 -2.62808,-4.19013 -2.62808,-7.31323 0,-3.04809 0.886028,-5.46616 2.657081,-7.25722 1.771054,-1.79005 4.009125,-2.68608 6.715205,-2.68608 3.963122,-0.002 6.800209,1.54105 8.515256,4.62714 z" />
+ </svg>
+ );
+};
+
+export default CCBySA;
diff --git a/src/components/atoms/icons/close.module.scss b/src/components/atoms/icons/close.module.scss
new file mode 100644
index 0000000..4a5d18d
--- /dev/null
+++ b/src/components/atoms/icons/close.module.scss
@@ -0,0 +1,12 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.line {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
diff --git a/src/components/atoms/icons/close.stories.tsx b/src/components/atoms/icons/close.stories.tsx
new file mode 100644
index 0000000..b1d88cd
--- /dev/null
+++ b/src/components/atoms/icons/close.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CloseIcon from './close';
+
+export default {
+ title: 'Atoms/Icons',
+ component: CloseIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CloseIcon>;
+
+const Template: ComponentStory<typeof CloseIcon> = (args) => (
+ <CloseIcon {...args} />
+);
+
+export const Close = Template.bind({});
diff --git a/src/components/atoms/icons/close.test.tsx b/src/components/atoms/icons/close.test.tsx
new file mode 100644
index 0000000..0357bec
--- /dev/null
+++ b/src/components/atoms/icons/close.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Close from './close';
+
+describe('Close', () => {
+ it('renders a Close icon', () => {
+ const { container } = render(<Close />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/close.tsx b/src/components/atoms/icons/close.tsx
new file mode 100644
index 0000000..eb9ce7c
--- /dev/null
+++ b/src/components/atoms/icons/close.tsx
@@ -0,0 +1,35 @@
+import { VFC } from 'react';
+import styles from './close.module.scss';
+
+export type CloseProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Close component
+ *
+ * Render a close svg icon.
+ */
+const Close: VFC<CloseProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.line}
+ d="m 3.6465461,3.6465455 c 2.8785908,-2.87859092 7.5134339,-2.87859092 10.3920249,0 L 96.353457,85.96143 c 2.878587,2.878591 2.878587,7.513434 0,10.392025 -2.878597,2.878591 -7.513432,2.878591 -10.392029,0 L 3.6465451,14.038571 C 0.76795421,11.15998 0.76795421,6.5251364 3.6465461,3.6465455 Z"
+ />
+ <path
+ className={styles.line}
+ d="m 96.353453,3.646546 c 2.878592,2.8785909 2.878592,7.513435 0,10.392026 L 14.03857,96.353457 c -2.878589,2.878587 -7.5134337,2.878587 -10.3920246,0 -2.87859084,-2.878597 -2.87858985,-7.513442 -1e-6,-10.392029 L 85.961428,3.646546 c 2.878591,-2.87859097 7.513434,-2.87859097 10.392025,0 z"
+ />
+ </svg>
+ );
+};
+
+export default Close;
diff --git a/src/components/atoms/icons/cog.module.scss b/src/components/atoms/icons/cog.module.scss
new file mode 100644
index 0000000..5201598
--- /dev/null
+++ b/src/components/atoms/icons/cog.module.scss
@@ -0,0 +1,8 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ width: var(--icon-size, #{fun.convert-px(40)});
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/cog.stories.tsx b/src/components/atoms/icons/cog.stories.tsx
new file mode 100644
index 0000000..ee883d8
--- /dev/null
+++ b/src/components/atoms/icons/cog.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CogIcon from './cog';
+
+export default {
+ title: 'Atoms/Icons',
+ component: CogIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CogIcon>;
+
+const Template: ComponentStory<typeof CogIcon> = (args) => (
+ <CogIcon {...args} />
+);
+
+export const Cog = Template.bind({});
diff --git a/src/components/atoms/icons/cog.test.tsx b/src/components/atoms/icons/cog.test.tsx
new file mode 100644
index 0000000..89090fa
--- /dev/null
+++ b/src/components/atoms/icons/cog.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Cog from './cog';
+
+describe('Cog', () => {
+ it('renders a Cog icon', () => {
+ const { container } = render(<Cog />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/cog.tsx b/src/components/atoms/icons/cog.tsx
new file mode 100644
index 0000000..df6d54d
--- /dev/null
+++ b/src/components/atoms/icons/cog.tsx
@@ -0,0 +1,29 @@
+import { VFC } from 'react';
+import styles from './cog.module.scss';
+
+export type CogProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Cog component
+ *
+ * Render a cog svg icon.
+ */
+const Cog: VFC<CogProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path d="m 71.782287,3.1230469 c -1.164356,0 -2.3107,0.076326 -3.435131,0.2227895 L 66.33766,9.1021499 C 64.651951,9.5517047 63.049493,10.204637 61.558109,11.033725 L 56.112383,8.2889128 c -1.970928,1.4609237 -3.730521,3.1910632 -5.22513,5.1351362 l 2.648234,5.494014 c -0.855644,1.477262 -1.537042,3.067161 -2.016082,4.743334 l -5.791433,1.911821 c -0.188001,1.269731 -0.286444,2.568579 -0.286444,3.890587 0,1.164355 0.07633,2.310701 0.222789,3.435131 l 5.756315,2.009497 c 0.449555,1.685708 1.102486,3.288168 1.931575,4.779551 l -2.744813,5.445725 c 1.460924,1.970927 3.191063,3.730521 5.135137,5.22513 l 5.494014,-2.648233 c 1.477261,0.85564 3.067161,1.537039 4.743334,2.016081 L 67.8917,55.51812 c 1.26973,0.188002 2.568578,0.286444 3.890587,0.286444 1.16565,0 2.313889,-0.07601 3.43952,-0.222789 l 2.008399,-5.756314 c 1.684332,-0.449523 3.285984,-1.103103 4.776259,-1.931575 l 5.445725,2.744812 c 1.970928,-1.460924 3.730521,-3.191061 5.22513,-5.135136 l -2.648233,-5.494015 c 0.85564,-1.477262 1.537039,-3.067161 2.016082,-4.743334 l 5.79253,-1.91182 c 0.187995,-1.269731 0.285346,-2.56858 0.285346,-3.890588 0,-1.16565 -0.07601,-2.313889 -0.222789,-3.439521 L 92.143942,24.015886 C 91.694419,22.331554 91.04084,20.729903 90.212367,19.239628 l 2.744812,-5.445726 C 91.496255,11.822973 89.766118,10.063381 87.822043,8.5687715 L 82.328028,11.217006 C 80.850766,10.361361 79.260867,9.6799641 77.584694,9.2009234 L 75.672874,3.4094907 C 74.403143,3.2214898 73.104295,3.1230469 71.782287,3.1230469 Z m 0,15.0520191 a 11.288679,11.288679 0 0 1 11.288739,11.288739 11.288679,11.288679 0 0 1 -11.288739,11.28874 11.288679,11.288679 0 0 1 -11.28874,-11.28874 11.288679,11.288679 0 0 1 11.28874,-11.288739 z" />
+ <path d="m 38.326115,25.84777 c -1.583642,0 -3.142788,0.103807 -4.672127,0.303016 l -2.73312,7.829173 c -2.292736,0.611441 -4.472242,1.499494 -6.500676,2.627139 L 17.01345,32.873874 c -2.680664,1.987004 -5.073889,4.340169 -7.1067095,6.984309 l 3.6018685,7.472418 c -1.163764,2.009226 -2.090533,4.171652 -2.742078,6.451418 l -7.8769382,2.60027 C 2.6338924,58.109252 2.5,59.875819 2.5,61.673885 c 0,1.583642 0.1038125,3.142788 0.3030165,4.672128 l 7.8291725,2.73312 c 0.611441,2.292734 1.499494,4.472243 2.627139,6.500673 L 9.5261037,82.98655 c 1.9870063,2.680661 4.3401703,5.07389 6.9843093,7.106709 l 7.472419,-3.601867 c 2.009226,1.16376 4.171651,2.090533 6.451418,2.742079 l 2.60027,7.876932 C 34.761483,97.366114 36.528049,97.5 38.326115,97.5 c 1.585404,0 3.147126,-0.103373 4.678099,-0.303015 l 2.731628,-7.829178 c 2.290862,-0.611397 4.469272,-1.500329 6.496197,-2.627132 l 7.406741,3.733224 c 2.680664,-1.987007 5.07389,-4.340171 7.10671,-6.984313 l -3.601866,-7.472415 c 1.163756,-2.00923 2.090529,-4.171655 2.742076,-6.45142 l 7.878431,-2.60027 c 0.255691,-1.726964 0.3881,-3.49353 0.3881,-5.291596 0,-1.585404 -0.103373,-3.147127 -0.303016,-4.678099 L 66.020041,54.264159 C 65.408645,51.973296 64.51971,49.794888 63.392903,47.767962 l 3.733224,-7.406742 c -1.987006,-2.680664 -4.340168,-5.073889 -6.984309,-7.10671 l -7.472419,3.601867 c -2.009228,-1.163762 -4.171651,-2.090533 -6.451418,-2.742076 l -2.60027,-7.876939 C 41.890748,25.981661 40.124181,25.84777 38.326115,25.84777 Z m 0,20.472278 A 15.353754,15.353754 0 0 1 53.679952,61.673885 15.353754,15.353754 0 0 1 38.326115,77.027724 15.353754,15.353754 0 0 1 22.972279,61.673885 15.353754,15.353754 0 0 1 38.326115,46.320048 Z" />
+ </svg>
+ );
+};
+
+export default Cog;
diff --git a/src/components/atoms/icons/computer-screen.module.scss b/src/components/atoms/icons/computer-screen.module.scss
new file mode 100644
index 0000000..6c8f701
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.module.scss
@@ -0,0 +1,39 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.root,
+.separator,
+.cursor,
+.line,
+.text {
+ fill: var(--color-fg);
+}
+
+.stand {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-dark);
+
+ &--top {
+ stroke-width: 3;
+ }
+
+ &--bottom {
+ stroke-width: 2;
+ }
+}
+
+.screen {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-dark);
+ stroke-width: 3;
+}
+
+.contour {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-dark);
+ stroke-width: 3;
+}
diff --git a/src/components/atoms/icons/computer-screen.stories.tsx b/src/components/atoms/icons/computer-screen.stories.tsx
new file mode 100644
index 0000000..46e3ad4
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ComputerScreenIcon from './computer-screen';
+
+export default {
+ title: 'Atoms/Icons',
+ component: ComputerScreenIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ComputerScreenIcon>;
+
+const Template: ComponentStory<typeof ComputerScreenIcon> = (args) => (
+ <ComputerScreenIcon {...args} />
+);
+
+export const ComputerScreen = Template.bind({});
diff --git a/src/components/atoms/icons/computer-screen.test.tsx b/src/components/atoms/icons/computer-screen.test.tsx
new file mode 100644
index 0000000..c0e53e0
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import ComputerScreen from './computer-screen';
+
+describe('ComputerScreen', () => {
+ it('renders a computer screen icon', () => {
+ const { container } = render(<ComputerScreen />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/computer-screen.tsx b/src/components/atoms/icons/computer-screen.tsx
new file mode 100644
index 0000000..310836f
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.tsx
@@ -0,0 +1,79 @@
+import { VFC } from 'react';
+import styles from './computer-screen.module.scss';
+
+export type ComputerScreenProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * ComputerScreen component
+ *
+ * Render a computer screen svg icon.
+ */
+const ComputerScreen: VFC<ComputerScreenProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ d="M 1.0206528,11.991149 H 98.979347 V 78.466748 H 1.0206528 Z"
+ className={styles.contour}
+ />
+ <path
+ d="M 6.2503581,18.032451 H 93.563283 V 71.12731 H 6.2503581 Z"
+ className={styles.screen}
+ />
+ <path
+ d="m 40.038268,78.939276 c 4.614714,2.7794 4.333151,10.099225 0,17.60572 H 50 59.961731 c -4.333151,-7.506495 -4.614715,-14.82632 0,-17.60572 H 50 Z"
+ className={`${styles.stand} ${styles['stand--top']}`}
+ />
+ <path
+ d="m 31.084262,96.254656 h 37.831475 c 1.394769,0 2.517635,0.404907 2.517635,0.907864 v 1.179616 c 0,0.502956 -1.122866,0.907864 -2.517635,0.907864 H 31.084262 c -1.394769,0 -2.517635,-0.404908 -2.517635,-0.907864 V 97.16252 c 0,-0.502957 1.122866,-0.907864 2.517635,-0.907864 z"
+ className={`${styles.stand} ${styles['stand--bottom']}`}
+ />
+ <path
+ d="m 13.259277,26.737199 h 29.132596 v 2.567314 H 13.259277 Z"
+ className={styles.line}
+ />
+ <path
+ d="M 13.259277,36.439141 H 36.46805 v 2.567315 H 13.259277 Z"
+ className={styles.line}
+ />
+ <path
+ d="m 13.259277,46.141084 h 26.586812 v 2.567314 H 13.259277 Z"
+ className={styles.line}
+ />
+ <path
+ d="m 18.443194,65.930804 h 4.417548 v 1 h -4.417548 z"
+ className={styles.cursor}
+ />
+ <path
+ d="m 77.586096,42.217577 v -1.680914 l 6.160884,-2.39919 -6.160884,-2.406595 v -1.68832 l 7.604842,2.89532 v 2.38438 z"
+ className={styles.text}
+ />
+ <path
+ d="m 68.396606,43.291289 6.07943,-11.136982 h 1.688318 l -6.049809,11.136982 z"
+ className={styles.text}
+ />
+ <path
+ d="m 59.384832,39.322258 v -2.38438 l 7.604841,-2.89532 v 1.68832 l -6.168289,2.406595 6.168289,2.399191 v 1.680915 z"
+ className={styles.text}
+ />
+ <path
+ d="M 7.1079167,57.876372 H 92.892083 v 0.813634 H 7.1079167 Z"
+ className={styles.separator}
+ />
+ <path
+ d="m 17.042456,64.960616 q 0,0.632276 -0.426175,0.9816 -0.422681,0.345831 -1.254074,0.37727 v 0.611318 h -0.380763 v -0.600838 q -0.751047,-0.02795 -1.170236,-0.352818 -0.419189,-0.328364 -0.551931,-1.002559 l 0.89427,-0.164183 q 0.06637,0.394736 0.261992,0.579878 0.199115,0.181648 0.565905,0.216581 v -1.365857 q -0.01048,-0.007 -0.0524,-0.01398 -0.04192,-0.01048 -0.05589,-0.01048 -0.562412,-0.129244 -0.848857,-0.303907 -0.286445,-0.178155 -0.443642,-0.447135 -0.153701,-0.272472 -0.153701,-0.663715 0,-0.579878 0.394736,-0.894269 0.394736,-0.317886 1.159755,-0.349325 v -0.468093 h 0.380763 v 0.468095 q 0.681183,0.02445 1.047973,0.303911 0.36679,0.275967 0.527479,0.918723 l -0.92222,0.136236 q -0.104797,-0.600837 -0.653236,-0.674195 v 1.22962 l 0.03843,0.007 q 0.101305,0 0.614811,0.167676 0.517,0.167676 0.772007,0.496041 0.255006,0.324871 0.255006,0.817418 z m -2.061012,-2.731715 q -0.639264,0.04891 -0.639264,0.558918 0,0.157196 0.0524,0.2585 0.0524,0.09781 0.157197,0.167676 0.104797,0.06986 0.429668,0.174662 z m 1.152769,2.745688 q 0,-0.174662 -0.06288,-0.282954 -0.06288,-0.111783 -0.185141,-0.181648 -0.118771,-0.06986 -0.523987,-0.185142 v 1.28202 q 0.772006,-0.0524 0.772006,-0.632276 z"
+ className={styles.root}
+ />
+ </svg>
+ );
+};
+
+export default ComputerScreen;
diff --git a/src/components/atoms/icons/envelop.module.scss b/src/components/atoms/icons/envelop.module.scss
new file mode 100644
index 0000000..202900b
--- /dev/null
+++ b/src/components/atoms/icons/envelop.module.scss
@@ -0,0 +1,28 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.envelop {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.lines {
+ fill: var(--color-fg);
+}
+
+.background {
+ fill: var(--color-shadow-dark);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.paper {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/envelop.stories.tsx b/src/components/atoms/icons/envelop.stories.tsx
new file mode 100644
index 0000000..9139b44
--- /dev/null
+++ b/src/components/atoms/icons/envelop.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import EnvelopIcon from './envelop';
+
+export default {
+ title: 'Atoms/Icons',
+ component: EnvelopIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof EnvelopIcon>;
+
+const Template: ComponentStory<typeof EnvelopIcon> = (args) => (
+ <EnvelopIcon {...args} />
+);
+
+export const Envelop = Template.bind({});
diff --git a/src/components/atoms/icons/envelop.test.tsx b/src/components/atoms/icons/envelop.test.tsx
new file mode 100644
index 0000000..072dc85
--- /dev/null
+++ b/src/components/atoms/icons/envelop.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Envelop from './envelop';
+
+describe('Envelop', () => {
+ it('renders an envelop icon', () => {
+ const { container } = render(<Envelop />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/envelop.tsx b/src/components/atoms/icons/envelop.tsx
new file mode 100644
index 0000000..7b50d1d
--- /dev/null
+++ b/src/components/atoms/icons/envelop.tsx
@@ -0,0 +1,67 @@
+import { VFC } from 'react';
+import styles from './envelop.module.scss';
+
+export type EnvelopProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Envelop Component
+ *
+ * Render an envelop svg icon.
+ */
+const Envelop: VFC<EnvelopProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.background}
+ d="M 1.5262527,42.535416 H 98.473747 V 98.371662 H 1.5262527 Z"
+ />
+ <path
+ className={styles.envelop}
+ d="m 49.999985,1.6283075 c 2.855148,0 48.473753,40.8563885 48.473753,40.8563885 H 1.5262359 c 0,0 45.6186001,-40.8563885 48.4737491,-40.8563885 z"
+ />
+ <path
+ className={styles.paper}
+ d="M 8.3434839,28.463842 H 91.656465 V 97.348661 H 8.3434839 Z"
+ />
+ <path
+ className={styles.envelop}
+ d="M 49.999985,63.571925 98.473738,98.371692 H 1.5262359 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 24.562439,37.640923 h 50.875053 v 1.5 H 24.562439 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 24.562439,45.140923 h 50.875053 v 1.5 H 24.562439 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 24.562443,52.640923 h 50.875053 v 1.5 H 24.562443 Z"
+ />
+ <path
+ className={styles.lines}
+ d="M 24.562447,60.140923 H 75.4375 v 1.5 H 24.562447 Z"
+ />
+ <path
+ className={styles.envelop}
+ d="M 39.93749,70.965004 1.5262559,43.55838 v 54.813242 z"
+ />
+ <path
+ className={styles.envelop}
+ d="M 60.0625,70.965004 98.473738,43.55838 v 54.813242 z"
+ />
+ </svg>
+ );
+};
+
+export default Envelop;
diff --git a/src/components/atoms/icons/hamburger.module.scss b/src/components/atoms/icons/hamburger.module.scss
new file mode 100644
index 0000000..4fba4df
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.module.scss
@@ -0,0 +1,42 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ display: flex;
+ align-items: center;
+ width: var(--icon-size, #{fun.convert-px(50)});
+ height: var(--icon-size, #{fun.convert-px(50)});
+ position: relative;
+}
+
+.icon {
+ &,
+ &::before,
+ &::after {
+ display: block;
+ height: fun.convert-px(7);
+ width: 100%;
+ position: absolute;
+ background: var(--color-primary-lighter);
+ background-image: linear-gradient(
+ to right,
+ var(--color-primary-light) 0%,
+ var(--color-primary-lighter) 100%
+ );
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ border-radius: fun.convert-px(3);
+ transition: all 0.25s ease-in-out 0s, transform 0.4s ease-in 0s;
+ }
+
+ &::before,
+ &::after {
+ content: "";
+ }
+
+ &::before {
+ top: fun.convert-px(-15);
+ }
+
+ &::after {
+ bottom: fun.convert-px(-15);
+ }
+}
diff --git a/src/components/atoms/icons/hamburger.stories.tsx b/src/components/atoms/icons/hamburger.stories.tsx
new file mode 100644
index 0000000..c753e69
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.stories.tsx
@@ -0,0 +1,41 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HamburgerIcon from './hamburger';
+
+export default {
+ title: 'Atoms/Icons',
+ component: HamburgerIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the icon wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ iconClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the icon.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof HamburgerIcon>;
+
+const Template: ComponentStory<typeof HamburgerIcon> = (args) => (
+ <HamburgerIcon {...args} />
+);
+
+export const Hamburger = Template.bind({});
diff --git a/src/components/atoms/icons/hamburger.test.tsx b/src/components/atoms/icons/hamburger.test.tsx
new file mode 100644
index 0000000..7173a23
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Hamburger from './hamburger';
+
+describe('Hamburger', () => {
+ it('renders a Hamburger icon', () => {
+ const { container } = render(<Hamburger />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/hamburger.tsx b/src/components/atoms/icons/hamburger.tsx
new file mode 100644
index 0000000..7e7c2c9
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.tsx
@@ -0,0 +1,32 @@
+import { FC } from 'react';
+import styles from './hamburger.module.scss';
+
+type HamburgerProps = {
+ /**
+ * Set additional classnames to the icon wrapper.
+ */
+ className?: string;
+
+ /**
+ * Set additional classnames to the icon.
+ */
+ iconClassName?: string;
+};
+
+/**
+ * Hamburger component
+ *
+ * Render a Hamburger icon.
+ */
+const Hamburger: FC<HamburgerProps> = ({
+ className = '',
+ iconClassName = '',
+}) => {
+ return (
+ <span className={`${styles.wrapper} ${className}`}>
+ <span className={`${styles.icon} ${iconClassName}`}></span>
+ </span>
+ );
+};
+
+export default Hamburger;
diff --git a/src/components/atoms/icons/home.module.scss b/src/components/atoms/icons/home.module.scss
new file mode 100644
index 0000000..48dcc6c
--- /dev/null
+++ b/src/components/atoms/icons/home.module.scss
@@ -0,0 +1,41 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.wall {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.indoor {
+ fill: var(--color-shadow-dark);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.door {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.roof {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.chimney {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.lines {
+ fill: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/home.stories.tsx b/src/components/atoms/icons/home.stories.tsx
new file mode 100644
index 0000000..b1c995c
--- /dev/null
+++ b/src/components/atoms/icons/home.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HomeIcon from './home';
+
+export default {
+ title: 'Atoms/Icons',
+ component: HomeIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof HomeIcon>;
+
+const Template: ComponentStory<typeof HomeIcon> = (args) => (
+ <HomeIcon {...args} />
+);
+
+export const Home = Template.bind({});
diff --git a/src/components/atoms/icons/home.test.tsx b/src/components/atoms/icons/home.test.tsx
new file mode 100644
index 0000000..a08a3cf
--- /dev/null
+++ b/src/components/atoms/icons/home.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Home from './home';
+
+describe('Home', () => {
+ it('renders a home icon', () => {
+ const { container } = render(<Home />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/home.tsx b/src/components/atoms/icons/home.tsx
new file mode 100644
index 0000000..71bbc4a
--- /dev/null
+++ b/src/components/atoms/icons/home.tsx
@@ -0,0 +1,55 @@
+import { VFC } from 'react';
+import styles from './home.module.scss';
+
+export type HomeProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Home component.
+ *
+ * Render a home svg icon.
+ */
+const Home: VFC<HomeProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.wall}
+ d="M 9.2669392,15.413749 H 90.709833 V 97.751815 H 9.2669392 Z"
+ />
+ <path
+ className={styles.indoor}
+ d="m 39.190941,65.836418 h 21.594871 v 31.91539 H 39.190941 Z"
+ />
+ <path
+ className={styles.door}
+ d="m 39.190941,65.836418 h 21.594871 v 31.91539 H 39.190941 Z"
+ />
+ <path
+ className={styles.roof}
+ d="M 4.8219096,11.719266 H 94.720716 l 3.47304,33.365604 H 1.7830046 Z"
+ />
+ <path
+ className={styles.chimney}
+ d="M 70.41848,2.2481852 H 82.957212 V 22.636212 H 70.41848 Z"
+ />
+ <path
+ className={styles.lines}
+ d="M 3.9536645,19.342648 H 61.003053 v 3.293563 H 3.9536645 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 38.973709,32.057171 h 57.049389 v 3.293563 H 38.973709 Z"
+ />
+ </svg>
+ );
+};
+
+export default Home;
diff --git a/src/components/atoms/icons/magnifying-glass.module.scss b/src/components/atoms/icons/magnifying-glass.module.scss
new file mode 100644
index 0000000..d14bec5
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.module.scss
@@ -0,0 +1,29 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.big-handle {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
+
+.glass {
+ fill: var(--color-bg-opacity);
+ stroke: var(--color-primary-darker);
+ stroke-width: 2;
+}
+
+.upright {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
+
+.small-handle {
+ fill: var(--color-primary);
+ stroke: var(--color-primary-darker);
+ stroke-width: 2;
+}
diff --git a/src/components/atoms/icons/magnifying-glass.stories.tsx b/src/components/atoms/icons/magnifying-glass.stories.tsx
new file mode 100644
index 0000000..80e183e
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MagnifyingGlassIcon from './magnifying-glass';
+
+export default {
+ title: 'Atoms/Icons',
+ component: MagnifyingGlassIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof MagnifyingGlassIcon>;
+
+const Template: ComponentStory<typeof MagnifyingGlassIcon> = (args) => (
+ <MagnifyingGlassIcon {...args} />
+);
+
+export const MagnifyingGlass = Template.bind({});
diff --git a/src/components/atoms/icons/magnifying-glass.test.tsx b/src/components/atoms/icons/magnifying-glass.test.tsx
new file mode 100644
index 0000000..8e788f7
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import MagnifyingGlass from './magnifying-glass';
+
+describe('MagnifyingGlass', () => {
+ it('renders a magnifying glass icon', () => {
+ const { container } = render(<MagnifyingGlass />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/magnifying-glass.tsx b/src/components/atoms/icons/magnifying-glass.tsx
new file mode 100644
index 0000000..445ef10
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.tsx
@@ -0,0 +1,43 @@
+import { VFC } from 'react';
+import styles from './magnifying-glass.module.scss';
+
+export type MagnifyingGlassProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * MagnifyingGlass component
+ *
+ * Render a magnifying glass svg icon.
+ */
+const MagnifyingGlass: VFC<MagnifyingGlassProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles['small-handle']}
+ d="m 45.39268,48.064692 5.611922,4.307881 -10.292886,13.414321 -5.611923,-4.307882 z"
+ />
+ <path
+ className={styles.upright}
+ d="M 90.904041,28.730105 A 27.725691,27.730085 0 0 1 63.17835,56.46019 27.725691,27.730085 0 0 1 35.45266,28.730105 27.725691,27.730085 0 0 1 63.17835,1.00002 27.725691,27.730085 0 0 1 90.904041,28.730105 Z"
+ />
+ <path
+ className={styles.glass}
+ d="M 82.438984,28.730105 A 19.260633,19.263685 0 0 1 63.17835,47.99379 19.260633,19.263685 0 0 1 43.917716,28.730105 19.260633,19.263685 0 0 1 63.17835,9.4664203 19.260633,19.263685 0 0 1 82.438984,28.730105 Z"
+ />
+ <path
+ className={styles['big-handle']}
+ d="m 35.826055,60.434903 5.75193,4.415356 c 0.998045,0.766128 1.184879,2.186554 0.418913,3.184809 L 18.914717,98.117182 c -0.765969,0.998256 -2.186094,1.185131 -3.18414,0.418997 L 9.9786472,94.120827 C 8.9806032,93.354698 8.7937692,91.934273 9.5597392,90.936014 L 32.641919,60.853903 c 0.765967,-0.998254 2.186091,-1.185129 3.184136,-0.419 z"
+ />
+ </svg>
+ );
+};
+
+export default MagnifyingGlass;
diff --git a/src/components/atoms/icons/moon.module.scss b/src/components/atoms/icons/moon.module.scss
new file mode 100644
index 0000000..e0b53d6
--- /dev/null
+++ b/src/components/atoms/icons/moon.module.scss
@@ -0,0 +1,8 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+ width: var(--icon-size, #{fun.convert-px(25)});
+}
diff --git a/src/components/atoms/icons/moon.stories.tsx b/src/components/atoms/icons/moon.stories.tsx
new file mode 100644
index 0000000..4d2fb9a
--- /dev/null
+++ b/src/components/atoms/icons/moon.stories.tsx
@@ -0,0 +1,41 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MoonIcon from './moon';
+
+export default {
+ title: 'Atoms/Icons',
+ component: MoonIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The SVG title.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof MoonIcon>;
+
+const Template: ComponentStory<typeof MoonIcon> = (args) => (
+ <MoonIcon {...args} />
+);
+
+export const Moon = Template.bind({});
diff --git a/src/components/atoms/icons/moon.test.tsx b/src/components/atoms/icons/moon.test.tsx
new file mode 100644
index 0000000..1c96303
--- /dev/null
+++ b/src/components/atoms/icons/moon.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Moon from './moon';
+
+describe('Moon', () => {
+ it('renders a moon icon', () => {
+ const { container } = render(<Moon />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/moon.tsx b/src/components/atoms/icons/moon.tsx
new file mode 100644
index 0000000..4f52319
--- /dev/null
+++ b/src/components/atoms/icons/moon.tsx
@@ -0,0 +1,28 @@
+import { VFC } from 'react';
+import styles from './moon.module.scss';
+
+type MoonProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * The SVG title.
+ */
+ title?: string;
+};
+
+const Moon: VFC<MoonProps> = ({ className = '', title }) => {
+ return (
+ <svg
+ className={`${styles.icon} ${className}`}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ {title !== 'undefined' && <title>{title}</title>}
+ <path d="M 51.077315,1.9893942 A 43.319985,43.319985 0 0 1 72.840039,39.563145 43.319985,43.319985 0 0 1 29.520053,82.88313 43.319985,43.319985 0 0 1 5.4309911,75.569042 48.132997,48.132997 0 0 0 46.126047,98 48.132997,48.132997 0 0 0 94.260004,49.867002 48.132997,48.132997 0 0 0 51.077315,1.9893942 Z" />
+ </svg>
+ );
+};
+
+export default Moon;
diff --git a/src/components/atoms/icons/plus-minus.module.scss b/src/components/atoms/icons/plus-minus.module.scss
new file mode 100644
index 0000000..c54db33
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.module.scss
@@ -0,0 +1,39 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: flex;
+ place-content: center;
+ place-items: center;
+ width: var(--icon-size, #{fun.convert-px(30)});
+ height: var(--icon-size, #{fun.convert-px(30)});
+ position: relative;
+ background: var(--color-bg);
+ border: fun.convert-px(1) solid var(--color-primary);
+ border-radius: fun.convert-px(3);
+ color: var(--color-primary);
+
+ &::before,
+ &::after {
+ content: "";
+ position: absolute;
+ background: var(--color-primary);
+ transition: transform 0.4s ease-out 0s;
+ }
+
+ &::after {
+ height: fun.convert-px(3);
+ width: 60%;
+ }
+
+ &::before {
+ height: 60%;
+ width: fun.convert-px(3);
+ transform: scaleY(1);
+ }
+
+ &--minus {
+ &::before {
+ transform: scaleY(0);
+ }
+ }
+}
diff --git a/src/components/atoms/icons/plus-minus.stories.tsx b/src/components/atoms/icons/plus-minus.stories.tsx
new file mode 100644
index 0000000..ffa28f2
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.stories.tsx
@@ -0,0 +1,43 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PlusMinusIcon from './plus-minus';
+
+export default {
+ title: 'Atoms/Icons',
+ component: PlusMinusIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ state: {
+ control: {
+ type: 'radio',
+ options: ['plus', 'minus'],
+ },
+ description: 'Which state should be displayed.',
+ type: {
+ name: 'enum',
+ required: true,
+ value: ['plus', 'minus'],
+ },
+ },
+ },
+} as ComponentMeta<typeof PlusMinusIcon>;
+
+const Template: ComponentStory<typeof PlusMinusIcon> = (args) => (
+ <PlusMinusIcon {...args} />
+);
+
+export const PlusMinus = Template.bind({});
+PlusMinus.args = {
+ state: 'plus',
+};
diff --git a/src/components/atoms/icons/plus-minus.test.tsx b/src/components/atoms/icons/plus-minus.test.tsx
new file mode 100644
index 0000000..6903c7a
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import PlusMinus from './plus-minus';
+
+describe('PlusMinus', () => {
+ it('renders a plus/minus icon', () => {
+ const { container } = render(<PlusMinus state="plus" />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/plus-minus.tsx b/src/components/atoms/icons/plus-minus.tsx
new file mode 100644
index 0000000..78aa14a
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.tsx
@@ -0,0 +1,31 @@
+import { FC } from 'react';
+import styles from './plus-minus.module.scss';
+
+type PlusMinusProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * Which state should be displayed.
+ */
+ state: 'plus' | 'minus';
+};
+
+/**
+ * PlusMinus component
+ *
+ * Render a plus or a minus icon.
+ */
+const PlusMinus: FC<PlusMinusProps> = ({ className, state }) => {
+ const stateClass = `icon--${state}`;
+
+ return (
+ <div
+ className={`${styles.icon} ${styles[stateClass]} ${className}`}
+ aria-hidden={true}
+ ></div>
+ );
+};
+
+export default PlusMinus;
diff --git a/src/components/atoms/icons/posts-stack.module.scss b/src/components/atoms/icons/posts-stack.module.scss
new file mode 100644
index 0000000..a22d265
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.module.scss
@@ -0,0 +1,22 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.lines {
+ fill: var(--color-fg);
+ stroke-width: 4;
+}
+
+.picture {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+}
+
+.background {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/posts-stack.stories.tsx b/src/components/atoms/icons/posts-stack.stories.tsx
new file mode 100644
index 0000000..46bb39f
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PostsStackIcon from './posts-stack';
+
+export default {
+ title: 'Atoms/Icons',
+ component: PostsStackIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof PostsStackIcon>;
+
+const Template: ComponentStory<typeof PostsStackIcon> = (args) => (
+ <PostsStackIcon {...args} />
+);
+
+export const PostsStack = Template.bind({});
diff --git a/src/components/atoms/icons/posts-stack.test.tsx b/src/components/atoms/icons/posts-stack.test.tsx
new file mode 100644
index 0000000..8f44fa9
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import PostsStack from './posts-stack';
+
+describe('PostsStack', () => {
+ it('renders a posts stack icon', () => {
+ const { container } = render(<PostsStack />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/posts-stack.tsx b/src/components/atoms/icons/posts-stack.tsx
new file mode 100644
index 0000000..1998d25
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.tsx
@@ -0,0 +1,75 @@
+import { VFC } from 'react';
+import styles from './posts-stack.module.scss';
+
+export type PostsStackProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Posts stack component.
+ *
+ * Render a posts stack svg icon.
+ */
+const PostsStack: VFC<PostsStackProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.background}
+ d="M 28.992096,1.4822128 H 90.770752 V 82.312253 H 28.992096 Z"
+ />
+ <path
+ className={styles.background}
+ d="m 19.110672,8.992094 h 61.778656 v 80.83004 H 19.110672 Z"
+ />
+ <path
+ className={styles.background}
+ d="m 9.229248,17.687748 h 61.778656 v 80.83004 H 9.229248 Z"
+ />
+ <path
+ className={styles.picture}
+ d="M 18.149242,74.65544 H 33.375246 V 90.194215 H 18.149242 Z"
+ />
+ <path
+ className={styles.picture}
+ d="M 18.142653,24.858688 H 62.094499 V 35.908926 H 18.142653 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 17.618576,41.908926 h 45 v 2 h -45 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 17.618576,49.908926 h 45 v 2 h -45 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 17.618576,57.908926 h 45 v 2 h -45 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 17.618576,65.908926 h 45 v 2 h -45 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 41.833105,73.424828 h 20.785471 v 2 H 41.833105 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 41.833105,81.424828 h 20.785471 v 2 H 41.833105 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 41.833105,89.424828 h 20.785471 v 2 H 41.833105 Z"
+ />
+ </svg>
+ );
+};
+
+export default PostsStack;
diff --git a/src/components/atoms/icons/sun.module.scss b/src/components/atoms/icons/sun.module.scss
new file mode 100644
index 0000000..5682aa3
--- /dev/null
+++ b/src/components/atoms/icons/sun.module.scss
@@ -0,0 +1,8 @@
+@use "@styles/abstracts/functions" as fun;
+
+.sun {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+ width: var(--icon-size, #{fun.convert-px(25)});
+}
diff --git a/src/components/atoms/icons/sun.stories.tsx b/src/components/atoms/icons/sun.stories.tsx
new file mode 100644
index 0000000..23c5b27
--- /dev/null
+++ b/src/components/atoms/icons/sun.stories.tsx
@@ -0,0 +1,41 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SunIcon from './sun';
+
+export default {
+ title: 'Atoms/Icons',
+ component: SunIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The SVG title.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SunIcon>;
+
+const Template: ComponentStory<typeof SunIcon> = (args) => (
+ <SunIcon {...args} />
+);
+
+export const Sun = Template.bind({});
diff --git a/src/components/atoms/icons/sun.test.tsx b/src/components/atoms/icons/sun.test.tsx
new file mode 100644
index 0000000..21661a9
--- /dev/null
+++ b/src/components/atoms/icons/sun.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Sun from './sun';
+
+describe('Sun', () => {
+ it('renders a sun icon', () => {
+ const { container } = render(<Sun />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/sun.tsx b/src/components/atoms/icons/sun.tsx
new file mode 100644
index 0000000..fa9d922
--- /dev/null
+++ b/src/components/atoms/icons/sun.tsx
@@ -0,0 +1,33 @@
+import { VFC } from 'react';
+import styles from './sun.module.scss';
+
+type SunProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * The SVG title.
+ */
+ title?: string;
+};
+
+/**
+ * Sun component.
+ *
+ * Render a svg sun icon.
+ */
+const Sun: VFC<SunProps> = ({ className = '', title }) => {
+ return (
+ <svg
+ className={`${styles.sun} ${className}`}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ {title !== 'undefined' && <title>{title}</title>}
+ <path d="M 69.398043,50.000437 A 19.399259,19.399204 0 0 1 49.998784,69.399641 19.399259,19.399204 0 0 1 30.599525,50.000437 19.399259,19.399204 0 0 1 49.998784,30.601234 19.399259,19.399204 0 0 1 69.398043,50.000437 Z m 27.699233,1.125154 c 2.657696,0.0679 1.156196,12.061455 -1.435545,11.463959 L 80.113224,59.000697 c -2.589801,-0.597494 -1.625657,-8.345536 1.032041,-8.278609 z m -18.06653,37.251321 c 1.644087,2.091234 -9.030355,8.610337 -10.126414,6.188346 L 62.331863,80.024585 c -1.096058,-2.423931 5.197062,-6.285342 6.839209,-4.194107 z M 38.611418,97.594444 C 38.02653,100.18909 26.24148,95.916413 27.436475,93.54001 l 7.168026,-14.256474 c 1.194024,-2.376403 8.102101,0.151313 7.517214,2.744986 z M 6.1661563,71.834242 C 3.7916868,73.028262 -0.25499873,61.16274 2.3386824,60.577853 L 17.905618,57.067567 c 2.593681,-0.584886 4.894434,6.403678 2.518995,7.598668 z M 6.146757,30.055146 c -2.3764094,-1.194991 4.46571,-11.714209 6.479353,-9.97798 l 12.090589,10.414462 c 2.014613,1.736229 -1.937017,7.926514 -4.314396,6.731524 z M 38.56777,4.2639045 C 37.982883,1.6682911 50.480855,0.41801247 50.415868,3.0766733 L 50.020123,19.028638 c -0.06596,2.657691 -7.357169,3.394862 -7.943027,0.800218 z m 40.403808,9.1622435 c 1.635357,-2.098023 10.437771,6.872168 8.339742,8.506552 l -12.58818,9.805327 c -2.099,1.634383 -7.192276,-3.626682 -5.557888,-5.724706 z M 97.096306,50.69105 c 2.657696,-0.06596 1.164926,12.462047 -1.425846,11.863582 L 80.122924,58.96578 c -2.590771,-0.597496 -1.636327,-7.814 1.021371,-7.879957 z" />
+ </svg>
+ );
+};
+
+export default Sun;
diff --git a/src/components/atoms/images/logo.module.scss b/src/components/atoms/images/logo.module.scss
new file mode 100644
index 0000000..f802a4b
--- /dev/null
+++ b/src/components/atoms/images/logo.module.scss
@@ -0,0 +1,28 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ width: var(--logo-size, fun.convert-px(100));
+ height: var(--logo-size, fun.convert-px(100));
+ max-width: 100%;
+ max-height: 100%;
+}
+
+.bg-left {
+ fill: var(--color-primary-light);
+}
+
+.bg-right {
+ fill: var(--color-primary-dark);
+}
+
+.letter {
+ fill: var(--color-fg-inverted);
+ stroke: var(--color-primary-darker);
+ stroke-width: 5;
+}
+
+.letter-shadow {
+ fill: var(--color-shadow-darker);
+ stroke: var(--color-shadow-darker);
+ stroke-width: 5;
+}
diff --git a/src/components/atoms/images/logo.stories.tsx b/src/components/atoms/images/logo.stories.tsx
new file mode 100644
index 0000000..fbc7501
--- /dev/null
+++ b/src/components/atoms/images/logo.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LogoComponent from './logo';
+
+export default {
+ title: 'Atoms/Images',
+ component: LogoComponent,
+ argTypes: {
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The SVG title.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof LogoComponent>;
+
+const Template: ComponentStory<typeof LogoComponent> = (args) => (
+ <LogoComponent {...args} />
+);
+
+export const Logo = Template.bind({});
diff --git a/src/components/atoms/images/logo.test.tsx b/src/components/atoms/images/logo.test.tsx
new file mode 100644
index 0000000..3e0d238
--- /dev/null
+++ b/src/components/atoms/images/logo.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Logo from './logo';
+
+describe('Logo', () => {
+ it('renders a logo with a title', () => {
+ render(<Logo title="My title" />);
+ expect(screen.getByTitle('My title')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/images/logo.tsx b/src/components/atoms/images/logo.tsx
new file mode 100644
index 0000000..2e52110
--- /dev/null
+++ b/src/components/atoms/images/logo.tsx
@@ -0,0 +1,46 @@
+import { VFC } from 'react';
+import styles from './logo.module.scss';
+
+type LogoProps = {
+ /**
+ * SVG Image title.
+ */
+ title?: string;
+};
+
+/**
+ * Logo component.
+ *
+ * Render a SVG logo.
+ */
+const Logo: VFC<LogoProps> = ({ title }) => {
+ return (
+ <svg
+ viewBox="0 0 512 512"
+ xmlns="http://www.w3.org/2000/svg"
+ className={styles.wrapper}
+ >
+ {title && <title>{title}</title>}
+ <path className={styles['bg-left']} d="M 0,0 H 506 L 0,506 Z" />
+ <path className={styles['bg-right']} d="M 512,512 H 6 L 512,6 Z" />
+ <path
+ className={styles['letter-shadow']}
+ d="m 66.049088,353.26557 h 57.233082 l 15.4763,-40.00476 h 56.64908 l 15.76831,40.00476 h 57.2331 L 196.28357,165.21398 h -58.10911 z m 80.009522,-79.42552 21.02441,-55.18904 21.02439,55.18904 z"
+ />
+ <path
+ className={styles['letter']}
+ d="m 59.569539,346.78602 h 57.233081 l 15.4763,-40.00476 H 188.928 l 15.76831,40.00476 h 57.2331 L 189.80402,158.73443 h -58.10911 z m 80.009521,-79.42552 21.02441,-55.18904 21.02439,55.18904 z"
+ />
+ <path
+ className={styles['letter-shadow']}
+ d="m 288.84935,353.26557 h 54.89704 v -50.51696 h 40.88078 c 42.04881,0 68.91332,-28.61654 68.91332,-68.32931 0,-38.5447 -21.60841,-69.20532 -67.74528,-69.20532 h -96.94586 z m 54.89704,-92.56578 v -53.437 h 29.78458 c 16.35231,0 23.94446,10.51221 23.94446,27.15651 0,15.47629 -8.46817,26.28049 -25.40449,26.28049 z"
+ />
+ <path
+ className={styles['letter']}
+ d="m 282.3698,346.78602 h 54.89704 v -50.51696 h 40.88078 c 42.04881,0 68.91332,-28.61654 68.91332,-68.3293 0,-38.54471 -21.60841,-69.20533 -67.74528,-69.20533 H 282.3698 Z m 54.89704,-92.56578 v -53.437 h 29.78458 c 16.35231,0 23.94446,10.51221 23.94446,27.15652 0,15.47628 -8.46817,26.28048 -25.40449,26.28048 z"
+ />
+ </svg>
+ );
+};
+
+export default Logo;
diff --git a/src/components/atoms/layout/copyright.module.scss b/src/components/atoms/layout/copyright.module.scss
new file mode 100644
index 0000000..a0e5347
--- /dev/null
+++ b/src/components/atoms/layout/copyright.module.scss
@@ -0,0 +1,32 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ --icon-size: #{fun.convert-px(70)};
+
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ place-content: center;
+ gap: var(--spacing-2xs);
+ margin: 0;
+ font-family: var(--font-family-secondary);
+ font-size: var(--font-size-md);
+ text-align: center;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ text-align: left;
+ }
+ }
+}
+
+.owner {
+ flex: 1 0 100%;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ flex: initial;
+ }
+ }
+}
diff --git a/src/components/atoms/layout/copyright.stories.tsx b/src/components/atoms/layout/copyright.stories.tsx
new file mode 100644
index 0000000..3b315fa
--- /dev/null
+++ b/src/components/atoms/layout/copyright.stories.tsx
@@ -0,0 +1,55 @@
+import CCBySA from '@components/atoms/icons/cc-by-sa';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import CopyrightComponent from './copyright';
+
+export default {
+ title: 'Atoms/Layout',
+ component: CopyrightComponent,
+ argTypes: {
+ dates: {
+ description: 'The copyright dates.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ icon: {
+ control: {
+ type: null,
+ },
+ description: 'The copyright icon.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ owner: {
+ control: {
+ type: 'text',
+ },
+ description: 'The copyright owner',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CopyrightComponent>;
+
+const Template: ComponentStory<typeof CopyrightComponent> = (args) => (
+ <IntlProvider locale="en">
+ <CopyrightComponent {...args} />
+ </IntlProvider>
+);
+
+export const Copyright = Template.bind({});
+Copyright.args = {
+ dates: {
+ start: '2012',
+ end: '2022',
+ },
+ icon: <CCBySA />,
+ owner: 'Your name',
+};
diff --git a/src/components/atoms/layout/copyright.test.tsx b/src/components/atoms/layout/copyright.test.tsx
new file mode 100644
index 0000000..6bfe612
--- /dev/null
+++ b/src/components/atoms/layout/copyright.test.tsx
@@ -0,0 +1,32 @@
+import CCBySA from '@components/atoms/icons/cc-by-sa';
+import { render, screen } from '@test-utils';
+import Copyright from './copyright';
+
+const dates = {
+ start: '2012',
+ end: '2022',
+};
+const icon = <CCBySA />;
+const owner = 'Your name';
+
+describe('Copyright', () => {
+ it('renders the copyright owner', () => {
+ render(<Copyright dates={dates} icon={icon} owner={owner} />);
+ expect(screen.getByText(owner)).toBeInTheDocument();
+ });
+
+ it('renders the copyright start date', () => {
+ render(<Copyright dates={dates} icon={icon} owner={owner} />);
+ expect(screen.getByText(dates.start)).toBeInTheDocument();
+ });
+
+ it('renders the copyright end date', () => {
+ render(<Copyright dates={dates} icon={icon} owner={owner} />);
+ expect(screen.getByText(dates.end)).toBeInTheDocument();
+ });
+
+ it('renders the copyright icon', () => {
+ render(<Copyright dates={dates} icon={icon} owner={owner} />);
+ expect(screen.getByTitle('CC BY SA')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/copyright.tsx b/src/components/atoms/layout/copyright.tsx
new file mode 100644
index 0000000..76252ee
--- /dev/null
+++ b/src/components/atoms/layout/copyright.tsx
@@ -0,0 +1,59 @@
+import { ReactNode, VFC } from 'react';
+import styles from './copyright.module.scss';
+
+export type CopyrightDates = {
+ /**
+ * The copyright start year.
+ */
+ start: string;
+ /**
+ * The copyright end year.
+ */
+ end?: string;
+};
+
+export type CopyrightProps = {
+ /**
+ * The copyright owner.
+ */
+ owner: string;
+ /**
+ * The copyright dates.
+ */
+ dates: CopyrightDates;
+ /**
+ * The copyright icon.
+ */
+ icon: ReactNode;
+};
+
+/**
+ * Copyright component
+ *
+ * Renders a copyright information (owner, dates, license icon).
+ */
+const Copyright: VFC<CopyrightProps> = ({ owner, dates, icon }) => {
+ const getFormattedDate = (date: string) => {
+ const datetime = new Date(date).toISOString();
+
+ return <time dateTime={datetime}>{date}</time>;
+ };
+
+ return (
+ <div className={styles.wrapper}>
+ <span className={styles.owner}>{owner}</span>
+ {icon}
+ {getFormattedDate(dates.start)}
+ {dates.end ? (
+ <>
+ <span>-</span>
+ {getFormattedDate(dates.end)}
+ </>
+ ) : (
+ ''
+ )}
+ </div>
+ );
+};
+
+export default Copyright;
diff --git a/src/components/atoms/layout/main.stories.tsx b/src/components/atoms/layout/main.stories.tsx
new file mode 100644
index 0000000..64df890
--- /dev/null
+++ b/src/components/atoms/layout/main.stories.tsx
@@ -0,0 +1,52 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MainComponent from './main';
+
+export default {
+ title: 'Atoms/Layout',
+ component: MainComponent,
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The content.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the main element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'The main element id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MainComponent>;
+
+const Template: ComponentStory<typeof MainComponent> = (args) => (
+ <MainComponent {...args} />
+);
+
+export const Main = Template.bind({});
+Main.args = {
+ children: 'The main content.',
+ id: '#main',
+};
diff --git a/src/components/atoms/layout/main.test.tsx b/src/components/atoms/layout/main.test.tsx
new file mode 100644
index 0000000..f91846f
--- /dev/null
+++ b/src/components/atoms/layout/main.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@test-utils';
+import Main from './main';
+
+const id = 'main';
+const children = 'The main content.';
+
+describe('Main', () => {
+ it('renders the content of main element', () => {
+ render(<Main id={id}>{children}</Main>);
+ expect(screen.getByRole('main')).toHaveTextContent(children);
+ });
+});
diff --git a/src/components/atoms/layout/main.tsx b/src/components/atoms/layout/main.tsx
new file mode 100644
index 0000000..4549328
--- /dev/null
+++ b/src/components/atoms/layout/main.tsx
@@ -0,0 +1,23 @@
+import { FC } from 'react';
+
+export type MainProps = {
+ /**
+ * Set additional classnames to the main element.
+ */
+ className?: string;
+ /**
+ * The main wrapper id.
+ */
+ id: string;
+};
+
+/**
+ * Main component
+ *
+ * Render a main element.
+ */
+const Main: FC<MainProps> = ({ children, ...props }) => {
+ return <main {...props}>{children}</main>;
+};
+
+export default Main;
diff --git a/src/components/atoms/layout/no-script.module.scss b/src/components/atoms/layout/no-script.module.scss
new file mode 100644
index 0000000..d8712af
--- /dev/null
+++ b/src/components/atoms/layout/no-script.module.scss
@@ -0,0 +1,19 @@
+@use "@styles/abstracts/functions" as fun;
+
+.noscript {
+ color: var(--color-primary-darker);
+
+ &--top {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 10;
+ background: var(--color-bg);
+ border-bottom: fun.convert-px(3) solid var(--color-border);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ text-align: center;
+ }
+}
diff --git a/src/components/atoms/layout/no-script.stories.tsx b/src/components/atoms/layout/no-script.stories.tsx
new file mode 100644
index 0000000..474e2fb
--- /dev/null
+++ b/src/components/atoms/layout/no-script.stories.tsx
@@ -0,0 +1,46 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoScriptComponent from './no-script';
+
+export default {
+ title: 'Atoms/Layout',
+ component: NoScriptComponent,
+ args: {
+ position: 'initial',
+ },
+ argTypes: {
+ message: {
+ control: {
+ type: 'text',
+ },
+ description: 'A message to display when Javascript is disabled.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ position: {
+ control: {
+ type: 'select',
+ },
+ description: 'The message position.',
+ options: ['initial', 'top'],
+ table: {
+ category: 'Options',
+ defaultValue: 'initial',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof NoScriptComponent>;
+
+const Template: ComponentStory<typeof NoScriptComponent> = (args) => (
+ <NoScriptComponent {...args} />
+);
+
+export const NoScript = Template.bind({});
+NoScript.args = {
+ message: 'A noscript only message.',
+};
diff --git a/src/components/atoms/layout/no-script.test.tsx b/src/components/atoms/layout/no-script.test.tsx
new file mode 100644
index 0000000..9ed9c4c
--- /dev/null
+++ b/src/components/atoms/layout/no-script.test.tsx
@@ -0,0 +1,11 @@
+import { render, screen } from '@test-utils';
+import NoScript from './no-script';
+
+const message = 'A noscript message.';
+
+describe('NoScript', () => {
+ it('renders a message', () => {
+ render(<NoScript message={message} />);
+ expect(screen.getByText(message)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/no-script.tsx b/src/components/atoms/layout/no-script.tsx
new file mode 100644
index 0000000..6358cf8
--- /dev/null
+++ b/src/components/atoms/layout/no-script.tsx
@@ -0,0 +1,21 @@
+import { VFC } from 'react';
+import styles from './no-script.module.scss';
+
+export type NoScriptProps = {
+ /**
+ * The noscript message.
+ */
+ message: string;
+ /**
+ * The message position. Default: initial.
+ */
+ position?: 'initial' | 'top';
+};
+
+const NoScript: VFC<NoScriptProps> = ({ message, position = 'initial' }) => {
+ const positionClass = styles[`noscript--${position}`];
+
+ return <div className={`${styles.noscript} ${positionClass}`}>{message}</div>;
+};
+
+export default NoScript;
diff --git a/src/components/atoms/layout/notice.module.scss b/src/components/atoms/layout/notice.module.scss
new file mode 100644
index 0000000..38ec7ee
--- /dev/null
+++ b/src/components/atoms/layout/notice.module.scss
@@ -0,0 +1,28 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ width: max-content;
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ border: fun.convert-px(2) solid;
+ font-weight: bold;
+
+ &--error {
+ border-color: var(--color-token-red);
+ color: var(--color-token-red);
+ }
+
+ &--info {
+ border-color: var(--color-token-blue);
+ color: var(--color-token-blue);
+ }
+
+ &--success {
+ border-color: var(--color-token-green);
+ color: var(--color-token-green);
+ }
+
+ &--warning {
+ border-color: var(--color-token-orange);
+ color: var(--color-token-orange);
+ }
+}
diff --git a/src/components/atoms/layout/notice.stories.tsx b/src/components/atoms/layout/notice.stories.tsx
new file mode 100644
index 0000000..0555a2e
--- /dev/null
+++ b/src/components/atoms/layout/notice.stories.tsx
@@ -0,0 +1,40 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoticeComponent from './notice';
+
+export default {
+ title: 'Atoms/Layout',
+ component: NoticeComponent,
+ argTypes: {
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The notice kind.',
+ options: ['error', 'info', 'success', 'warning'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ message: {
+ control: {
+ type: 'text',
+ },
+ description: 'The notice body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof NoticeComponent>;
+
+const Template: ComponentStory<typeof NoticeComponent> = (args) => (
+ <NoticeComponent {...args} />
+);
+
+export const Notice = Template.bind({});
+Notice.args = {
+ kind: 'info',
+ message: 'Nisi provident sapiente.',
+};
diff --git a/src/components/atoms/layout/notice.test.tsx b/src/components/atoms/layout/notice.test.tsx
new file mode 100644
index 0000000..4501f8f
--- /dev/null
+++ b/src/components/atoms/layout/notice.test.tsx
@@ -0,0 +1,11 @@
+import { render, screen } from '@test-utils';
+import Notice from './notice';
+
+const message = 'Tenetur consequuntur tempore.';
+
+describe('Notice', () => {
+ it('renders a message', () => {
+ render(<Notice kind="info" message={message} />);
+ expect(screen.getByText(message)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/notice.tsx b/src/components/atoms/layout/notice.tsx
new file mode 100644
index 0000000..b6e09c5
--- /dev/null
+++ b/src/components/atoms/layout/notice.tsx
@@ -0,0 +1,30 @@
+import { VFC } from 'react';
+import styles from './notice.module.scss';
+
+export type NoticeKind = 'error' | 'info' | 'success' | 'warning';
+
+export type NoticeProps = {
+ /**
+ * The notice kind.
+ */
+ kind: NoticeKind;
+ /**
+ * The notice body.
+ */
+ message: string;
+};
+
+/**
+ * Notice component
+ *
+ * Render a colored message depending on notice kind.
+ */
+const Notice: VFC<NoticeProps> = ({ kind, message }) => {
+ const kindClass = `wrapper--${kind}`;
+
+ return (
+ <div className={`${styles.wrapper} ${styles[kindClass]}`}>{message}</div>
+ );
+};
+
+export default Notice;
diff --git a/src/components/atoms/layout/section.module.scss b/src/components/atoms/layout/section.module.scss
new file mode 100644
index 0000000..012493a
--- /dev/null
+++ b/src/components/atoms/layout/section.module.scss
@@ -0,0 +1,25 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ @extend %grid;
+
+ padding: var(--spacing-md) 0;
+
+ &--borders {
+ border-bottom: fun.convert-px(1) solid var(--color-border);
+ }
+
+ &--dark {
+ background: var(--color-bg-secondary);
+ }
+
+ &--light {
+ background: var(--color-bg);
+ }
+}
+
+.body,
+.title {
+ grid-column: 2;
+}
diff --git a/src/components/atoms/layout/section.stories.tsx b/src/components/atoms/layout/section.stories.tsx
new file mode 100644
index 0000000..abbbeed
--- /dev/null
+++ b/src/components/atoms/layout/section.stories.tsx
@@ -0,0 +1,85 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SectionComponent from './section';
+
+export default {
+ title: 'Atoms/Layout',
+ component: SectionComponent,
+ args: {
+ variant: 'dark',
+ withBorder: true,
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the section element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ content: {
+ control: {
+ type: 'text',
+ },
+ description: 'The section content.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The section title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ variant: {
+ control: {
+ type: 'select',
+ },
+ description: 'The section variant.',
+ options: ['light', 'dark'],
+ table: {
+ category: 'Styles',
+ defaultValue: { summary: 'dark' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ withBorder: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Add a border at the bottom of the section.',
+ table: {
+ category: 'Styles',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SectionComponent>;
+
+const Template: ComponentStory<typeof SectionComponent> = (args) => (
+ <SectionComponent {...args} />
+);
+
+export const Section = Template.bind({});
+Section.args = {
+ title: 'A title',
+ content: 'The content.',
+};
diff --git a/src/components/atoms/layout/section.test.tsx b/src/components/atoms/layout/section.test.tsx
new file mode 100644
index 0000000..ca5f03a
--- /dev/null
+++ b/src/components/atoms/layout/section.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@test-utils';
+import Section from './section';
+
+const title = 'Section title';
+const content = 'Section content.';
+
+describe('Section', () => {
+ it('renders a title (h2)', () => {
+ render(<Section title={title} content={content} />);
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(title);
+ });
+
+ it('renders a content', () => {
+ render(<Section title={title} content={content} />);
+ expect(screen.getByText(content)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/section.tsx b/src/components/atoms/layout/section.tsx
new file mode 100644
index 0000000..f1bbb34
--- /dev/null
+++ b/src/components/atoms/layout/section.tsx
@@ -0,0 +1,57 @@
+import { ReactNode, VFC } from 'react';
+import Heading from '../headings/heading';
+import styles from './section.module.scss';
+
+export type SectionVariant = 'dark' | 'light';
+
+export type SectionProps = {
+ /**
+ * Set additional classnames to the section element.
+ */
+ className?: string;
+ /**
+ * The section content.
+ */
+ content: ReactNode;
+ /**
+ * The section title.
+ */
+ title: string;
+ /**
+ * The section variant.
+ */
+ variant?: SectionVariant;
+ /**
+ * Add a border at the bottom of the section. Default: true.
+ */
+ withBorder?: boolean;
+};
+
+/**
+ * Section component
+ *
+ * Render a section element.
+ */
+const Section: VFC<SectionProps> = ({
+ className = '',
+ content,
+ title,
+ variant = 'dark',
+ withBorder = true,
+}) => {
+ const borderClass = withBorder ? styles[`wrapper--borders`] : '';
+ const variantClass = styles[`wrapper--${variant}`];
+
+ return (
+ <section
+ className={`${styles.wrapper} ${borderClass} ${variantClass} ${className}`}
+ >
+ <Heading level={2} className={styles.title}>
+ {title}
+ </Heading>
+ <div className={styles.body}>{content}</div>
+ </section>
+ );
+};
+
+export default Section;
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
new file mode 100644
index 0000000..e7ead86
--- /dev/null
+++ b/src/components/atoms/links/link.module.scss
@@ -0,0 +1,37 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/variables" as var;
+
+.link {
+ &[hreflang] {
+ &::after {
+ display: inline-block;
+ content: "\0000a0["attr(hreflang) "]";
+ font-size: var(--font-size-sm);
+ }
+ }
+
+ &--external {
+ &::after {
+ display: inline-block;
+ content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+ }
+
+ &:focus:not(:active)::after {
+ content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+ }
+
+ &[hreflang] {
+ &::after {
+ content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
+ '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
+ ));
+ }
+
+ &:focus:not(:active)::after {
+ content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
+ '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
+ ));
+ }
+ }
+ }
+}
diff --git a/src/components/atoms/links/link.stories.tsx b/src/components/atoms/links/link.stories.tsx
new file mode 100644
index 0000000..569c874
--- /dev/null
+++ b/src/components/atoms/links/link.stories.tsx
@@ -0,0 +1,79 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LinkComponent from './link';
+
+export default {
+ title: 'Atoms/Links',
+ component: LinkComponent,
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ external: {
+ control: {
+ type: 'boolean',
+ },
+ table: {
+ category: 'Options',
+ },
+ description: 'Determine if the link is external of the current website.',
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ href: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link target.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ lang: {
+ control: {
+ type: 'text',
+ },
+ table: {
+ category: 'Options',
+ },
+ description: 'The target language as code language.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof LinkComponent>;
+
+const Template: ComponentStory<typeof LinkComponent> = (args) => (
+ <LinkComponent {...args} />
+);
+
+export const Link = Template.bind({});
+Link.args = {
+ children: 'A link',
+ href: '#',
+ external: false,
+};
diff --git a/src/components/atoms/links/link.test.tsx b/src/components/atoms/links/link.test.tsx
new file mode 100644
index 0000000..54e2414
--- /dev/null
+++ b/src/components/atoms/links/link.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Link from './link';
+
+describe('Link', () => {
+ it('render a link', () => {
+ render(<Link href="#">A link</Link>);
+ expect(screen.getByRole('link')).toHaveTextContent('A link');
+ });
+});
diff --git a/src/components/atoms/links/link.tsx b/src/components/atoms/links/link.tsx
new file mode 100644
index 0000000..87f11fc
--- /dev/null
+++ b/src/components/atoms/links/link.tsx
@@ -0,0 +1,51 @@
+import NextLink from 'next/link';
+import { FC } from 'react';
+import styles from './link.module.scss';
+
+export type LinkProps = {
+ /**
+ * Set additional classnames to the link.
+ */
+ className?: string;
+ /**
+ * True if it is an external link. Default: false.
+ */
+ external?: boolean;
+ /**
+ * The link target.
+ */
+ href: string;
+ /**
+ * The link target code language.
+ */
+ lang?: string;
+};
+
+/**
+ * Link Component
+ *
+ * Render a link.
+ */
+const Link: FC<LinkProps> = ({
+ children,
+ className = '',
+ href,
+ lang,
+ external = false,
+}) => {
+ return external ? (
+ <a
+ href={href}
+ hrefLang={lang}
+ className={`${styles.link} ${styles['link--external']} ${className}`}
+ >
+ {children}
+ </a>
+ ) : (
+ <NextLink href={href}>
+ <a className={`${styles.link} ${className}`}>{children}</a>
+ </NextLink>
+ );
+};
+
+export default Link;
diff --git a/src/components/atoms/links/nav-link.module.scss b/src/components/atoms/links/nav-link.module.scss
new file mode 100644
index 0000000..241c9c3
--- /dev/null
+++ b/src/components/atoms/links/nav-link.module.scss
@@ -0,0 +1,46 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.link {
+ --draw-border-thickness: #{fun.convert-px(4)};
+ --draw-border-color1: var(--color-primary-light);
+ --draw-border-color2: var(--color-primary-lighter);
+ --icon-size: #{fun.convert-px(30)};
+
+ display: inline-flex;
+ flex-flow: column nowrap;
+ place-items: center;
+ place-content: center;
+ row-gap: var(--spacing-2xs);
+ min-width: var(--link-min-width, fun.convert-px(85));
+ padding: var(--spacing-xs);
+ background: inherit;
+ font-size: var(--font-size-sm);
+ font-variant: small-caps;
+ font-weight: 600;
+ line-height: 1;
+ text-decoration: none;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ border-radius: 8%;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ @extend %draw-borders;
+ }
+
+ &:focus {
+ color: var(--color-primary-light);
+ }
+
+ &:active {
+ --draw-border-color1: var(--color-primary-dark);
+ --draw-border-color2: var(--color-primary-light);
+
+ @extend %draw-borders;
+ }
+}
diff --git a/src/components/atoms/links/nav-link.stories.tsx b/src/components/atoms/links/nav-link.stories.tsx
new file mode 100644
index 0000000..08553be
--- /dev/null
+++ b/src/components/atoms/links/nav-link.stories.tsx
@@ -0,0 +1,49 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NavLinkComponent from './nav-link';
+
+export default {
+ title: 'Atoms/Links',
+ component: NavLinkComponent,
+ argTypes: {
+ href: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link target.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ logo: {
+ control: {
+ type: null,
+ },
+ description: 'The link logo.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof NavLinkComponent>;
+
+const Template: ComponentStory<typeof NavLinkComponent> = (args) => (
+ <NavLinkComponent {...args} />
+);
+
+export const NavLink = Template.bind({});
+NavLink.args = {
+ href: '#',
+ label: 'A nav link',
+};
diff --git a/src/components/atoms/links/nav-link.test.tsx b/src/components/atoms/links/nav-link.test.tsx
new file mode 100644
index 0000000..7750cee
--- /dev/null
+++ b/src/components/atoms/links/nav-link.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@test-utils';
+import NavLink from './nav-link';
+
+describe('NavLink', () => {
+ it('renders a nav link to blog page', () => {
+ render(<NavLink href="/blog" label="Blog" />);
+ expect(screen.getByRole('link', { name: 'Blog' })).toHaveAttribute(
+ 'href',
+ '/blog'
+ );
+ });
+});
diff --git a/src/components/atoms/links/nav-link.tsx b/src/components/atoms/links/nav-link.tsx
new file mode 100644
index 0000000..25c0e7d
--- /dev/null
+++ b/src/components/atoms/links/nav-link.tsx
@@ -0,0 +1,36 @@
+import Link from 'next/link';
+import { VFC, ReactNode } from 'react';
+import styles from './nav-link.module.scss';
+
+export type NavLinkProps = {
+ /**
+ * Link target.
+ */
+ href: string;
+ /**
+ * Link label.
+ */
+ label: string;
+ /**
+ * Link logo.
+ */
+ logo?: ReactNode;
+};
+
+/**
+ * NavLink component
+ *
+ * Render a navigation link.
+ */
+const NavLink: VFC<NavLinkProps> = ({ href, label, logo }) => {
+ return (
+ <Link href={href}>
+ <a className={styles.link}>
+ {logo}
+ {label}
+ </a>
+ </Link>
+ );
+};
+
+export default NavLink;
diff --git a/src/components/atoms/links/sharing-link.module.scss b/src/components/atoms/links/sharing-link.module.scss
new file mode 100644
index 0000000..26ca737
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.module.scss
@@ -0,0 +1,157 @@
+@use "@styles/abstracts/functions" as fun;
+
+.link {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ border-radius: fun.convert-px(3);
+
+ &:hover,
+ &:focus {
+ transform: translateX(#{fun.convert-px(-3)})
+ translateY(#{fun.convert-px(-3)});
+ }
+
+ &:active {
+ transform: translateX(#{fun.convert-px(2)}) translateY(#{fun.convert-px(2)});
+ }
+
+ &::before {
+ content: "";
+ display: block;
+ width: fun.convert-px(30);
+ height: fun.convert-px(30);
+ background-repeat: no-repeat;
+ filter: drop-shadow(
+ #{fun.convert-px(1)} #{fun.convert-px(1)} #{fun.convert-px(1)} hsl(0, 0%, 0%)
+ );
+ }
+
+ &--diaspora {
+ background: hsl(0, 0%, 13%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(0, 0%, 3%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 hsl(0, 0%, 3%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 hsl(0, 0%, 3%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path style="fill:#ffffff;" d="M15.257 21.928l-2.33-3.255c-.622-.87-1.128-1.549-1.155-1.55-.027 0-1.007 1.317-2.317 3.115-1.248 1.713-2.28 3.115-2.292 3.115-.035 0-4.5-3.145-4.51-3.178-.006-.016 1.003-1.497 2.242-3.292 1.239-1.794 2.252-3.29 2.252-3.325 0-.056-.401-.197-3.55-1.247a1604.93 1604.93 0 01-3.593-1.2c-.033-.013.153-.635.79-2.648.46-1.446.845-2.642.857-2.656.013-.015 1.71.528 3.772 1.207 2.062.678 3.766 1.233 3.787 1.233.021 0 .045-.032.053-.07.008-.039.026-1.794.04-3.902.013-2.107.036-3.848.05-3.87.02-.03.599-.038 2.725-.038 1.485 0 2.716.01 2.735.023.023.016.064 1.175.132 3.776.112 4.273.115 4.33.183 4.33.026 0 1.66-.547 3.631-1.216 1.97-.668 3.593-1.204 3.605-1.191.04.045 1.656 5.307 1.636 5.327-.011.01-1.656.574-3.655 1.252-2.75.932-3.638 1.244-3.645 1.284-.006.029.94 1.442 2.143 3.202 1.184 1.733 2.148 3.164 2.143 3.18-.012.036-4.442 3.299-4.48 3.299-.015 0-.577-.767-1.249-1.705z"/></svg>'
+ ));
+ }
+ }
+
+ &--email {
+ background: hsl(0, 0%, 44%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(0, 0%, 34%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 hsl(0, 0%, 34%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 hsl(0, 0%, 34%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M15.909 12.123L24 17.238V6.792zM0 6.792v10.446l8.091-5.115zM22.5 3.75h-21c-.748 0-1.343.558-1.455 1.276L12 12.904l11.955-7.877c-.112-.718-.706-1.276-1.455-1.276zm-7.965 9.279l-2.123 1.398a.75.75 0 01-.825 0l-2.122-1.4-9.417 5.957c.116.712.707 1.266 1.452 1.266h21c.746 0 1.337-.553 1.452-1.266z"/></svg>'
+ ));
+ }
+ }
+
+ &--facebook {
+ background: hsl(214, 89%, 52%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(214, 89%, 42%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0
+ hsl(214, 89%, 42%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0
+ hsl(214, 89%, 42%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'
+ ));
+ }
+ }
+
+ &--journal-du-hacker {
+ background: hsl(210, 24%, 51%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(210, 24%, 41%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0
+ hsl(210, 24%, 41%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0
+ hsl(210, 24%, 41%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M17.822 23.297a6.644 6.644 0 00-.654.032c-1.104.1-2.451-.378-3.244-1.15a3.223 3.223 0 01-.52-.739c-.209-.425-.22-.489-.211-1.178a8.174 8.174 0 01.19-1.585c.243-1.151.155-1.449-.514-1.737-.4-.172-.632-.135-1 .16-.268.215-.28.463-.07 1.532.298 1.526.286 2.238-.05 2.907-.28.56-.443.703-1.287 1.133-1.005.513-1.461.638-2.332.638-.73 0-1.014-.082-1.276-.366-.134-.145-.148-.2-.085-.32.099-.184.329-.3.959-.488.277-.082.604-.236.727-.341.123-.105.329-.265.457-.354.32-.222.562-.761.563-1.254 0-.331-.188-1.034-.45-1.676-.138-.338-.38.085-.38.666 0 .434-.673 1.569-.93 1.569-.048 0-.288.101-.532.225-.43.219-.47.225-1.31.225-.815 0-.889-.011-1.235-.194-.42-.22-.902-.694-1.094-1.073a2.752 2.752 0 00-.227-.377c-.083-.102-.08-.143.018-.293.206-.314.473-.317 1.186-.011.583.25 1.22.215 1.582-.086.168-.139.325-.697.342-1.217.02-.598-.049-.66-.596-.528-.86.206-1.762-.084-2.76-.887-.916-.739-1.362-.845-2.241-.538-.262.092-.51.153-.552.137-.042-.016-.134-.136-.204-.268-.118-.218-.12-.252-.02-.403.156-.24.714-.573 1.185-.708.297-.086.588-.11 1.076-.09.655.026.687.035 1.567.458.54.259.99.43 1.127.43.27 0 1.014-.37 1.159-.577.167-.238.124-.34-.322-.776-1.19-1.16-1.943-2.608-2.24-4.31-.124-.702-.14-1.888-.035-2.483.116-.656.677-2.273.915-2.64.385-.59 1.823-1.965 2.585-2.469C9.187.905 11.43.395 13.715.785c2.457.42 4.507 1.61 5.849 3.394 1.062 1.414 1.554 2.859 1.553 4.57 0 1.778-.497 3.238-1.599 4.693a6.207 6.207 0 00-.34.476c0 .013.205.12.456.238.737.345 1.169.844 1.726 1.994.256.527.531 1.031.613 1.12.225.247.614.42 1.099.49.588.085.804.178.9.388.109.24-.111.55-.402.563-.11.005-.394.033-.63.062-.887.107-1.851-.251-2.416-.898-.17-.193-.503-.616-.74-.939-.455-.616-.818-.922-1.054-.888-.117.017-.14.066-.127.28.008.142.068.34.133.438.09.137.127.412.161 1.196.05 1.153.147 1.458.55 1.726.306.204.552.198 1.11-.025.581-.233.923-.238 1.159-.018.243.227.2.637-.11 1.026-.33.419-1.338.899-2.001.954-1.194.1-2.371-.602-2.828-1.686-.062-.147-.197-.61-.301-1.03-.12-.486-.221-.762-.28-.762-.109 0-.263.401-.27.705-.003.12-.056.417-.118.657-.328 1.282.307 2.309 1.66 2.684.657.182.808.299.808.623 0 .319-.165.494-.454.481z"/></svg>'
+ ));
+ }
+ }
+
+ &--linkedin {
+ background: hsl(210, 90%, 40%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(210, 90%, 30%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0
+ hsl(210, 90%, 30%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0
+ hsl(210, 90%, 30%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path style="fill:#ffffff;" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'
+ ));
+ }
+ }
+
+ &--twitter {
+ background: hsl(203, 89%, 53%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(203, 89%, 43%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0
+ hsl(203, 89%, 43%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0
+ hsl(203, 89%, 43%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>'
+ ));
+ }
+ }
+}
diff --git a/src/components/atoms/links/sharing-link.stories.tsx b/src/components/atoms/links/sharing-link.stories.tsx
new file mode 100644
index 0000000..335fa50
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.stories.tsx
@@ -0,0 +1,50 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SharingLinkComponent from './sharing-link';
+
+export default {
+ title: 'Atoms/Links',
+ component: SharingLinkComponent,
+ argTypes: {
+ medium: {
+ control: {
+ type: 'select',
+ },
+ description: 'The sharing medium.',
+ options: [
+ 'diaspora',
+ 'email',
+ 'facebook',
+ 'journal-du-hacker',
+ 'linkedin',
+ 'twitter',
+ ],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'The sharing url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SharingLinkComponent>;
+
+const Template: ComponentStory<typeof SharingLinkComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SharingLinkComponent {...args} />
+ </IntlProvider>
+);
+
+export const SharingLink = Template.bind({});
+SharingLink.args = {
+ medium: 'diaspora',
+ url: '#',
+};
diff --git a/src/components/atoms/links/sharing-link.test.tsx b/src/components/atoms/links/sharing-link.test.tsx
new file mode 100644
index 0000000..e4c849c
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@test-utils';
+import SharingLink from './sharing-link';
+
+describe('SharingLink', () => {
+ it('render a Diaspora sharing link', () => {
+ render(<SharingLink medium="diaspora" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on diaspora' })).toHaveClass(
+ 'link--diaspora'
+ );
+ });
+
+ it('render an Email sharing link', () => {
+ render(<SharingLink medium="email" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on email' })).toHaveClass(
+ 'link--email'
+ );
+ });
+
+ it('render a Facebook sharing link', () => {
+ render(<SharingLink medium="facebook" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on facebook' })).toHaveClass(
+ 'link--facebook'
+ );
+ });
+
+ it('render a Journal du Hacker sharing link', () => {
+ render(<SharingLink medium="journal-du-hacker" url="#" />);
+ expect(
+ screen.getByRole('link', { name: 'Share on journal-du-hacker' })
+ ).toHaveClass('link--journal-du-hacker');
+ });
+
+ it('render a LinkedIn sharing link', () => {
+ render(<SharingLink medium="linkedin" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on linkedin' })).toHaveClass(
+ 'link--linkedin'
+ );
+ });
+
+ it('render a Twitter sharing link', () => {
+ render(<SharingLink medium="twitter" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on twitter' })).toHaveClass(
+ 'link--twitter'
+ );
+ });
+});
diff --git a/src/components/atoms/links/sharing-link.tsx b/src/components/atoms/links/sharing-link.tsx
new file mode 100644
index 0000000..3cd2dd1
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.tsx
@@ -0,0 +1,48 @@
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './sharing-link.module.scss';
+
+export type SharingMedium =
+ | 'diaspora'
+ | 'email'
+ | 'facebook'
+ | 'journal-du-hacker'
+ | 'linkedin'
+ | 'twitter';
+
+export type SharingLinkProps = {
+ /**
+ * The sharing medium id.
+ */
+ medium: SharingMedium;
+ /**
+ * The sharing url.
+ */
+ url: string;
+};
+
+/**
+ * SharingLink component
+ *
+ * Render a sharing link.
+ */
+const SharingLink: VFC<SharingLinkProps> = ({ medium, url }) => {
+ const intl = useIntl();
+ const text = intl.formatMessage(
+ {
+ defaultMessage: 'Share on {name}',
+ description: 'Sharing: share on social network text',
+ id: 'ureXFw',
+ },
+ { name: medium }
+ );
+ const mediumClass = `link--${medium}`;
+
+ return (
+ <a href={url} className={`${styles.link} ${styles[mediumClass]}`}>
+ <span className="screen-reader-text">{text}</span>
+ </a>
+ );
+};
+
+export default SharingLink;
diff --git a/src/components/atoms/links/social-link.module.scss b/src/components/atoms/links/social-link.module.scss
new file mode 100644
index 0000000..02fc61c
--- /dev/null
+++ b/src/components/atoms/links/social-link.module.scss
@@ -0,0 +1,43 @@
+@use "@styles/abstracts/functions" as fun;
+
+.link {
+ display: flex;
+ width: var(--link-size, #{fun.convert-px(60)});
+ height: var(--link-size, #{fun.convert-px(60)});
+ 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(-1)
+ var(--color-shadow),
+ fun.convert-px(3) fun.convert-px(4) fun.convert-px(4) fun.convert-px(-3)
+ var(--color-shadow),
+ 0 0 0 0 var(--color-shadow);
+ transition: all 0.25s linear 0s;
+
+ &:hover,
+ &:focus {
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow),
+ fun.convert-px(1) fun.convert-px(1) fun.convert-px(2) fun.convert-px(-1)
+ var(--color-shadow-light),
+ fun.convert-px(3) fun.convert-px(3) fun.convert-px(4) fun.convert-px(-4)
+ var(--color-shadow-light),
+ fun.convert-px(6) fun.convert-px(6) fun.convert-px(10) fun.convert-px(-3)
+ var(--color-shadow);
+ transform: scale(1.15);
+ }
+
+ &:focus {
+ outline: var(--color-primary) dashed fun.convert-px(2);
+ }
+
+ &:active {
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ outline: none;
+ transform: scale(0.9);
+ }
+}
+
+.icon {
+ max-width: 100%;
+ max-height: 100%;
+}
diff --git a/src/components/atoms/links/social-link.stories.tsx b/src/components/atoms/links/social-link.stories.tsx
new file mode 100644
index 0000000..bd9a364
--- /dev/null
+++ b/src/components/atoms/links/social-link.stories.tsx
@@ -0,0 +1,40 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SocialLinkComponent from './social-link';
+
+export default {
+ title: 'Atoms/Links',
+ component: SocialLinkComponent,
+ argTypes: {
+ name: {
+ control: {
+ type: 'select',
+ },
+ description: 'Social website name.',
+ options: ['Github', 'Gitlab', 'LinkedIn', 'Twitter'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ url: {
+ control: {
+ type: null,
+ },
+ description: 'Social profile url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SocialLinkComponent>;
+
+const Template: ComponentStory<typeof SocialLinkComponent> = (args) => (
+ <SocialLinkComponent {...args} />
+);
+
+export const SocialLink = Template.bind({});
+SocialLink.args = {
+ name: 'Github',
+ url: '#',
+};
diff --git a/src/components/atoms/links/social-link.test.tsx b/src/components/atoms/links/social-link.test.tsx
new file mode 100644
index 0000000..f49fb5a
--- /dev/null
+++ b/src/components/atoms/links/social-link.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@test-utils';
+import SocialLink from './social-link';
+
+/**
+ * Next.js mock images to use next/image component. So for now, I need to mock
+ * the svg files manually.
+ */
+jest.mock('@assets/images/social-media/github.svg', () => 'svg-file');
+
+describe('SocialLink', () => {
+ it('render a social link', () => {
+ render(<SocialLink name="Github" url="#" />);
+ expect(screen.getByRole('link')).toHaveAccessibleName('Github');
+ });
+});
diff --git a/src/components/atoms/links/social-link.tsx b/src/components/atoms/links/social-link.tsx
new file mode 100644
index 0000000..8c7c790
--- /dev/null
+++ b/src/components/atoms/links/social-link.tsx
@@ -0,0 +1,53 @@
+import GithubIcon from '@assets/images/social-media/github.svg';
+import GitlabIcon from '@assets/images/social-media/gitlab.svg';
+import LinkedInIcon from '@assets/images/social-media/linkedin.svg';
+import TwitterIcon from '@assets/images/social-media/twitter.svg';
+import { VFC } from 'react';
+import styles from './social-link.module.scss';
+
+export type SocialWebsite = 'Github' | 'Gitlab' | 'LinkedIn' | 'Twitter';
+
+export type SocialLinkProps = {
+ /**
+ * The social website name.
+ */
+ name: SocialWebsite;
+ /**
+ * The social profile url.
+ */
+ url: string;
+};
+
+/**
+ * SocialLink component
+ *
+ * Render a social icon link.
+ */
+const SocialLink: VFC<SocialLinkProps> = ({ name, url }) => {
+ /**
+ * Retrieve a social link icon by id.
+ * @param {string} id - The social website id.
+ */
+ const getIcon = (id: string) => {
+ switch (id) {
+ case 'Github':
+ return <GithubIcon className={styles.icon} aria-hidden="true" />;
+ case 'Gitlab':
+ return <GitlabIcon className={styles.icon} aria-hidden="true" />;
+ case 'LinkedIn':
+ return <LinkedInIcon className={styles.icon} aria-hidden="true" />;
+ case 'Twitter':
+ return <TwitterIcon className={styles.icon} aria-hidden="true" />;
+ default:
+ break;
+ }
+ };
+
+ return (
+ <a href={url} className={styles.link} aria-label={name}>
+ {getIcon(name)}
+ </a>
+ );
+};
+
+export default SocialLink;
diff --git a/src/components/atoms/lists/description-list.module.scss b/src/components/atoms/lists/description-list.module.scss
new file mode 100644
index 0000000..caa2711
--- /dev/null
+++ b/src/components/atoms/lists/description-list.module.scss
@@ -0,0 +1,54 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.list {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-2xs);
+ margin: 0;
+
+ &__term {
+ flex: 0 0 max-content;
+ color: var(--color-fg-light);
+ font-weight: 600;
+ }
+
+ &__description {
+ flex: 0 0 auto;
+ margin: 0;
+ }
+
+ &__item {
+ display: flex;
+ }
+
+ &--inline &__item {
+ flex-flow: column wrap;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ flex-flow: row wrap;
+ gap: var(--spacing-2xs);
+
+ .list__description:not(:first-of-type) {
+ &::before {
+ content: "/";
+ margin-right: var(--spacing-2xs);
+ }
+ }
+ }
+ }
+ }
+
+ &--column#{&}--responsive {
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ flex-flow: row wrap;
+ gap: var(--spacing-lg);
+ }
+ }
+ }
+
+ &--column &__item {
+ flex-flow: column wrap;
+ }
+}
diff --git a/src/components/atoms/lists/description-list.stories.tsx b/src/components/atoms/lists/description-list.stories.tsx
new file mode 100644
index 0000000..66d94af
--- /dev/null
+++ b/src/components/atoms/lists/description-list.stories.tsx
@@ -0,0 +1,73 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import DescriptionListComponent, {
+ DescriptionListItem,
+} from './description-list';
+
+export default {
+ title: 'Atoms/Lists',
+ component: DescriptionListComponent,
+ args: {
+ layout: 'column',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ control: {
+ type: null,
+ },
+ description: 'The list items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ layout: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list layout.',
+ options: ['column', 'inline'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'column' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof DescriptionListComponent>;
+
+const Template: ComponentStory<typeof DescriptionListComponent> = (args) => (
+ <DescriptionListComponent {...args} />
+);
+
+const items: DescriptionListItem[] = [
+ { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] },
+ { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] },
+ {
+ id: 'term-3',
+ term: 'Term 3:',
+ value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'],
+ },
+ { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] },
+];
+
+export const DescriptionList = Template.bind({});
+DescriptionList.args = {
+ items,
+};
diff --git a/src/components/atoms/lists/description-list.test.tsx b/src/components/atoms/lists/description-list.test.tsx
new file mode 100644
index 0000000..d3f7045
--- /dev/null
+++ b/src/components/atoms/lists/description-list.test.tsx
@@ -0,0 +1,20 @@
+import { render } from '@test-utils';
+import DescriptionList, { DescriptionListItem } from './description-list';
+
+const items: DescriptionListItem[] = [
+ { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] },
+ { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] },
+ {
+ id: 'term-3',
+ term: 'Term 3:',
+ value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'],
+ },
+ { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] },
+];
+
+describe('DescriptionList', () => {
+ it('renders a list of terms and description', () => {
+ const { container } = render(<DescriptionList items={items} />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx
new file mode 100644
index 0000000..0a92465
--- /dev/null
+++ b/src/components/atoms/lists/description-list.tsx
@@ -0,0 +1,100 @@
+import { VFC } from 'react';
+import styles from './description-list.module.scss';
+
+export type DescriptionListItem = {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * A list term.
+ */
+ term: string;
+ /**
+ * An array of values for the list term.
+ */
+ value: any[];
+};
+
+export type DescriptionListProps = {
+ /**
+ * Set additional classnames to the list wrapper.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the `dd` element.
+ */
+ descriptionClassName?: string;
+ /**
+ * Set additional classnames to the `dt`/`dd` couple wrapper.
+ */
+ groupClassName?: string;
+ /**
+ * The list items.
+ */
+ items: DescriptionListItem[];
+ /**
+ * The list items layout. Default: column.
+ */
+ layout?: 'inline' | 'column';
+ /**
+ * Define if the layout should automatically create rows/columns.
+ */
+ responsiveLayout?: boolean;
+ /**
+ * Set additional classnames to the `dt` element.
+ */
+ termClassName?: string;
+};
+
+/**
+ * DescriptionList component
+ *
+ * Render a description list.
+ */
+const DescriptionList: VFC<DescriptionListProps> = ({
+ className = '',
+ descriptionClassName = '',
+ groupClassName = '',
+ items,
+ layout = 'column',
+ responsiveLayout = false,
+ termClassName = '',
+}) => {
+ const layoutModifier = `list--${layout}`;
+ const responsiveModifier = responsiveLayout ? 'list--responsive' : '';
+
+ /**
+ * Retrieve the description list items wrapped in a div element.
+ *
+ * @param {DescriptionListItem[]} listItems - An array of term and description couples.
+ * @returns {JSX.Element[]} The description list items.
+ */
+ const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => {
+ return listItems.map(({ id, term, value }) => {
+ return (
+ <div key={id} className={`${styles.list__item} ${groupClassName}`}>
+ <dt className={`${styles.list__term} ${termClassName}`}>{term}</dt>
+ {value.map((currentValue, index) => (
+ <dd
+ key={`${id}-${index}`}
+ className={`${styles.list__description} ${descriptionClassName}`}
+ >
+ {currentValue}
+ </dd>
+ ))}
+ </div>
+ );
+ });
+ };
+
+ return (
+ <dl
+ className={`${styles.list} ${styles[layoutModifier]} ${styles[responsiveModifier]} ${className}`}
+ >
+ {getItems(items)}
+ </dl>
+ );
+};
+
+export default DescriptionList;
diff --git a/src/components/atoms/lists/list.module.scss b/src/components/atoms/lists/list.module.scss
new file mode 100644
index 0000000..df3b49c
--- /dev/null
+++ b/src/components/atoms/lists/list.module.scss
@@ -0,0 +1,39 @@
+.list {
+ margin: 0;
+
+ ::marker {
+ color: var(--color-primary-dark);
+ }
+
+ & & {
+ margin-top: var(--spacing-2xs);
+ }
+
+ &--ordered {
+ padding: 0;
+ counter-reset: li;
+ list-style-type: none;
+ }
+
+ &--ordered &__item {
+ display: table;
+ counter-increment: li;
+
+ &::before {
+ content: counters(li, ".") ". ";
+ display: table-cell;
+ padding-right: var(--spacing-2xs);
+ color: var(--color-secondary);
+ }
+ }
+
+ &--unordered {
+ padding: 0 0 0 var(--spacing-sm);
+ }
+
+ &--has-margin &__item {
+ &:not(:last-child) {
+ margin-bottom: var(--spacing-2xs);
+ }
+ }
+}
diff --git a/src/components/atoms/lists/list.stories.tsx b/src/components/atoms/lists/list.stories.tsx
new file mode 100644
index 0000000..30079cb
--- /dev/null
+++ b/src/components/atoms/lists/list.stories.tsx
@@ -0,0 +1,80 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ListComponent, { type ListItem } from './list';
+
+export default {
+ title: 'Atoms/Lists',
+ component: ListComponent,
+ args: {
+ kind: 'unordered',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ control: {
+ type: null,
+ },
+ description: 'The list items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind: ordered or unordered.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ListComponent>;
+
+const Template: ComponentStory<typeof ListComponent> = (args) => (
+ <ListComponent {...args} />
+);
+
+const items: ListItem[] = [
+ { id: 'item-1', value: 'Item 1' },
+ { id: 'item-2', value: 'Item 2' },
+ {
+ child: [
+ { id: 'nested-item-1', value: 'Nested item 1' },
+ { id: 'nested-item-2', value: 'Nested item 2' },
+ ],
+ id: 'item-3',
+ value: 'Item 3',
+ },
+ { id: 'item-4', value: 'Item 4' },
+];
+
+export const Unordered = Template.bind({});
+Unordered.args = {
+ items,
+};
+
+export const Ordered = Template.bind({});
+Ordered.args = {
+ items,
+ kind: 'ordered',
+};
diff --git a/src/components/atoms/lists/list.test.tsx b/src/components/atoms/lists/list.test.tsx
new file mode 100644
index 0000000..fcf8813
--- /dev/null
+++ b/src/components/atoms/lists/list.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@test-utils';
+import List, { type ListItem } from './list';
+
+const items: ListItem[] = [
+ { id: 'item-1', value: 'Item 1' },
+ { id: 'item-2', value: 'Item 2' },
+ {
+ child: [
+ { id: 'nested-item-1', value: 'Nested item 1' },
+ { id: 'nested-item-2', value: 'Nested item 2' },
+ ],
+ id: 'item-3',
+ value: 'Item 3',
+ },
+ { id: 'item-4', value: 'Item 4' },
+];
+
+describe('List', () => {
+ it('renders a nested unordered list', () => {
+ render(<List items={items} />);
+ const listItems = screen.getAllByRole('list');
+ listItems.forEach((listItem) =>
+ expect(listItem).toHaveClass('list--unordered')
+ );
+ });
+});
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
new file mode 100644
index 0000000..d100a31
--- /dev/null
+++ b/src/components/atoms/lists/list.tsx
@@ -0,0 +1,87 @@
+import { VFC } from 'react';
+import styles from './list.module.scss';
+
+export type ListItem = {
+ /**
+ * Nested list.
+ */
+ child?: ListItem[];
+ /**
+ * Item id.
+ */
+ id: string;
+ /**
+ * Item value.
+ */
+ value: any;
+};
+
+export type ListProps = {
+ /**
+ * Set additional classnames to the list wrapper.
+ */
+ className?: string;
+ /**
+ * An array of list items.
+ */
+ items: ListItem[];
+ /**
+ * Set additional classnames to the list items.
+ */
+ itemsClassName?: string;
+ /**
+ * The list kind (ordered or unordered).
+ */
+ kind?: 'ordered' | 'unordered';
+ /**
+ * Set margin between list items. Default: true.
+ */
+ withMargin?: boolean;
+};
+
+/**
+ * List component
+ *
+ * Render either an ordered or an unordered list.
+ */
+const List: VFC<ListProps> = ({
+ className = '',
+ items,
+ itemsClassName = '',
+ kind = 'unordered',
+ withMargin = true,
+}) => {
+ const ListTag = kind === 'ordered' ? 'ol' : 'ul';
+ const kindClass = `list--${kind}`;
+ const marginClass = withMargin ? 'list--has-margin' : 'list--no-margin';
+
+ /**
+ * Retrieve the list items.
+ * @param array - An array of items.
+ * @returns {JSX.Element[]} - An array of li elements.
+ */
+ const getItems = (array: ListItem[]): JSX.Element[] => {
+ return array.map(({ child, id, value }) => (
+ <li key={id} className={`${styles.list__item} ${itemsClassName}`}>
+ {value}
+ {child && (
+ <ListTag
+ className={`${styles.list} ${styles[kindClass]} ${styles[marginClass]} ${className}`}
+ >
+ {getItems(child)}
+ </ListTag>
+ )}
+ </li>
+ ));
+ };
+
+ return (
+ <ListTag
+ className={`${styles.list} ${styles[kindClass]} ${styles[marginClass]} ${className}`}
+ >
+ {getItems(items)}
+ </ListTag>
+ );
+};
+
+export default List;
diff --git a/src/components/atoms/loaders/progress-bar.module.scss b/src/components/atoms/loaders/progress-bar.module.scss
new file mode 100644
index 0000000..166b7c4
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.module.scss
@@ -0,0 +1,43 @@
+@use "@styles/abstracts/functions" as fun;
+
+.progress {
+ width: max-content;
+ margin: var(--spacing-sm) auto var(--spacing-md);
+ text-align: center;
+
+ &__info {
+ margin-bottom: var(--spacing-2xs);
+ font-size: var(--font-size-sm);
+ }
+
+ &__bar[value] {
+ display: block;
+ width: clamp(25ch, 20vw, 30ch);
+ max-width: 100%;
+ height: fun.convert-px(13);
+ appearance: none;
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ border-radius: 1em;
+ box-shadow: inset 0 0 fun.convert-px(4) fun.convert-px(1)
+ var(--color-shadow-light);
+
+ &::-webkit-progress-value {
+ background-color: var(--color-primary-dark);
+ border-radius: 1em;
+ }
+
+ &::-moz-progress-bar {
+ background-color: var(--color-primary-dark);
+ border-radius: 1em;
+ }
+
+ &::-webkit-progress-bar {
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ border-radius: 1em;
+ box-shadow: inset 0 0 fun.convert-px(4) fun.convert-px(1)
+ var(--color-shadow-light);
+ }
+ }
+}
diff --git a/src/components/atoms/loaders/progress-bar.stories.tsx b/src/components/atoms/loaders/progress-bar.stories.tsx
new file mode 100644
index 0000000..4fde5a7
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.stories.tsx
@@ -0,0 +1,76 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ProgressBarComponent from './progress-bar';
+
+export default {
+ title: 'Atoms/Loaders',
+ component: ProgressBarComponent,
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'string',
+ },
+ description: 'An accessible name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ current: {
+ control: {
+ type: 'number',
+ },
+ description: 'The current value.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ info: {
+ control: {
+ type: 'text',
+ },
+ description: 'An additional information to display.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ max: {
+ control: {
+ type: 'number',
+ },
+ description: 'The maximal value.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ min: {
+ control: {
+ type: 'number',
+ },
+ description: 'The minimal value.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ProgressBarComponent>;
+
+const Template: ComponentStory<typeof ProgressBarComponent> = (args) => (
+ <ProgressBarComponent {...args} />
+);
+
+export const ProgressBar = Template.bind({});
+ProgressBar.args = {
+ current: 10,
+ min: 0,
+ max: 50,
+};
diff --git a/src/components/atoms/loaders/progress-bar.test.tsx b/src/components/atoms/loaders/progress-bar.test.tsx
new file mode 100644
index 0000000..37a7364
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import ProgressBar from './progress-bar';
+
+describe('ProgressBar', () => {
+ it('renders a progress bar', () => {
+ render(<ProgressBar min={0} max={50} current={10} />);
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/loaders/progress-bar.tsx b/src/components/atoms/loaders/progress-bar.tsx
new file mode 100644
index 0000000..1b1ff06
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.tsx
@@ -0,0 +1,55 @@
+import { VFC } from 'react';
+import styles from './progress-bar.module.scss';
+
+export type ProgressBarProps = {
+ /**
+ * Accessible progress bar name.
+ */
+ 'aria-label'?: string;
+ /**
+ * Current value.
+ */
+ current: number;
+ /**
+ * Additional information to display before progress bar.
+ */
+ info?: string;
+ /**
+ * Minimal value.
+ */
+ min: number;
+ /**
+ * Maximal value.
+ */
+ max: number;
+};
+
+/**
+ * ProgressBar component
+ *
+ * Render a progress bar.
+ */
+const ProgressBar: VFC<ProgressBarProps> = ({
+ current,
+ info,
+ min,
+ max,
+ ...props
+}) => {
+ return (
+ <div className={styles.progress}>
+ {info && <div className={styles.progress__info}>{info}</div>}
+ <progress
+ className={styles.progress__bar}
+ max={max}
+ value={current}
+ aria-valuemin={min}
+ aria-valuemax={max}
+ aria-valuenow={current}
+ {...props}
+ ></progress>
+ </div>
+ );
+};
+
+export default ProgressBar;
diff --git a/src/components/atoms/loaders/spinner.module.scss b/src/components/atoms/loaders/spinner.module.scss
new file mode 100644
index 0000000..8d818a2
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.module.scss
@@ -0,0 +1,48 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-2xs);
+ margin: var(--spacing-md) 0;
+}
+
+.ball {
+ width: fun.convert-px(8);
+ height: fun.convert-px(8);
+ background: linear-gradient(
+ to right,
+ var(--color-primary-light) 0%,
+ var(--color-primary-lighter) 100%
+ );
+ border-radius: 50%;
+ animation: spinner 1.4s infinite ease-in-out both;
+
+ &:first-child {
+ animation-delay: -0.32s;
+ }
+
+ &:nth-child(2) {
+ animation-delay: -0.16s;
+ }
+}
+
+.text {
+ margin-left: var(--spacing-xs);
+ color: var(--color-primary-darker);
+ text-align: center;
+}
+
+@keyframes spinner {
+ 0%,
+ 80%,
+ 100% {
+ transform: scale(0);
+ }
+
+ 40% {
+ transform: scale(1);
+ }
+}
diff --git a/src/components/atoms/loaders/spinner.stories.tsx b/src/components/atoms/loaders/spinner.stories.tsx
new file mode 100644
index 0000000..5006ce4
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SpinnerComponent from './spinner';
+
+export default {
+ title: 'Atoms/Loaders',
+ component: SpinnerComponent,
+ argTypes: {
+ message: {
+ control: {
+ type: 'text',
+ },
+ description: 'Loading message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SpinnerComponent>;
+
+const Template: ComponentStory<typeof SpinnerComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SpinnerComponent {...args} />
+ </IntlProvider>
+);
+
+export const Spinner = Template.bind({});
diff --git a/src/components/atoms/loaders/spinner.test.tsx b/src/components/atoms/loaders/spinner.test.tsx
new file mode 100644
index 0000000..0a6db91
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@test-utils';
+import Spinner from './spinner';
+
+describe('Spinner', () => {
+ it('renders a spinner loader', () => {
+ render(<Spinner />);
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('renders a spinner loader with a custom message', () => {
+ render(<Spinner message="Submitting" />);
+ expect(screen.getByText('Submitting')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/loaders/spinner.tsx b/src/components/atoms/loaders/spinner.tsx
new file mode 100644
index 0000000..bff0f25
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.tsx
@@ -0,0 +1,37 @@
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './spinner.module.scss';
+
+export type SpinnerProps = {
+ /**
+ * The loading message. Default: "Loading...".
+ */
+ message?: string;
+};
+
+/**
+ * Spinner component
+ *
+ * Render a loading message with animation.
+ */
+const Spinner: VFC<SpinnerProps> = ({ message }) => {
+ const intl = useIntl();
+
+ return (
+ <div className={styles.wrapper}>
+ <div className={styles.ball}></div>
+ <div className={styles.ball}></div>
+ <div className={styles.ball}></div>
+ <div className={styles.text}>
+ {message ||
+ intl.formatMessage({
+ defaultMessage: 'Loading...',
+ description: 'Spinner: loading text',
+ id: 'q9cJQe',
+ })}
+ </div>
+ </div>
+ );
+};
+
+export default Spinner;
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..9721bff
--- /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(44)}, 6vw, #{fun.convert-px(55)});
+ height: clamp(#{fun.convert-px(44)}, 6vw, #{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..fe58293
--- /dev/null
+++ b/src/components/molecules/buttons/back-to-top.stories.tsx
@@ -0,0 +1,44 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import BackToTopComponent from './back-to-top';
+
+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) => (
+ <IntlProvider locale="en">
+ <BackToTopComponent {...args} />
+ </IntlProvider>
+);
+
+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..8a52231
--- /dev/null
+++ b/src/components/molecules/buttons/back-to-top.tsx
@@ -0,0 +1,45 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Arrow from '@components/atoms/icons/arrow';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './back-to-top.module.scss';
+
+export type BackToTopProps = {
+ /**
+ * Set additional classnames to the button wrapper.
+ */
+ className?: string;
+ /**
+ * An element id (without hashtag) to use as anchor.
+ */
+ target: string;
+};
+
+/**
+ * BackToTop component
+ *
+ * Render a back to top link.
+ */
+const BackToTop: VFC<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..1d16410
--- /dev/null
+++ b/src/components/molecules/buttons/heading-button.module.scss
@@ -0,0 +1,41 @@
+@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;
+
+ &:hover,
+ &:focus {
+ .icon {
+ background: var(--color-primary-light);
+ color: var(--color-fg-inverted);
+ transform: scale(1.25);
+
+ &::before,
+ &::after {
+ background: var(--color-bg);
+ }
+ }
+ }
+}
+
+.heading {
+ background: none;
+ padding: var(--spacing-2xs) 0;
+}
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..0a23b08
--- /dev/null
+++ b/src/components/molecules/buttons/heading-button.stories.tsx
@@ -0,0 +1,75 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { IntlProvider } from 'react-intl';
+import HeadingButtonComponent from './heading-button';
+
+export default {
+ title: 'Molecules/Buttons',
+ component: HeadingButtonComponent,
+ argTypes: {
+ expanded: {
+ control: {
+ type: null,
+ },
+ description: 'Heading button state (plus or minus).',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ 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 (
+ <IntlProvider locale="en">
+ <HeadingButtonComponent
+ expanded={isExpanded}
+ setExpanded={setIsExpanded}
+ {...args}
+ />
+ </IntlProvider>
+ );
+};
+
+export const HeadingButton = Template.bind({});
+HeadingButton.args = {
+ 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..fc79749
--- /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 { SetStateAction, VFC } 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: VFC<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..cfc1b0b
--- /dev/null
+++ b/src/components/molecules/buttons/help-button.stories.tsx
@@ -0,0 +1,44 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import HelpButtonComponent from './help-button';
+
+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) => (
+ <IntlProvider locale="en">
+ <HelpButtonComponent {...args} />
+ </IntlProvider>
+);
+
+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..aeb84ec
--- /dev/null
+++ b/src/components/molecules/buttons/help-button.tsx
@@ -0,0 +1,38 @@
+import Button, { ButtonProps } from '@components/atoms/buttons/button';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './help-button.module.scss';
+
+export type HelpButtonProps = Pick<ButtonProps, 'onClick'> & {
+ /**
+ * Set additional classnames to the button wrapper.
+ */
+ className?: string;
+};
+
+/**
+ * HelpButton component
+ *
+ * Render a button with an interrogation mark icon.
+ */
+const HelpButton: VFC<HelpButtonProps> = ({ className = '', onClick }) => {
+ const intl = useIntl();
+ const text = intl.formatMessage({
+ defaultMessage: 'Help',
+ id: 'i+/ckF',
+ description: 'HelpButton: screen reader text',
+ });
+
+ return (
+ <Button
+ shape="circle"
+ className={`${styles.btn} ${className}`}
+ onClick={onClick}
+ >
+ <span className="screen-reader-text">{text}</span>
+ <span className={styles.icon}>?</span>
+ </Button>
+ );
+};
+
+export default 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..a59bfa9
--- /dev/null
+++ b/src/components/molecules/forms/ackee-select.stories.tsx
@@ -0,0 +1,32 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import AckeeSelectComponent from './ackee-select';
+
+export default {
+ title: 'Molecules/Forms',
+ component: AckeeSelectComponent,
+ argTypes: {
+ initialValue: {
+ control: {
+ type: 'select',
+ },
+ description: 'Initial selected option.',
+ options: ['full', 'partial'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof AckeeSelectComponent>;
+
+const Template: ComponentStory<typeof AckeeSelectComponent> = (args) => (
+ <IntlProvider locale="en">
+ <AckeeSelectComponent {...args} />
+ </IntlProvider>
+);
+
+export const AckeeSelect = Template.bind({});
+AckeeSelect.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..e1e6b2d
--- /dev/null
+++ b/src/components/molecules/forms/ackee-select.test.tsx
@@ -0,0 +1,23 @@
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '@test-utils';
+import AckeeSelect from './ackee-select';
+
+describe('Select', () => {
+ it('should correctly set default option', () => {
+ render(<AckeeSelect 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', () => {
+ render(<AckeeSelect initialValue="full" />);
+
+ userEvent.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..4a8410c
--- /dev/null
+++ b/src/components/molecules/forms/ackee-select.tsx
@@ -0,0 +1,89 @@
+import { SelectOptions } from '@components/atoms/forms/select';
+import { Dispatch, SetStateAction, useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+import SelectWithTooltip, {
+ SelectWithTooltipProps,
+} from './select-with-tooltip';
+
+export type AckeeOptions = 'full' | 'partial';
+
+export type AckeeSelectProps = Pick<
+ SelectWithTooltipProps,
+ 'labelClassName' | 'tooltipClassName'
+> & {
+ /**
+ * A default value for Ackee settings.
+ */
+ initialValue: AckeeOptions;
+};
+
+/**
+ * AckeeSelect component
+ *
+ * Render a select to set Ackee settings.
+ */
+const AckeeSelect: VFC<AckeeSelectProps> = ({ initialValue, ...props }) => {
+ const intl = useIntl();
+ const [value, setValue] = useState<AckeeOptions>(initialValue);
+
+ 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/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..b77d71e
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field.stories.tsx
@@ -0,0 +1,201 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import LabelledFieldComponent from './labelled-field';
+
+export default {
+ title: 'Molecules/Forms',
+ component: LabelledFieldComponent,
+ 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,
+ },
+ },
+ 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 LabelledFieldComponent>;
+
+const Template: ComponentStory<typeof LabelledFieldComponent> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [value, setValue] = useState<string>('');
+
+ return <LabelledFieldComponent value={value} setValue={setValue} {...args} />;
+};
+
+export const LabelledField = Template.bind({});
+LabelledField.args = {
+ 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..08d0126
--- /dev/null
+++ b/src/components/molecules/forms/labelled-field.tsx
@@ -0,0 +1,51 @@
+import Field, { type FieldProps } from '@components/atoms/forms/field';
+import Label from '@components/atoms/forms/label';
+import { VFC } 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: VFC<LabelledFieldProps> = ({
+ hideLabel = false,
+ id,
+ label,
+ labelPosition = 'top',
+ required,
+ ...props
+}) => {
+ 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} required={required} {...props} />
+ </>
+ );
+};
+
+export default 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..0c569f5
--- /dev/null
+++ b/src/components/molecules/forms/labelled-select.stories.tsx
@@ -0,0 +1,195 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import LabelledSelectComponent 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' },
+];
+
+export default {
+ title: 'Molecules/Forms',
+ component: LabelledSelectComponent,
+ 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 LabelledSelectComponent>;
+
+const Template: ComponentStory<typeof LabelledSelectComponent> = ({
+ value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [selected, setSelected] = useState<string>(value);
+
+ return (
+ <LabelledSelectComponent
+ value={selected}
+ setValue={setSelected}
+ {...args}
+ />
+ );
+};
+
+export const LabelledSelect = Template.bind({});
+LabelledSelect.args = {
+ id: 'labelled-select-storybook',
+ label: 'Labelled select',
+ name: 'labelled-select-storybook',
+ options: selectOptions,
+ 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..7d4237a
--- /dev/null
+++ b/src/components/molecules/forms/labelled-select.tsx
@@ -0,0 +1,64 @@
+import Label, { LabelProps } from '@components/atoms/forms/label';
+import Select, { type SelectProps } from '@components/atoms/forms/select';
+import { VFC } 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?: string;
+ /**
+ * The label position. Default: top.
+ */
+ labelPosition?: 'left' | 'top';
+ /**
+ * The label size.
+ */
+ labelSize?: LabelProps['size'];
+ /**
+ * Set additional classnames to the select field.
+ */
+ selectClassName?: string;
+};
+
+const LabelledSelect: VFC<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..dc4d2a9
--- /dev/null
+++ b/src/components/molecules/forms/motion-toggle.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import MotionToggleComponent from './motion-toggle';
+
+export default {
+ title: 'Molecules/Forms',
+ component: MotionToggleComponent,
+ argTypes: {
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'The reduce motion value.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MotionToggleComponent>;
+
+const Template: ComponentStory<typeof MotionToggleComponent> = (args) => (
+ <IntlProvider locale="en">
+ <MotionToggleComponent {...args} />
+ </IntlProvider>
+);
+
+export const MotionToggle = Template.bind({});
+MotionToggle.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..77bc17c
--- /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 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..9f30b42
--- /dev/null
+++ b/src/components/molecules/forms/motion-toggle.tsx
@@ -0,0 +1,52 @@
+import Toggle, {
+ ToggleChoices,
+ ToggleProps,
+} from '@components/molecules/forms/toggle';
+import { useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type MotionToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
+
+/**
+ * MotionToggle component
+ *
+ * Render a Toggle component to set reduce motion.
+ */
+const MotionToggle: VFC<MotionToggleProps> = ({ value, ...props }) => {
+ const intl = useIntl();
+ const [isDeactivated, setIsDeactivated] = useState<boolean>(value);
+ 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={isDeactivated}
+ setValue={setIsDeactivated}
+ {...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..dc9090b
--- /dev/null
+++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import PrismThemeToggleComponent from './prism-theme-toggle';
+
+export default {
+ title: 'Molecules/Forms',
+ component: PrismThemeToggleComponent,
+ argTypes: {
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'The prism theme value.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof PrismThemeToggleComponent>;
+
+const Template: ComponentStory<typeof PrismThemeToggleComponent> = (args) => (
+ <IntlProvider locale="en">
+ <PrismThemeToggleComponent {...args} />
+ </IntlProvider>
+);
+
+export const PrismThemeToggle = Template.bind({});
+PrismThemeToggle.args = {
+ value: false,
+};
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..0dceb92
--- /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 checked toggle (dark theme choice)', () => {
+ render(<PrismThemeToggle value={true} />);
+ expect(
+ screen.getByRole('checkbox', {
+ name: `Code blocks: Light theme Dark theme`,
+ })
+ ).toBeChecked();
+ });
+});
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..daee6bd
--- /dev/null
+++ b/src/components/molecules/forms/prism-theme-toggle.tsx
@@ -0,0 +1,57 @@
+import Moon from '@components/atoms/icons/moon';
+import Sun from '@components/atoms/icons/sun';
+import Toggle, {
+ ToggleChoices,
+ ToggleProps,
+} from '@components/molecules/forms/toggle';
+import { useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type PrismThemeToggleProps = Pick<
+ ToggleProps,
+ 'labelClassName' | 'value'
+>;
+
+/**
+ * PrismThemeToggle component
+ *
+ * Render a Toggle component to set code blocks theme.
+ */
+const PrismThemeToggle: VFC<PrismThemeToggleProps> = ({ value, ...props }) => {
+ const intl = useIntl();
+ const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value);
+ 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={setIsDarkTheme}
+ {...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..c63e9b8
--- /dev/null
+++ b/src/components/molecules/forms/select-with-tooltip.stories.tsx
@@ -0,0 +1,211 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { IntlProvider } from 'react-intl';
+import SelectWithTooltipComponent from './select-with-tooltip';
+
+export default {
+ title: 'Molecules/Forms',
+ component: SelectWithTooltipComponent,
+ 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 SelectWithTooltipComponent>;
+
+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 SelectWithTooltipComponent> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [selected, setSelected] = useState<string>('option1');
+ return (
+ <IntlProvider locale="en">
+ <SelectWithTooltipComponent
+ value={selected}
+ setValue={setSelected}
+ {...args}
+ />
+ </IntlProvider>
+ );
+};
+
+export const SelectWithTooltip = Template.bind({});
+SelectWithTooltip.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..f537e1e
--- /dev/null
+++ b/src/components/molecules/forms/select-with-tooltip.tsx
@@ -0,0 +1,62 @@
+import { useState, VFC } 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'> & {
+ /**
+ * The select label.
+ */
+ label: string;
+ /**
+ * Set additional classnames to the tooltip wrapper.
+ */
+ tooltipClassName?: string;
+ };
+
+/**
+ * SelectWithTooltip component
+ *
+ * Render a select with a button to display a tooltip about options.
+ */
+const SelectWithTooltip: VFC<SelectWithTooltipProps> = ({
+ title,
+ content,
+ id,
+ tooltipClassName = '',
+ ...props
+}) => {
+ const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false);
+ const buttonModifier = isTooltipOpened ? styles['btn--activated'] : '';
+ const tooltipModifier = isTooltipOpened
+ ? styles['tooltip--visible']
+ : styles['tooltip--hidden'];
+
+ return (
+ <div className={styles.wrapper}>
+ <LabelledSelect
+ labelPosition="left"
+ id={id}
+ labelClassName={styles.label}
+ {...props}
+ />
+ <HelpButton
+ onClick={() => setIsTooltipOpened(!isTooltipOpened)}
+ className={`${styles.btn} ${buttonModifier}`}
+ />
+ <Tooltip
+ title={title}
+ content={content}
+ icon="?"
+ className={`${styles.tooltip} ${tooltipModifier} ${tooltipClassName}`}
+ />
+ </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..a9bcf73
--- /dev/null
+++ b/src/components/molecules/forms/theme-toggle.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import ThemeToggleComponent from './theme-toggle';
+
+export default {
+ title: 'Molecules/Forms',
+ component: ThemeToggleComponent,
+ argTypes: {
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'The theme value.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ThemeToggleComponent>;
+
+const Template: ComponentStory<typeof ThemeToggleComponent> = (args) => (
+ <IntlProvider locale="en">
+ <ThemeToggleComponent {...args} />
+ </IntlProvider>
+);
+
+export const ThemeToggle = Template.bind({});
+ThemeToggle.args = {
+ value: false,
+};
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..5cd3209
--- /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 checked toggle (dark theme choice)', () => {
+ render(<ThemeToggle value={true} />);
+ expect(
+ screen.getByRole('checkbox', {
+ name: `Theme: Light theme Dark theme`,
+ })
+ ).toBeChecked();
+ });
+});
diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx
new file mode 100644
index 0000000..eb56ce9
--- /dev/null
+++ b/src/components/molecules/forms/theme-toggle.tsx
@@ -0,0 +1,54 @@
+import Moon from '@components/atoms/icons/moon';
+import Sun from '@components/atoms/icons/sun';
+import Toggle, {
+ ToggleChoices,
+ ToggleProps,
+} from '@components/molecules/forms/toggle';
+import { useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
+
+/**
+ * ThemeToggle component
+ *
+ * Render a Toggle component to set theme.
+ */
+const ThemeToggle: VFC<ThemeToggleProps> = ({ value, ...props }) => {
+ const intl = useIntl();
+ const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value);
+ 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={setIsDarkTheme}
+ {...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..078a34c
--- /dev/null
+++ b/src/components/molecules/forms/toggle.stories.tsx
@@ -0,0 +1,117 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import ToggleComponent from './toggle';
+
+export default {
+ title: 'Molecules/Forms',
+ component: ToggleComponent,
+ 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 ToggleComponent>;
+
+const Template: ComponentStory<typeof ToggleComponent> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [isChecked, setIsChecked] = useState<boolean>(false);
+ return (
+ <ToggleComponent value={isChecked} setValue={setIsChecked} {...args} />
+ );
+};
+
+export const Toggle = Template.bind({});
+Toggle.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..dff2d2d
--- /dev/null
+++ b/src/components/molecules/forms/toggle.tsx
@@ -0,0 +1,86 @@
+import Checkbox from '@components/atoms/forms/checkbox';
+import Label, { type LabelProps } from '@components/atoms/forms/label';
+import { ReactNode, VFC } 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 = {
+ /**
+ * The toggle choices.
+ */
+ choices: ToggleChoices;
+ /**
+ * The input id.
+ */
+ id: string;
+ /**
+ * The toggle label.
+ */
+ label: string;
+ /**
+ * Set additional classnames to the label.
+ */
+ labelClassName?: string;
+ /**
+ * The label size.
+ */
+ labelSize?: LabelProps['size'];
+ /**
+ * The input name.
+ */
+ name: string;
+ /**
+ * 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: VFC<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/responsive-image.module.scss b/src/components/molecules/images/responsive-image.module.scss
new file mode 100644
index 0000000..83e8d10
--- /dev/null
+++ b/src/components/molecules/images/responsive-image.module.scss
@@ -0,0 +1,52 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ display: flex;
+ flex-flow: column;
+ width: 100%;
+ max-width: max-content;
+ margin: var(--spacing-sm) auto;
+ position: relative;
+ text-align: center;
+}
+
+.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);
+ box-shadow: 0 fun.convert-px(-1) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow-light);
+ font-weight: 500;
+}
+
+.link {
+ display: flex;
+ flex-flow: column;
+ background: none;
+ text-decoration: none;
+
+ .caption {
+ color: var(--color-primary-darker);
+ }
+
+ &:hover,
+ &:focus {
+ transform: scale(1.1);
+ }
+
+ &:focus {
+ .caption {
+ text-decoration: underline solid var(--color-primary-darker)
+ fun.convert-px(3);
+ }
+ }
+
+ &:active {
+ transform: scale(0.9);
+
+ .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..f9c1d2b
--- /dev/null
+++ b/src/components/molecules/images/responsive-image.stories.tsx
@@ -0,0 +1,87 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ResponsiveImageComponent from './responsive-image';
+
+export default {
+ title: 'Molecules/Images',
+ component: ResponsiveImageComponent,
+ 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,
+ },
+ },
+ 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,
+ },
+ },
+ },
+} as ComponentMeta<typeof ResponsiveImageComponent>;
+
+const Template: ComponentStory<typeof ResponsiveImageComponent> = (args) => (
+ <ResponsiveImageComponent {...args} />
+);
+
+export const ResponsiveImage = Template.bind({});
+ResponsiveImage.args = {
+ alt: 'An example',
+ src: 'http://placeimg.com/640/480/transport',
+ width: 640,
+ height: 480,
+};
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..1d8787e
--- /dev/null
+++ b/src/components/molecules/images/responsive-image.tsx
@@ -0,0 +1,81 @@
+import Link from '@components/atoms/links/link';
+import Image, { ImageProps } from 'next/image';
+import { VFC } 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?: string;
+ /**
+ * Set additional classnames to the figure wrapper.
+ */
+ className?: string;
+ /**
+ * The image height.
+ */
+ height: number | string;
+ /**
+ * A link target.
+ */
+ target?: string;
+ /**
+ * The image width.
+ */
+ width: number | string;
+};
+
+/**
+ * ResponsiveImage component
+ *
+ * Render a responsive image wrapped in a figure element.
+ */
+const ResponsiveImage: VFC<ResponsiveImageProps> = ({
+ alt,
+ caption,
+ className = '',
+ layout,
+ objectFit,
+ target,
+ ...props
+}) => {
+ return (
+ <figure className={`${styles.wrapper} ${className}`}>
+ {target ? (
+ <Link href={target} className={styles.link}>
+ <Image
+ alt={alt}
+ layout={layout || 'intrinsic'}
+ objectFit={objectFit || 'contain'}
+ {...props}
+ />
+ {caption && (
+ <figcaption className={styles.caption}>{caption}</figcaption>
+ )}
+ </Link>
+ ) : (
+ <>
+ <Image
+ alt={alt}
+ layout={layout || 'intrinsic'}
+ objectFit={objectFit || 'contain'}
+ {...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..aa18002
--- /dev/null
+++ b/src/components/molecules/layout/branding.module.scss
@@ -0,0 +1,48 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ display: grid;
+ grid-template-columns:
+ var(--logo-size, fun.convert-px(100))
+ minmax(0, 1fr);
+ grid-template-rows: 1fr min-content;
+ align-items: center;
+ column-gap: var(--spacing-sm);
+}
+
+.logo {
+ grid-row: span 2;
+}
+
+.title {
+ font-size: var(--font-size-2xl);
+}
+
+.baseline {
+ color: var(--color-fg-light);
+}
+
+.link {
+ background: linear-gradient(
+ to top,
+ var(--color-primary-light) fun.convert-px(5),
+ transparent fun.convert-px(5)
+ )
+ left / 0 100% no-repeat;
+ text-decoration: none;
+ transition: all 0.6s ease-out 0s;
+
+ &:hover,
+ &:focus {
+ background-size: 100% 100%;
+ }
+
+ &:focus {
+ color: var(--color-primary-light);
+ }
+
+ &:active {
+ background-size: 0 100%;
+ color: var(--color-primary-dark);
+ }
+}
diff --git a/src/components/molecules/layout/branding.stories.tsx b/src/components/molecules/layout/branding.stories.tsx
new file mode 100644
index 0000000..726ba26
--- /dev/null
+++ b/src/components/molecules/layout/branding.stories.tsx
@@ -0,0 +1,83 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import BrandingComponent from './branding';
+
+export default {
+ title: 'Molecules/Layout',
+ component: BrandingComponent,
+ args: {
+ isHome: false,
+ },
+ argTypes: {
+ baseline: {
+ control: {
+ type: 'text',
+ },
+ description: 'The Branding baseline.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isHome: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Use H1 if the current page is homepage.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ photo: {
+ control: {
+ type: 'text',
+ },
+ description: 'The Branding photo.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The Branding title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ withLink: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Wraps the title with a link to homepage.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof BrandingComponent>;
+
+const Template: ComponentStory<typeof BrandingComponent> = (args) => (
+ <IntlProvider locale="en">
+ <BrandingComponent {...args} />
+ </IntlProvider>
+);
+
+export const Branding = Template.bind({});
+Branding.args = {
+ title: 'Website title',
+ photo: 'http://placeimg.com/640/480',
+};
diff --git a/src/components/molecules/layout/branding.test.tsx b/src/components/molecules/layout/branding.test.tsx
new file mode 100644
index 0000000..4fe1e9a
--- /dev/null
+++ b/src/components/molecules/layout/branding.test.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@test-utils';
+import Branding from './branding';
+
+describe('Branding', () => {
+ it('renders a photo', () => {
+ render(
+ <Branding
+ photo="http://placeimg.com/640/480/city"
+ title="Website title"
+ />
+ );
+ expect(
+ screen.getByRole('img', { name: 'Website title picture' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a logo', () => {
+ render(
+ <Branding photo="http://placeimg.com/640/480/city" title="Website name" />
+ );
+ expect(screen.getByTitle('Website name logo')).toBeInTheDocument();
+ });
+
+ it('renders a baseline', () => {
+ render(
+ <Branding
+ photo="http://placeimg.com/640/480"
+ title="Website title"
+ baseline="Website baseline"
+ />
+ );
+ expect(screen.getByText('Website baseline')).toBeInTheDocument();
+ });
+
+ it('renders a title wrapped with h1 element', () => {
+ render(
+ <Branding
+ photo="http://placeimg.com/640/480"
+ title="Website title"
+ isHome={true}
+ />
+ );
+ expect(
+ screen.getByRole('heading', { level: 1, name: 'Website title' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a title with h1 styles', () => {
+ render(
+ <Branding
+ photo="http://placeimg.com/640/480"
+ title="Website title"
+ isHome={false}
+ />
+ );
+ expect(
+ screen.queryByRole('heading', { level: 1, name: 'Website title' })
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('Website title')).toHaveClass('heading--1');
+ });
+});
diff --git a/src/components/molecules/layout/branding.tsx b/src/components/molecules/layout/branding.tsx
new file mode 100644
index 0000000..9f564bf
--- /dev/null
+++ b/src/components/molecules/layout/branding.tsx
@@ -0,0 +1,97 @@
+import Heading from '@components/atoms/headings/heading';
+import Link from 'next/link';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './branding.module.scss';
+import FlippingLogo from './flipping-logo';
+
+type BrandingProps = {
+ /**
+ * The Branding baseline.
+ */
+ baseline?: string;
+ /**
+ * Use H1 if the current page is homepage. Default: false.
+ */
+ isHome?: boolean;
+ /**
+ * A photography URL.
+ */
+ photo: string;
+ /**
+ * The Branding title;
+ */
+ title: string;
+ /**
+ * Wraps the title with a link to homepage. Default: false.
+ */
+ withLink?: boolean;
+};
+
+/**
+ * Branding component
+ *
+ * Render the branding logo, title and optional baseline.
+ */
+const Branding: VFC<BrandingProps> = ({
+ baseline,
+ isHome = false,
+ photo,
+ title,
+ withLink = false,
+}) => {
+ const intl = useIntl();
+ const altText = intl.formatMessage(
+ {
+ defaultMessage: '{website} picture',
+ description: 'Branding: photo alternative text',
+ id: 'dDK5oc',
+ },
+ { website: title }
+ );
+ const logoTitle = intl.formatMessage(
+ {
+ defaultMessage: '{website} logo',
+ description: 'Branding: logo title',
+ id: 'x55qsD',
+ },
+ { website: title }
+ );
+
+ return (
+ <div className={styles.wrapper}>
+ <FlippingLogo
+ className={styles.logo}
+ altText={altText}
+ logoTitle={logoTitle}
+ photo={photo}
+ />
+ <Heading
+ isFake={!isHome}
+ level={1}
+ withMargin={false}
+ className={styles.title}
+ >
+ {withLink ? (
+ <Link href="/">
+ <a className={styles.link}>{title}</a>
+ </Link>
+ ) : (
+ title
+ )}
+ </Heading>
+ {baseline && (
+ <Heading
+ isFake={true}
+ level={4}
+ withMargin={false}
+ className={styles.baseline}
+ >
+ {baseline}
+ </Heading>
+ )}
+ </div>
+ );
+};
+
+export default Branding;
diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss
new file mode 100644
index 0000000..2b1b7dc
--- /dev/null
+++ b/src/components/molecules/layout/card.module.scss
@@ -0,0 +1,77 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ --scale-up: 1.05;
+ --scale-down: 0.95;
+
+ display: flex;
+ flex-flow: column wrap;
+ max-width: var(--card-width, 40ch);
+ padding: 0;
+ text-align: center;
+
+ .article {
+ flex: 1;
+ display: flex;
+ flex-flow: column nowrap;
+ justify-content: flex-start;
+ }
+
+ .footer {
+ margin-top: var(--spacing-md);
+ }
+
+ .cover {
+ align-self: flex-start;
+ max-height: fun.convert-px(150);
+ margin: auto;
+ border-bottom: fun.convert-px(1) solid var(--color-border);
+ }
+
+ .title,
+ .tagline,
+ .footer {
+ padding: 0 var(--spacing-md);
+ }
+
+ .title {
+ flex: 1;
+ margin: var(--spacing-sm) 0;
+ }
+
+ h2.title {
+ background: none;
+ text-shadow: none;
+ }
+
+ .tagline {
+ flex: 1;
+ color: var(--color-fg);
+ font-weight: 400;
+ }
+
+ .list {
+ margin-bottom: var(--spacing-md);
+ }
+
+ .items {
+ flex-flow: row wrap;
+ place-content: center;
+ gap: var(--spacing-2xs);
+ }
+
+ .term {
+ flex: 0 0 100%;
+ }
+
+ .description {
+ padding: fun.convert-px(2) var(--spacing-xs);
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ color: var(--color-fg);
+ font-weight: 400;
+
+ &::before {
+ display: none;
+ }
+ }
+}
diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx
new file mode 100644
index 0000000..a07f8dc
--- /dev/null
+++ b/src/components/molecules/layout/card.stories.tsx
@@ -0,0 +1,102 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CardComponent from './card';
+
+export default {
+ title: 'Molecules/Layout',
+ component: CardComponent,
+ argTypes: {
+ cover: {
+ description: 'The card cover data (src, dimensions, alternative text).',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ meta: {
+ description: 'The card metadata (a publication date for example).',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ tagline: {
+ control: {
+ type: 'text',
+ },
+ description: 'A few words about the card.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The card title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The title level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'The card target.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CardComponent>;
+
+const Template: ComponentStory<typeof CardComponent> = (args) => (
+ <CardComponent {...args} />
+);
+
+const cover = {
+ alt: 'A picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+const meta = [
+ {
+ id: 'an-id',
+ term: 'Voluptates',
+ value: ['Autem', 'Eos'],
+ },
+];
+
+export const Card = Template.bind({});
+Card.args = {
+ cover,
+ meta,
+ title: 'Veritatis dicta quod',
+ titleLevel: 2,
+ url: '#',
+};
diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx
new file mode 100644
index 0000000..404bc7a
--- /dev/null
+++ b/src/components/molecules/layout/card.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from '@test-utils';
+import Card from './card';
+
+const cover = {
+ alt: 'A picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+const meta = [
+ {
+ id: 'an-id',
+ term: 'Voluptates',
+ value: ['Autem', 'Eos'],
+ },
+];
+
+const tagline = 'Ut rerum incidunt';
+
+const title = 'Alias qui porro';
+
+const url = '/an-existing-url';
+
+describe('Card', () => {
+ it('renders a title wrapped in h2 element', () => {
+ render(<Card title={title} titleLevel={2} url={url} />);
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a link to another page', () => {
+ render(<Card title={title} titleLevel={2} url={url} />);
+ expect(screen.getByRole('link')).toHaveAttribute('href', url);
+ });
+
+ it('renders a cover', () => {
+ render(<Card title={title} titleLevel={2} url={url} cover={cover} />);
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+
+ it('renders a tagline', () => {
+ render(<Card title={title} titleLevel={2} url={url} tagline={tagline} />);
+ expect(screen.getByText(tagline)).toBeInTheDocument();
+ });
+
+ it('renders some meta', () => {
+ render(<Card title={title} titleLevel={2} url={url} meta={meta} />);
+ expect(screen.getByText(meta[0].term)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
new file mode 100644
index 0000000..23a0e54
--- /dev/null
+++ b/src/components/molecules/layout/card.tsx
@@ -0,0 +1,114 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import DescriptionList, {
+ DescriptionListItem,
+} from '@components/atoms/lists/description-list';
+import { VFC } from 'react';
+import ResponsiveImage, {
+ ResponsiveImageProps,
+} from '../images/responsive-image';
+import styles from './card.module.scss';
+
+export type Cover = {
+ /**
+ * The cover alternative text.
+ */
+ alt: string;
+ /**
+ * The cover height.
+ */
+ height: number;
+ /**
+ * The cover source.
+ */
+ src: string;
+ /**
+ * The cover width.
+ */
+ width: number;
+};
+
+export type CardProps = {
+ /**
+ * Set additional classnames to the card wrapper.
+ */
+ className?: string;
+ /**
+ * The card cover.
+ */
+ cover?: Cover;
+ /**
+ * The cover fit. Default: cover.
+ */
+ coverFit?: ResponsiveImageProps['objectFit'];
+ /**
+ * The card meta.
+ */
+ meta?: DescriptionListItem[];
+ /**
+ * The card tagline.
+ */
+ tagline?: string;
+ /**
+ * The card title.
+ */
+ title: string;
+ /**
+ * The title level (hn).
+ */
+ titleLevel: HeadingLevel;
+ /**
+ * The card target.
+ */
+ url: string;
+};
+
+/**
+ * Card component
+ *
+ * Render a link with minimal information about its content.
+ */
+const Card: VFC<CardProps> = ({
+ className = '',
+ cover,
+ coverFit = 'cover',
+ meta,
+ tagline,
+ title,
+ titleLevel,
+ url,
+}) => {
+ return (
+ <ButtonLink target={url} className={`${styles.wrapper} ${className}`}>
+ <article className={styles.article}>
+ <header className={styles.header}>
+ {cover && (
+ <ResponsiveImage
+ {...cover}
+ objectFit={coverFit}
+ className={styles.cover}
+ />
+ )}
+ <Heading level={titleLevel} className={styles.title}>
+ {title}
+ </Heading>
+ </header>
+ <div className={styles.tagline}>{tagline}</div>
+ {meta && (
+ <footer className={styles.footer}>
+ <DescriptionList
+ items={meta}
+ layout="inline"
+ className={styles.list}
+ groupClassName={styles.items}
+ termClassName={styles.term}
+ descriptionClassName={styles.description}
+ />
+ </footer>
+ )}
+ </article>
+ </ButtonLink>
+ );
+};
+
+export default Card;
diff --git a/src/components/molecules/layout/flipping-logo.module.scss b/src/components/molecules/layout/flipping-logo.module.scss
new file mode 100644
index 0000000..89b9499
--- /dev/null
+++ b/src/components/molecules/layout/flipping-logo.module.scss
@@ -0,0 +1,59 @@
+@use "@styles/abstracts/functions" as fun;
+
+.logo {
+ width: var(--logo-size, fun.convert-px(100));
+ height: var(--logo-size, fun.convert-px(100));
+ position: relative;
+ border-radius: 50%;
+ transform-style: preserve-3d;
+ transition: all 0.6s linear 0s;
+
+ &__front,
+ &__back {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ backface-visibility: hidden;
+ background: var(--color-bg);
+ border: fun.convert-px(2) solid var(--color-primary-dark);
+ border-radius: 50%;
+ transition: all 0.6s linear 0s;
+
+ svg,
+ img {
+ // !important is required to override next/image styles...
+ padding: fun.convert-px(2) !important;
+ border-radius: 50%;
+ }
+ }
+
+ &__front {
+ box-shadow: fun.convert-px(1) fun.convert-px(2) fun.convert-px(1) 0
+ var(--color-shadow-light),
+ fun.convert-px(2) fun.convert-px(3) fun.convert-px(3) 0
+ var(--color-shadow-light);
+ }
+
+ &__back {
+ transform: rotateY(180deg);
+ }
+
+ &:hover {
+ transform: rotateY(180deg);
+ }
+
+ &:hover & {
+ &__front {
+ box-shadow: none;
+ }
+
+ &__back {
+ box-shadow: fun.convert-px(1) fun.convert-px(2) fun.convert-px(1) 0
+ var(--color-shadow-light),
+ fun.convert-px(2) fun.convert-px(3) fun.convert-px(3) 0
+ var(--color-shadow-light);
+ }
+ }
+}
diff --git a/src/components/molecules/layout/flipping-logo.stories.tsx b/src/components/molecules/layout/flipping-logo.stories.tsx
new file mode 100644
index 0000000..1ac8de8
--- /dev/null
+++ b/src/components/molecules/layout/flipping-logo.stories.tsx
@@ -0,0 +1,66 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import FlippingLogoComponent from './flipping-logo';
+
+export default {
+ title: 'Molecules/Layout',
+ component: FlippingLogoComponent,
+ argTypes: {
+ altText: {
+ control: {
+ type: 'text',
+ },
+ description: 'Photo alternative text.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the logo wrapper.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ logoTitle: {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible name for the logo.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ photo: {
+ control: {
+ type: 'text',
+ },
+ description: 'Photo url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FlippingLogoComponent>;
+
+const Template: ComponentStory<typeof FlippingLogoComponent> = (args) => (
+ <FlippingLogoComponent {...args} />
+);
+
+export const FlippingLogo = Template.bind({});
+FlippingLogo.args = {
+ altText: 'Website picture',
+ logoTitle: 'Website logo',
+ photo: 'http://placeimg.com/640/480',
+};
diff --git a/src/components/molecules/layout/flipping-logo.test.tsx b/src/components/molecules/layout/flipping-logo.test.tsx
new file mode 100644
index 0000000..806fdbe
--- /dev/null
+++ b/src/components/molecules/layout/flipping-logo.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@test-utils';
+import FlippingLogo from './flipping-logo';
+
+describe('FlippingLogo', () => {
+ it('renders a photo', () => {
+ render(
+ <FlippingLogo
+ altText="Alternative text"
+ photo="http://placeimg.com/640/480"
+ />
+ );
+ expect(screen.getByAltText('Alternative text')).toBeInTheDocument();
+ });
+
+ it('renders a logo', () => {
+ render(
+ <FlippingLogo
+ altText="Alternative text"
+ logoTitle="A logo title"
+ photo="http://placeimg.com/640/480"
+ />
+ );
+ expect(screen.getByTitle('A logo title')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/layout/flipping-logo.tsx b/src/components/molecules/layout/flipping-logo.tsx
new file mode 100644
index 0000000..6f7645f
--- /dev/null
+++ b/src/components/molecules/layout/flipping-logo.tsx
@@ -0,0 +1,48 @@
+import Logo from '@components/atoms/images/logo';
+import Image from 'next/image';
+import { VFC } from 'react';
+import styles from './flipping-logo.module.scss';
+
+type FlippingLogoProps = {
+ /**
+ * Set additional classnames to the logo wrapper.
+ */
+ className?: string;
+ /**
+ * Photo alternative text.
+ */
+ altText: string;
+ /**
+ * Logo image title.
+ */
+ logoTitle?: string;
+ /**
+ * Photo url.
+ */
+ photo: string;
+};
+
+/**
+ * FlippingLogo component
+ *
+ * Render a logo and a photo with a flipping effect.
+ */
+const FlippingLogo: VFC<FlippingLogoProps> = ({
+ className = '',
+ altText,
+ logoTitle,
+ photo,
+}) => {
+ return (
+ <div className={`${styles.logo} ${className}`}>
+ <div className={styles.logo__front}>
+ <Image src={photo} alt={altText} layout="fill" objectFit="cover" />
+ </div>
+ <div className={styles.logo__back}>
+ <Logo title={logoTitle} />
+ </div>
+ </div>
+ );
+};
+
+export default FlippingLogo;
diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx
new file mode 100644
index 0000000..e7a932d
--- /dev/null
+++ b/src/components/molecules/layout/meta.stories.tsx
@@ -0,0 +1,57 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MetaComponent from './meta';
+
+export default {
+ title: 'Molecules/Layout',
+ component: MetaComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the meta wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ meta: {
+ control: {
+ type: null,
+ },
+ description: 'The page metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof MetaComponent>;
+
+const Template: ComponentStory<typeof MetaComponent> = (args) => (
+ <MetaComponent {...args} />
+);
+
+const data = {
+ publication: { name: 'Published on:', value: 'April 9th 2022' },
+ categories: {
+ name: 'Categories:',
+ value: [
+ <a key="category1" href="#">
+ Category 1
+ </a>,
+ <a key="category2" href="#">
+ Category 2
+ </a>,
+ ],
+ },
+};
+
+export const Meta = Template.bind({});
+Meta.args = {
+ data,
+};
diff --git a/src/components/molecules/layout/meta.test.tsx b/src/components/molecules/layout/meta.test.tsx
new file mode 100644
index 0000000..a738bdb
--- /dev/null
+++ b/src/components/molecules/layout/meta.test.tsx
@@ -0,0 +1,8 @@
+import { render } from '@test-utils';
+import Meta from './meta';
+
+describe('Meta', () => {
+ it('renders a Meta component', () => {
+ render(<Meta data={{}} />);
+ });
+});
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
new file mode 100644
index 0000000..218ebd9
--- /dev/null
+++ b/src/components/molecules/layout/meta.tsx
@@ -0,0 +1,74 @@
+import DescriptionList, {
+ type DescriptionListProps,
+ type DescriptionListItem,
+} from '@components/atoms/lists/description-list';
+import { ReactNode, VFC } from 'react';
+
+export type MetaItem = {
+ /**
+ * The meta name.
+ */
+ name: string;
+ /**
+ * The meta value.
+ */
+ value: ReactNode | ReactNode[];
+};
+
+export type MetaMap = {
+ [key: string]: MetaItem | undefined;
+};
+
+export type MetaProps = {
+ /**
+ * Set additional classnames to the meta wrapper.
+ */
+ className?: string;
+ /**
+ * The meta data.
+ */
+ data: MetaMap;
+ /**
+ * The meta layout.
+ */
+ layout?: DescriptionListProps['layout'];
+ /**
+ * Determine if the layout should be responsive.
+ */
+ responsiveLayout?: DescriptionListProps['responsiveLayout'];
+};
+
+/**
+ * Meta component
+ *
+ * Renders the page metadata.
+ */
+const Meta: VFC<MetaProps> = ({ data, ...props }) => {
+ /**
+ * Transform the metadata to description list item format.
+ *
+ * @param {MetaMap} items - The meta.
+ * @returns {DescriptionListItem[]} The formatted description list items.
+ */
+ const getItems = (items: MetaMap): DescriptionListItem[] => {
+ const listItems: DescriptionListItem[] = Object.entries(items)
+ .map(([key, item]) => {
+ if (!item) return;
+
+ const { name, value } = item;
+
+ return {
+ id: key,
+ term: name,
+ value: Array.isArray(value) ? value : [value],
+ } as DescriptionListItem;
+ })
+ .filter((item): item is DescriptionListItem => !!item);
+
+ return listItems;
+ };
+
+ return <DescriptionList items={getItems(data)} {...props} />;
+};
+
+export default Meta;
diff --git a/src/components/molecules/layout/widget.module.scss b/src/components/molecules/layout/widget.module.scss
new file mode 100644
index 0000000..727ffb7
--- /dev/null
+++ b/src/components/molecules/layout/widget.module.scss
@@ -0,0 +1,40 @@
+@use "@styles/abstracts/functions" as fun;
+
+.widget {
+ display: flex;
+ flex-flow: column;
+
+ &__header {
+ background: var(--color-bg);
+ }
+
+ &--has-borders & {
+ &__body {
+ border: fun.convert-px(2) solid var(--color-primary-dark);
+ }
+ }
+
+ &--collapsed & {
+ &__body {
+ max-height: 0;
+ margin: 0;
+ visibility: hidden;
+ opacity: 0;
+ overflow: hidden;
+ border: 0 solid transparent;
+ transition: all 0.1s linear 0.3s,
+ max-height 0.5s cubic-bezier(0, 1, 0, 1) 0s, margin 0.3s ease-in-out 0s;
+ }
+ }
+
+ &--expanded & {
+ &__body {
+ max-height: 10000px; // needs a fixed value for transition.
+ margin: var(--spacing-sm) 0;
+ opacity: 1;
+ visibility: visible;
+ transition: all 0.5s ease-in-out 0s, border 0s linear 0s,
+ max-height 0.6s ease-in-out 0s;
+ }
+ }
+}
diff --git a/src/components/molecules/layout/widget.stories.tsx b/src/components/molecules/layout/widget.stories.tsx
new file mode 100644
index 0000000..d79f66e
--- /dev/null
+++ b/src/components/molecules/layout/widget.stories.tsx
@@ -0,0 +1,85 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import WidgetComponent from './widget';
+
+export default {
+ title: 'Molecules/Layout',
+ component: WidgetComponent,
+ args: {
+ expanded: true,
+ withBorders: false,
+ },
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget body',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ expanded: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'The widget state (expanded or collapsed)',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ withBorders: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Define if the content should have borders.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof WidgetComponent>;
+
+const Template: ComponentStory<typeof WidgetComponent> = (args) => (
+ <IntlProvider locale="en">
+ <WidgetComponent {...args} />
+ </IntlProvider>
+);
+
+export const Widget = Template.bind({});
+Widget.args = {
+ children: 'Widget body',
+ level: 2,
+ title: 'Widget title',
+};
diff --git a/src/components/molecules/layout/widget.test.tsx b/src/components/molecules/layout/widget.test.tsx
new file mode 100644
index 0000000..af561ea
--- /dev/null
+++ b/src/components/molecules/layout/widget.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@test-utils';
+import Widget from './widget';
+
+const children = 'Widget body';
+const title = 'Widget title';
+const titleLevel = 2;
+
+describe('Widget', () => {
+ it('renders the widget title', () => {
+ render(
+ <Widget expanded={true} title={title} level={titleLevel}>
+ {children}
+ </Widget>
+ );
+ expect(
+ screen.getByRole('heading', { level: titleLevel })
+ ).toHaveTextContent(title);
+ });
+});
diff --git a/src/components/molecules/layout/widget.tsx b/src/components/molecules/layout/widget.tsx
new file mode 100644
index 0000000..c04362a
--- /dev/null
+++ b/src/components/molecules/layout/widget.tsx
@@ -0,0 +1,54 @@
+import { FC, useState } from 'react';
+import HeadingButton, { HeadingButtonProps } from '../buttons/heading-button';
+import styles from './widget.module.scss';
+
+export type WidgetProps = Pick<
+ HeadingButtonProps,
+ 'expanded' | 'level' | 'title'
+> & {
+ /**
+ * Set additional classnames to the widget wrapper.
+ */
+ className?: string;
+ /**
+ * Determine if the widget body should have borders. Default: false.
+ */
+ withBorders?: boolean;
+};
+
+/**
+ * Widget component
+ *
+ * Render an expandable widget.
+ */
+const Widget: FC<WidgetProps> = ({
+ children,
+ className = '',
+ expanded = true,
+ level,
+ title,
+ withBorders = false,
+}) => {
+ const [isExpanded, setIsExpanded] = useState<boolean>(expanded);
+ const stateClass = isExpanded ? 'widget--expanded' : 'widget--collapsed';
+ const bordersClass = withBorders
+ ? 'widget--has-borders'
+ : 'widget--no-borders';
+
+ return (
+ <div
+ className={`${styles.widget} ${styles[bordersClass]} ${styles[stateClass]} ${className}`}
+ >
+ <HeadingButton
+ level={level}
+ title={title}
+ expanded={isExpanded}
+ setExpanded={setIsExpanded}
+ className={styles.widget__header}
+ />
+ <div className={styles.widget__body}>{children}</div>
+ </div>
+ );
+};
+
+export default Widget;
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..bd7d9f4
--- /dev/null
+++ b/src/components/molecules/modals/modal.stories.tsx
@@ -0,0 +1,69 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ModalComponent from './modal';
+
+export default {
+ title: 'Molecules/Modals',
+ component: ModalComponent,
+ 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,
+ },
+ },
+ 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 ModalComponent>;
+
+const Template: ComponentStory<typeof ModalComponent> = (args) => (
+ <ModalComponent {...args} />
+);
+
+export const Modal = Template.bind({});
+Modal.args = {
+ children:
+ 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.',
+};
diff --git a/src/components/molecules/modals/modal.test.tsx b/src/components/molecules/modals/modal.test.tsx
new file mode 100644
index 0000000..14fb224
--- /dev/null
+++ b/src/components/molecules/modals/modal.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Modal from './modal';
+
+describe('Modal', () => {
+ it('renders a title', () => {
+ render(<Modal title="A custom title" />);
+ expect(screen.getByText('A custom title')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/molecules/modals/modal.tsx b/src/components/molecules/modals/modal.tsx
new file mode 100644
index 0000000..52ada57
--- /dev/null
+++ b/src/components/molecules/modals/modal.tsx
@@ -0,0 +1,77 @@
+import Heading from '@components/atoms/headings/heading';
+import { CogProps } from '@components/atoms/icons/cog';
+import { MagnifyingGlassProps } from '@components/atoms/icons/magnifying-glass';
+import dynamic from 'next/dynamic';
+import { FC } from 'react';
+import styles from './modal.module.scss';
+
+export type Icons = 'cogs' | 'search';
+
+export type ModalProps = {
+ /**
+ * Set additional classnames.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the heading.
+ */
+ headingClassName?: string;
+ /**
+ * 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..63fc71d
--- /dev/null
+++ b/src/components/molecules/modals/tooltip.stories.tsx
@@ -0,0 +1,64 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import TooltipComponent from './tooltip';
+
+export default {
+ title: 'Molecules/Modals',
+ component: TooltipComponent,
+ 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 TooltipComponent>;
+
+const Template: ComponentStory<typeof TooltipComponent> = (args) => (
+ <TooltipComponent {...args} />
+);
+
+export const Tooltip = Template.bind({});
+Tooltip.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..73f36e7
--- /dev/null
+++ b/src/components/molecules/modals/tooltip.tsx
@@ -0,0 +1,62 @@
+import List, { type ListItem } from '@components/atoms/lists/list';
+import { ReactNode, VFC } 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: VFC<TooltipProps> = ({
+ className = '',
+ content,
+ icon,
+ title,
+}) => {
+ /**
+ * 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}`}>
+ <div className={styles.title}>
+ <span className={styles.icon}>{icon}</span>
+ {title}
+ </div>
+ {Array.isArray(content) ? (
+ <List items={getListItems(content)} />
+ ) : (
+ content
+ )}
+ </div>
+ );
+};
+
+export default 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..d283619
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.stories.tsx
@@ -0,0 +1,48 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import BreadcrumbComponent, { type BreadcrumbItem } from './breadcrumb';
+
+export default {
+ title: 'Molecules/Nav',
+ component: BreadcrumbComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ table: {
+ category: 'Styles',
+ },
+ description: 'Set additional classnames to the nav element.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ description: 'The breadcrumb items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof BreadcrumbComponent>;
+
+const Template: ComponentStory<typeof BreadcrumbComponent> = (args) => (
+ <IntlProvider locale="en">
+ <BreadcrumbComponent {...args} />
+ </IntlProvider>
+);
+
+const items: BreadcrumbItem[] = [
+ { id: 'home', url: '#', name: 'Home' },
+ { id: 'blog', url: '#', name: 'Blog' },
+ { id: 'post1', url: '#', name: 'A Post' },
+];
+
+export const Breadcrumb = Template.bind({});
+Breadcrumb.args = {
+ items,
+};
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..33af735
--- /dev/null
+++ b/src/components/molecules/nav/breadcrumb.tsx
@@ -0,0 +1,113 @@
+import Link from '@components/atoms/links/link';
+import { settings } from '@utils/config';
+import Script from 'next/script';
+import { VFC } 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;
+ /**
+ * The breadcrumb items
+ */
+ items: BreadcrumbItem[];
+};
+
+/**
+ * Breadcrumb component
+ *
+ * Render a breadcrumb navigation.
+ */
+const Breadcrumb: VFC<BreadcrumbProps> = ({ items, ...props }) => {
+ const intl = useIntl();
+
+ /**
+ * 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 itemClassnames = isLastItem
+ ? `${styles.item} screen-reader-text`
+ : styles.item;
+
+ return (
+ <li key={item.id} className={itemClassnames}>
+ {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 {...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..9975bbd
--- /dev/null
+++ b/src/components/molecules/nav/nav.stories.tsx
@@ -0,0 +1,75 @@
+import Envelop from '@components/atoms/icons/envelop';
+import Home from '@components/atoms/icons/home';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import NavComponent, { type NavItem } from './nav';
+
+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' },
+];
+
+export default {
+ title: 'Molecules/Nav',
+ component: NavComponent,
+ argTypes: {
+ 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,
+ },
+ },
+ },
+} as ComponentMeta<typeof NavComponent>;
+
+const Template: ComponentStory<typeof NavComponent> = (args) => (
+ <IntlProvider locale="en">
+ <NavComponent {...args} />
+ </IntlProvider>
+);
+
+export const MainNav = Template.bind({});
+MainNav.args = {
+ items: MainNavItems,
+ kind: 'main',
+};
+
+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..6ef9158
--- /dev/null
+++ b/src/components/molecules/nav/nav.tsx
@@ -0,0 +1,80 @@
+import Link from '@components/atoms/links/link';
+import NavLink from '@components/atoms/links/nav-link';
+import { ReactNode, VFC } 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 = {
+ /**
+ * 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: VFC<NavProps> = ({
+ className = '',
+ items,
+ kind,
+ listClassName = '',
+}) => {
+ 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}`}>
+ <ul className={`${styles.nav__list} ${listClassName}`}>{getItems()}</ul>
+ </nav>
+ );
+};
+
+export default Nav;
diff --git a/src/components/organisms/forms/comment-form.module.scss b/src/components/organisms/forms/comment-form.module.scss
new file mode 100644
index 0000000..f3f2646
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.module.scss
@@ -0,0 +1,8 @@
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: auto;
+}
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
new file mode 100644
index 0000000..1ab7cf2
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -0,0 +1,86 @@
+import Notice from '@components/atoms/layout/notice';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import CommentFormComponent from './comment-form';
+
+export default {
+ title: 'Organisms/Forms',
+ component: CommentFormComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ saveComment: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to process the comment form data.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The form title.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The title level (hn).',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CommentFormComponent>;
+
+const Template: ComponentStory<typeof CommentFormComponent> = (args) => (
+ <IntlProvider locale="en">
+ <CommentFormComponent {...args} />
+ </IntlProvider>
+);
+
+export const CommentForm = Template.bind({});
+CommentForm.args = {
+ saveComment: (reset: () => void) => {
+ reset();
+ },
+};
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form.test.tsx
new file mode 100644
index 0000000..0d387b5
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from '@test-utils';
+import CommentForm from './comment-form';
+
+const title = 'Cum voluptas voluptatibus';
+
+describe('CommentForm', () => {
+ it('renders a form', () => {
+ render(<CommentForm saveComment={() => null} />);
+ expect(screen.getByRole('form')).toBeInTheDocument();
+ });
+
+ it('renders an optional title', () => {
+ render(
+ <CommentForm saveComment={() => null} title={title} titleLevel={2} />
+ );
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
new file mode 100644
index 0000000..6acbf94
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -0,0 +1,174 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { ReactNode, useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './comment-form.module.scss';
+
+export type CommentFormProps = {
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * A callback function to save comment. It takes a function as parameter to
+ * reset the form.
+ */
+ saveComment: (reset: () => void) => void;
+ /**
+ * The form title.
+ */
+ title?: string;
+ /**
+ * The title level.
+ */
+ titleLevel?: HeadingLevel;
+};
+
+const CommentForm: VFC<CommentFormProps> = ({
+ className = '',
+ Notice,
+ saveComment,
+ title,
+ titleLevel = 2,
+}) => {
+ const intl = useIntl();
+ const [name, setName] = useState<string>('');
+ const [email, setEmail] = useState<string>('');
+ const [website, setWebsite] = useState<string>('');
+ const [comment, setComment] = useState<string>('');
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setName('');
+ setEmail('');
+ setWebsite('');
+ setComment('');
+ setIsSubmitting(false);
+ };
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'CommentForm: name label',
+ id: 'ZIrTee',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'CommentForm: email label',
+ id: 'Bh7z5v',
+ });
+
+ const websiteLabel = intl.formatMessage({
+ defaultMessage: 'Website:',
+ description: 'CommentForm: website label',
+ id: 'u41qSk',
+ });
+
+ const commentLabel = intl.formatMessage({
+ defaultMessage: 'Comment:',
+ description: 'CommentForm: comment label',
+ id: 'A8hGaK',
+ });
+
+ const formTitle = intl.formatMessage({
+ defaultMessage: 'Comment form',
+ description: 'CommentForm: aria label',
+ id: 'dz2kDV',
+ });
+
+ const formAriaLabel = title ? undefined : formTitle;
+ const formId = 'comment-form-title';
+ const formLabelledBy = title ? formId : undefined;
+
+ /**
+ * Handle form submit.
+ */
+ const submitHandler = () => {
+ setIsSubmitting(true);
+ saveComment(resetForm);
+ };
+
+ return (
+ <Form
+ onSubmit={submitHandler}
+ className={className}
+ aria-label={formAriaLabel}
+ aria-labelledby={formLabelledBy}
+ >
+ {title && (
+ <Heading id={formId} level={titleLevel}>
+ {title}
+ </Heading>
+ )}
+ <LabelledField
+ type="text"
+ id="commenter-name"
+ name="commenter-name"
+ label={nameLabel}
+ required={true}
+ value={name}
+ setValue={setName}
+ className={styles.field}
+ />
+ <LabelledField
+ type="email"
+ id="commenter-email"
+ name="commenter-email"
+ label={emailLabel}
+ required={true}
+ value={email}
+ setValue={setEmail}
+ className={styles.field}
+ />
+ <LabelledField
+ type="text"
+ id="commenter-website"
+ name="commenter-website"
+ label={websiteLabel}
+ required={false}
+ value={website}
+ setValue={setWebsite}
+ className={styles.field}
+ />
+ <LabelledField
+ type="textarea"
+ id="commenter-comment"
+ name="commenter-comment"
+ label={commentLabel}
+ required={true}
+ value={comment}
+ setValue={setComment}
+ className={styles.field}
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Publish',
+ description: 'CommentForm: submit button',
+ id: 'OL0Yzx',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Submitting...',
+ description: 'CommentForm: spinner message on submit',
+ id: 'IY5ew6',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
+
+export default CommentForm;
diff --git a/src/components/organisms/forms/contact-form.module.scss b/src/components/organisms/forms/contact-form.module.scss
new file mode 100644
index 0000000..f3f2646
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.module.scss
@@ -0,0 +1,8 @@
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: auto;
+}
diff --git a/src/components/organisms/forms/contact-form.stories.tsx b/src/components/organisms/forms/contact-form.stories.tsx
new file mode 100644
index 0000000..2c8ab32
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.stories.tsx
@@ -0,0 +1,59 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import ContactFormComponent from './contact-form';
+
+export default {
+ title: 'Organisms/Forms',
+ component: ContactFormComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ sendMail: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to process the contact form data.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ContactFormComponent>;
+
+const Template: ComponentStory<typeof ContactFormComponent> = (args) => (
+ <IntlProvider locale="en">
+ <ContactFormComponent {...args} />
+ </IntlProvider>
+);
+
+export const ContactForm = Template.bind({});
+ContactForm.args = {
+ sendMail: (reset: () => void) => {
+ reset();
+ },
+};
diff --git a/src/components/organisms/forms/contact-form.test.tsx b/src/components/organisms/forms/contact-form.test.tsx
new file mode 100644
index 0000000..744f147
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@test-utils';
+import ContactForm from './contact-form';
+
+const props = {
+ sendMail: () => null,
+};
+
+describe('ContactForm', () => {
+ it('renders a contact form', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('form', { name: 'Contact form' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a name field', () => {
+ render(<ContactForm {...props} />);
+ expect(screen.getByRole('textbox', { name: /^Name:/ })).toBeInTheDocument();
+ });
+
+ it('renders an email field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Email:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an object field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Object:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a message field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Message:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ render(<ContactForm {...props} />);
+ expect(screen.getByRole('button', { name: /^Send/ })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx
new file mode 100644
index 0000000..994244a
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.tsx
@@ -0,0 +1,150 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { ReactNode, useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './contact-form.module.scss';
+
+export type ContactFormProps = {
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * A callback function to send mail. It takes a function as parameter to
+ * reset the form.
+ */
+ sendMail: (reset: () => void) => void;
+};
+
+/**
+ * ContactForm component
+ *
+ * Render a contact form.
+ */
+const ContactForm: VFC<ContactFormProps> = ({
+ className = '',
+ Notice,
+ sendMail,
+}) => {
+ const intl = useIntl();
+ const [name, setName] = useState<string>('');
+ const [email, setEmail] = useState<string>('');
+ const [object, setObject] = useState<string>('');
+ const [message, setMessage] = useState<string>('');
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setName('');
+ setEmail('');
+ setObject('');
+ setMessage('');
+ setIsSubmitting(false);
+ };
+
+ const formName = intl.formatMessage({
+ defaultMessage: 'Contact form',
+ description: 'ContactForm: form accessible name',
+ id: 'HFdzae',
+ });
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'ContactForm: name label',
+ id: '1dCuCx',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'ContactForm: email label',
+ id: 'w4B5PA',
+ });
+
+ const objectLabel = intl.formatMessage({
+ defaultMessage: 'Object:',
+ description: 'ContactForm: object label',
+ id: 's8/tyz',
+ });
+
+ const messageLabel = intl.formatMessage({
+ defaultMessage: 'Message:',
+ description: 'ContactForm: message label',
+ id: 'yN5P+m',
+ });
+
+ const submitHandler = () => {
+ setIsSubmitting(true);
+ sendMail(resetForm);
+ };
+
+ return (
+ <Form aria-label={formName} onSubmit={submitHandler} className={className}>
+ <LabelledField
+ type="text"
+ id="contact-name"
+ name="contact-name"
+ label={nameLabel}
+ required={true}
+ value={name}
+ setValue={setName}
+ className={styles.field}
+ />
+ <LabelledField
+ type="email"
+ id="contact-email"
+ name="contact-email"
+ label={emailLabel}
+ required={true}
+ value={email}
+ setValue={setEmail}
+ className={styles.field}
+ />
+ <LabelledField
+ type="text"
+ id="contact-object"
+ name="contact-object"
+ label={objectLabel}
+ value={object}
+ setValue={setObject}
+ className={styles.field}
+ />
+ <LabelledField
+ type="textarea"
+ id="contact-message"
+ name="contact-message"
+ label={messageLabel}
+ required={true}
+ value={message}
+ setValue={setMessage}
+ className={styles.field}
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Send',
+ description: 'ContactForm: send button',
+ id: 'VkAnvv',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Sending mail...',
+ description: 'ContactForm: spinner message on submit',
+ id: 'xaqaYQ',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
+
+export default ContactForm;
diff --git a/src/components/organisms/forms/search-form.module.scss b/src/components/organisms/forms/search-form.module.scss
new file mode 100644
index 0000000..1d388a4
--- /dev/null
+++ b/src/components/organisms/forms/search-form.module.scss
@@ -0,0 +1,58 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ align-items: center;
+ position: relative;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ max-width: 35ch;
+ }
+ }
+}
+
+.btn {
+ position: absolute;
+ right: 0;
+
+ &__icon {
+ transform: scale(0.85);
+ transition: all 0.3s ease-in-out 0s;
+ }
+
+ &:focus {
+ outline: var(--color-primary-light) solid fun.convert-px(3);
+ }
+
+ &:active {
+ outline: none;
+ }
+
+ &:hover &,
+ &:focus & {
+ &__icon {
+ transform: scale(0.85) rotate(20deg) translateY(#{fun.convert-px(3)});
+ }
+ }
+
+ &:active & {
+ &__icon {
+ transform: scale(0.7);
+ }
+ }
+}
+
+.field {
+ width: 100%;
+ padding-right: var(--spacing-lg);
+
+ &:hover ~ .btn {
+ transform: translate(fun.convert-px(-3), fun.convert-px(-3));
+ }
+
+ &:focus ~ .btn {
+ transform: translate(fun.convert-px(3), fun.convert-px(3));
+ }
+}
diff --git a/src/components/organisms/forms/search-form.stories.tsx b/src/components/organisms/forms/search-form.stories.tsx
new file mode 100644
index 0000000..4ec1c21
--- /dev/null
+++ b/src/components/organisms/forms/search-form.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SearchFormComponent from './search-form';
+
+export default {
+ title: 'Organisms/Forms',
+ component: SearchFormComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SearchFormComponent>;
+
+const Template: ComponentStory<typeof SearchFormComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SearchFormComponent {...args} />
+ </IntlProvider>
+);
+
+export const SearchForm = Template.bind({});
+SearchForm.args = {
+ hideLabel: true,
+};
diff --git a/src/components/organisms/forms/search-form.test.tsx b/src/components/organisms/forms/search-form.test.tsx
new file mode 100644
index 0000000..4e3d285
--- /dev/null
+++ b/src/components/organisms/forms/search-form.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@test-utils';
+import SearchForm from './search-form';
+
+describe('SearchForm', () => {
+ it('renders a search input', () => {
+ render(<SearchForm />);
+ expect(
+ screen.getByRole('searchbox', { name: 'Search for:' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ render(<SearchForm />);
+ expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/search-form.tsx b/src/components/organisms/forms/search-form.tsx
new file mode 100644
index 0000000..351d93c
--- /dev/null
+++ b/src/components/organisms/forms/search-form.tsx
@@ -0,0 +1,57 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
+import LabelledField, {
+ LabelledFieldProps,
+} from '@components/molecules/forms/labelled-field';
+import { useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './search-form.module.scss';
+
+export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'>;
+
+const SearchForm: VFC<SearchFormProps> = ({ hideLabel }) => {
+ const intl = useIntl();
+ const fieldLabel = intl.formatMessage({
+ defaultMessage: 'Search for:',
+ description: 'SearchForm: field accessible label',
+ id: 'X8oujO',
+ });
+ const buttonLabel = intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'SearchForm: button accessible name',
+ id: 'WMqQrv',
+ });
+
+ const [value, setValue] = useState<string>('');
+
+ const submitHandler = () => {
+ return;
+ };
+
+ return (
+ <Form grouped={false} onSubmit={submitHandler} className={styles.wrapper}>
+ <LabelledField
+ type="search"
+ id="search-form"
+ name="search-form"
+ label={fieldLabel}
+ value={value}
+ setValue={setValue}
+ hideLabel={hideLabel}
+ className={styles.field}
+ />
+ <Button
+ type="submit"
+ kind="neutral"
+ shape="initial"
+ className={styles.btn}
+ aria-label={buttonLabel}
+ >
+ <MagnifyingGlass className={styles.btn__icon} />
+ </Button>
+ </Form>
+ );
+};
+
+export default SearchForm;
diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss
new file mode 100644
index 0000000..9fe428c
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.module.scss
@@ -0,0 +1,27 @@
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ --card-width: 30ch;
+
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width))
+ );
+ gap: var(--spacing-sm);
+ place-content: center;
+ align-items: stretch;
+ justify-items: stretch;
+
+ &--ordered {
+ @extend %reset-ordered-list;
+ }
+
+ &--unordered {
+ @extend %reset-list;
+ }
+}
+
+.card {
+ height: 100%;
+}
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
new file mode 100644
index 0000000..5182808
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -0,0 +1,105 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CardsListComponent, { type CardsListItem } from './cards-list';
+
+export default {
+ title: 'Organisms/Layout',
+ component: CardsListComponent,
+ args: {
+ kind: 'unordered',
+ },
+ argTypes: {
+ items: {
+ description: 'The cards data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level for each card.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CardsListComponent>;
+
+const Template: ComponentStory<typeof CardsListComponent> = (args) => (
+ <CardsListComponent {...args} />
+);
+
+const items: CardsListItem[] = [
+ {
+ id: 'card-1',
+ cover: {
+ alt: 'card 1 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] },
+ ],
+ tagline: 'Molestias ut error',
+ title: 'Et alias omnis',
+ url: '#',
+ },
+ {
+ id: 'card-2',
+ cover: {
+ alt: 'card 2 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }],
+ tagline: 'Quod vel accusamus',
+ title: 'Laboriosam doloremque mollitia',
+ url: '#',
+ },
+ {
+ id: 'card-3',
+ cover: {
+ alt: 'card 3 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ {
+ id: 'meta-1',
+ term: 'Omnis',
+ value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+ },
+ ],
+ tagline: 'Quo error eum',
+ title: 'Magni rem nulla',
+ url: '#',
+ },
+];
+
+export const CardsList = Template.bind({});
+CardsList.args = {
+ items,
+ titleLevel: 2,
+};
diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx
new file mode 100644
index 0000000..2df3f59
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.test.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@test-utils';
+import CardsList from './cards-list';
+
+const items = [
+ {
+ id: 'card-1',
+ cover: {
+ alt: 'card 1 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] },
+ ],
+ tagline: 'Molestias ut error',
+ title: 'Et alias omnis',
+ url: '#',
+ },
+ {
+ id: 'card-2',
+ cover: {
+ alt: 'card 2 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }],
+ tagline: 'Quod vel accusamus',
+ title: 'Laboriosam doloremque mollitia',
+ url: '#',
+ },
+ {
+ id: 'card-3',
+ cover: {
+ alt: 'card 3 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ {
+ id: 'meta-1',
+ term: 'Omnis',
+ value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+ },
+ ],
+ tagline: 'Quo error eum',
+ title: 'Magni rem nulla',
+ url: '#',
+ },
+];
+
+describe('CardsList', () => {
+ it('renders a list of cards', () => {
+ render(<CardsList items={items} titleLevel={2} />);
+ expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(
+ items.length
+ );
+ });
+});
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
new file mode 100644
index 0000000..a53df0d
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -0,0 +1,80 @@
+import List, {
+ type ListItem,
+ type ListProps,
+} from '@components/atoms/lists/list';
+import Card, { type CardProps } from '@components/molecules/layout/card';
+import { VFC } from 'react';
+import styles from './cards-list.module.scss';
+
+export type CardsListItem = Omit<
+ CardProps,
+ 'className' | 'coverFit' | 'titleLevel'
+> & {
+ id: string;
+};
+
+export type CardsListProps = {
+ /**
+ * The cover fit.
+ */
+ coverFit?: CardProps['coverFit'];
+ /**
+ * The cards data.
+ */
+ items: CardsListItem[];
+ /**
+ * The list kind. Either ordered or unordered.
+ */
+ kind?: ListProps['kind'];
+ /**
+ * The title level (hn).
+ */
+ titleLevel: CardProps['titleLevel'];
+};
+
+/**
+ * CardsList component
+ *
+ * Return a list of Card components.
+ */
+const CardsList: VFC<CardsListProps> = ({
+ coverFit,
+ items,
+ kind = 'unordered',
+ titleLevel,
+}) => {
+ const kindModifier = `wrapper--${kind}`;
+
+ /**
+ * Format the cards data to be used by the List component.
+ *
+ * @param {CardsListItem[]} cards - An array of card data.
+ * @returns {ListItem[]} The formatted cards data.
+ */
+ const getCards = (cards: CardsListItem[]): ListItem[] => {
+ return cards.map(({ id, ...card }) => {
+ return {
+ id,
+ value: (
+ <Card
+ key={id}
+ coverFit={coverFit}
+ titleLevel={titleLevel}
+ className={styles.card}
+ {...card}
+ />
+ ),
+ };
+ });
+ };
+
+ return (
+ <List
+ items={getCards(items)}
+ withMargin={false}
+ className={`${styles.wrapper} ${styles[kindModifier]}`}
+ />
+ );
+};
+
+export default CardsList;
diff --git a/src/components/organisms/layout/footer.module.scss b/src/components/organisms/layout/footer.module.scss
new file mode 100644
index 0000000..c180e86
--- /dev/null
+++ b/src/components/organisms/layout/footer.module.scss
@@ -0,0 +1,41 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-xs);
+ place-items: center;
+ place-content: center;
+ padding: var(--spacing-md) 0 calc(var(--toolbar-size) + var(--spacing-md));
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ --toolbar-size: 0px;
+
+ flex-flow: row wrap;
+ font-size: var(--font-size-sm);
+ }
+ }
+}
+
+.nav {
+ display: flex;
+ flex-flow: row wrap;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ &::before {
+ content: "\2022";
+ margin-right: var(--spacing-2xs);
+ }
+ }
+ }
+}
+
+.back-to-top {
+ position: fixed;
+ bottom: calc(var(--toolbar-size, 0px) + var(--spacing-md));
+ right: var(--spacing-md);
+ transition: all 0.4s ease-in 0s;
+}
diff --git a/src/components/organisms/layout/footer.stories.tsx b/src/components/organisms/layout/footer.stories.tsx
new file mode 100644
index 0000000..2ce7ee1
--- /dev/null
+++ b/src/components/organisms/layout/footer.stories.tsx
@@ -0,0 +1,74 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import FooterComponent from './footer';
+
+export default {
+ title: 'Organisms/Layout',
+ component: FooterComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the footer element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ copyright: {
+ description: 'The copyright information.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ navItems: {
+ description: 'The footer nav items.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ topId: {
+ control: {
+ type: 'text',
+ },
+ description:
+ 'An element id (without hashtag) used as target by back to top button.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FooterComponent>;
+
+const Template: ComponentStory<typeof FooterComponent> = (args) => (
+ <IntlProvider locale="en">
+ <FooterComponent {...args} />
+ </IntlProvider>
+);
+
+const copyright = {
+ dates: { start: '2017', end: '2022' },
+ owner: 'Lorem ipsum',
+ icon: 'CC',
+};
+
+const navItems = [{ id: 'legal-notice', href: '#', label: 'Legal notice' }];
+
+export const Footer = Template.bind({});
+Footer.args = {
+ copyright,
+ navItems,
+ topId: 'top',
+};
diff --git a/src/components/organisms/layout/footer.test.tsx b/src/components/organisms/layout/footer.test.tsx
new file mode 100644
index 0000000..bc23732
--- /dev/null
+++ b/src/components/organisms/layout/footer.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import Footer, { type FooterProps } from './footer';
+
+const copyright: FooterProps['copyright'] = {
+ dates: { start: '2017', end: '2022' },
+ owner: 'Lorem ipsum',
+ icon: 'CC',
+};
+
+const navItems: FooterProps['navItems'] = [
+ { id: 'legal-notice', href: '#', label: 'Legal notice' },
+];
+
+describe('Footer', () => {
+ it('renders the website copyright', () => {
+ render(<Footer copyright={copyright} topId="top" />);
+ expect(screen.getByText(copyright.owner)).toBeInTheDocument();
+ });
+
+ it('renders a back to top link', () => {
+ render(<Footer copyright={copyright} topId="top" />);
+ expect(
+ screen.getByRole('link', { name: 'Back to top' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders some nav items', () => {
+ render(<Footer copyright={copyright} navItems={navItems} topId="top" />);
+ expect(
+ screen.getByRole('link', { name: navItems[0].label })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
new file mode 100644
index 0000000..c9cb067
--- /dev/null
+++ b/src/components/organisms/layout/footer.tsx
@@ -0,0 +1,52 @@
+import Copyright, { CopyrightProps } from '@components/atoms/layout/copyright';
+import BackToTop from '@components/molecules/buttons/back-to-top';
+import Nav, { type NavItem } from '@components/molecules/nav/nav';
+import { VFC } from 'react';
+import styles from './footer.module.scss';
+
+export type FooterProps = {
+ /**
+ * Set additional classnames to the footer element.
+ */
+ className?: string;
+ /**
+ * Set the copyright information.
+ */
+ copyright: CopyrightProps;
+ /**
+ * The footer nav items.
+ */
+ navItems?: NavItem[];
+ /**
+ * An element id (without hashtag) used as anchor for back to top button.
+ */
+ topId: string;
+};
+
+/**
+ * Footer component
+ *
+ * Renders a footer with copyright and nav;
+ */
+const Footer: VFC<FooterProps> = ({
+ className,
+ copyright,
+ navItems,
+ topId,
+}) => {
+ return (
+ <footer className={`${styles.wrapper} ${className}`}>
+ <Copyright
+ dates={copyright.dates}
+ owner={copyright.owner}
+ icon={copyright.icon}
+ />
+ {navItems && (
+ <Nav kind="footer" items={navItems} className={styles.nav} />
+ )}
+ <BackToTop target={topId} className={styles['back-to-top']} />
+ </footer>
+ );
+};
+
+export default Footer;
diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss
new file mode 100644
index 0000000..4d50ad1
--- /dev/null
+++ b/src/components/organisms/layout/overview.module.scss
@@ -0,0 +1,12 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-border);
+}
+
+.cover {
+ max-height: fun.convert-px(150);
+ margin: 0 auto var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-border);
+}
diff --git a/src/components/organisms/layout/overview.stories.tsx b/src/components/organisms/layout/overview.stories.tsx
new file mode 100644
index 0000000..61d8b35
--- /dev/null
+++ b/src/components/organisms/layout/overview.stories.tsx
@@ -0,0 +1,50 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import OverviewComponent from './overview';
+
+export default {
+ title: 'Organisms/Layout',
+ component: OverviewComponent,
+ argTypes: {
+ cover: {
+ description: 'The overview cover.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ meta: {
+ description: 'The overview metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof OverviewComponent>;
+
+const Template: ComponentStory<typeof OverviewComponent> = (args) => (
+ <OverviewComponent {...args} />
+);
+
+const cover = {
+ alt: 'picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480/cats',
+ width: 640,
+};
+
+const meta = {
+ publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
+ update: {
+ name: 'Perspiciatis vel laudantium:',
+ value: 'Dignissimos ratione veritatis',
+ },
+};
+
+export const Overview = Template.bind({});
+Overview.args = {
+ cover,
+ meta,
+};
diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx
new file mode 100644
index 0000000..0738d3f
--- /dev/null
+++ b/src/components/organisms/layout/overview.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@test-utils';
+import Overview from './overview';
+
+const cover = {
+ alt: 'Incidunt unde quam',
+ height: 480,
+ src: 'http://placeimg.com/640/480/cats',
+ width: 640,
+};
+
+const meta = {
+ publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
+ update: {
+ name: 'Perspiciatis vel laudantium:',
+ value: 'Dignissimos ratione veritatis',
+ },
+};
+
+describe('Overview', () => {
+ it('renders some meta', () => {
+ render(<Overview meta={meta} />);
+ expect(screen.getByText(meta['publication'].name)).toBeInTheDocument();
+ });
+
+ it('renders a cover', () => {
+ render(<Overview meta={meta} cover={cover} />);
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx
new file mode 100644
index 0000000..3f83342
--- /dev/null
+++ b/src/components/organisms/layout/overview.tsx
@@ -0,0 +1,33 @@
+import ResponsiveImage, {
+ type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Meta, { type MetaMap } from '@components/molecules/layout/meta';
+import { VFC } from 'react';
+import styles from './overview.module.scss';
+
+export type OverviewProps = {
+ cover?: Pick<ResponsiveImageProps, 'alt' | 'src' | 'width' | 'height'>;
+ meta: MetaMap;
+};
+
+/**
+ * Overview component
+ *
+ * Render an overview.
+ */
+const Overview: VFC<OverviewProps> = ({ cover, meta }) => {
+ return (
+ <div className={styles.wrapper}>
+ {cover && (
+ <ResponsiveImage
+ objectFit="cover"
+ className={styles.cover}
+ {...cover}
+ />
+ )}
+ <Meta data={meta} layout="column" responsiveLayout={true} />
+ </div>
+ );
+};
+
+export default Overview;
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
new file mode 100644
index 0000000..5da0a18
--- /dev/null
+++ b/src/components/organisms/layout/summary.module.scss
@@ -0,0 +1,84 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-primary-dark);
+ border-radius: fun.convert-px(3);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+ var(--color-shadow),
+ fun.convert-px(3) fun.convert-px(3) fun.convert-px(3) fun.convert-px(-1)
+ var(--color-shadow-light),
+ fun.convert-px(5) fun.convert-px(5) fun.convert-px(7) fun.convert-px(-1)
+ var(--color-shadow-light);
+ }
+
+ @include mix.dimensions("sm") {
+ display: grid;
+ grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
+ grid-template-rows: repeat(3, max-content);
+ column-gap: var(--spacing-md);
+ }
+ }
+}
+
+.cover {
+ width: auto;
+ max-height: fun.convert-px(100);
+ max-width: 100%;
+ border: fun.convert-px(1) solid var(--color-border);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 2;
+ grid-row: 1;
+ }
+ }
+}
+
+.header {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 1;
+ grid-row: 1;
+ align-self: center;
+ }
+ }
+}
+
+.body {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ }
+}
+
+.footer {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 2;
+ grid-row: 2 / 4;
+ }
+ }
+}
+
+.title {
+ background: none;
+ text-shadow: none;
+}
+
+.read-more {
+ display: flex;
+ flex-flow: row nowrap;
+ column-gap: var(--spacing-xs);
+ width: max-content;
+ margin: var(--spacing-sm) 0;
+}
+
+.meta {
+ font-size: var(--font-size-sm);
+}
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
new file mode 100644
index 0000000..5214d70
--- /dev/null
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -0,0 +1,114 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SummaryComponent from './summary';
+
+export default {
+ title: 'Organisms/Layout',
+ component: SummaryComponent,
+ args: {
+ titleLevel: 2,
+ },
+ argTypes: {
+ cover: {
+ description: 'The cover data.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ excerpt: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page excerpt.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ meta: {
+ description: 'The page metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page title',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The page title level (hn)',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 2 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SummaryComponent>;
+
+const Template: ComponentStory<typeof SummaryComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SummaryComponent {...args} />
+ </IntlProvider>
+);
+
+const meta = {
+ publication: { name: 'Published on:', value: 'April 11th 2022' },
+ readingTime: { name: 'Reading time:', value: '5 minutes' },
+ categories: {
+ name: 'Categories:',
+ value: [
+ <a key="cat-1" href="#">
+ Cat 1
+ </a>,
+ <a key="cat-2" href="#">
+ Cat 2
+ </a>,
+ ],
+ },
+ comments: { name: 'Comments:', value: '1 comment' },
+};
+
+export const Summary = Template.bind({});
+Summary.args = {
+ cover: {
+ alt: 'A cover',
+ height: 480,
+ url: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ excerpt:
+ 'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.',
+ meta,
+ title: 'Odio odit necessitatibus',
+ url: '#',
+};
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
new file mode 100644
index 0000000..ce87c0c
--- /dev/null
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen } from '@test-utils';
+import Summary from './summary';
+
+const cover = {
+ alt: 'A cover',
+ height: 480,
+ url: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+const excerpt =
+ 'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
+
+const meta = {
+ publication: { name: 'Published on:', value: 'April 11th 2022' },
+ readingTime: { name: 'Reading time:', value: '5 minutes' },
+ categories: {
+ name: 'Categories:',
+ value: [
+ <a key="cat-1" href="#">
+ Cat 1
+ </a>,
+ <a key="cat-2" href="#">
+ Cat 2
+ </a>,
+ ],
+ },
+ comments: { name: 'Comments:', value: '1 comment' },
+};
+
+const title = 'Odio odit necessitatibus';
+
+const url = '#';
+
+describe('Summary', () => {
+ it('renders a title wrapped in a h2 element', () => {
+ render(
+ <Summary
+ excerpt={excerpt}
+ meta={meta}
+ title={title}
+ titleLevel={2}
+ url={url}
+ />
+ );
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an excerpt', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByText(excerpt)).toBeInTheDocument();
+ });
+
+ it('renders a cover', () => {
+ render(
+ <Summary
+ cover={cover}
+ excerpt={excerpt}
+ meta={meta}
+ title={title}
+ url={url}
+ />
+ );
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+
+ it('renders a link to the full post', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByRole('link', { name: title })).toBeInTheDocument();
+ });
+
+ it('renders a read more link', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(
+ screen.getByRole('link', { name: `Read more about ${title}` })
+ ).toBeInTheDocument();
+ });
+
+ it('renders some meta', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByText(meta.publication.name)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
new file mode 100644
index 0000000..3624e5d
--- /dev/null
+++ b/src/components/organisms/layout/summary.tsx
@@ -0,0 +1,105 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Arrow from '@components/atoms/icons/arrow';
+import Link from '@components/atoms/links/link';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import Meta, { type MetaItem } from '@components/molecules/layout/meta';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './summary.module.scss';
+
+export type Cover = {
+ alt: string;
+ height: number;
+ url: string;
+ width: number;
+};
+
+export type RequiredMetaKey = 'publication';
+
+export type RequiredMeta = {
+ [key in RequiredMetaKey]: MetaItem;
+};
+
+export type OptionalMetaKey =
+ | 'author'
+ | 'categories'
+ | 'comments'
+ | 'readingTime'
+ | 'update';
+
+export type OptionalMeta = {
+ [key in OptionalMetaKey]?: MetaItem;
+};
+
+export type Meta = RequiredMeta & OptionalMeta;
+
+export type SummaryProps = {
+ cover?: Cover;
+ excerpt: string;
+ meta: Meta;
+ title: string;
+ titleLevel?: HeadingLevel;
+ url: string;
+};
+
+/**
+ * Summary component
+ *
+ * Render a page summary.
+ */
+const Summary: VFC<SummaryProps> = ({
+ cover,
+ excerpt,
+ meta,
+ title,
+ titleLevel = 2,
+ url,
+}) => {
+ const intl = useIntl();
+
+ return (
+ <article className={styles.wrapper}>
+ {cover && (
+ <ResponsiveImage
+ alt={cover.alt}
+ src={cover.url}
+ width={cover.width}
+ height={cover.height}
+ className={styles.cover}
+ />
+ )}
+ <header className={styles.header}>
+ <Link href={url}>
+ <Heading level={titleLevel} className={styles.title}>
+ {title}
+ </Heading>
+ </Link>
+ </header>
+ <div className={styles.body}>
+ {excerpt}
+ <ButtonLink target={url} className={styles['read-more']}>
+ {intl.formatMessage(
+ {
+ defaultMessage: 'Read more<a11y> about {title}</a11y>',
+ description: 'Summary: read more link',
+ id: 'Zpgv+f',
+ },
+ {
+ title,
+ a11y: (chunks: string) => (
+ <span className="screen-reader-text">{chunks}</span>
+ ),
+ }
+ )}
+ <Arrow direction="right" />
+ </ButtonLink>
+ </div>
+ <footer className={styles.footer}>
+ <Meta data={meta} layout="column" className={styles.meta} />
+ </footer>
+ </article>
+ );
+};
+
+export default Summary;
diff --git a/src/components/organisms/modals/search-modal.module.scss b/src/components/organisms/modals/search-modal.module.scss
new file mode 100644
index 0000000..aba0593
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.module.scss
@@ -0,0 +1,11 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ padding-bottom: var(--spacing-md);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ max-width: 40ch;
+ }
+ }
+}
diff --git a/src/components/organisms/modals/search-modal.stories.tsx b/src/components/organisms/modals/search-modal.stories.tsx
new file mode 100644
index 0000000..b35c841
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.stories.tsx
@@ -0,0 +1,16 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SearchModalComponent from './search-modal';
+
+export default {
+ title: 'Organisms/Modals',
+ component: SearchModalComponent,
+} as ComponentMeta<typeof SearchModalComponent>;
+
+const Template: ComponentStory<typeof SearchModalComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SearchModalComponent {...args} />
+ </IntlProvider>
+);
+
+export const SearchModal = Template.bind({});
diff --git a/src/components/organisms/modals/search-modal.test.tsx b/src/components/organisms/modals/search-modal.test.tsx
new file mode 100644
index 0000000..249c523
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import SearchModal from './search-modal';
+
+describe('SearchModal', () => {
+ it('renders a search modal', () => {
+ render(<SearchModal />);
+ expect(screen.getByText('Search')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx
new file mode 100644
index 0000000..883b783
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -0,0 +1,34 @@
+import Modal from '@components/molecules/modals/modal';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import SearchForm from '../forms/search-form';
+import styles from './search-modal.module.scss';
+
+export type SearchModalProps = {
+ /**
+ * Set additional classnames to modal wrapper.
+ */
+ className?: string;
+};
+
+/**
+ * SearchModal
+ *
+ * Render a search form modal.
+ */
+const SearchModal: VFC<SearchModalProps> = ({ className }) => {
+ const intl = useIntl();
+ const modalTitle = intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'SearchModal: modal title',
+ id: 'G+Twgm',
+ });
+
+ return (
+ <Modal title={modalTitle} className={`${styles.wrapper} ${className}`}>
+ <SearchForm hideLabel={true} />
+ </Modal>
+ );
+};
+
+export default SearchModal;
diff --git a/src/components/organisms/modals/settings-modal.module.scss b/src/components/organisms/modals/settings-modal.module.scss
new file mode 100644
index 0000000..ebae3da
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.module.scss
@@ -0,0 +1,21 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ .label {
+ margin-right: auto;
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ font-size: var(--font-size-sm);
+
+ .heading {
+ font-size: var(--font-size-lg);
+ }
+
+ .label {
+ font-size: var(--font-size-sm);
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx
new file mode 100644
index 0000000..c19a6d7
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SettingsModal from './settings-modal';
+
+export default {
+ title: 'Organisms/Modals',
+ component: SettingsModal,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SettingsModal>;
+
+const Template: ComponentStory<typeof SettingsModal> = (args) => (
+ <IntlProvider locale="en">
+ <SettingsModal {...args} />
+ </IntlProvider>
+);
+
+export const Settings = Template.bind({});
diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx
new file mode 100644
index 0000000..6291e54
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@test-utils';
+import SettingsModal from './settings-modal';
+
+describe('SettingsModal', () => {
+ it('renders a theme toggle setting', () => {
+ render(<SettingsModal />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Theme:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a code blocks toggle setting', () => {
+ render(<SettingsModal />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Code blocks:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a motion setting', () => {
+ render(<SettingsModal />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Animations:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a Ackee setting', () => {
+ render(<SettingsModal />);
+ expect(
+ screen.getByRole('combobox', { name: /^Tracking:/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx
new file mode 100644
index 0000000..25d6f6f
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.tsx
@@ -0,0 +1,59 @@
+import Form from '@components/atoms/forms/form';
+import AckeeSelect from '@components/molecules/forms/ackee-select';
+import MotionToggle from '@components/molecules/forms/motion-toggle';
+import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle';
+import ThemeToggle from '@components/molecules/forms/theme-toggle';
+import Modal from '@components/molecules/modals/modal';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './settings-modal.module.scss';
+
+export type SettingsModalProps = {
+ /**
+ * Set additional classnames to the modal wrapper.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the tooltip wrapper.
+ */
+ tooltipClassName?: string;
+};
+
+/**
+ * SettingsModal component
+ *
+ * Render a modal with settings options.
+ */
+const SettingsModal: VFC<SettingsModalProps> = ({
+ className = '',
+ tooltipClassName = '',
+}) => {
+ const intl = useIntl();
+ const title = intl.formatMessage({
+ defaultMessage: 'Settings',
+ description: 'SettingsModal: title',
+ id: 'gPfT/K',
+ });
+
+ return (
+ <Modal
+ title={title}
+ icon="cogs"
+ className={`${styles.wrapper} ${className}`}
+ headingClassName={styles.heading}
+ >
+ <Form onSubmit={() => null}>
+ <ThemeToggle labelClassName={styles.label} value={false} />
+ <PrismThemeToggle labelClassName={styles.label} value={false} />
+ <MotionToggle labelClassName={styles.label} value={false} />
+ <AckeeSelect
+ initialValue="full"
+ labelClassName={styles.label}
+ tooltipClassName={tooltipClassName}
+ />
+ </Form>
+ </Modal>
+ );
+};
+
+export default SettingsModal;
diff --git a/src/components/organisms/toolbar/main-nav.module.scss b/src/components/organisms/toolbar/main-nav.module.scss
new file mode 100644
index 0000000..76af935
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.module.scss
@@ -0,0 +1,88 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.item {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ .checkbox,
+ .label {
+ display: none;
+ }
+
+ .modal {
+ position: relative;
+ }
+ }
+ }
+
+ .modal {
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "md") {
+ padding: var(--spacing-2xs);
+ background: var(--color-bg-secondary);
+ border-top: fun.convert-px(4) solid;
+ border-bottom: 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.dimensions("sm", "md") {
+ border-left: fun.convert-px(4) solid;
+ border-right: fun.convert-px(4) solid;
+ }
+
+ @include mix.dimensions("md") {
+ top: unset;
+ }
+ }
+ }
+
+ .modal__list {
+ display: flex;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm", "md") {
+ flex-flow: column;
+ }
+ }
+ }
+
+ .checkbox {
+ &:checked {
+ ~ .label .icon {
+ background: transparent;
+ border: transparent;
+
+ &::before {
+ top: 0;
+ transform-origin: 50% 50%;
+ transform: rotate(-45deg);
+ }
+
+ &::after {
+ bottom: 0;
+ transform-origin: 50% 50%;
+ transform: rotate(45deg);
+ }
+ }
+ }
+
+ &:not(:checked) {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ ~ .modal {
+ opacity: 1;
+ visibility: visible;
+ transform: none;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/main-nav.stories.tsx b/src/components/organisms/toolbar/main-nav.stories.tsx
new file mode 100644
index 0000000..27387c0
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.stories.tsx
@@ -0,0 +1,75 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { IntlProvider } from 'react-intl';
+import MainNavComponent from './main-nav';
+
+export default {
+ title: 'Organisms/Toolbar',
+ component: MainNavComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the main nav wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Determine if the main nav is open or not.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ items: {
+ description: 'The main nav items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to change main nav state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MainNavComponent>;
+
+const Template: ComponentStory<typeof MainNavComponent> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return (
+ <IntlProvider locale="en">
+ <MainNavComponent isActive={isOpen} setIsActive={setIsOpen} {...args} />
+ </IntlProvider>
+ );
+};
+
+export const MainNav = Template.bind({});
+MainNav.args = {
+ isActive: false,
+ items: [
+ { id: 'home', label: 'Home', href: '#' },
+ { id: 'contact', label: 'Contact', href: '#' },
+ ],
+};
diff --git a/src/components/organisms/toolbar/main-nav.test.tsx b/src/components/organisms/toolbar/main-nav.test.tsx
new file mode 100644
index 0000000..6e50562
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import MainNav from './main-nav';
+
+const items = [
+ { id: 'home', label: 'Home', href: '/' },
+ { id: 'blog', label: 'Blog', href: '/blog' },
+ { id: 'contact', label: 'Contact', href: '/contact' },
+];
+
+describe('MainNav', () => {
+ it('renders a checkbox to open main nav', () => {
+ render(<MainNav items={items} isActive={false} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Open menu');
+ });
+
+ it('renders a checkbox to close main nav', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close menu');
+ });
+
+ it('renders the correct number of items', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(items.length);
+ });
+
+ it('renders some links with the right label', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('link', { name: items[0].label })).toHaveAttribute(
+ 'href',
+ items[0].href
+ );
+ });
+});
diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx
new file mode 100644
index 0000000..50bbe8b
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.tsx
@@ -0,0 +1,74 @@
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
+import Label from '@components/atoms/forms/label';
+import Hamburger from '@components/atoms/icons/hamburger';
+import Nav, { type NavItem } from '@components/molecules/nav/nav';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import sharedStyles from './toolbar-items.module.scss';
+import mainNavStyles from './main-nav.module.scss';
+
+export type MainNavProps = {
+ /**
+ * Set additional classnames to the nav element.
+ */
+ className?: string;
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * The main nav items.
+ */
+ items: NavItem[];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+};
+
+const MainNav: VFC<MainNavProps> = ({
+ className = '',
+ isActive,
+ items,
+ setIsActive,
+}) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close menu',
+ description: 'MainNav: Close label',
+ id: 'aJC7D2',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open menu',
+ description: 'MainNav: Open label',
+ id: 'GTbGMy',
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${mainNavStyles.item}`}>
+ <Checkbox
+ id="main-nav-button"
+ name="main-nav-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${mainNavStyles.checkbox}`}
+ />
+ <Label
+ htmlFor="main-nav-button"
+ aria-label={label}
+ className={`${sharedStyles.label} ${mainNavStyles.label}`}
+ >
+ <Hamburger iconClassName={mainNavStyles.icon} />
+ </Label>
+ <Nav
+ kind="main"
+ items={items}
+ className={`${sharedStyles.modal} ${mainNavStyles.modal} ${className}`}
+ listClassName={mainNavStyles.modal__list}
+ />
+ </div>
+ );
+};
+
+export default MainNav;
diff --git a/src/components/organisms/toolbar/search.module.scss b/src/components/organisms/toolbar/search.module.scss
new file mode 100644
index 0000000..c310594
--- /dev/null
+++ b/src/components/organisms/toolbar/search.module.scss
@@ -0,0 +1,3 @@
+.modal {
+ padding-bottom: var(--spacing-md);
+}
diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx
new file mode 100644
index 0000000..8c2e871
--- /dev/null
+++ b/src/components/organisms/toolbar/search.stories.tsx
@@ -0,0 +1,63 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { IntlProvider } from 'react-intl';
+import SearchComponent from './search';
+
+export default {
+ title: 'Organisms/Toolbar',
+ component: SearchComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Define the modal state: either opened or closed.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to update modal state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SearchComponent>;
+
+const Template: ComponentStory<typeof SearchComponent> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return (
+ <IntlProvider locale="en">
+ <SearchComponent isActive={isOpen} setIsActive={setIsOpen} {...args} />
+ </IntlProvider>
+ );
+};
+
+export const Search = Template.bind({});
+Search.args = {
+ isActive: false,
+};
diff --git a/src/components/organisms/toolbar/search.test.tsx b/src/components/organisms/toolbar/search.test.tsx
new file mode 100644
index 0000000..0ce09d8
--- /dev/null
+++ b/src/components/organisms/toolbar/search.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@test-utils';
+import Search from './search';
+
+describe('Search', () => {
+ it('renders a button to open search modal', () => {
+ render(<Search isActive={false} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Open search');
+ });
+
+ it('renders a button to close search modal', () => {
+ render(<Search isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close search');
+ });
+
+ it('renders a search form', () => {
+ render(<Search isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx
new file mode 100644
index 0000000..070bce0
--- /dev/null
+++ b/src/components/organisms/toolbar/search.tsx
@@ -0,0 +1,66 @@
+import Checkbox, { CheckboxProps } from '@components/atoms/forms/checkbox';
+import Label from '@components/atoms/forms/label';
+import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import SearchModal from '../modals/search-modal';
+import sharedStyles from './toolbar-items.module.scss';
+import searchStyles from './search.module.scss';
+
+export type SearchProps = {
+ /**
+ * Set additional classnames to the modal wrapper.
+ */
+ className?: string;
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+};
+
+const Search: VFC<SearchProps> = ({
+ className = '',
+ isActive,
+ setIsActive,
+}) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close search',
+ id: 'LDDUNO',
+ description: 'Search: Close label',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open search',
+ id: 'Xj+WXB',
+ description: 'Search: Open label',
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${searchStyles.item}`}>
+ <Checkbox
+ id="search-button"
+ name="search-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${searchStyles.checkbox}`}
+ />
+ <Label
+ htmlFor="search-button"
+ aria-label={label}
+ className={`${sharedStyles.label} ${searchStyles.label}`}
+ >
+ <MagnifyingGlass />
+ </Label>
+ <SearchModal
+ className={`${sharedStyles.modal} ${searchStyles.modal} ${className}`}
+ />
+ </div>
+ );
+};
+
+export default Search;
diff --git a/src/components/organisms/toolbar/settings.module.scss b/src/components/organisms/toolbar/settings.module.scss
new file mode 100644
index 0000000..08c8cd4
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.modal {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ width: 120%;
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx
new file mode 100644
index 0000000..f01e772
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.stories.tsx
@@ -0,0 +1,76 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { IntlProvider } from 'react-intl';
+import SettingsComponent from './settings';
+
+export default {
+ title: 'Organisms/Toolbar',
+ component: SettingsComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Define the modal state: either opened or closed.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to update modal state.',
+ type: {
+ name: 'function',
+ 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 SettingsComponent>;
+
+const Template: ComponentStory<typeof SettingsComponent> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return (
+ <IntlProvider locale="en">
+ <SettingsComponent isActive={isOpen} setIsActive={setIsOpen} {...args} />
+ </IntlProvider>
+ );
+};
+
+export const Settings = Template.bind({});
+Settings.args = {
+ isActive: false,
+};
diff --git a/src/components/organisms/toolbar/settings.test.tsx b/src/components/organisms/toolbar/settings.test.tsx
new file mode 100644
index 0000000..96a32c9
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@test-utils';
+import Settings from './settings';
+
+describe('Settings', () => {
+ it('renders a button to open settings modal', () => {
+ render(<Settings isActive={false} setIsActive={() => null} />);
+ expect(
+ screen.getByRole('checkbox', { name: 'Open settings' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a button to close settings modal', () => {
+ render(<Settings isActive={true} setIsActive={() => null} />);
+ expect(
+ screen.getByRole('checkbox', { name: 'Close settings' })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx
new file mode 100644
index 0000000..88539fb
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -0,0 +1,72 @@
+import Checkbox, { CheckboxProps } from '@components/atoms/forms/checkbox';
+import Label from '@components/atoms/forms/label';
+import Cog from '@components/atoms/icons/cog';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import SettingsModal from '../modals/settings-modal';
+import sharedStyles from './toolbar-items.module.scss';
+import settingsStyles from './settings.module.scss';
+
+export type SettingsProps = {
+ /**
+ * Set additional classnames to the modal wrapper.
+ */
+ className?: string;
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+ /**
+ * Set additional classnames to the tooltip wrapper.
+ */
+ tooltipClassName?: string;
+};
+
+const Settings: VFC<SettingsProps> = ({
+ className = '',
+ isActive,
+ setIsActive,
+ tooltipClassName = '',
+}) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close settings',
+ id: '+viX9b',
+ description: 'Settings: Close label',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open settings',
+ id: 'QCW3cy',
+ description: 'Settings: Open label',
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${settingsStyles.item}`}>
+ <Checkbox
+ id="settings-button"
+ name="settings-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${settingsStyles.checkbox}`}
+ />
+ <Label
+ htmlFor="settings-button"
+ aria-label={label}
+ className={`${sharedStyles.label} ${settingsStyles.label}`}
+ >
+ <Cog />
+ </Label>
+ <SettingsModal
+ className={`${sharedStyles.modal} ${settingsStyles.modal} ${className}`}
+ tooltipClassName={tooltipClassName}
+ />
+ </div>
+ );
+};
+
+export default Settings;
diff --git a/src/components/organisms/toolbar/toolbar-items.module.scss b/src/components/organisms/toolbar/toolbar-items.module.scss
new file mode 100644
index 0000000..fd526d6
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar-items.module.scss
@@ -0,0 +1,96 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.item {
+ --btn-size: #{fun.convert-px(65)};
+
+ display: flex;
+ position: relative;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ justify-content: flex-end;
+ }
+
+ @include mix.dimensions("md") {
+ justify-content: flex-end;
+ }
+ }
+}
+
+.modal {
+ position: absolute;
+ top: var(--toolbar-size, calc(var(--btn-size) + var(--spacing-2xs)));
+ transition: all 0.8s ease-in-out 0s;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ left: 0;
+ right: 0;
+ }
+ }
+}
+
+.label {
+ --draw-border-thickness: #{fun.convert-px(4)};
+ --draw-border-color1: var(--color-primary-light);
+ --draw-border-color2: var(--color-primary-lighter);
+
+ display: flex;
+ place-content: center;
+ place-items: center;
+ width: var(--btn-size);
+ height: var(--btn-size);
+ padding: var(--spacing-2xs);
+
+ &:hover,
+ &:focus {
+ @extend %draw-borders;
+ }
+
+ &:focus {
+ color: var(--color-primary-light);
+ }
+
+ &:active {
+ --draw-border-color1: var(--color-primary-dark);
+ --draw-border-color2: var(--color-primary-light);
+
+ @extend %draw-borders;
+ }
+}
+
+.checkbox {
+ position: absolute;
+ top: calc(var(--btn-size) / 2);
+ left: calc(var(--btn-size) / 2);
+ opacity: 0;
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ ~ .label {
+ @extend %draw-borders;
+ }
+ }
+
+ &:not(:checked) {
+ ~ .modal {
+ opacity: 0;
+ visibility: hidden;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ transform: translateX(-100vw);
+ }
+
+ @include mix.dimensions("sm") {
+ transform: perspective(#{fun.convert-px(400)})
+ translate3d(0, 0, #{fun.convert-px(-400)});
+ transform-origin: 100% -50%;
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/toolbar.module.scss b/src/components/organisms/toolbar/toolbar.module.scss
new file mode 100644
index 0000000..bb0a91f
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.module.scss
@@ -0,0 +1,80 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ --toolbar-size: #{fun.convert-px(75)};
+
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: space-around;
+ width: 100%;
+ height: var(--toolbar-size);
+ position: relative;
+ background: var(--color-bg);
+ border-top: fun.convert-px(4) solid;
+ border-image: radial-gradient(
+ ellipse at top,
+ var(--color-primary-lighter) 20%,
+ var(--color-primary) 100%
+ )
+ 1;
+ box-shadow: 0 fun.convert-px(-2) fun.convert-px(3) fun.convert-px(-1)
+ var(--color-shadow-dark);
+
+ .modal {
+ &--search,
+ &--settings {
+ min-width: 30ch;
+ }
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ --toolbar-size: #{fun.convert-px(70)};
+ }
+
+ @include mix.dimensions(null, "sm") {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ z-index: 5;
+
+ .modal {
+ top: unset;
+ bottom: calc(var(--toolbar-size) - #{fun.convert-px(4)});
+ max-height: calc(100vh - var(--toolbar-size));
+ }
+
+ .tooltip {
+ top: unset;
+ bottom: calc(100% + var(--spacing-xs));
+ transform-origin: bottom right;
+ }
+ }
+
+ @include mix.dimensions("sm", "md") {
+ .modal {
+ top: calc(var(--toolbar-size) + var(--spacing-2xs));
+ bottom: unset;
+ }
+ }
+
+ @include mix.dimensions("sm") {
+ justify-content: flex-end;
+ gap: var(--spacing-sm);
+
+ .tooltip {
+ transform-origin: top right;
+ }
+ }
+
+ @include mix.dimensions("md") {
+ .tooltip {
+ width: 120%;
+ right: -10%;
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/toolbar.stories.tsx b/src/components/organisms/toolbar/toolbar.stories.tsx
new file mode 100644
index 0000000..75d70d8
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.stories.tsx
@@ -0,0 +1,26 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import ToolbarComponent from './toolbar';
+
+export default {
+ title: 'Organisms/Toolbar',
+ component: ToolbarComponent,
+} as ComponentMeta<typeof ToolbarComponent>;
+
+const Template: ComponentStory<typeof ToolbarComponent> = (args) => (
+ <IntlProvider locale="en">
+ <ToolbarComponent {...args} />
+ </IntlProvider>
+);
+
+const nav = [
+ { id: 'home-link', href: '#', label: 'Home' },
+ { id: 'blog-link', href: '#', label: 'Blog' },
+ { id: 'cv-link', href: '#', label: 'CV' },
+ { id: 'contact-link', href: '#', label: 'Contact' },
+];
+
+export const Toolbar = Template.bind({});
+Toolbar.args = {
+ nav,
+};
diff --git a/src/components/organisms/toolbar/toolbar.test.tsx b/src/components/organisms/toolbar/toolbar.test.tsx
new file mode 100644
index 0000000..4bfe8a8
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@test-utils';
+import Toolbar from './toolbar';
+
+const nav = [
+ { id: 'home-link', href: '/', label: 'Home' },
+ { id: 'blog-link', href: '/blog', label: 'Blog' },
+ { id: 'cv-link', href: '/cv', label: 'CV' },
+ { id: 'contact-link', href: '/contact', label: 'Contact' },
+];
+
+describe('Toolbar', () => {
+ it('renders a navigation menu', () => {
+ render(<Toolbar nav={nav} />);
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx
new file mode 100644
index 0000000..81e80cf
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -0,0 +1,51 @@
+import { useState, VFC } from 'react';
+import MainNav, { type MainNavProps } from '../toolbar/main-nav';
+import Search from '../toolbar/search';
+import Settings from '../toolbar/settings';
+import styles from './toolbar.module.scss';
+
+export type ToolbarProps = {
+ /**
+ * Set additional classnames to the toolbar wrapper.
+ */
+ className?: string;
+ /**
+ * The main nav items.
+ */
+ nav: MainNavProps['items'];
+};
+
+/**
+ * Toolbar component
+ *
+ * Render the website toolbar.
+ */
+const Toolbar: VFC<ToolbarProps> = ({ className = '', nav }) => {
+ const [isNavOpened, setIsNavOpened] = useState<boolean>(false);
+ const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false);
+ const [isSearchOpened, setIsSearchOpened] = useState<boolean>(false);
+
+ return (
+ <div className={`${styles.wrapper} ${className}`}>
+ <MainNav
+ items={nav}
+ isActive={isNavOpened}
+ setIsActive={setIsNavOpened}
+ className={styles.modal}
+ />
+ <Search
+ isActive={isSearchOpened}
+ setIsActive={setIsSearchOpened}
+ className={`${styles.modal} ${styles['modal--search']}`}
+ />
+ <Settings
+ isActive={isSettingsOpened}
+ setIsActive={setIsSettingsOpened}
+ className={`${styles.modal} ${styles['modal--settings']}`}
+ tooltipClassName={styles.tooltip}
+ />
+ </div>
+ );
+};
+
+export default Toolbar;
diff --git a/src/components/organisms/widgets/image-widget.module.scss b/src/components/organisms/widgets/image-widget.module.scss
new file mode 100644
index 0000000..78c0d26
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.module.scss
@@ -0,0 +1,43 @@
+@use "@styles/abstracts/functions" as fun;
+
+.img {
+ max-height: fun.convert-px(350);
+ margin: 0;
+}
+
+.txt {
+ padding: var(--spacing-sm);
+}
+
+.widget {
+ &--left {
+ .img {
+ margin-right: auto;
+ }
+
+ .txt {
+ text-align: left;
+ }
+ }
+
+ &--center {
+ .img {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .txt {
+ text-align: center;
+ }
+ }
+
+ &--right {
+ .img {
+ margin-left: auto;
+ }
+
+ .txt {
+ text-align: right;
+ }
+ }
+}
diff --git a/src/components/organisms/widgets/image-widget.stories.tsx b/src/components/organisms/widgets/image-widget.stories.tsx
new file mode 100644
index 0000000..1c2397b
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.stories.tsx
@@ -0,0 +1,113 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import ImageWidgetComponent from './image-widget';
+
+export default {
+ title: 'Organisms/Widgets',
+ component: ImageWidgetComponent,
+ args: {
+ alignment: 'left',
+ },
+ argTypes: {
+ alignment: {
+ control: {
+ type: 'select',
+ },
+ description: 'The content alignment.',
+ options: ['left', 'center', 'right'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'left' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ description: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add a caption image.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ expanded: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'The state of the widget.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ img: {
+ description: 'An image object.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The widget title level (hn).',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add a link to the image.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ImageWidgetComponent>;
+
+const Template: ComponentStory<typeof ImageWidgetComponent> = (args) => (
+ <IntlProvider locale="en">
+ <ImageWidgetComponent {...args} />
+ </IntlProvider>
+);
+
+const img = {
+ alt: 'Et perferendis quaerat',
+ height: 480,
+ src: 'http://placeimg.com/640/480/nature',
+ width: 640,
+};
+
+export const ImageWidget = Template.bind({});
+ImageWidget.args = {
+ expanded: true,
+ img,
+ level: 2,
+ title: 'Quo et totam',
+};
diff --git a/src/components/organisms/widgets/image-widget.test.tsx b/src/components/organisms/widgets/image-widget.test.tsx
new file mode 100644
index 0000000..8c24bd9
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@test-utils';
+import ImageWidget from './image-widget';
+
+const description = 'Ut vitae sit';
+
+const img = {
+ alt: 'Et perferendis quaerat',
+ height: 480,
+ src: 'http://placeimg.com/640/480/nature',
+ width: 640,
+};
+
+const title = 'Fugiat cumque et';
+const titleLevel = 2;
+
+const url = '/another-page';
+
+describe('ImageWidget', () => {
+ it('renders an image', () => {
+ render(
+ <ImageWidget expanded={true} img={img} title={title} level={titleLevel} />
+ );
+ expect(screen.getByRole('img', { name: img.alt })).toBeInTheDocument();
+ });
+
+ it('renders a link', () => {
+ render(
+ <ImageWidget
+ expanded={true}
+ img={img}
+ title={title}
+ level={titleLevel}
+ url={url}
+ />
+ );
+ expect(screen.getByRole('link', { name: img.alt })).toHaveAttribute(
+ 'href',
+ url
+ );
+ });
+
+ it('renders a description', () => {
+ render(
+ <ImageWidget
+ expanded={true}
+ img={img}
+ description={description}
+ title={title}
+ level={titleLevel}
+ />
+ );
+ expect(screen.getByText(description)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/widgets/image-widget.tsx b/src/components/organisms/widgets/image-widget.tsx
new file mode 100644
index 0000000..928d5ea
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.tsx
@@ -0,0 +1,75 @@
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { VFC } from 'react';
+import styles from './image-widget.module.scss';
+
+export type Alignment = 'left' | 'center' | 'right';
+
+export type Image = {
+ /**
+ * An alternative text for the image.
+ */
+ alt: string;
+ /**
+ * The image height.
+ */
+ height: number;
+ /**
+ * The image source.
+ */
+ src: string;
+ /**
+ * The image width.
+ */
+ width: number;
+};
+
+export type ImageWidgetProps = Pick<
+ WidgetProps,
+ 'expanded' | 'level' | 'title'
+> & {
+ /**
+ * The content alignment.
+ */
+ alignment?: Alignment;
+ /**
+ * Add a caption to the image.
+ */
+ description?: string;
+ /**
+ * An object describing the image.
+ */
+ img: Image;
+ /**
+ * Add a link to the image.
+ */
+ url?: string;
+};
+
+/**
+ * ImageWidget component
+ *
+ * Renders a widget that print an image and an optional text.
+ */
+const ImageWidget: VFC<ImageWidgetProps> = ({
+ alignment = 'left',
+ description,
+ img,
+ url,
+ ...props
+}) => {
+ const alignmentClass = `widget--${alignment}`;
+
+ return (
+ <Widget className={styles[alignmentClass]} {...props}>
+ <ResponsiveImage
+ target={url}
+ caption={description}
+ className={styles.img}
+ {...img}
+ />
+ </Widget>
+ );
+};
+
+export default ImageWidget;
diff --git a/src/components/organisms/widgets/links-list-widget.module.scss b/src/components/organisms/widgets/links-list-widget.module.scss
new file mode 100644
index 0000000..cbad83e
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.module.scss
@@ -0,0 +1,71 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/placeholders";
+
+.widget {
+ .list {
+ &__link {
+ display: block;
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ background: none;
+ text-decoration: underline solid transparent 0;
+
+ &:hover,
+ &:focus {
+ background: var(--color-bg-secondary);
+ font-weight: 600;
+ }
+
+ &:focus {
+ color: var(--color-primary);
+ text-decoration-color: var(--color-primary-light);
+ text-decoration-thickness: 0.25ex;
+ }
+
+ &:active {
+ background: var(--color-bg-tertiary);
+ text-decoration-color: transparent;
+ text-decoration-thickness: 0;
+ }
+ }
+
+ &--ordered {
+ @extend %reset-ordered-list;
+
+ counter-reset: link;
+
+ .list__link {
+ counter-increment: link;
+
+ &::before {
+ padding-right: var(--spacing-2xs);
+ content: counters(link, ".") ". ";
+ color: var(--color-secondary);
+ }
+ }
+ }
+
+ &--unordered {
+ @extend %reset-list;
+ }
+
+ &__item {
+ &:not(:last-child) {
+ .list__link {
+ border-bottom: fun.convert-px(1) solid var(--color-primary);
+ }
+ }
+
+ > .list {
+ .list__link {
+ padding-left: var(--spacing-md);
+ }
+
+ .list__item > .list {
+ .list__link {
+ padding-left: var(--spacing-xl);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/widgets/links-list-widget.stories.tsx b/src/components/organisms/widgets/links-list-widget.stories.tsx
new file mode 100644
index 0000000..528f6f7
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.stories.tsx
@@ -0,0 +1,92 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import LinksListWidget from './links-list-widget';
+
+export default {
+ title: 'Organisms/Widgets',
+ component: LinksListWidget,
+ args: {
+ kind: 'unordered',
+ },
+ argTypes: {
+ items: {
+ description: 'The widget data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind: either ordered or unordered.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof LinksListWidget>;
+
+const Template: ComponentStory<typeof LinksListWidget> = (args) => (
+ <IntlProvider locale="en">
+ <LinksListWidget {...args} />
+ </IntlProvider>
+);
+
+const items = [
+ { name: 'Level 1: Item 1', url: '#' },
+ {
+ name: 'Level 1: Item 2',
+ url: '#',
+ child: [
+ { name: 'Level 2: Item 1', url: '#' },
+ { name: 'Level 2: Item 2', url: '#' },
+ {
+ name: 'Level 2: Item 3',
+ url: '#',
+ child: [
+ { name: 'Level 3: Item 1', url: '#' },
+ { name: 'Level 3: Item 2', url: '#' },
+ ],
+ },
+ { name: 'Level 2: Item 4', url: '#' },
+ ],
+ },
+ { name: 'Level 1: Item 3', url: '#' },
+ { name: 'Level 1: Item 4', url: '#' },
+];
+
+export const LinksList = Template.bind({});
+LinksList.args = {
+ items,
+ level: 2,
+ title: 'A list of links',
+};
diff --git a/src/components/organisms/widgets/links-list-widget.test.tsx b/src/components/organisms/widgets/links-list-widget.test.tsx
new file mode 100644
index 0000000..a8d6a35
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@test-utils';
+import LinksListWidget from './links-list-widget';
+
+const title = 'Voluptatem minus autem';
+
+const items = [
+ { name: 'Item 1', url: '/item-1' },
+ { name: 'Item 2', url: '/item-2' },
+ { name: 'Item 3', url: '/item-3' },
+];
+
+describe('LinksListWidget', () => {
+ it('renders a widget title', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(
+ screen.getByRole('heading', { level: 2, name: new RegExp(title, 'i') })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the correct number of items', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(items.length);
+ });
+
+ it('renders some links', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(screen.getByRole('link', { name: items[0].name })).toHaveAttribute(
+ 'href',
+ items[0].url
+ );
+ });
+});
diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx
new file mode 100644
index 0000000..155354e
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.tsx
@@ -0,0 +1,81 @@
+import Link from '@components/atoms/links/link';
+import List, { ListProps, type ListItem } from '@components/atoms/lists/list';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { slugify } from '@utils/helpers/slugify';
+import { VFC } from 'react';
+import styles from './links-list-widget.module.scss';
+
+export type LinksListItems = {
+ /**
+ * An array of name/url couple child of this list item.
+ */
+ child?: LinksListItems[];
+ /**
+ * The item name.
+ */
+ name: string;
+ /**
+ * The item url.
+ */
+ url: string;
+};
+
+export type LinksListWidgetProps = Pick<WidgetProps, 'level' | 'title'> &
+ Pick<ListProps, 'kind'> & {
+ /**
+ * An array of name/url couple.
+ */
+ items: LinksListItems[];
+ };
+
+/**
+ * LinksListWidget component
+ *
+ * Render a list of links inside a widget.
+ */
+const LinksListWidget: VFC<LinksListWidgetProps> = ({
+ items,
+ kind = 'unordered',
+ ...props
+}) => {
+ const listKindClass = `list--${kind}`;
+
+ /**
+ * Format the widget data to be used as List items.
+ *
+ * @param {LinksListItems[]} data - The widget data.
+ * @returns {ListItem[]} The list items data.
+ */
+ const getListItems = (data: LinksListItems[]): ListItem[] => {
+ return data.map((item) => {
+ return {
+ id: slugify(item.name),
+ child: item.child && getListItems(item.child),
+ value: (
+ <Link href={item.url} className={styles.list__link}>
+ {item.name}
+ </Link>
+ ),
+ };
+ });
+ };
+
+ return (
+ <Widget
+ expanded={true}
+ withBorders={true}
+ className={styles.widget}
+ {...props}
+ >
+ <List
+ items={getListItems(items)}
+ kind={kind}
+ withMargin={false}
+ className={`${styles.list} ${styles[listKindClass]}`}
+ itemsClassName={styles.list__item}
+ />
+ </Widget>
+ );
+};
+
+export default LinksListWidget;
diff --git a/src/components/organisms/widgets/sharing.module.scss b/src/components/organisms/widgets/sharing.module.scss
new file mode 100644
index 0000000..e06d4e3
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.list {
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-xs);
+ margin: 0;
+ padding: 0 var(--spacing-2xs);
+ list-style-type: none;
+}
diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx
new file mode 100644
index 0000000..be20b84
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.stories.tsx
@@ -0,0 +1,78 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SharingWidget from './sharing';
+
+export default {
+ title: 'Organisms/Widgets',
+ component: SharingWidget,
+ argTypes: {
+ data: {
+ description: 'The page data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ expanded: {
+ control: {
+ type: null,
+ },
+ description: 'Default widget state (expanded or collapsed).',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ media: {
+ control: {
+ type: null,
+ },
+ description: 'An array of active and ordered sharing medium.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SharingWidget>;
+
+const Template: ComponentStory<typeof SharingWidget> = (args) => (
+ <IntlProvider locale="en">
+ <SharingWidget {...args} />
+ </IntlProvider>
+);
+
+export const Sharing = Template.bind({});
+Sharing.args = {
+ expanded: true,
+ data: {
+ excerpt:
+ 'Alias similique eius ducimus laudantium aspernatur. Est rem ut eum temporibus sit reprehenderit aut non molestias. Vel dolorem expedita labore quo inventore aliquid nihil nam. Possimus nobis enim quas corporis eos.',
+ title: 'Accusantium totam nostrum',
+ url: 'https://www.example.test',
+ },
+ level: 2,
+ media: ['diaspora', 'facebook', 'linkedin', 'twitter', 'email'],
+ title: 'Sharing',
+};
diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx
new file mode 100644
index 0000000..265dbe1
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@test-utils';
+import Sharing, { type SharingData } from './sharing';
+
+const postData: SharingData = {
+ excerpt: 'A post excerpt',
+ title: 'A post title',
+ url: 'https://sharing-website.test',
+};
+
+describe('Sharing', () => {
+ it('renders a sharing widget', () => {
+ render(
+ <Sharing
+ data={postData}
+ media={['facebook', 'twitter']}
+ expanded={true}
+ title="Sharing"
+ level={2}
+ />
+ );
+ expect(
+ screen.getByRole('link', { name: 'Share on facebook' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: 'Share on twitter' })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: 'Share on linkedin' })
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
new file mode 100644
index 0000000..ccd3a21
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -0,0 +1,190 @@
+import SharingLink, {
+ type SharingMedium,
+} from '@components/atoms/links/sharing-link';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './sharing.module.scss';
+
+export type SharingData = {
+ /**
+ * The content excerpt.
+ */
+ excerpt: string;
+ /**
+ * The content title.
+ */
+ title: string;
+ /**
+ * The content url.
+ */
+ url: string;
+};
+
+export type SharingProps = WidgetProps & {
+ /**
+ * The page data to share.
+ */
+ data: SharingData;
+ /**
+ * A list of active and ordered sharing medium.
+ */
+ media: SharingMedium[];
+};
+
+/**
+ * Sharing widget component
+ *
+ * Render a list of sharing links inside a widget.
+ */
+const Sharing: VFC<SharingProps> = ({ data, media, ...props }) => {
+ const intl = useIntl();
+
+ /**
+ * Build the Diaspora sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Diaspora url.
+ */
+ const buildDiasporaUrl = (title: string, url: string): string => {
+ const titleParam = `title=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://share.diasporafoundation.org/?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Build the mailto url from provided data.
+ *
+ * @param {string} excerpt - The content excerpt.
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The mailto url with params.
+ */
+ const buildEmailUrl = (
+ excerpt: string,
+ title: string,
+ url: string
+ ): string => {
+ const intro = intl.formatMessage({
+ defaultMessage: 'Introduction:',
+ description: 'Sharing: email content prefix',
+ id: 'yfgMcl',
+ });
+ const readMore = intl.formatMessage({
+ defaultMessage: 'Read more here:',
+ description: 'Sharing: content link prefix',
+ id: 'UsQske',
+ });
+ const body = `${intro}\n\n"${excerpt}"\n\n${readMore} ${url}`;
+ const bodyParam = excerpt ? `body=${encodeURI(body)}` : '';
+
+ const subject = intl.formatMessage(
+ {
+ defaultMessage: 'You should read {title}',
+ description: 'Sharing: subject text',
+ id: 'azgQuH',
+ },
+ { title }
+ );
+ const subjectParam = `subject=${encodeURI(subject)}`;
+
+ return `mailto:?${bodyParam}&${subjectParam}`;
+ };
+
+ /**
+ * Build the Facebook sharing url with provided data.
+ *
+ * @param {string} url - The content url.
+ * @returns {string} The Facebook url.
+ */
+ const buildFacebookUrl = (url: string): string => {
+ const urlParam = `u=${encodeURI(url)}`;
+ return `https://www.facebook.com/sharer/sharer.php?${urlParam}`;
+ };
+
+ /**
+ * Build the Journal du Hacker sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Journal du Hacker url.
+ */
+ const buildJdHUrl = (title: string, url: string): string => {
+ const titleParam = `title=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://www.journalduhacker.net/stories/new?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Build the LinkedIn sharing url with provided data.
+ *
+ * @param {string} url - The content url.
+ * @returns {string} The LinkedIn url.
+ */
+ const buildLinkedInUrl = (url: string): string => {
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://www.linkedin.com/sharing/share-offsite/?${urlParam}`;
+ };
+
+ /**
+ * Build the Twitter sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Twitter url.
+ */
+ const buildTwitterUrl = (title: string, url: string): string => {
+ const titleParam = `text=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://twitter.com/intent/tweet?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Retrieve the sharing url by medium id.
+ *
+ * @param {SharingMedium} medium - A sharing medium id.
+ * @returns {string} The sharing url.
+ */
+ const getUrl = (medium: SharingMedium): string => {
+ const { excerpt, title, url } = data;
+
+ switch (medium) {
+ case 'diaspora':
+ return buildDiasporaUrl(title, url);
+ case 'email':
+ return buildEmailUrl(excerpt, title, url);
+ case 'facebook':
+ return buildFacebookUrl(url);
+ case 'journal-du-hacker':
+ return buildJdHUrl(title, url);
+ case 'linkedin':
+ return buildLinkedInUrl(url);
+ case 'twitter':
+ return buildTwitterUrl(title, url);
+ default:
+ return '#';
+ }
+ };
+
+ /**
+ * Get the sharing list items.
+ *
+ * @returns {JSX.Element[]} The sharing links wrapped with li element.
+ */
+ const getItems = (): JSX.Element[] => {
+ return media.map((medium) => (
+ <li key={medium}>
+ <SharingLink medium={medium} url={getUrl(medium)} />
+ </li>
+ ));
+ };
+
+ return (
+ <Widget {...props}>
+ <ul className={styles.list}>{getItems()}</ul>
+ </Widget>
+ );
+};
+
+export default Sharing;
diff --git a/src/components/organisms/widgets/social-media.module.scss b/src/components/organisms/widgets/social-media.module.scss
new file mode 100644
index 0000000..01b6c0e
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/placeholders";
+
+.list {
+ @extend %reset-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-xs);
+ padding: 0 var(--spacing-2xs);
+}
diff --git a/src/components/organisms/widgets/social-media.stories.tsx b/src/components/organisms/widgets/social-media.stories.tsx
new file mode 100644
index 0000000..2b84012
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.stories.tsx
@@ -0,0 +1,56 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SocialMediaWidget, { Media } from './social-media';
+
+export default {
+ title: 'Organisms/Widgets',
+ component: SocialMediaWidget,
+ argTypes: {
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ media: {
+ description: 'The links data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SocialMediaWidget>;
+
+const Template: ComponentStory<typeof SocialMediaWidget> = (args) => (
+ <IntlProvider locale="en">
+ <SocialMediaWidget {...args} />
+ </IntlProvider>
+);
+
+const media: Media[] = [
+ { name: 'Github', url: '#' },
+ { name: 'LinkedIn', url: '#' },
+];
+
+export const SocialMedia = Template.bind({});
+SocialMedia.args = {
+ media,
+ title: 'Follow me',
+ level: 2,
+};
diff --git a/src/components/organisms/widgets/social-media.test.tsx b/src/components/organisms/widgets/social-media.test.tsx
new file mode 100644
index 0000000..e40db30
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import SocialMedia, { Media } from './social-media';
+
+const media: Media[] = [
+ { name: 'Github', url: '#' },
+ { name: 'LinkedIn', url: '#' },
+];
+const title = 'Dolores ut ut';
+const titleLevel = 2;
+
+/**
+ * Next.js mock images with next/image component. So for now, I need to mock
+ * the svg files manually.
+ */
+jest.mock('@assets/images/social-media/github.svg', () => 'svg-file');
+jest.mock('@assets/images/social-media/linkedin.svg', () => 'svg-file');
+
+describe('SocialMedia', () => {
+ it('renders the widget title', () => {
+ render(<SocialMedia media={media} title={title} level={titleLevel} />);
+ expect(
+ screen.getByRole('heading', {
+ level: titleLevel,
+ name: new RegExp(title, 'i'),
+ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the correct number of items', () => {
+ render(<SocialMedia media={media} title={title} level={titleLevel} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(media.length);
+ });
+});
diff --git a/src/components/organisms/widgets/social-media.tsx b/src/components/organisms/widgets/social-media.tsx
new file mode 100644
index 0000000..58b2f73
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.tsx
@@ -0,0 +1,41 @@
+import SocialLink, {
+ type SocialLinkProps,
+} from '@components/atoms/links/social-link';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { FC } from 'react';
+import styles from './social-media.module.scss';
+
+export type Media = SocialLinkProps;
+
+export type SocialMediaProps = Pick<WidgetProps, 'level' | 'title'> & {
+ media: Media[];
+};
+
+/**
+ * Social Media widget component
+ *
+ * Render a social media list with links.
+ */
+const SocialMedia: FC<SocialMediaProps> = ({ media, ...props }) => {
+ /**
+ * Retrieve the social media items.
+ *
+ * @param {SocialMedia[]} links - An array of social media name and url.
+ * @returns {JSX.Element[]} The social links.
+ */
+ const getItems = (links: Media[]): JSX.Element[] => {
+ return links.map((link, index) => (
+ <li key={`media-${index}`}>
+ <SocialLink name={link.name} url={link.url} />
+ </li>
+ ));
+ };
+
+ return (
+ <Widget expanded={true} {...props}>
+ <ul className={styles.list}>{getItems(media)}</ul>
+ </Widget>
+ );
+};
+
+export default SocialMedia;