summaryrefslogtreecommitdiffstats
path: root/src/components/atoms
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/atoms')
-rw-r--r--src/components/atoms/buttons/button-link.stories.tsx139
-rw-r--r--src/components/atoms/buttons/button-link.test.tsx9
-rw-r--r--src/components/atoms/buttons/button-link.tsx73
-rw-r--r--src/components/atoms/buttons/button.stories.tsx172
-rw-r--r--src/components/atoms/buttons/button.test.tsx18
-rw-r--r--src/components/atoms/buttons/button.tsx77
-rw-r--r--src/components/atoms/buttons/buttons.module.scss177
-rw-r--r--src/components/atoms/forms/checkbox.stories.tsx102
-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.tsx257
-rw-r--r--src/components/atoms/forms/field.test.tsx30
-rw-r--r--src/components/atoms/forms/field.tsx111
-rw-r--r--src/components/atoms/forms/form.test.tsx13
-rw-r--r--src/components/atoms/forms/form.tsx76
-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.tsx104
-rw-r--r--src/components/atoms/forms/label.test.tsx9
-rw-r--r--src/components/atoms/forms/label.tsx53
-rw-r--r--src/components/atoms/forms/select.stories.tsx151
-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.scss69
-rw-r--r--src/components/atoms/headings/heading.stories.tsx160
-rw-r--r--src/components/atoms/headings/heading.test.tsx56
-rw-r--r--src/components/atoms/headings/heading.tsx94
-rw-r--r--src/components/atoms/icons/arrow.module.scss16
-rw-r--r--src/components/atoms/icons/arrow.stories.tsx48
-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.tsx34
-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.tsx34
-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.tsx34
-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.tsx34
-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.tsx34
-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.tsx34
-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/feed.module.scss6
-rw-r--r--src/components/atoms/icons/feed.stories.tsx34
-rw-r--r--src/components/atoms/icons/feed.test.tsx9
-rw-r--r--src/components/atoms/icons/feed.tsx74
-rw-r--r--src/components/atoms/icons/hamburger.module.scss42
-rw-r--r--src/components/atoms/icons/hamburger.stories.tsx47
-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.tsx34
-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.tsx34
-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.tsx47
-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.tsx49
-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.tsx34
-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.tsx47
-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.tsx34
-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/column.stories.tsx29
-rw-r--r--src/components/atoms/layout/column.test.tsx12
-rw-r--r--src/components/atoms/layout/column.tsx16
-rw-r--r--src/components/atoms/layout/copyright.module.scss32
-rw-r--r--src/components/atoms/layout/copyright.stories.tsx58
-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.tsx58
-rw-r--r--src/components/atoms/layout/main.test.tsx12
-rw-r--r--src/components/atoms/layout/main.tsx27
-rw-r--r--src/components/atoms/layout/no-script.module.scss19
-rw-r--r--src/components/atoms/layout/no-script.stories.tsx62
-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.scss27
-rw-r--r--src/components/atoms/layout/notice.stories.tsx86
-rw-r--r--src/components/atoms/layout/notice.test.tsx11
-rw-r--r--src/components/atoms/layout/notice.tsx38
-rw-r--r--src/components/atoms/layout/section.module.scss25
-rw-r--r--src/components/atoms/layout/section.stories.tsx102
-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/layout/sidebar.module.scss12
-rw-r--r--src/components/atoms/layout/sidebar.stories.tsx60
-rw-r--r--src/components/atoms/layout/sidebar.test.tsx11
-rw-r--r--src/components/atoms/layout/sidebar.tsx32
-rw-r--r--src/components/atoms/links/link.module.scss220
-rw-r--r--src/components/atoms/links/link.stories.tsx180
-rw-r--r--src/components/atoms/links/link.test.tsx9
-rw-r--r--src/components/atoms/links/link.tsx67
-rw-r--r--src/components/atoms/links/nav-link.module.scss46
-rw-r--r--src/components/atoms/links/nav-link.stories.tsx55
-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.tsx98
-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.tsx73
-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-item.module.scss40
-rw-r--r--src/components/atoms/lists/description-list-item.stories.tsx132
-rw-r--r--src/components/atoms/lists/description-list-item.test.tsx17
-rw-r--r--src/components/atoms/lists/description-list-item.tsx73
-rw-r--r--src/components/atoms/lists/description-list.module.scss17
-rw-r--r--src/components/atoms/lists/description-list.stories.tsx131
-rw-r--r--src/components/atoms/lists/description-list.test.tsx20
-rw-r--r--src/components/atoms/lists/description-list.tsx103
-rw-r--r--src/components/atoms/lists/list.module.scss45
-rw-r--r--src/components/atoms/lists/list.stories.tsx111
-rw-r--r--src/components/atoms/lists/list.test.tsx26
-rw-r--r--src/components/atoms/lists/list.tsx79
-rw-r--r--src/components/atoms/loaders/progress-bar.module.scss43
-rw-r--r--src/components/atoms/loaders/progress-bar.stories.tsx93
-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.tsx42
-rw-r--r--src/components/atoms/loaders/spinner.test.tsx14
-rw-r--r--src/components/atoms/loaders/spinner.tsx37
153 files changed, 7428 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..d06aff3
--- /dev/null
+++ b/src/components/atoms/buttons/button-link.stories.tsx
@@ -0,0 +1,139 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ButtonLink from './button-link';
+
+/**
+ * ButtonLink - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Buttons/ButtonLink',
+ component: ButtonLink,
+ args: {
+ shape: 'rectangle',
+ },
+ 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,
+ },
+ },
+ external: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the link is an external link.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ 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 ButtonLink>;
+
+const Template: ComponentStory<typeof ButtonLink> = (args) => (
+ <ButtonLink {...args} />
+);
+
+/**
+ * ButtonLink Story - Primary
+ */
+export const Primary = Template.bind({});
+Primary.args = {
+ children: 'Link',
+ kind: 'primary',
+ target: '#',
+};
+
+/**
+ * ButtonLink Story - Secondary
+ */
+export const Secondary = Template.bind({});
+Secondary.args = {
+ children: 'Link',
+ kind: 'secondary',
+ target: '#',
+};
+
+/**
+ * ButtonLink Story - Tertiary
+ */
+export const Tertiary = Template.bind({});
+Tertiary.args = {
+ children: 'Link',
+ kind: 'tertiary',
+ 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..64e0afd
--- /dev/null
+++ b/src/components/atoms/buttons/button-link.tsx
@@ -0,0 +1,73 @@
+import Link from 'next/link';
+import { FC, ReactNode } from 'react';
+import styles from './buttons.module.scss';
+
+export type ButtonLinkProps = {
+ /**
+ * ButtonLink accessible label.
+ */
+ 'aria-label'?: string;
+ /**
+ * The button link body.
+ */
+ children: ReactNode;
+ /**
+ * 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' | 'tertiary';
+ /**
+ * 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..6803706
--- /dev/null
+++ b/src/components/atoms/buttons/button.stories.tsx
@@ -0,0 +1,172 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Button from './button';
+
+/**
+ * Button - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Buttons/Button',
+ component: Button,
+ args: {
+ disabled: false,
+ 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 Button>;
+
+const Template: ComponentStory<typeof Button> = (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 (
+ <Button type={type} {...props}>
+ {getBody()}
+ </Button>
+ );
+};
+
+/**
+ * Button Story - Primary
+ */
+export const Primary = Template.bind({});
+Primary.args = {
+ kind: 'primary',
+};
+
+/**
+ * Button Story - Secondary
+ */
+export const Secondary = Template.bind({});
+Secondary.args = {
+ kind: 'secondary',
+};
+
+/**
+ * Button Story - Tertiary
+ */
+export const Tertiary = Template.bind({});
+Tertiary.args = {
+ kind: 'tertiary',
+};
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..9776687
--- /dev/null
+++ b/src/components/atoms/buttons/button.tsx
@@ -0,0 +1,77 @@
+import {
+ forwardRef,
+ ForwardRefRenderFunction,
+ MouseEventHandler,
+ ReactNode,
+} from 'react';
+import styles from './buttons.module.scss';
+
+export type ButtonProps = {
+ /**
+ * The button body.
+ */
+ children: ReactNode;
+ /**
+ * 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: ForwardRefRenderFunction<HTMLButtonElement, ButtonProps> = (
+ {
+ className = '',
+ children,
+ disabled = false,
+ kind = 'secondary',
+ shape = 'rectangle',
+ type = 'button',
+ ...props
+ },
+ ref
+) => {
+ const kindClass = styles[`btn--${kind}`];
+ const shapeClass = styles[`btn--${shape}`];
+
+ return (
+ <button
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
+ disabled={disabled}
+ ref={ref}
+ type={type}
+ {...props}
+ >
+ {children}
+ </button>
+ );
+};
+
+export default forwardRef(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..2444bb1
--- /dev/null
+++ b/src/components/atoms/buttons/buttons.module.scss
@@ -0,0 +1,177 @@
+@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;
+ text-decoration: none;
+ transition: all 0.3s ease-in-out 0s;
+
+ &--initial {
+ border-radius: 0;
+ }
+
+ &--rectangle {
+ padding: var(--spacing-2xs) var(--spacing-sm);
+ }
+
+ &--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-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);
+
+ &: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: none;
+ 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..588fdcc
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.stories.tsx
@@ -0,0 +1,102 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import CheckboxComponent from './checkbox';
+
+/**
+ * Checkbox - Storybook Meta
+ */
+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} />
+ );
+};
+
+/**
+ * Checkbox Story
+ */
+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..aec97f0
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.tsx
@@ -0,0 +1,46 @@
+import { FC, SetStateAction } 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: FC<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..00a183d
--- /dev/null
+++ b/src/components/atoms/forms/field.stories.tsx
@@ -0,0 +1,257 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import Field from './field';
+
+/**
+ * Field - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Forms/Fields',
+ component: Field,
+ args: {
+ disabled: false,
+ required: false,
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: '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 Field>;
+
+const Template: ComponentStory<typeof Field> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [value, setValue] = useState<string>('');
+
+ return <Field value={value} setValue={setValue} {...args} />;
+};
+
+/**
+ * Field Story - DateTime
+ */
+export const DateTime = Template.bind({});
+DateTime.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'datetime-local',
+};
+
+/**
+ * Field Story - Email
+ */
+export const Email = Template.bind({});
+Email.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'email',
+};
+
+/**
+ * Field Story - Text
+ */
+export const Text = Template.bind({});
+Text.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'text',
+};
+
+/**
+ * Field Story - Number
+ */
+export const Number = Template.bind({});
+Number.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'number',
+};
+
+/**
+ * Field Story - TextArea
+ */
+export const TextArea = Template.bind({});
+TextArea.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'textarea',
+};
+
+/**
+ * Field Story - Time
+ */
+export const Time = Template.bind({});
+Time.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+ type: 'time',
+};
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..377e1b0
--- /dev/null
+++ b/src/components/atoms/forms/field.tsx
@@ -0,0 +1,111 @@
+import {
+ ChangeEvent,
+ forwardRef,
+ ForwardRefRenderFunction,
+ SetStateAction,
+} 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: ForwardRefRenderFunction<HTMLInputElement, FieldProps> = (
+ { className = '', setValue, type, ...props },
+ ref
+) => {
+ /**
+ * 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
+ className={`${styles.field} ${className}`}
+ onChange={updateValue}
+ ref={ref}
+ type={type}
+ {...props}
+ />
+ );
+};
+
+export default forwardRef(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..8b534f1
--- /dev/null
+++ b/src/components/atoms/forms/form.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@test-utils';
+import Form from './form';
+
+describe('Form', () => {
+ it('renders a form', () => {
+ render(
+ <Form aria-label="Jest form" onSubmit={() => null}>
+ Fields
+ </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..b819aea
--- /dev/null
+++ b/src/components/atoms/forms/form.tsx
@@ -0,0 +1,76 @@
+import { Children, FC, FormEvent, Fragment, ReactNode } 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;
+ /**
+ * The form body.
+ */
+ children: ReactNode;
+ /**
+ * 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,
+ 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} {...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..f66aa13
--- /dev/null
+++ b/src/components/atoms/forms/label.stories.tsx
@@ -0,0 +1,104 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LabelComponent from './label';
+
+/**
+ * Label - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Forms',
+ component: LabelComponent,
+ args: {
+ required: false,
+ size: 'small',
+ },
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'Define an accessible name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ 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>;
+
+/**
+ * Label Story
+ */
+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..2ec614f
--- /dev/null
+++ b/src/components/atoms/forms/label.tsx
@@ -0,0 +1,53 @@
+import { FC, ReactNode } from 'react';
+import styles from './label.module.scss';
+
+export type LabelProps = {
+ /**
+ * An accessible name for the label.
+ */
+ 'aria-label'?: string;
+ /**
+ * The label body.
+ */
+ children: ReactNode;
+ /**
+ * 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..7127597
--- /dev/null
+++ b/src/components/atoms/forms/select.stories.tsx
@@ -0,0 +1,151 @@
+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' },
+];
+
+/**
+ * Select - Storybook Meta
+ */
+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} />;
+};
+
+/**
+ * Select Story
+ */
+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..dbe9b37
--- /dev/null
+++ b/src/components/atoms/forms/select.tsx
@@ -0,0 +1,99 @@
+import { ChangeEvent, FC, SetStateAction } 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: FC<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..a420bc1
--- /dev/null
+++ b/src/components/atoms/headings/heading.module.scss
@@ -0,0 +1,69 @@
+@use "@styles/abstracts/functions" as fun;
+
+.heading {
+ color: var(--color-primary-dark);
+ font-family: var(--font-family-secondary);
+ letter-spacing: 0.01ex;
+
+ &--regular {
+ margin-bottom: 0;
+ margin-top: 0;
+ }
+
+ &--left {
+ text-align: left;
+ }
+
+ &--center {
+ width: fit-content;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ &--margin {
+ margin-top: 0;
+ margin-bottom: 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..0e3885d
--- /dev/null
+++ b/src/components/atoms/headings/heading.stories.tsx
@@ -0,0 +1,160 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Heading from './heading';
+
+/**
+ * Heading - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Typography/Headings',
+ component: Heading,
+ args: {
+ alignment: 'left',
+ isFake: false,
+ withMargin: true,
+ },
+ argTypes: {
+ alignment: {
+ control: {
+ type: 'select',
+ },
+ description: 'The title alignment.',
+ options: ['center', 'left'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'left' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ 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,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'An unique id.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ 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: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'Heading level.',
+ 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 Heading>;
+
+const Template: ComponentStory<typeof Heading> = (args) => (
+ <Heading {...args} />
+);
+
+/**
+ * Heading Story - h1
+ */
+export const H1 = Template.bind({});
+H1.args = {
+ children: 'Your title',
+ level: 1,
+};
+
+/**
+ * Heading Story - h2
+ */
+export const H2 = Template.bind({});
+H2.args = {
+ children: 'Your title',
+ level: 2,
+};
+
+/**
+ * Heading Story - h3
+ */
+export const H3 = Template.bind({});
+H3.args = {
+ children: 'Your title',
+ level: 3,
+};
+
+/**
+ * Heading Story - h4
+ */
+export const H4 = Template.bind({});
+H4.args = {
+ children: 'Your title',
+ level: 4,
+};
+
+/**
+ * Heading Story - h5
+ */
+export const H5 = Template.bind({});
+H5.args = {
+ children: 'Your title',
+ level: 5,
+};
+
+/**
+ * Heading Story - h6
+ */
+export const H6 = Template.bind({});
+H6.args = {
+ children: 'Your title',
+ level: 6,
+};
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..e385249
--- /dev/null
+++ b/src/components/atoms/headings/heading.tsx
@@ -0,0 +1,94 @@
+import {
+ createElement,
+ ForwardedRef,
+ forwardRef,
+ ForwardRefRenderFunction,
+ ReactNode,
+} from 'react';
+import styles from './heading.module.scss';
+
+export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
+
+export type HeadingProps = {
+ /**
+ * The title alignment. Default: left;
+ */
+ alignment?: 'center' | 'left';
+ /**
+ * The heading body.
+ */
+ children: ReactNode;
+ /**
+ * 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;
+};
+
+type TitleTagProps = Pick<HeadingProps, 'children' | 'className' | 'id'> & {
+ tagName: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p';
+};
+
+const TitleTag = forwardRef<
+ HTMLHeadingElement | HTMLParagraphElement,
+ TitleTagProps
+>(
+ (
+ { children, tagName, ...props },
+ ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement>
+ ) => {
+ return createElement(tagName, { ...props, ref }, children);
+ }
+);
+TitleTag.displayName = 'TitleTag';
+
+/**
+ * Heading component.
+ *
+ * Render an HTML heading element or a paragraph with heading styles.
+ */
+const Heading: ForwardRefRenderFunction<HTMLDivElement, HeadingProps> = (
+ {
+ alignment = 'left',
+ children,
+ className,
+ id,
+ isFake = false,
+ level,
+ withMargin = true,
+ },
+ ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement>
+) => {
+ const tagName = isFake ? 'p' : (`h${level}` as TitleTagProps['tagName']);
+ const levelClass = `heading--${level}`;
+ const alignmentClass = `heading--${alignment}`;
+ const marginClass = withMargin ? 'heading--margin' : 'heading--regular';
+
+ return (
+ <TitleTag
+ tagName={tagName}
+ className={`${styles.heading} ${styles[levelClass]} ${styles[alignmentClass]} ${styles[marginClass]} ${className}`}
+ id={id}
+ ref={ref}
+ >
+ {children}
+ </TitleTag>
+ );
+};
+
+export default forwardRef(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..1941479
--- /dev/null
+++ b/src/components/atoms/icons/arrow.stories.tsx
@@ -0,0 +1,48 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ArrowIcon from './arrow';
+
+/**
+ * Arrow icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Arrow
+ */
+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..2962530
--- /dev/null
+++ b/src/components/atoms/icons/arrow.tsx
@@ -0,0 +1,101 @@
+import { FC } 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: FC<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..7b11bb8
--- /dev/null
+++ b/src/components/atoms/icons/career.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CareerIcon from './career';
+
+/**
+ * Career icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Career
+ */
+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..f28d399
--- /dev/null
+++ b/src/components/atoms/icons/career.tsx
@@ -0,0 +1,71 @@
+import { FC } 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: FC<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..4229725
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CCBySAIcon from './cc-by-sa';
+
+/**
+ * CC BY SA icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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) => (
+ <CCBySAIcon {...args} />
+);
+
+/**
+ * Icons Stories - CC BY SA
+ */
+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..8239154
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.tsx
@@ -0,0 +1,45 @@
+import { FC } 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: FC<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..f9628db
--- /dev/null
+++ b/src/components/atoms/icons/close.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CloseIcon from './close';
+
+/**
+ * Close icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Close
+ */
+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..3e0adb5
--- /dev/null
+++ b/src/components/atoms/icons/close.tsx
@@ -0,0 +1,35 @@
+import { FC } 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: FC<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..631f30d
--- /dev/null
+++ b/src/components/atoms/icons/cog.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CogIcon from './cog';
+
+/**
+ * Cogs icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Cogs
+ */
+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..9e78a7b
--- /dev/null
+++ b/src/components/atoms/icons/cog.tsx
@@ -0,0 +1,29 @@
+import { FC } 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: FC<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..19649ad
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ComputerScreenIcon from './computer-screen';
+
+/**
+ * Computer Screen icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Computer Screen
+ */
+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..8786139
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.tsx
@@ -0,0 +1,79 @@
+import { FC } 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: FC<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..efa94dd
--- /dev/null
+++ b/src/components/atoms/icons/envelop.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import EnvelopIcon from './envelop';
+
+/**
+ * Envelop icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Envelop
+ */
+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..84dca97
--- /dev/null
+++ b/src/components/atoms/icons/envelop.tsx
@@ -0,0 +1,67 @@
+import { FC } 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: FC<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/feed.module.scss b/src/components/atoms/icons/feed.module.scss
new file mode 100644
index 0000000..56a5253
--- /dev/null
+++ b/src/components/atoms/icons/feed.module.scss
@@ -0,0 +1,6 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
diff --git a/src/components/atoms/icons/feed.stories.tsx b/src/components/atoms/icons/feed.stories.tsx
new file mode 100644
index 0000000..e3587a8
--- /dev/null
+++ b/src/components/atoms/icons/feed.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import FeedIcon from './feed';
+
+/**
+ * Feed icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/Icons',
+ component: FeedIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof FeedIcon>;
+
+const Template: ComponentStory<typeof FeedIcon> = (args) => (
+ <FeedIcon {...args} />
+);
+
+/**
+ * Icons Stories - Feed
+ */
+export const Feed = Template.bind({});
diff --git a/src/components/atoms/icons/feed.test.tsx b/src/components/atoms/icons/feed.test.tsx
new file mode 100644
index 0000000..fed9da9
--- /dev/null
+++ b/src/components/atoms/icons/feed.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Feed from './feed';
+
+describe('Feed', () => {
+ it('renders a feed icon', () => {
+ const { container } = render(<Feed />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/feed.tsx b/src/components/atoms/icons/feed.tsx
new file mode 100644
index 0000000..6839abd
--- /dev/null
+++ b/src/components/atoms/icons/feed.tsx
@@ -0,0 +1,74 @@
+import { FC } from 'react';
+import styles from './feed.module.scss';
+
+export type FeedProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Feed Component
+ *
+ * Render a feed svg icon.
+ */
+const Feed: FC<FeedProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 256 256"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <defs>
+ <linearGradient x1="0.085" y1="0.085" x2="0.915" y2="0.915" id="RSSg">
+ <stop offset="0.0" stopColor="#E3702D" />
+ <stop offset="0.1071" stopColor="#EA7D31" />
+ <stop offset="0.3503" stopColor="#F69537" />
+ <stop offset="0.5" stopColor="#FB9E3A" />
+ <stop offset="0.7016" stopColor="#EA7C31" />
+ <stop offset="0.8866" stopColor="#DE642B" />
+ <stop offset="1.0" stopColor="#D95B29" />
+ </linearGradient>
+ </defs>
+ <rect
+ width="256"
+ height="256"
+ rx="55"
+ ry="55"
+ x="0"
+ y="0"
+ fill="#CC5D15"
+ />
+ <rect
+ width="246"
+ height="246"
+ rx="50"
+ ry="50"
+ x="5"
+ y="5"
+ fill="#F49C52"
+ />
+ <rect
+ width="236"
+ height="236"
+ rx="47"
+ ry="47"
+ x="10"
+ y="10"
+ fill="url(#RSSg)"
+ />
+ <circle cx="68" cy="189" r="24" fill="#FFF" />
+ <path
+ d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z"
+ fill="#FFF"
+ />
+ <path
+ d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z"
+ fill="#FFF"
+ />
+ </svg>
+ );
+};
+
+export default Feed;
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..0a8a8cc
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HamburgerIcon from './hamburger';
+
+/**
+ * Hamburger icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Hamburger
+ */
+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..93aed2a
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.tsx
@@ -0,0 +1,32 @@
+import { FC } from 'react';
+import styles from './hamburger.module.scss';
+
+export 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..ffb3061
--- /dev/null
+++ b/src/components/atoms/icons/home.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HomeIcon from './home';
+
+/**
+ * Home icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Home
+ */
+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..3b6732d
--- /dev/null
+++ b/src/components/atoms/icons/home.tsx
@@ -0,0 +1,55 @@
+import { FC } 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: FC<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..3e33deb
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MagnifyingGlassIcon from './magnifying-glass';
+
+/**
+ * Magnifying Glass icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Magnifying Glass
+ */
+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..1ca2a44
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.tsx
@@ -0,0 +1,43 @@
+import { FC } 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: FC<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..e8b34de
--- /dev/null
+++ b/src/components/atoms/icons/moon.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MoonIcon from './moon';
+
+/**
+ * Moon icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Moon
+ */
+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..ec4fa0c
--- /dev/null
+++ b/src/components/atoms/icons/moon.tsx
@@ -0,0 +1,28 @@
+import { FC } from 'react';
+import styles from './moon.module.scss';
+
+export type MoonProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * The SVG title.
+ */
+ title?: string;
+};
+
+const Moon: FC<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..eebf1e5
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.stories.tsx
@@ -0,0 +1,49 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PlusMinusIcon from './plus-minus';
+
+/**
+ * Plus/Minus icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/Icons',
+ component: PlusMinusIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ state: {
+ control: {
+ type: 'radio',
+ },
+ description: 'Which state should be displayed.',
+ options: ['plus', 'minus'],
+ type: {
+ name: 'enum',
+ required: true,
+ value: ['plus', 'minus'],
+ },
+ },
+ },
+} as ComponentMeta<typeof PlusMinusIcon>;
+
+const Template: ComponentStory<typeof PlusMinusIcon> = (args) => (
+ <PlusMinusIcon {...args} />
+);
+
+/**
+ * Icons Stories - Plus/Minus
+ */
+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..e8897b7
--- /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';
+
+export 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..1990b98
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PostsStackIcon from './posts-stack';
+
+/**
+ * Posts Stack icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Posts Stack
+ */
+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..ab21323
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.tsx
@@ -0,0 +1,75 @@
+import { FC } 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: FC<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..60af648
--- /dev/null
+++ b/src/components/atoms/icons/sun.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SunIcon from './sun';
+
+/**
+ * Sun icon - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Icons Stories - Sun
+ */
+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..ca31747
--- /dev/null
+++ b/src/components/atoms/icons/sun.tsx
@@ -0,0 +1,33 @@
+import { FC } from 'react';
+import styles from './sun.module.scss';
+
+export type SunProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * The SVG title.
+ */
+ title?: string;
+};
+
+/**
+ * Sun component.
+ *
+ * Render a svg sun icon.
+ */
+const Sun: FC<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..458ec08
--- /dev/null
+++ b/src/components/atoms/images/logo.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LogoComponent from './logo';
+
+/**
+ * Logo - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Illustrations/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} />
+);
+
+/**
+ * Images Stories - Logo
+ */
+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..3978882
--- /dev/null
+++ b/src/components/atoms/images/logo.tsx
@@ -0,0 +1,46 @@
+import { FC } from 'react';
+import styles from './logo.module.scss';
+
+export type LogoProps = {
+ /**
+ * SVG Image title.
+ */
+ title?: string;
+};
+
+/**
+ * Logo component.
+ *
+ * Render a SVG logo.
+ */
+const Logo: FC<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/column.stories.tsx b/src/components/atoms/layout/column.stories.tsx
new file mode 100644
index 0000000..a03c462
--- /dev/null
+++ b/src/components/atoms/layout/column.stories.tsx
@@ -0,0 +1,29 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ColumnComponent from './column';
+
+export default {
+ title: 'Atoms/Layout/Column',
+ component: ColumnComponent,
+ argTypes: {
+ children: {
+ description: 'The column body.',
+ type: {
+ name: 'array',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof ColumnComponent>;
+
+const Template: ComponentStory<typeof ColumnComponent> = (args) => (
+ <ColumnComponent {...args} />
+);
+
+const body =
+ 'Non praesentium voluptas quisquam ex est. Distinctio accusamus facilis libero in aut. Et veritatis quo impedit fugit amet sit accusantium. Ut est rerum asperiores sint libero eveniet. Molestias placeat recusandae suscipit eligendi sunt hic.';
+
+export const Column = Template.bind({});
+Column.args = {
+ children: body,
+};
diff --git a/src/components/atoms/layout/column.test.tsx b/src/components/atoms/layout/column.test.tsx
new file mode 100644
index 0000000..c5c6554
--- /dev/null
+++ b/src/components/atoms/layout/column.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@test-utils';
+import Column from './column';
+
+const body =
+ 'Non praesentium voluptas quisquam ex est. Distinctio accusamus facilis libero in aut. Et veritatis quo impedit fugit amet sit accusantium. Ut est rerum asperiores sint libero eveniet. Molestias placeat recusandae suscipit eligendi sunt hic.';
+
+describe('Column', () => {
+ it('renders the column body', () => {
+ render(<Column>{body}</Column>);
+ expect(screen.getByText(body)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/column.tsx b/src/components/atoms/layout/column.tsx
new file mode 100644
index 0000000..ec6440d
--- /dev/null
+++ b/src/components/atoms/layout/column.tsx
@@ -0,0 +1,16 @@
+import { FC, ReactNode } from 'react';
+
+export type ColumnProps = {
+ children: ReactNode | ReactNode[];
+};
+
+/**
+ * Column component.
+ *
+ * Render the body as a column.
+ */
+const Column: FC<ColumnProps> = ({ children }) => {
+ return <div>{children}</div>;
+};
+
+export default Column;
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..612b114
--- /dev/null
+++ b/src/components/atoms/layout/copyright.stories.tsx
@@ -0,0 +1,58 @@
+import CCBySA from '@components/atoms/icons/cc-by-sa';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CopyrightComponent from './copyright';
+
+/**
+ * Copyright - Storybook Meta
+ */
+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) => (
+ <CopyrightComponent {...args} />
+);
+
+/**
+ * Layout Stories - Copyright
+ */
+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..f70695d
--- /dev/null
+++ b/src/components/atoms/layout/copyright.tsx
@@ -0,0 +1,59 @@
+import { FC, ReactNode } 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: FC<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..5bde475
--- /dev/null
+++ b/src/components/atoms/layout/main.stories.tsx
@@ -0,0 +1,58 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MainComponent from './main';
+
+/**
+ * Main - Storybook Meta
+ */
+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} />
+);
+
+/**
+ * Layout Stories - Main
+ */
+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..d92a5c7
--- /dev/null
+++ b/src/components/atoms/layout/main.tsx
@@ -0,0 +1,27 @@
+import { FC, ReactNode } from 'react';
+
+export type MainProps = {
+ /**
+ * The main body.
+ */
+ children: ReactNode;
+ /**
+ * 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..22d2fea
--- /dev/null
+++ b/src/components/atoms/layout/no-script.stories.tsx
@@ -0,0 +1,62 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoScript from './no-script';
+
+/**
+ * NoScript - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Layout/NoScript',
+ component: NoScript,
+ 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 NoScript>;
+
+const Template: ComponentStory<typeof NoScript> = (args) => (
+ <NoScript {...args} />
+);
+
+/**
+ * NoScript Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ message: 'A noscript only message.',
+ position: 'initial',
+};
+
+/**
+ * NoScript Stories - Top
+ */
+export const Top = Template.bind({});
+Top.args = {
+ message: 'A noscript only message.',
+ position: 'top',
+};
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..a503e0c
--- /dev/null
+++ b/src/components/atoms/layout/no-script.tsx
@@ -0,0 +1,21 @@
+import { FC } 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: FC<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..7fd972c
--- /dev/null
+++ b/src/components/atoms/layout/notice.module.scss
@@ -0,0 +1,27 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ 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..dedf834
--- /dev/null
+++ b/src/components/atoms/layout/notice.stories.tsx
@@ -0,0 +1,86 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoticeComponent from './notice';
+
+/**
+ * Notice - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Layout/Notice',
+ component: NoticeComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the notice wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ 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} />
+);
+
+/**
+ * Notice stories - Error
+ */
+export const Error = Template.bind({});
+Error.args = {
+ kind: 'error',
+ message: 'Nisi provident sapiente.',
+};
+
+/**
+ * Notice stories - Info
+ */
+export const Info = Template.bind({});
+Info.args = {
+ kind: 'info',
+ message: 'Nisi provident sapiente.',
+};
+
+/**
+ * Notice stories - Success
+ */
+export const Success = Template.bind({});
+Success.args = {
+ kind: 'success',
+ message: 'Nisi provident sapiente.',
+};
+
+/**
+ * Notice stories - Warning
+ */
+export const Warning = Template.bind({});
+Warning.args = {
+ kind: 'warning',
+ 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..a0d1d3e
--- /dev/null
+++ b/src/components/atoms/layout/notice.tsx
@@ -0,0 +1,38 @@
+import { FC } from 'react';
+import styles from './notice.module.scss';
+
+export type NoticeKind = 'error' | 'info' | 'success' | 'warning';
+
+export type NoticeProps = {
+ /**
+ * Set additional classnames to the notice wrapper.
+ */
+ className?: string;
+ /**
+ * The notice kind.
+ */
+ kind: NoticeKind;
+ /**
+ * The notice body.
+ */
+ message: string;
+};
+
+/**
+ * Notice component
+ *
+ * Render a colored message depending on notice kind.
+ */
+const Notice: FC<NoticeProps> = ({ className = '', kind, message }) => {
+ const kindClass = `wrapper--${kind}`;
+
+ return message ? (
+ <div className={`${styles.wrapper} ${styles[kindClass]} ${className}`}>
+ {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..530b2a0
--- /dev/null
+++ b/src/components/atoms/layout/section.stories.tsx
@@ -0,0 +1,102 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Section from './section';
+
+/**
+ * Section - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Layout/Section',
+ component: Section,
+ 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 Section>;
+
+const Template: ComponentStory<typeof Section> = (args) => (
+ <Section {...args} />
+);
+
+/**
+ * Section Stories - Light
+ */
+export const Light = Template.bind({});
+Light.args = {
+ title: 'A title',
+ content: 'The content.',
+ variant: 'light',
+};
+
+/**
+ * Section Stories - Dark
+ */
+export const Dark = Template.bind({});
+Dark.args = {
+ title: 'A title',
+ content: 'The content.',
+ variant: 'dark',
+};
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..cb727ff
--- /dev/null
+++ b/src/components/atoms/layout/section.tsx
@@ -0,0 +1,57 @@
+import { FC, ReactNode } 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: FC<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/layout/sidebar.module.scss b/src/components/atoms/layout/sidebar.module.scss
new file mode 100644
index 0000000..5d36f18
--- /dev/null
+++ b/src/components/atoms/layout/sidebar.module.scss
@@ -0,0 +1,12 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ > *:not(:first-child) {
+ margin-top: fun.convert-px(-2);
+ }
+}
+
+.body {
+ position: sticky;
+ top: var(--spacing-xs);
+}
diff --git a/src/components/atoms/layout/sidebar.stories.tsx b/src/components/atoms/layout/sidebar.stories.tsx
new file mode 100644
index 0000000..6876f95
--- /dev/null
+++ b/src/components/atoms/layout/sidebar.stories.tsx
@@ -0,0 +1,60 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SidebarComponent from './sidebar';
+
+/**
+ * Sidebar - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Layout',
+ component: SidebarComponent,
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible name for the sidebar.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The sidebar content.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the aside element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SidebarComponent>;
+
+const Template: ComponentStory<typeof SidebarComponent> = (args) => (
+ <SidebarComponent {...args} />
+);
+
+/**
+ * Layout Stories - Sidebar
+ */
+export const Sidebar = Template.bind({});
+Sidebar.args = {
+ children: 'Some widgets.',
+};
diff --git a/src/components/atoms/layout/sidebar.test.tsx b/src/components/atoms/layout/sidebar.test.tsx
new file mode 100644
index 0000000..4c9459d
--- /dev/null
+++ b/src/components/atoms/layout/sidebar.test.tsx
@@ -0,0 +1,11 @@
+import { render, screen } from '@test-utils';
+import Sidebar from './sidebar';
+
+const children = 'A widget';
+
+describe('Sidebar', () => {
+ it('renders an aside element', () => {
+ render(<Sidebar>{children}</Sidebar>);
+ expect(screen.getByRole('complementary')).toHaveTextContent(children);
+ });
+});
diff --git a/src/components/atoms/layout/sidebar.tsx b/src/components/atoms/layout/sidebar.tsx
new file mode 100644
index 0000000..d86af37
--- /dev/null
+++ b/src/components/atoms/layout/sidebar.tsx
@@ -0,0 +1,32 @@
+import { FC, ReactNode } from 'react';
+import styles from './sidebar.module.scss';
+
+export type SidebarProps = {
+ /**
+ * An accessible name for the sidebar.
+ */
+ 'aria-label'?: string;
+ /**
+ * The sidebar body.
+ */
+ children: ReactNode;
+ /**
+ * Set additional classnames to the aside element.
+ */
+ className?: string;
+};
+
+/**
+ * Sidebar component
+ *
+ * Render an aside element.
+ */
+const Sidebar: FC<SidebarProps> = ({ children, className = '', ...props }) => {
+ return (
+ <aside className={`${styles.wrapper} ${className}`} {...props}>
+ <div className={styles.body}>{children}</div>
+ </aside>
+ );
+};
+
+export default Sidebar;
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
new file mode 100644
index 0000000..bb5775f
--- /dev/null
+++ b/src/components/atoms/links/link.module.scss
@@ -0,0 +1,220 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/variables" as var;
+
+/* stylelint-disable no-descending-specificity */
+.link {
+ &[hreflang] {
+ &::after {
+ display: inline-block;
+
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ content: "\0000a0[" attr(hreflang) "]";
+ font-size: var(--font-size-sm);
+ }
+ }
+
+ &--download {
+ &::after {
+ display: inline-block;
+
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+ }
+
+ &:focus:not(:active)::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_white}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+ }
+
+ &[hreflang] {
+ &::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+ }
+
+ &:focus:not(:active)::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_white}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+ }
+ }
+ }
+
+ &--external {
+ &::after {
+ display: inline-block;
+
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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>'));
+ }
+ }
+ }
+
+ &--external#{&}--download {
+ &::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_white}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_white}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\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>'));
+ }
+ }
+ }
+}
+
+:global([data-theme="dark"]) {
+ :local {
+ .link {
+ &--download {
+ &::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$dark-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+ }
+
+ &:focus:not(:active)::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-theme_black}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$dark-theme_black}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+ }
+
+ &[hreflang] {
+ &::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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.$dark-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$dark-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+ }
+
+ &:focus:not(:active)::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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.$dark-theme_black}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$dark-theme_black}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+ }
+ }
+ }
+
+ &--external {
+ &::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-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.$dark-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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-theme_black}" 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.$dark-theme_black}" 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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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.$dark-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.$dark-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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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.$dark-theme_black}" 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.$dark-theme_black}" 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>'));
+ }
+ }
+ }
+
+ &--external.link--download {
+ &::after {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$dark-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-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.$dark-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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-theme_black}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$dark-theme_black}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-theme_black}" 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.$dark-theme_black}" 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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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.$dark-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$dark-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-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.$dark-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 {
+ /* Prettier is removing spacing between content parts. */
+
+ /* prettier-ignore */
+ 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.$dark-theme_black}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$dark-theme_black}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$dark-theme_black}" 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.$dark-theme_black}" 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>'));
+ }
+ }
+ }
+ }
+ }
+}
+/* stylelint-enable no-descending-specificity */
diff --git a/src/components/atoms/links/link.stories.tsx b/src/components/atoms/links/link.stories.tsx
new file mode 100644
index 0000000..4baabe5
--- /dev/null
+++ b/src/components/atoms/links/link.stories.tsx
@@ -0,0 +1,180 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Link from './link';
+
+/**
+ * Link - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Typography/Links',
+ component: Link,
+ 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,
+ },
+ },
+ download: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the link purpose is to download a file.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ external: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the link is external of the current website.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ 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 Link>;
+
+const Template: ComponentStory<typeof Link> = (args) => <Link {...args} />;
+
+/**
+ * Links Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ children: 'A link',
+ href: '#',
+ download: false,
+ external: false,
+};
+
+/**
+ * Links Stories - Download
+ */
+export const Download = Template.bind({});
+Download.args = {
+ children: 'A link to a file',
+ href: '#',
+ download: true,
+ external: false,
+};
+
+/**
+ * Links Stories - DownloadWithLang
+ */
+export const DownloadWithLang = Template.bind({});
+DownloadWithLang.args = {
+ children: 'A link to a file',
+ href: '#',
+ download: true,
+ external: false,
+ lang: 'en',
+};
+
+/**
+ * Links Stories - External
+ */
+export const External = Template.bind({});
+External.args = {
+ children: 'A link',
+ href: '#',
+ download: false,
+ external: true,
+};
+
+/**
+ * Links Stories - External download
+ */
+export const ExternalDownload = Template.bind({});
+ExternalDownload.args = {
+ children: 'A link',
+ href: '#',
+ download: true,
+ external: true,
+};
+
+/**
+ * Links Stories - External With Lang
+ */
+export const ExternalWithLang = Template.bind({});
+ExternalWithLang.args = {
+ children: 'A link',
+ href: '#',
+ download: false,
+ external: true,
+ lang: 'en',
+};
+
+/**
+ * Links Stories - External download with lang
+ */
+export const ExternalDownloadWithLang = Template.bind({});
+ExternalDownloadWithLang.args = {
+ children: 'A link',
+ href: '#',
+ download: true,
+ external: true,
+ lang: 'en',
+};
+
+/**
+ * Links Stories - With Lang
+ */
+export const WithLang = Template.bind({});
+WithLang.args = {
+ children: 'A link',
+ href: '#',
+ download: false,
+ external: false,
+ lang: 'en',
+};
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..c8ba273
--- /dev/null
+++ b/src/components/atoms/links/link.tsx
@@ -0,0 +1,67 @@
+import NextLink from 'next/link';
+import { FC, ReactNode } from 'react';
+import styles from './link.module.scss';
+
+export type LinkProps = {
+ /**
+ * The link body.
+ */
+ children: ReactNode;
+ /**
+ * Set additional classnames to the link.
+ */
+ className?: string;
+ /**
+ * True if it is a download link. Default: false.
+ */
+ download?: boolean;
+ /**
+ * 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 = '',
+ download = false,
+ external = false,
+ href,
+ lang,
+}) => {
+ const downloadClass = download ? styles['link--download'] : '';
+
+ return external ? (
+ <a
+ href={href}
+ hrefLang={lang}
+ className={`${styles.link} ${styles['link--external']} ${downloadClass} ${className}`}
+ >
+ {children}
+ </a>
+ ) : (
+ <NextLink href={href}>
+ <a
+ hrefLang={lang}
+ className={`${styles.link} ${downloadClass} ${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..7f7a334
--- /dev/null
+++ b/src/components/atoms/links/nav-link.stories.tsx
@@ -0,0 +1,55 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NavLinkComponent from './nav-link';
+
+/**
+ * NavLink - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Typography/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} />
+);
+
+/**
+ * Links Stories - Nav Link
+ */
+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..7c6fede
--- /dev/null
+++ b/src/components/atoms/links/nav-link.tsx
@@ -0,0 +1,36 @@
+import Link from 'next/link';
+import { FC, 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: FC<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..e6bd11b
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.stories.tsx
@@ -0,0 +1,98 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SharingLinkComponent from './sharing-link';
+
+/**
+ * SharingLink - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Buttons/Sharing',
+ 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) => (
+ <SharingLinkComponent {...args} />
+);
+
+/**
+ * Sharing Link Stories - Diaspora
+ */
+export const Diaspora = Template.bind({});
+Diaspora.args = {
+ medium: 'diaspora',
+ url: '#',
+};
+
+/**
+ * Sharing Link Stories - Email
+ */
+export const Email = Template.bind({});
+Email.args = {
+ medium: 'email',
+ url: '#',
+};
+
+/**
+ * Sharing Link Stories - Facebook
+ */
+export const Facebook = Template.bind({});
+Facebook.args = {
+ medium: 'facebook',
+ url: '#',
+};
+
+/**
+ * Sharing Link Stories - Journal du Hacker
+ */
+export const JournalDuHacker = Template.bind({});
+JournalDuHacker.args = {
+ medium: 'journal-du-hacker',
+ url: '#',
+};
+
+/**
+ * Sharing Link Stories - LinkedIn
+ */
+export const LinkedIn = Template.bind({});
+LinkedIn.args = {
+ medium: 'linkedin',
+ url: '#',
+};
+
+/**
+ * Sharing Link Stories - Twitter
+ */
+export const Twitter = Template.bind({});
+Twitter.args = {
+ medium: 'twitter',
+ 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..ca53ef9
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.tsx
@@ -0,0 +1,48 @@
+import { FC } 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: FC<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..977ae6b
--- /dev/null
+++ b/src/components/atoms/links/social-link.stories.tsx
@@ -0,0 +1,73 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SocialLink from './social-link';
+
+/**
+ * SocialLink - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Buttons/Social',
+ component: SocialLink,
+ 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 SocialLink>;
+
+const Template: ComponentStory<typeof SocialLink> = (args) => (
+ <SocialLink {...args} />
+);
+
+/**
+ * Social Link Stories - Github
+ */
+export const Github = Template.bind({});
+Github.args = {
+ name: 'Github',
+ url: '#',
+};
+
+/**
+ * Social Link Stories - Gitlab
+ */
+export const Gitlab = Template.bind({});
+Gitlab.args = {
+ name: 'Gitlab',
+ url: '#',
+};
+
+/**
+ * Social Link Stories - LinkedIn
+ */
+export const LinkedIn = Template.bind({});
+LinkedIn.args = {
+ name: 'LinkedIn',
+ url: '#',
+};
+
+/**
+ * Social Link Stories - Twitter
+ */
+export const Twitter = Template.bind({});
+Twitter.args = {
+ name: 'Twitter',
+ 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..464bc60
--- /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 { FC } 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: FC<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-item.module.scss b/src/components/atoms/lists/description-list-item.module.scss
new file mode 100644
index 0000000..aba90ce
--- /dev/null
+++ b/src/components/atoms/lists/description-list-item.module.scss
@@ -0,0 +1,40 @@
+.term {
+ color: var(--color-fg-light);
+ font-weight: 600;
+}
+
+.description {
+ margin: 0;
+ word-break: break-all;
+}
+
+.wrapper {
+ display: flex;
+ width: fit-content;
+
+ &--has-separator {
+ .description:not(:first-of-type) {
+ &::before {
+ content: "/\0000a0";
+ }
+ }
+ }
+
+ &--inline,
+ &--inline-values {
+ flex-flow: row wrap;
+ column-gap: var(--spacing-2xs);
+ }
+
+ &--inline-values {
+ row-gap: var(--spacing-2xs);
+
+ .term {
+ flex: 1 1 100%;
+ }
+ }
+
+ &--stacked {
+ flex-flow: column wrap;
+ }
+}
diff --git a/src/components/atoms/lists/description-list-item.stories.tsx b/src/components/atoms/lists/description-list-item.stories.tsx
new file mode 100644
index 0000000..c7beb0d
--- /dev/null
+++ b/src/components/atoms/lists/description-list-item.stories.tsx
@@ -0,0 +1,132 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import DescriptionListItemComponent from './description-list-item';
+
+export default {
+ title: 'Atoms/Typography/Lists/DescriptionList/Item',
+ component: DescriptionListItemComponent,
+ args: {
+ layout: 'stacked',
+ withSeparator: false,
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list item wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ descriptionClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list item description.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'The item label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ layout: {
+ control: {
+ type: 'select',
+ },
+ description: 'The item layout.',
+ options: ['inline', 'inline-values', 'stacked'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'stacked' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ termClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list item term.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ value: {
+ description: 'The item value.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ withSeparator: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Add a slash as separator between multiple values.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof DescriptionListItemComponent>;
+
+const Template: ComponentStory<typeof DescriptionListItemComponent> = (
+ args
+) => <DescriptionListItemComponent {...args} />;
+
+export const SingleValueStacked = Template.bind({});
+SingleValueStacked.args = {
+ label: 'Recusandae vitae tenetur',
+ value: ['praesentium'],
+ layout: 'stacked',
+};
+
+export const SingleValueInlined = Template.bind({});
+SingleValueInlined.args = {
+ label: 'Recusandae vitae tenetur',
+ value: ['praesentium'],
+ layout: 'inline',
+};
+
+export const MultipleValuesStacked = Template.bind({});
+MultipleValuesStacked.args = {
+ label: 'Recusandae vitae tenetur',
+ value: ['praesentium', 'voluptate', 'tempore'],
+ layout: 'stacked',
+};
+
+export const MultipleValuesInlined = Template.bind({});
+MultipleValuesInlined.args = {
+ label: 'Recusandae vitae tenetur',
+ value: ['praesentium', 'voluptate', 'tempore'],
+ layout: 'inline-values',
+ withSeparator: true,
+};
diff --git a/src/components/atoms/lists/description-list-item.test.tsx b/src/components/atoms/lists/description-list-item.test.tsx
new file mode 100644
index 0000000..730a52f
--- /dev/null
+++ b/src/components/atoms/lists/description-list-item.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@test-utils';
+import DescriptionListItem from './description-list-item';
+
+const itemLabel = 'Repellendus corporis facilis';
+const itemValue = ['quos', 'eum'];
+
+describe('DescriptionListItem', () => {
+ it('renders a couple of label', () => {
+ render(<DescriptionListItem label={itemLabel} value={itemValue} />);
+ expect(screen.getByRole('term')).toHaveTextContent(itemLabel);
+ });
+
+ it('renders the right number of values', () => {
+ render(<DescriptionListItem label={itemLabel} value={itemValue} />);
+ expect(screen.getAllByRole('definition')).toHaveLength(itemValue.length);
+ });
+});
diff --git a/src/components/atoms/lists/description-list-item.tsx b/src/components/atoms/lists/description-list-item.tsx
new file mode 100644
index 0000000..9505d01
--- /dev/null
+++ b/src/components/atoms/lists/description-list-item.tsx
@@ -0,0 +1,73 @@
+import { FC, ReactNode, useId } from 'react';
+import styles from './description-list-item.module.scss';
+
+export type ItemLayout = 'inline' | 'inline-values' | 'stacked';
+
+export type DescriptionListItemProps = {
+ /**
+ * Set additional classnames to the list item wrapper.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the list item description.
+ */
+ descriptionClassName?: string;
+ /**
+ * The item label.
+ */
+ label: string;
+ /**
+ * The item layout.
+ */
+ layout?: ItemLayout;
+ /**
+ * Set additional classnames to the list item term.
+ */
+ termClassName?: string;
+ /**
+ * The item value.
+ */
+ value: ReactNode | ReactNode[];
+ /**
+ * If true, use a slash to delimitate multiple values.
+ */
+ withSeparator?: boolean;
+};
+
+/**
+ * DescriptionListItem component
+ *
+ * Render a couple of dt/dd wrapped in a div.
+ */
+const DescriptionListItem: FC<DescriptionListItemProps> = ({
+ className = '',
+ descriptionClassName = '',
+ label,
+ termClassName = '',
+ value,
+ layout = 'stacked',
+ withSeparator = false,
+}) => {
+ const id = useId();
+ const layoutStyles = styles[`wrapper--${layout}`];
+ const separatorStyles = withSeparator ? styles['wrapper--has-separator'] : '';
+ const itemValues = Array.isArray(value) ? value : [value];
+
+ return (
+ <div
+ className={`${styles.wrapper} ${layoutStyles} ${separatorStyles} ${className}`}
+ >
+ <dt className={`${styles.term} ${termClassName}`}>{label}</dt>
+ {itemValues.map((currentValue, index) => (
+ <dd
+ key={`${id}-${index}`}
+ className={`${styles.description} ${descriptionClassName}`}
+ >
+ {currentValue}
+ </dd>
+ ))}
+ </div>
+ );
+};
+
+export default DescriptionListItem;
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..9e913d4
--- /dev/null
+++ b/src/components/atoms/lists/description-list.module.scss
@@ -0,0 +1,17 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.list {
+ display: flex;
+ column-gap: var(--spacing-md);
+ row-gap: var(--spacing-2xs);
+ margin: 0;
+
+ &--inline {
+ flex-flow: row wrap;
+ align-items: baseline;
+ }
+
+ &--column {
+ 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..347fd78
--- /dev/null
+++ b/src/components/atoms/lists/description-list.stories.tsx
@@ -0,0 +1,131 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import DescriptionList, { DescriptionListItem } from './description-list';
+
+/**
+ * DescriptionList - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Typography/Lists/DescriptionList',
+ component: DescriptionList,
+ args: {
+ layout: 'column',
+ withSeparator: false,
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ groupClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the item wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ control: {
+ type: null,
+ },
+ description: 'The list items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ labelClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the label wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ layout: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list layout.',
+ options: ['column', 'inline'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'column' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ valueClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the value wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ withSeparator: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Add a slash as separator between multiple values.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof DescriptionList>;
+
+const Template: ComponentStory<typeof DescriptionList> = (args) => (
+ <DescriptionList {...args} />
+);
+
+const items: DescriptionListItem[] = [
+ { id: 'term-1', label: 'Term 1:', value: ['Value for term 1'] },
+ { id: 'term-2', label: 'Term 2:', value: ['Value for term 2'] },
+ {
+ id: 'term-3',
+ label: 'Term 3:',
+ value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'],
+ },
+ { id: 'term-4', label: 'Term 4:', value: ['Value for term 4'] },
+];
+
+/**
+ * List Stories - Description list
+ */
+export const List = Template.bind({});
+List.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..83e405f
--- /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', label: 'Term 1:', value: ['Value for term 1'] },
+ { id: 'term-2', label: 'Term 2:', value: ['Value for term 2'] },
+ {
+ id: 'term-3',
+ label: 'Term 3:',
+ value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'],
+ },
+ { id: 'term-4', label: '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..a8e2d53
--- /dev/null
+++ b/src/components/atoms/lists/description-list.tsx
@@ -0,0 +1,103 @@
+import { FC } from 'react';
+import DescriptionListItem, {
+ type DescriptionListItemProps,
+} from './description-list-item';
+import styles from './description-list.module.scss';
+
+export type DescriptionListItem = {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * The list item layout.
+ */
+ layout?: DescriptionListItemProps['layout'];
+ /**
+ * A list label.
+ */
+ label: DescriptionListItemProps['label'];
+ /**
+ * An array of values for the list item.
+ */
+ value: DescriptionListItemProps['value'];
+};
+
+export type DescriptionListProps = {
+ /**
+ * Set additional classnames to the list wrapper.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the `dt`/`dd` couple wrapper.
+ */
+ groupClassName?: string;
+ /**
+ * The list items.
+ */
+ items: DescriptionListItem[];
+ /**
+ * Set additional classnames to the `dt` element.
+ */
+ labelClassName?: string;
+ /**
+ * The list layout. Default: column.
+ */
+ layout?: 'inline' | 'column';
+ /**
+ * Set additional classnames to the `dd` element.
+ */
+ valueClassName?: string;
+ /**
+ * If true, use a slash to delimitate multiple values.
+ */
+ withSeparator?: DescriptionListItemProps['withSeparator'];
+};
+
+/**
+ * DescriptionList component
+ *
+ * Render a description list.
+ */
+const DescriptionList: FC<DescriptionListProps> = ({
+ className = '',
+ groupClassName = '',
+ items,
+ labelClassName = '',
+ layout = 'column',
+ valueClassName = '',
+ withSeparator,
+}) => {
+ const layoutModifier = `list--${layout}`;
+
+ /**
+ * Retrieve the description list items.
+ *
+ * @param {DescriptionListItem[]} listItems - An array of items.
+ * @returns {JSX.Element[]} The description list items.
+ */
+ const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => {
+ return listItems.map(({ id, layout: itemLayout, label, value }) => {
+ return (
+ <DescriptionListItem
+ key={id}
+ label={label}
+ value={value}
+ layout={itemLayout}
+ className={groupClassName}
+ descriptionClassName={valueClassName}
+ termClassName={labelClassName}
+ withSeparator={withSeparator}
+ />
+ );
+ });
+ };
+
+ return (
+ <dl className={`${styles.list} ${styles[layoutModifier]} ${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..95f9b40
--- /dev/null
+++ b/src/components/atoms/lists/list.module.scss
@@ -0,0 +1,45 @@
+@use "@styles/abstracts/placeholders";
+
+.list {
+ margin: 0;
+
+ ::marker {
+ color: var(--color-primary-dark);
+ }
+
+ &--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);
+ }
+
+ &--flex {
+ @extend %reset-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-sm);
+ }
+
+ &--flex &--flex {
+ display: initial;
+ position: relative;
+ top: 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..eac3cd3
--- /dev/null
+++ b/src/components/atoms/lists/list.stories.tsx
@@ -0,0 +1,111 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ListComponent, { type ListItem } from './list';
+
+/**
+ * List - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Typography/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: {},
+ },
+ },
+ itemsClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list items.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind: flex, ordered or unordered.',
+ options: ['flex', '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' },
+];
+
+/**
+ * List Stories - Flex list
+ */
+export const Flex = Template.bind({});
+Flex.args = {
+ items,
+ kind: 'flex',
+};
+
+/**
+ * List Stories - Ordered list
+ */
+export const Ordered = Template.bind({});
+Ordered.args = {
+ items,
+ kind: 'ordered',
+};
+
+/**
+ * List Stories - Unordered list
+ */
+export const Unordered = Template.bind({});
+Unordered.args = {
+ items,
+};
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..aa0a241
--- /dev/null
+++ b/src/components/atoms/lists/list.tsx
@@ -0,0 +1,79 @@
+import { FC } 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.
+ */
+ kind?: 'ordered' | 'unordered' | 'flex';
+};
+
+/**
+ * List component
+ *
+ * Render either an ordered or an unordered list.
+ */
+const List: FC<ListProps> = ({
+ className = '',
+ items,
+ itemsClassName = '',
+ kind = 'unordered',
+}) => {
+ const ListTag = kind === 'ordered' ? 'ol' : 'ul';
+ const kindClass = `list--${kind}`;
+
+ /**
+ * 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]} ${className}`}
+ >
+ {getItems(child)}
+ </ListTag>
+ )}
+ </li>
+ ));
+ };
+
+ return (
+ <ListTag className={`${styles.list} ${styles[kindClass]} ${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..878010a
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.module.scss
@@ -0,0 +1,43 @@
+@use "@styles/abstracts/functions" as fun;
+
+.progress {
+ 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);
+ margin: auto;
+ 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..fcd631c
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.stories.tsx
@@ -0,0 +1,93 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ProgressBarComponent from './progress-bar';
+
+/**
+ * ProgressBar - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Loaders/ProgressBar',
+ 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} />
+);
+
+/**
+ * Loaders Stories - Default Progress bar
+ */
+export const ProgressBar = Template.bind({});
+ProgressBar.args = {
+ current: 10,
+ min: 0,
+ max: 50,
+};
+
+/**
+ * Loaders Stories - Progress bar With Info
+ */
+export const ProgressBarWithInfo = Template.bind({});
+ProgressBarWithInfo.args = {
+ current: 10,
+ info: 'Loaded: 10 / 50',
+ 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..9bac847
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.tsx
@@ -0,0 +1,55 @@
+import { FC } 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: FC<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..1792c6c
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.stories.tsx
@@ -0,0 +1,42 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SpinnerComponent from './spinner';
+
+/**
+ * Spinner - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Loaders/Spinner',
+ 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) => (
+ <SpinnerComponent {...args} />
+);
+
+/**
+ * Loaders Stories - Default Spinner
+ */
+export const Spinner = Template.bind({});
+
+/**
+ * Loaders Stories - Spinner with custom message
+ */
+export const SpinnerCustomMessage = Template.bind({});
+SpinnerCustomMessage.args = {
+ message: 'Submitting...',
+};
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..6655141
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.tsx
@@ -0,0 +1,37 @@
+import { FC } 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: FC<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;