aboutsummaryrefslogtreecommitdiffstats
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.tsx95
-rw-r--r--src/components/atoms/buttons/button-link.test.tsx9
-rw-r--r--src/components/atoms/buttons/button-link.tsx69
-rw-r--r--src/components/atoms/buttons/button.stories.tsx148
-rw-r--r--src/components/atoms/buttons/button.test.tsx18
-rw-r--r--src/components/atoms/buttons/button.tsx64
-rw-r--r--src/components/atoms/buttons/buttons.module.scss178
-rw-r--r--src/components/atoms/forms/checkbox.stories.tsx96
-rw-r--r--src/components/atoms/forms/checkbox.test.tsx28
-rw-r--r--src/components/atoms/forms/checkbox.tsx46
-rw-r--r--src/components/atoms/forms/field.stories.tsx201
-rw-r--r--src/components/atoms/forms/field.test.tsx30
-rw-r--r--src/components/atoms/forms/field.tsx107
-rw-r--r--src/components/atoms/forms/form.test.tsx9
-rw-r--r--src/components/atoms/forms/form.tsx73
-rw-r--r--src/components/atoms/forms/forms.module.scss53
-rw-r--r--src/components/atoms/forms/label.module.scss17
-rw-r--r--src/components/atoms/forms/label.stories.tsx85
-rw-r--r--src/components/atoms/forms/label.test.tsx9
-rw-r--r--src/components/atoms/forms/label.tsx45
-rw-r--r--src/components/atoms/forms/select.stories.tsx145
-rw-r--r--src/components/atoms/forms/select.test.tsx30
-rw-r--r--src/components/atoms/forms/select.tsx99
-rw-r--r--src/components/atoms/headings/heading.module.scss57
-rw-r--r--src/components/atoms/headings/heading.stories.tsx82
-rw-r--r--src/components/atoms/headings/heading.test.tsx56
-rw-r--r--src/components/atoms/headings/heading.tsx56
-rw-r--r--src/components/atoms/icons/arrow.module.scss16
-rw-r--r--src/components/atoms/icons/arrow.stories.tsx42
-rw-r--r--src/components/atoms/icons/arrow.test.tsx9
-rw-r--r--src/components/atoms/icons/arrow.tsx101
-rw-r--r--src/components/atoms/icons/career.module.scss53
-rw-r--r--src/components/atoms/icons/career.stories.tsx28
-rw-r--r--src/components/atoms/icons/career.test.tsx9
-rw-r--r--src/components/atoms/icons/career.tsx71
-rw-r--r--src/components/atoms/icons/cc-by-sa.module.scss7
-rw-r--r--src/components/atoms/icons/cc-by-sa.stories.tsx31
-rw-r--r--src/components/atoms/icons/cc-by-sa.test.tsx9
-rw-r--r--src/components/atoms/icons/cc-by-sa.tsx45
-rw-r--r--src/components/atoms/icons/close.module.scss12
-rw-r--r--src/components/atoms/icons/close.stories.tsx28
-rw-r--r--src/components/atoms/icons/close.test.tsx9
-rw-r--r--src/components/atoms/icons/close.tsx35
-rw-r--r--src/components/atoms/icons/cog.module.scss8
-rw-r--r--src/components/atoms/icons/cog.stories.tsx28
-rw-r--r--src/components/atoms/icons/cog.test.tsx9
-rw-r--r--src/components/atoms/icons/cog.tsx29
-rw-r--r--src/components/atoms/icons/computer-screen.module.scss39
-rw-r--r--src/components/atoms/icons/computer-screen.stories.tsx28
-rw-r--r--src/components/atoms/icons/computer-screen.test.tsx9
-rw-r--r--src/components/atoms/icons/computer-screen.tsx79
-rw-r--r--src/components/atoms/icons/envelop.module.scss28
-rw-r--r--src/components/atoms/icons/envelop.stories.tsx28
-rw-r--r--src/components/atoms/icons/envelop.test.tsx9
-rw-r--r--src/components/atoms/icons/envelop.tsx67
-rw-r--r--src/components/atoms/icons/hamburger.module.scss42
-rw-r--r--src/components/atoms/icons/hamburger.stories.tsx41
-rw-r--r--src/components/atoms/icons/hamburger.test.tsx9
-rw-r--r--src/components/atoms/icons/hamburger.tsx32
-rw-r--r--src/components/atoms/icons/home.module.scss41
-rw-r--r--src/components/atoms/icons/home.stories.tsx28
-rw-r--r--src/components/atoms/icons/home.test.tsx9
-rw-r--r--src/components/atoms/icons/home.tsx55
-rw-r--r--src/components/atoms/icons/magnifying-glass.module.scss29
-rw-r--r--src/components/atoms/icons/magnifying-glass.stories.tsx28
-rw-r--r--src/components/atoms/icons/magnifying-glass.test.tsx9
-rw-r--r--src/components/atoms/icons/magnifying-glass.tsx43
-rw-r--r--src/components/atoms/icons/moon.module.scss8
-rw-r--r--src/components/atoms/icons/moon.stories.tsx41
-rw-r--r--src/components/atoms/icons/moon.test.tsx9
-rw-r--r--src/components/atoms/icons/moon.tsx28
-rw-r--r--src/components/atoms/icons/plus-minus.module.scss39
-rw-r--r--src/components/atoms/icons/plus-minus.stories.tsx43
-rw-r--r--src/components/atoms/icons/plus-minus.test.tsx9
-rw-r--r--src/components/atoms/icons/plus-minus.tsx31
-rw-r--r--src/components/atoms/icons/posts-stack.module.scss22
-rw-r--r--src/components/atoms/icons/posts-stack.stories.tsx28
-rw-r--r--src/components/atoms/icons/posts-stack.test.tsx9
-rw-r--r--src/components/atoms/icons/posts-stack.tsx75
-rw-r--r--src/components/atoms/icons/sun.module.scss8
-rw-r--r--src/components/atoms/icons/sun.stories.tsx41
-rw-r--r--src/components/atoms/icons/sun.test.tsx9
-rw-r--r--src/components/atoms/icons/sun.tsx33
-rw-r--r--src/components/atoms/images/logo.module.scss28
-rw-r--r--src/components/atoms/images/logo.stories.tsx28
-rw-r--r--src/components/atoms/images/logo.test.tsx9
-rw-r--r--src/components/atoms/images/logo.tsx46
-rw-r--r--src/components/atoms/layout/copyright.module.scss32
-rw-r--r--src/components/atoms/layout/copyright.stories.tsx55
-rw-r--r--src/components/atoms/layout/copyright.test.tsx32
-rw-r--r--src/components/atoms/layout/copyright.tsx59
-rw-r--r--src/components/atoms/layout/main.stories.tsx52
-rw-r--r--src/components/atoms/layout/main.test.tsx12
-rw-r--r--src/components/atoms/layout/main.tsx23
-rw-r--r--src/components/atoms/layout/no-script.module.scss19
-rw-r--r--src/components/atoms/layout/no-script.stories.tsx46
-rw-r--r--src/components/atoms/layout/no-script.test.tsx11
-rw-r--r--src/components/atoms/layout/no-script.tsx21
-rw-r--r--src/components/atoms/layout/notice.module.scss28
-rw-r--r--src/components/atoms/layout/notice.stories.tsx40
-rw-r--r--src/components/atoms/layout/notice.test.tsx11
-rw-r--r--src/components/atoms/layout/notice.tsx30
-rw-r--r--src/components/atoms/layout/section.module.scss25
-rw-r--r--src/components/atoms/layout/section.stories.tsx85
-rw-r--r--src/components/atoms/layout/section.test.tsx17
-rw-r--r--src/components/atoms/layout/section.tsx57
-rw-r--r--src/components/atoms/links/link.module.scss37
-rw-r--r--src/components/atoms/links/link.stories.tsx79
-rw-r--r--src/components/atoms/links/link.test.tsx9
-rw-r--r--src/components/atoms/links/link.tsx51
-rw-r--r--src/components/atoms/links/nav-link.module.scss46
-rw-r--r--src/components/atoms/links/nav-link.stories.tsx49
-rw-r--r--src/components/atoms/links/nav-link.test.tsx12
-rw-r--r--src/components/atoms/links/nav-link.tsx36
-rw-r--r--src/components/atoms/links/sharing-link.module.scss157
-rw-r--r--src/components/atoms/links/sharing-link.stories.tsx50
-rw-r--r--src/components/atoms/links/sharing-link.test.tsx46
-rw-r--r--src/components/atoms/links/sharing-link.tsx48
-rw-r--r--src/components/atoms/links/social-link.module.scss43
-rw-r--r--src/components/atoms/links/social-link.stories.tsx40
-rw-r--r--src/components/atoms/links/social-link.test.tsx15
-rw-r--r--src/components/atoms/links/social-link.tsx53
-rw-r--r--src/components/atoms/lists/description-list.module.scss54
-rw-r--r--src/components/atoms/lists/description-list.stories.tsx73
-rw-r--r--src/components/atoms/lists/description-list.test.tsx20
-rw-r--r--src/components/atoms/lists/description-list.tsx100
-rw-r--r--src/components/atoms/lists/list.module.scss39
-rw-r--r--src/components/atoms/lists/list.stories.tsx80
-rw-r--r--src/components/atoms/lists/list.test.tsx26
-rw-r--r--src/components/atoms/lists/list.tsx87
-rw-r--r--src/components/atoms/loaders/progress-bar.module.scss43
-rw-r--r--src/components/atoms/loaders/progress-bar.stories.tsx76
-rw-r--r--src/components/atoms/loaders/progress-bar.test.tsx9
-rw-r--r--src/components/atoms/loaders/progress-bar.tsx55
-rw-r--r--src/components/atoms/loaders/spinner.module.scss48
-rw-r--r--src/components/atoms/loaders/spinner.stories.tsx31
-rw-r--r--src/components/atoms/loaders/spinner.test.tsx14
-rw-r--r--src/components/atoms/loaders/spinner.tsx37
138 files changed, 5899 insertions, 0 deletions
diff --git a/src/components/atoms/buttons/button-link.stories.tsx b/src/components/atoms/buttons/button-link.stories.tsx
new file mode 100644
index 0000000..92b7521
--- /dev/null
+++ b/src/components/atoms/buttons/button-link.stories.tsx
@@ -0,0 +1,95 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ButtonLinkComponent from './button-link';
+
+export default {
+ title: 'Atoms/Buttons',
+ component: ButtonLinkComponent,
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible label.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the button link.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The link kind.',
+ options: ['primary', 'secondary'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'secondary' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ shape: {
+ control: {
+ type: 'select',
+ },
+ description: 'The link shape.',
+ options: ['rectangle', 'square'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'rectangle' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ target: {
+ control: {
+ type: null,
+ },
+ description: 'The link target.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ButtonLinkComponent>;
+
+const Template: ComponentStory<typeof ButtonLinkComponent> = (args) => (
+ <ButtonLinkComponent {...args} />
+);
+
+export const ButtonLink = Template.bind({});
+ButtonLink.args = {
+ children: 'Link',
+ target: '#',
+};
diff --git a/src/components/atoms/buttons/button-link.test.tsx b/src/components/atoms/buttons/button-link.test.tsx
new file mode 100644
index 0000000..52ccdc7
--- /dev/null
+++ b/src/components/atoms/buttons/button-link.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import ButtonLink from './button-link';
+
+describe('ButtonLink', () => {
+ it('renders a ButtonLink component', () => {
+ render(<ButtonLink target="#">Button Link</ButtonLink>);
+ expect(screen.getByRole('link')).toHaveTextContent('Button Link');
+ });
+});
diff --git a/src/components/atoms/buttons/button-link.tsx b/src/components/atoms/buttons/button-link.tsx
new file mode 100644
index 0000000..77a7f7b
--- /dev/null
+++ b/src/components/atoms/buttons/button-link.tsx
@@ -0,0 +1,69 @@
+import Link from 'next/link';
+import { FC } from 'react';
+import styles from './buttons.module.scss';
+
+export type ButtonLinkProps = {
+ /**
+ * ButtonLink accessible label.
+ */
+ 'aria-label'?: string;
+ /**
+ * Set additional classnames to the button link.
+ */
+ className?: string;
+ /**
+ * True if it is an external link. Default: false.
+ */
+ external?: boolean;
+ /**
+ * ButtonLink kind. Default: secondary.
+ */
+ kind?: 'primary' | 'secondary';
+ /**
+ * ButtonLink shape. Default: rectangle.
+ */
+ shape?: 'circle' | 'rectangle' | 'square';
+ /**
+ * Define an URL as target.
+ */
+ target: string;
+};
+
+/**
+ * ButtonLink component
+ *
+ * Use a button-like link as call to action.
+ */
+const ButtonLink: FC<ButtonLinkProps> = ({
+ children,
+ className,
+ target,
+ kind = 'secondary',
+ shape = 'rectangle',
+ external = false,
+ ...props
+}) => {
+ const kindClass = styles[`btn--${kind}`];
+ const shapeClass = styles[`btn--${shape}`];
+
+ return external ? (
+ <a
+ href={target}
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
+ {...props}
+ >
+ {children}
+ </a>
+ ) : (
+ <Link href={target}>
+ <a
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
+ {...props}
+ >
+ {children}
+ </a>
+ </Link>
+ );
+};
+
+export default ButtonLink;
diff --git a/src/components/atoms/buttons/button.stories.tsx b/src/components/atoms/buttons/button.stories.tsx
new file mode 100644
index 0000000..d47a1ea
--- /dev/null
+++ b/src/components/atoms/buttons/button.stories.tsx
@@ -0,0 +1,148 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ButtonComponent from './button';
+
+export default {
+ title: 'Atoms/Buttons',
+ component: ButtonComponent,
+ args: {
+ disabled: false,
+ kind: 'secondary',
+ type: 'button',
+ },
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible label.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The button body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the button wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Render button as disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'Button kind.',
+ options: ['primary', 'secondary', 'tertiary', 'neutral'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'secondary' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ onClick: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle click.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ shape: {
+ control: {
+ type: 'select',
+ },
+ description: 'The link shape.',
+ options: ['circle', 'rectangle', 'square', 'initial'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'rectangle' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ type: {
+ control: {
+ type: 'select',
+ },
+ description: 'Button type attribute.',
+ options: ['button', 'reset', 'submit'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'button' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ButtonComponent>;
+
+const Template: ComponentStory<typeof ButtonComponent> = (args) => {
+ const { children, type, ...props } = args;
+
+ const getBody = () => {
+ if (children) return children;
+
+ switch (type) {
+ case 'reset':
+ return 'Reset';
+ case 'submit':
+ return 'Submit';
+ case 'button':
+ default:
+ return 'Button';
+ }
+ };
+
+ return (
+ <ButtonComponent type={type} {...props}>
+ {getBody()}
+ </ButtonComponent>
+ );
+};
+
+export const Button = Template.bind({});
diff --git a/src/components/atoms/buttons/button.test.tsx b/src/components/atoms/buttons/button.test.tsx
new file mode 100644
index 0000000..57c79c6
--- /dev/null
+++ b/src/components/atoms/buttons/button.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@test-utils';
+import Button from './button';
+
+describe('Button', () => {
+ it('renders the Button component', () => {
+ render(<Button onClick={() => null}>Button</Button>);
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('renders the Button component with disabled state', () => {
+ render(
+ <Button onClick={() => null} disabled={true}>
+ Disabled Button
+ </Button>
+ );
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+});
diff --git a/src/components/atoms/buttons/button.tsx b/src/components/atoms/buttons/button.tsx
new file mode 100644
index 0000000..545c5c5
--- /dev/null
+++ b/src/components/atoms/buttons/button.tsx
@@ -0,0 +1,64 @@
+import { FC, MouseEventHandler } from 'react';
+import styles from './buttons.module.scss';
+
+export type ButtonProps = {
+ /**
+ * Set additional classnames to the button wrapper.
+ */
+ className?: string;
+ /**
+ * Button accessible label.
+ */
+ 'aria-label'?: string;
+ /**
+ * Button state. Default: false.
+ */
+ disabled?: boolean;
+ /**
+ * Button kind. Default: secondary.
+ */
+ kind?: 'primary' | 'secondary' | 'tertiary' | 'neutral';
+ /**
+ * A callback function to handle click.
+ */
+ onClick?: MouseEventHandler<HTMLButtonElement>;
+ /**
+ * Button shape. Default: rectangle.
+ */
+ shape?: 'circle' | 'rectangle' | 'square' | 'initial';
+ /**
+ * Button type attribute. Default: button.
+ */
+ type?: 'button' | 'reset' | 'submit';
+};
+
+/**
+ * Button component
+ *
+ * Use a button as call to action.
+ */
+const Button: FC<ButtonProps> = ({
+ className = '',
+ children,
+ disabled = false,
+ kind = 'secondary',
+ shape = 'rectangle',
+ type = 'button',
+ ...props
+}) => {
+ const kindClass = styles[`btn--${kind}`];
+ const shapeClass = styles[`btn--${shape}`];
+
+ return (
+ <button
+ type={type}
+ disabled={disabled}
+ className={`${styles.btn} ${kindClass} ${shapeClass} ${className}`}
+ {...props}
+ >
+ {children}
+ </button>
+ );
+};
+
+export default Button;
diff --git a/src/components/atoms/buttons/buttons.module.scss b/src/components/atoms/buttons/buttons.module.scss
new file mode 100644
index 0000000..8e3e196
--- /dev/null
+++ b/src/components/atoms/buttons/buttons.module.scss
@@ -0,0 +1,178 @@
+@use "@styles/abstracts/functions" as fun;
+
+.btn {
+ display: inline-flex;
+ place-content: center;
+ align-items: center;
+ border: none;
+ border-radius: fun.convert-px(5);
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ transition: all 0.3s ease-in-out 0s;
+
+ &--initial {
+ border-radius: 0;
+ }
+
+ &--rectangle {
+ padding: var(--spacing-2xs) var(--spacing-md);
+ }
+
+ &--square,
+ &--circle {
+ padding: var(--spacing-xs);
+ aspect-ratio: 1 / 1;
+ }
+
+ &--circle {
+ border-radius: 50%;
+ }
+
+ &:disabled {
+ cursor: wait;
+ }
+
+ &--neutral {
+ background: inherit;
+ }
+
+ &--primary {
+ background: var(--color-primary);
+ border: fun.convert-px(2) solid var(--color-bg);
+ box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary),
+ 0 0 0 fun.convert-px(3) var(--color-primary-darker),
+ fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(3)
+ var(--color-primary-dark);
+ color: var(--color-fg-inverted);
+ text-decoration: none;
+ text-shadow: fun.convert-px(2) fun.convert-px(2) 0 var(--color-shadow);
+
+ &:disabled {
+ background: var(--color-primary-darker);
+ }
+
+ &:not(:disabled) {
+ &:hover,
+ &:focus {
+ background: var(--color-primary-light);
+ box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary-light),
+ 0 0 0 fun.convert-px(3) var(--color-primary-darker),
+ fun.convert-px(7) fun.convert-px(7) 0 fun.convert-px(2)
+ var(--color-primary-dark);
+ color: var(--color-fg-inverted);
+ transform: translateX(#{fun.convert-px(-4)})
+ translateY(#{fun.convert-px(-4)});
+ }
+
+ &:focus {
+ text-decoration: underline solid var(--color-fg-inverted)
+ fun.convert-px(2);
+ }
+
+ &:active {
+ background: var(--color-primary-dark);
+ box-shadow: 0 0 0 fun.convert-px(2) var(--color-primary),
+ 0 0 0 fun.convert-px(3) var(--color-primary-darker),
+ 0 0 0 0 var(--color-primary-dark);
+ text-decoration: none;
+ transform: translateX(#{fun.convert-px(4)})
+ translateY(#{fun.convert-px(4)});
+ }
+ }
+ }
+
+ &--secondary {
+ background: var(--color-bg);
+ border: fun.convert-px(3) solid var(--color-primary);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow),
+ fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2)
+ var(--color-shadow),
+ fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4)
+ var(--color-shadow);
+ color: var(--color-primary);
+ text-decoration: underline transparent 0;
+
+ &:disabled {
+ border-color: var(--color-border-dark);
+ color: var(--color-fg-light);
+ }
+
+ &:not(:disabled) {
+ &:hover,
+ &:focus {
+ border-color: var(--color-primary-light);
+ color: var(--color-primary-light);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow-light),
+ fun.convert-px(1) fun.convert-px(2) fun.convert-px(2)
+ fun.convert-px(-2) var(--color-shadow-light),
+ fun.convert-px(3) fun.convert-px(4) fun.convert-px(5)
+ fun.convert-px(-4) var(--color-shadow-light),
+ fun.convert-px(7) fun.convert-px(10) fun.convert-px(12)
+ fun.convert-px(-3) var(--color-shadow-light);
+ transform: scale(var(--scale-up, 1.1));
+ }
+
+ &:focus {
+ text-decoration: underline var(--color-primary-light) fun.convert-px(3);
+ }
+
+ &:active {
+ border-color: var(--color-primary-dark);
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ color: var(--color-primary-dark);
+ text-decoration: underline transparent 0;
+ transform: scale(var(--scale-down, 0.94));
+ }
+ }
+ }
+
+ &--tertiary {
+ background: var(--color-bg);
+ border: fun.convert-px(3) solid var(--color-primary);
+ box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg),
+ fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-dark),
+ fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg),
+ fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-dark);
+ color: var(--color-primary);
+
+ &:disabled {
+ color: var(--color-fg-light);
+ border-color: var(--color-border-dark);
+ box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 var(--color-bg),
+ fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-primary-darker),
+ fun.convert-px(5) fun.convert-px(5) 0 0 var(--color-bg),
+ fun.convert-px(6) fun.convert-px(6) 0 0 var(--color-primary-darker);
+ }
+
+ &:not(:disabled) {
+ &:hover,
+ &:focus {
+ border-color: var(--color-primary-light);
+ box-shadow: fun.convert-px(2) fun.convert-px(3) 0 0 var(--color-bg),
+ fun.convert-px(4) fun.convert-px(5) 0 0 var(--color-primary),
+ fun.convert-px(6) fun.convert-px(8) 0 0 var(--color-bg),
+ fun.convert-px(8) fun.convert-px(10) 0 0 var(--color-primary),
+ fun.convert-px(10) fun.convert-px(12) fun.convert-px(1) 0
+ var(--color-shadow-light),
+ fun.convert-px(10) fun.convert-px(12) fun.convert-px(5)
+ fun.convert-px(1) var(--color-shadow-light);
+ color: var(--color-primary-light);
+ transform: translateX(#{fun.convert-px(-3)})
+ translateY(#{fun.convert-px(-5)});
+ }
+
+ &:focus {
+ text-decoration: underline var(--color-primary) fun.convert-px(2);
+ }
+
+ &:active {
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ text-decoration: none;
+ transform: translateX(#{fun.convert-px(5)})
+ translateY(#{fun.convert-px(6)});
+ }
+ }
+ }
+}
diff --git a/src/components/atoms/forms/checkbox.stories.tsx b/src/components/atoms/forms/checkbox.stories.tsx
new file mode 100644
index 0000000..7faf343
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.stories.tsx
@@ -0,0 +1,96 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import CheckboxComponent from './checkbox';
+
+export default {
+ title: 'Atoms/Forms',
+ component: CheckboxComponent,
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the checkbox name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the checkbox.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'The checkbox id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'The checkbox name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to handle checkbox state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description:
+ 'The checkbox state: either checked (true) or unchecked (false).',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CheckboxComponent>;
+
+const Template: ComponentStory<typeof CheckboxComponent> = ({
+ value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [isChecked, setIsChecked] = useState<boolean>(value);
+
+ return (
+ <CheckboxComponent value={isChecked} setValue={setIsChecked} {...args} />
+ );
+};
+
+export const Checkbox = Template.bind({});
+Checkbox.args = {
+ id: 'storybook-checkbox',
+ name: 'storybook-checkbox',
+ value: false,
+};
diff --git a/src/components/atoms/forms/checkbox.test.tsx b/src/components/atoms/forms/checkbox.test.tsx
new file mode 100644
index 0000000..3b54549
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from '@test-utils';
+import Checkbox from './checkbox';
+
+describe('Checkbox', () => {
+ it('renders an unchecked checkbox', () => {
+ render(
+ <Checkbox
+ id="jest-checkbox"
+ name="jest-checkbox"
+ value={false}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('checkbox')).not.toBeChecked();
+ });
+
+ it('renders a checked checkbox', () => {
+ render(
+ <Checkbox
+ id="jest-checkbox"
+ name="jest-checkbox"
+ value={true}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('checkbox')).toBeChecked();
+ });
+});
diff --git a/src/components/atoms/forms/checkbox.tsx b/src/components/atoms/forms/checkbox.tsx
new file mode 100644
index 0000000..8babcc8
--- /dev/null
+++ b/src/components/atoms/forms/checkbox.tsx
@@ -0,0 +1,46 @@
+import { SetStateAction, VFC } from 'react';
+
+export type CheckboxProps = {
+ /**
+ * One or more ids that refers to the checkbox name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the checkbox.
+ */
+ className?: string;
+ /**
+ * Checkbox id attribute.
+ */
+ id: string;
+ /**
+ * Checkbox name attribute.
+ */
+ name: string;
+ /**
+ * Callback function to set checkbox value.
+ */
+ setValue: (value: SetStateAction<boolean>) => void;
+ /**
+ * Checkbox value.
+ */
+ value: boolean;
+};
+
+/**
+ * Checkbox component
+ *
+ * Render a checkbox type input.
+ */
+const Checkbox: VFC<CheckboxProps> = ({ value, setValue, ...props }) => {
+ return (
+ <input
+ type="checkbox"
+ checked={value}
+ onChange={() => setValue(!value)}
+ {...props}
+ />
+ );
+};
+
+export default Checkbox;
diff --git a/src/components/atoms/forms/field.stories.tsx b/src/components/atoms/forms/field.stories.tsx
new file mode 100644
index 0000000..ec81922
--- /dev/null
+++ b/src/components/atoms/forms/field.stories.tsx
@@ -0,0 +1,201 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import FieldComponent from './field';
+
+export default {
+ title: 'Atoms/Forms',
+ component: FieldComponent,
+ args: {
+ disabled: false,
+ required: false,
+ type: 'text',
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Field state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ max: {
+ control: {
+ type: 'number',
+ },
+ description: 'Maximum value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ min: {
+ control: {
+ type: 'number',
+ },
+ description: 'Minimum value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ placeholder: {
+ control: {
+ type: 'text',
+ },
+ description: 'A placeholder value.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'Callback function to set field value.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ step: {
+ control: {
+ type: 'number',
+ },
+ description: 'Field incremental values that are valid.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ type: {
+ control: {
+ type: 'select',
+ },
+ description: 'Field type: input type or textarea.',
+ options: [
+ 'datetime-local',
+ 'email',
+ 'number',
+ 'search',
+ 'tel',
+ 'text',
+ 'textarea',
+ 'time',
+ 'url',
+ ],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'Field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FieldComponent>;
+
+const Template: ComponentStory<typeof FieldComponent> = ({
+ value: _value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [value, setValue] = useState<string>('');
+
+ return <FieldComponent value={value} setValue={setValue} {...args} />;
+};
+
+export const Field = Template.bind({});
+Field.args = {
+ id: 'field-storybook',
+ name: 'field-storybook',
+};
diff --git a/src/components/atoms/forms/field.test.tsx b/src/components/atoms/forms/field.test.tsx
new file mode 100644
index 0000000..a04a976
--- /dev/null
+++ b/src/components/atoms/forms/field.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@test-utils';
+import Field from './field';
+
+describe('Field', () => {
+ it('renders a text input', () => {
+ render(
+ <Field
+ id="text-field"
+ name="text-field"
+ type="text"
+ value=""
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
+ });
+
+ it('renders a search input', () => {
+ render(
+ <Field
+ id="search-field"
+ name="search-field"
+ type="search"
+ value=""
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('searchbox')).toHaveAttribute('type', 'search');
+ });
+});
diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx
new file mode 100644
index 0000000..2e75d0f
--- /dev/null
+++ b/src/components/atoms/forms/field.tsx
@@ -0,0 +1,107 @@
+import { ChangeEvent, SetStateAction, VFC } from 'react';
+import styles from './forms.module.scss';
+
+export type FieldType =
+ | 'datetime-local'
+ | 'email'
+ | 'number'
+ | 'search'
+ | 'tel'
+ | 'text'
+ | 'textarea'
+ | 'time'
+ | 'url';
+
+export type FieldProps = {
+ /**
+ * One or more ids that refers to the field name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the field.
+ */
+ className?: string;
+ /**
+ * Field state. Either enabled (false) or disabled (true).
+ */
+ disabled?: boolean;
+ /**
+ * Field id attribute.
+ */
+ id: string;
+ /**
+ * Field maximum value.
+ */
+ max?: number | string;
+ /**
+ * Field minimum value.
+ */
+ min?: number | string;
+ /**
+ * Field name attribute.
+ */
+ name: string;
+ /**
+ * Placeholder value.
+ */
+ placeholder?: string;
+ /**
+ * True if the field is required. Default: false.
+ */
+ required?: boolean;
+ /**
+ * Callback function to set field value.
+ */
+ setValue: (value: SetStateAction<string>) => void;
+ /**
+ * Field incremental values that are valid.
+ */
+ step?: number | string;
+ /**
+ * Field type. Default: text.
+ */
+ type: FieldType;
+ /**
+ * Field value.
+ */
+ value: string;
+};
+
+/**
+ * Field component.
+ *
+ * Render either an input or a textarea.
+ */
+const Field: VFC<FieldProps> = ({
+ className = '',
+ setValue,
+ type,
+ ...props
+}) => {
+ /**
+ * Update select value when an option is selected.
+ * @param e - The option change event.
+ */
+ const updateValue = (
+ e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+ ) => {
+ setValue(e.target.value);
+ };
+
+ return type === 'textarea' ? (
+ <textarea
+ onChange={updateValue}
+ className={`${styles.field} ${styles['field--textarea']} ${className}`}
+ {...props}
+ />
+ ) : (
+ <input
+ type={type}
+ onChange={updateValue}
+ className={`${styles.field} ${className}`}
+ {...props}
+ />
+ );
+};
+
+export default Field;
diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx
new file mode 100644
index 0000000..9cd3c58
--- /dev/null
+++ b/src/components/atoms/forms/form.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Form from './form';
+
+describe('Form', () => {
+ it('renders a form', () => {
+ render(<Form aria-label="Jest form" onSubmit={() => null}></Form>);
+ expect(screen.getByRole('form', { name: 'Jest form' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx
new file mode 100644
index 0000000..8e80930
--- /dev/null
+++ b/src/components/atoms/forms/form.tsx
@@ -0,0 +1,73 @@
+import { Children, FC, FormEvent, Fragment } from 'react';
+import styles from './forms.module.scss';
+
+export type FormProps = {
+ /**
+ * An accessible name.
+ */
+ 'aria-label'?: string;
+ /**
+ * One or more ids that refers to the form name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Wrap each items with a div. Default: true.
+ */
+ grouped?: boolean;
+ /**
+ * A callback function to execute on submit.
+ */
+ onSubmit: () => void;
+};
+
+/**
+ * Form component.
+ *
+ * Render children wrapped in a form element.
+ */
+const Form: FC<FormProps> = ({
+ children,
+ className = '',
+ grouped = true,
+ onSubmit,
+ ...props
+}) => {
+ const arrayChildren = Children.toArray(children);
+
+ /**
+ * Get the form items.
+ * @returns {JSX.Element[]} An array of child elements wrapped in a div.
+ */
+ const getFormItems = (): JSX.Element[] => {
+ return arrayChildren.map((child, index) =>
+ grouped ? (
+ <div key={`item-${index}`} className={styles.item}>
+ {child}
+ </div>
+ ) : (
+ <Fragment key={`item-${index}`}>{child}</Fragment>
+ )
+ );
+ };
+
+ /**
+ * Handle form submit.
+ * @param {FormEvent} e - The form event.
+ */
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ onSubmit();
+ };
+
+ return (
+ <form onSubmit={handleSubmit} className={className} {...props}>
+ {getFormItems()}
+ </form>
+ );
+};
+
+export default Form;
diff --git a/src/components/atoms/forms/forms.module.scss b/src/components/atoms/forms/forms.module.scss
new file mode 100644
index 0000000..19c7aee
--- /dev/null
+++ b/src/components/atoms/forms/forms.module.scss
@@ -0,0 +1,53 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.item {
+ margin: var(--spacing-xs) 0;
+ width: 100%;
+ max-width: 45ch;
+}
+
+.field {
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(2) solid var(--color-border);
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow);
+ transition: all 0.25s linear 0s;
+
+ &--select {
+ cursor: pointer;
+
+ @include mix.pointer("fine") {
+ padding: fun.convert-px(3) var(--spacing-xs);
+ }
+ }
+
+ &--textarea {
+ min-height: fun.convert-px(200);
+ }
+
+ &:disabled {
+ background: var(--color-bg-secondary);
+ border: fun.convert-px(2) solid var(--color-border-light);
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0
+ var(--color-shadow-light);
+ cursor: not-allowed;
+ }
+
+ &:not(:disabled) {
+ &:hover {
+ box-shadow: fun.convert-px(5) fun.convert-px(5) 0 fun.convert-px(1)
+ var(--color-shadow);
+ transform: translate(#{fun.convert-px(-3)}, #{fun.convert-px(-3)});
+ }
+
+ &:focus {
+ background: var(--color-bg);
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ transform: translate(#{fun.convert-px(3)}, #{fun.convert-px(3)});
+ outline: none;
+ transition: all 0.2s ease-in-out 0s, transform 0.3s ease-out 0s;
+ }
+ }
+}
diff --git a/src/components/atoms/forms/label.module.scss b/src/components/atoms/forms/label.module.scss
new file mode 100644
index 0000000..f900925
--- /dev/null
+++ b/src/components/atoms/forms/label.module.scss
@@ -0,0 +1,17 @@
+.label {
+ color: var(--color-primary-darker);
+ font-weight: 600;
+
+ &--small {
+ font-size: var(--font-size-sm);
+ font-variant: small-caps;
+ }
+
+ &--medium {
+ font-size: var(--font-size-md);
+ }
+}
+
+.required {
+ color: var(--color-secondary);
+}
diff --git a/src/components/atoms/forms/label.stories.tsx b/src/components/atoms/forms/label.stories.tsx
new file mode 100644
index 0000000..463e8ac
--- /dev/null
+++ b/src/components/atoms/forms/label.stories.tsx
@@ -0,0 +1,85 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LabelComponent from './label';
+
+export default {
+ title: 'Atoms/Forms',
+ component: LabelComponent,
+ args: {
+ required: false,
+ size: 'small',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the label.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The label body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ htmlFor: {
+ control: {
+ type: 'text',
+ },
+ description: 'The field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Set to true if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ size: {
+ control: {
+ type: 'select',
+ },
+ description: 'The label size.',
+ options: ['medium', 'small'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'small' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof LabelComponent>;
+
+const Template: ComponentStory<typeof LabelComponent> = ({
+ children,
+ ...args
+}) => <LabelComponent {...args}>{children}</LabelComponent>;
+
+export const Label = Template.bind({});
+Label.args = {
+ children: 'A label',
+};
diff --git a/src/components/atoms/forms/label.test.tsx b/src/components/atoms/forms/label.test.tsx
new file mode 100644
index 0000000..14257c3
--- /dev/null
+++ b/src/components/atoms/forms/label.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Label from './label';
+
+describe('Label', () => {
+ it('renders a field label', () => {
+ render(<Label>A label</Label>);
+ expect(screen.getByText('A label')).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx
new file mode 100644
index 0000000..8d57ee2
--- /dev/null
+++ b/src/components/atoms/forms/label.tsx
@@ -0,0 +1,45 @@
+import { FC } from 'react';
+import styles from './label.module.scss';
+
+export type LabelProps = {
+ /**
+ * Add classnames to the label.
+ */
+ className?: string;
+ /**
+ * The field id.
+ */
+ htmlFor?: string;
+ /**
+ * Is the field required? Default: false.
+ */
+ required?: boolean;
+ /**
+ * The label size. Default: small.
+ */
+ size?: 'medium' | 'small';
+};
+
+/**
+ * Label Component
+ *
+ * Render a HTML label element.
+ */
+const Label: FC<LabelProps> = ({
+ children,
+ className = '',
+ required = false,
+ size = 'small',
+ ...props
+}) => {
+ const sizeClass = styles[`label--${size}`];
+
+ return (
+ <label className={`${styles.label} ${sizeClass} ${className}`} {...props}>
+ {children}
+ {required && <span className={styles.required}> *</span>}
+ </label>
+ );
+};
+
+export default Label;
diff --git a/src/components/atoms/forms/select.stories.tsx b/src/components/atoms/forms/select.stories.tsx
new file mode 100644
index 0000000..c2fb8c6
--- /dev/null
+++ b/src/components/atoms/forms/select.stories.tsx
@@ -0,0 +1,145 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import SelectComponent from './select';
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+
+export default {
+ title: 'Atoms/Forms',
+ component: SelectComponent,
+ args: {
+ disabled: false,
+ required: false,
+ },
+ argTypes: {
+ 'aria-labelledby': {
+ control: {
+ type: 'text',
+ },
+ description: 'One or more ids that refers to the select field name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add classnames to the select field.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ disabled: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Field state: either enabled or disabled.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ name: {
+ control: {
+ type: 'text',
+ },
+ description: 'Field name.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ options: {
+ description: 'Select options.',
+ type: {
+ name: 'array',
+ required: true,
+ value: {
+ name: 'string',
+ },
+ },
+ },
+ required: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the field is required.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ setValue: {
+ control: {
+ type: null,
+ },
+ description: 'Callback function to set field value.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ value: {
+ control: {
+ type: null,
+ },
+ description: 'Field value.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SelectComponent>;
+
+const Template: ComponentStory<typeof SelectComponent> = ({
+ value,
+ setValue: _setValue,
+ ...args
+}) => {
+ const [selected, setSelected] = useState<string>(value);
+
+ return <SelectComponent value={selected} setValue={setSelected} {...args} />;
+};
+
+export const Select = Template.bind({});
+Select.args = {
+ id: 'storybook-select',
+ name: 'storybook-select',
+ options: selectOptions,
+ value: 'option2',
+};
diff --git a/src/components/atoms/forms/select.test.tsx b/src/components/atoms/forms/select.test.tsx
new file mode 100644
index 0000000..22efb86
--- /dev/null
+++ b/src/components/atoms/forms/select.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@test-utils';
+import Select from './select';
+
+const selectOptions = [
+ { id: 'option1', name: 'Option 1', value: 'option1' },
+ { id: 'option2', name: 'Option 2', value: 'option2' },
+ { id: 'option3', name: 'Option 3', value: 'option3' },
+];
+const selected = selectOptions[0];
+
+describe('Select', () => {
+ it('should correctly set default option', () => {
+ render(
+ <Select
+ id="jest-select"
+ name="jest-select"
+ options={selectOptions}
+ value={selected.value}
+ setValue={() => null}
+ />
+ );
+ expect(screen.getByRole('combobox')).toHaveValue(selected.value);
+ expect(screen.queryByRole('combobox')).not.toHaveValue(
+ selectOptions[1].value
+ );
+ expect(screen.queryByRole('combobox')).not.toHaveValue(
+ selectOptions[2].value
+ );
+ });
+});
diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx
new file mode 100644
index 0000000..25e86e0
--- /dev/null
+++ b/src/components/atoms/forms/select.tsx
@@ -0,0 +1,99 @@
+import { ChangeEvent, SetStateAction, VFC } from 'react';
+import styles from './forms.module.scss';
+
+export type SelectOptions = {
+ /**
+ * The option id.
+ */
+ id: string;
+ /**
+ * The option name.
+ */
+ name: string;
+ /**
+ * The option value.
+ */
+ value: string;
+};
+
+export type SelectProps = {
+ /**
+ * One or more ids that refers to the select field name.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * Add classnames to the select field.
+ */
+ className?: string;
+ /**
+ * Field state. Either enabled (false) or disabled (true).
+ */
+ disabled?: boolean;
+ /**
+ * Field id attribute.
+ */
+ id: string;
+ /**
+ * Field name attribute.
+ */
+ name: string;
+ /**
+ * True if the field is required. Default: false.
+ */
+ options: SelectOptions[];
+ /**
+ * True if the field is required. Default: false.
+ */
+ required?: boolean;
+ /**
+ * Callback function to set field value.
+ */
+ setValue: (value: SetStateAction<string>) => void;
+ /**
+ * Field value.
+ */
+ value: string;
+};
+
+/**
+ * Select component
+ *
+ * Render a HTML select element.
+ */
+const Select: VFC<SelectProps> = ({
+ className = '',
+ options,
+ setValue,
+ ...props
+}) => {
+ /**
+ * Update select value when an option is selected.
+ * @param e - The option change event.
+ */
+ const updateValue = (e: ChangeEvent<HTMLSelectElement>) => {
+ setValue(e.target.value);
+ };
+
+ /**
+ * Get the option elements.
+ * @returns {JSX.Element[]} An array of HTML option elements.
+ */
+ const getOptions = (): JSX.Element[] =>
+ options.map((option) => (
+ <option key={option.id} value={option.value}>
+ {option.name}
+ </option>
+ ));
+
+ return (
+ <select
+ className={`${styles.field} ${styles['field--select']} ${className}`}
+ onChange={updateValue}
+ {...props}
+ >
+ {getOptions()}
+ </select>
+ );
+};
+
+export default Select;
diff --git a/src/components/atoms/headings/heading.module.scss b/src/components/atoms/headings/heading.module.scss
new file mode 100644
index 0000000..8620f6f
--- /dev/null
+++ b/src/components/atoms/headings/heading.module.scss
@@ -0,0 +1,57 @@
+@use "@styles/abstracts/functions" as fun;
+
+.heading {
+ color: var(--color-primary-dark);
+ font-family: var(--font-family-secondary);
+ letter-spacing: 0.01ex;
+
+ &--regular {
+ margin: 0;
+ }
+
+ &--margin {
+ margin: 0 0 var(--spacing-sm);
+
+ & + & {
+ margin-top: var(--spacing-md);
+ }
+ }
+
+ &--1 {
+ font-size: var(--font-size-3xl);
+ font-weight: 500;
+ }
+
+ &--2 {
+ padding-bottom: fun.convert-px(3);
+ background: linear-gradient(
+ to top,
+ var(--color-primary-dark) 0.3rem,
+ transparent 0.3rem
+ )
+ 0 0 / 3rem 100% no-repeat;
+ font-size: var(--font-size-2xl);
+ font-weight: 500;
+ text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light);
+ }
+
+ &--3 {
+ font-size: var(--font-size-xl);
+ font-weight: 500;
+ }
+
+ &--4 {
+ font-size: var(--font-size-lg);
+ font-weight: 500;
+ }
+
+ &--5 {
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ }
+
+ &--6 {
+ font-size: var(--font-size-md);
+ font-weight: 500;
+ }
+}
diff --git a/src/components/atoms/headings/heading.stories.tsx b/src/components/atoms/headings/heading.stories.tsx
new file mode 100644
index 0000000..66a84dc
--- /dev/null
+++ b/src/components/atoms/headings/heading.stories.tsx
@@ -0,0 +1,82 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HeadingComponent from './heading';
+
+export default {
+ title: 'Atoms/Headings',
+ component: HeadingComponent,
+ args: {
+ isFake: false,
+ withMargin: true,
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ children: {
+ description: 'Heading body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ isFake: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Use an heading element or only its styles.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'select',
+ },
+ description: 'Heading level.',
+ options: [1, 2, 3, 4, 5, 6],
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ withMargin: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Adds margin.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof HeadingComponent>;
+
+const Template: ComponentStory<typeof HeadingComponent> = (args) => (
+ <HeadingComponent {...args} />
+);
+
+export const Heading = Template.bind({});
+Heading.args = {
+ children: 'Your title',
+ level: 1,
+};
diff --git a/src/components/atoms/headings/heading.test.tsx b/src/components/atoms/headings/heading.test.tsx
new file mode 100644
index 0000000..6b6789a
--- /dev/null
+++ b/src/components/atoms/headings/heading.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from '@test-utils';
+import Heading from './heading';
+
+describe('Heading', () => {
+ it('renders a h1', () => {
+ render(<Heading level={1}>Level 1</Heading>);
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ 'Level 1'
+ );
+ });
+
+ it('renders a h2', () => {
+ render(<Heading level={2}>Level 2</Heading>);
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
+ 'Level 2'
+ );
+ });
+
+ it('renders a h3', () => {
+ render(<Heading level={3}>Level 3</Heading>);
+ expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent(
+ 'Level 3'
+ );
+ });
+
+ it('renders a h4', () => {
+ render(<Heading level={4}>Level 4</Heading>);
+ expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent(
+ 'Level 4'
+ );
+ });
+
+ it('renders a h5', () => {
+ render(<Heading level={5}>Level 5</Heading>);
+ expect(screen.getByRole('heading', { level: 5 })).toHaveTextContent(
+ 'Level 5'
+ );
+ });
+
+ it('renders a h6', () => {
+ render(<Heading level={6}>Level 6</Heading>);
+ expect(screen.getByRole('heading', { level: 6 })).toHaveTextContent(
+ 'Level 6'
+ );
+ });
+
+ it('renders a text with heading styles', () => {
+ render(
+ <Heading isFake={true} level={2}>
+ Fake heading
+ </Heading>
+ );
+ expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument();
+ expect(screen.getByText('Fake heading')).toHaveClass('heading');
+ });
+});
diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx
new file mode 100644
index 0000000..4703b5d
--- /dev/null
+++ b/src/components/atoms/headings/heading.tsx
@@ -0,0 +1,56 @@
+import { FC } from 'react';
+import styles from './heading.module.scss';
+
+export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
+
+export type HeadingProps = {
+ /**
+ * Set additional classnames.
+ */
+ className?: string;
+ /**
+ * The heading id.
+ */
+ id?: string;
+ /**
+ * Use an heading element or only its styles. Default: false.
+ */
+ isFake?: boolean;
+ /**
+ * HTML heading level.
+ */
+ level: HeadingLevel;
+ /**
+ * Adds margin. Default: true.
+ */
+ withMargin?: boolean;
+};
+
+/**
+ * Heading component.
+ *
+ * Render an HTML heading element or a paragraph with heading styles.
+ */
+const Heading: FC<HeadingProps> = ({
+ children,
+ className,
+ id,
+ isFake = false,
+ level,
+ withMargin = true,
+}) => {
+ const TitleTag = isFake ? `p` : (`h${level}` as keyof JSX.IntrinsicElements);
+ const levelClass = `heading--${level}`;
+ const marginClass = withMargin ? 'heading--margin' : 'heading--regular';
+
+ return (
+ <TitleTag
+ className={`${styles.heading} ${styles[levelClass]} ${styles[marginClass]} ${className}`}
+ id={id}
+ >
+ {children}
+ </TitleTag>
+ );
+};
+
+export default Heading;
diff --git a/src/components/atoms/icons/arrow.module.scss b/src/components/atoms/icons/arrow.module.scss
new file mode 100644
index 0000000..76e6aea
--- /dev/null
+++ b/src/components/atoms/icons/arrow.module.scss
@@ -0,0 +1,16 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ fill: var(--color-primary);
+ transition: all 0.25s ease-in-out 0s;
+
+ &--left,
+ &--right {
+ width: var(--icon-size, #{fun.convert-px(30)});
+ }
+
+ &--bottom,
+ &--top {
+ height: var(--icon-size, #{fun.convert-px(30)});
+ }
+}
diff --git a/src/components/atoms/icons/arrow.stories.tsx b/src/components/atoms/icons/arrow.stories.tsx
new file mode 100644
index 0000000..96ce1d8
--- /dev/null
+++ b/src/components/atoms/icons/arrow.stories.tsx
@@ -0,0 +1,42 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ArrowIcon from './arrow';
+
+export default {
+ title: 'Atoms/Icons',
+ component: ArrowIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ direction: {
+ control: {
+ type: 'select',
+ },
+ description: 'An arrow icon.',
+ options: ['bottom', 'left', 'right', 'top'],
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ArrowIcon>;
+
+const Template: ComponentStory<typeof ArrowIcon> = (args) => (
+ <ArrowIcon {...args} />
+);
+
+export const Arrow = Template.bind({});
+Arrow.args = {
+ direction: 'right',
+};
diff --git a/src/components/atoms/icons/arrow.test.tsx b/src/components/atoms/icons/arrow.test.tsx
new file mode 100644
index 0000000..502dcc1
--- /dev/null
+++ b/src/components/atoms/icons/arrow.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Arrow from './arrow';
+
+describe('Arrow', () => {
+ it('renders an arrow icon oriented to the right', () => {
+ const { container } = render(<Arrow direction="right" />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/arrow.tsx b/src/components/atoms/icons/arrow.tsx
new file mode 100644
index 0000000..5f3c460
--- /dev/null
+++ b/src/components/atoms/icons/arrow.tsx
@@ -0,0 +1,101 @@
+import { VFC } from 'react';
+import styles from './arrow.module.scss';
+
+export type ArrowDirection = 'top' | 'right' | 'bottom' | 'left';
+
+export type ArrowProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * The arrow direction. Default: right.
+ */
+ direction: ArrowDirection;
+};
+
+/**
+ * Arrow component
+ *
+ * Render a svg arrow icon.
+ */
+const Arrow: VFC<ArrowProps> = ({ className = '', direction }) => {
+ const directionClass = styles[`icon--${direction}`];
+ const classes = `${styles.icon} ${directionClass} ${className}`;
+
+ if (direction === 'top') {
+ return (
+ <svg
+ className={classes}
+ viewBox="0 0 23.476 64.644995"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ className="arrow-head"
+ d="M 23.476001,24.637 11.715001,0 0,24.800001 Z"
+ />
+ <path
+ className="arrow-bar"
+ d="m 15.441001,64.644997 -0.018,-40.007999 H 8.035 l 0.142,40.007999 z"
+ />
+ </svg>
+ );
+ }
+
+ if (direction === 'bottom') {
+ return (
+ <svg
+ className={classes}
+ viewBox="0 0 23.476 64.644995"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ className="arrow-head"
+ d="m 23.476001,40.007997 -11.761,24.637 L 0,39.844996 Z"
+ />
+ <path
+ className="arrow-bar"
+ d="m 15.441001,0 -0.018,40.007999 H 8.035 L 8.177,0 Z"
+ />
+ </svg>
+ );
+ }
+
+ if (direction === 'left') {
+ return (
+ <svg
+ className={classes}
+ viewBox="0 0 64.644997 23.476001"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ className="arrow-head"
+ d="M 24.637,23.476 0,11.715 24.8,-8.3923343e-8 Z"
+ />
+ <path
+ className="arrow-bar"
+ d="m 64.644997,15.441 -40.008,-0.018 V 8.0349999 l 40.008,0.142 z"
+ />
+ </svg>
+ );
+ }
+
+ return (
+ <svg
+ className={classes}
+ viewBox="0 0 64.644997 23.476001"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ className="arrow-head"
+ d="M 40.007997,23.476 64.644997,11.715 39.844997,-8.3923343e-8 Z"
+ />
+ <path
+ className="arrow-bar"
+ d="M 0,15.441 40.008,15.423 V 8.0349999 L 0,8.1769999 Z"
+ />
+ </svg>
+ );
+};
+
+export default Arrow;
diff --git a/src/components/atoms/icons/career.module.scss b/src/components/atoms/icons/career.module.scss
new file mode 100644
index 0000000..c5d65eb
--- /dev/null
+++ b/src/components/atoms/icons/career.module.scss
@@ -0,0 +1,53 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.lock {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
+
+.lines {
+ fill: var(--color-fg);
+ stroke-width: 4;
+}
+
+.seal-top {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 2;
+}
+
+.seal-bottom {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 2;
+}
+
+.diploma {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.top {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.handle {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
+
+.bottom {
+ fill: var(--color-primary);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/career.stories.tsx b/src/components/atoms/icons/career.stories.tsx
new file mode 100644
index 0000000..8575cb9
--- /dev/null
+++ b/src/components/atoms/icons/career.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CareerIcon from './career';
+
+export default {
+ title: 'Atoms/Icons',
+ component: CareerIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CareerIcon>;
+
+const Template: ComponentStory<typeof CareerIcon> = (args) => (
+ <CareerIcon {...args} />
+);
+
+export const Career = Template.bind({});
diff --git a/src/components/atoms/icons/career.test.tsx b/src/components/atoms/icons/career.test.tsx
new file mode 100644
index 0000000..62ffc14
--- /dev/null
+++ b/src/components/atoms/icons/career.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Career from './career';
+
+describe('Career', () => {
+ it('renders a Career icon', () => {
+ const { container } = render(<Career />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/career.tsx b/src/components/atoms/icons/career.tsx
new file mode 100644
index 0000000..28edcc7
--- /dev/null
+++ b/src/components/atoms/icons/career.tsx
@@ -0,0 +1,71 @@
+import { VFC } from 'react';
+import styles from './career.module.scss';
+
+export type CareerProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Career Component
+ *
+ * Render a career svg icon.
+ */
+const Career: VFC<CareerProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.bottom}
+ d="M 0.72670447,19.813041 H 77.467597 v 54.36591 H 0.72670447 Z"
+ />
+ <path
+ className={styles.handle}
+ d="m 22.263958,10.17849 c 12.6493,-1.81512 21.613185,-1.732794 33.666442,0 l 1.683339,10.99517 h -5.891624 v -5.474639 c -7.949741,-2.722434 -16.311959,-2.706359 -25.249837,0 v 5.474639 h -5.891625 z"
+ />
+ <path
+ className={styles.top}
+ d="M 0.72670447,19.813041 H 77.467597 V 51.17622 H 0.72670447 Z"
+ />
+ <path
+ className={styles.diploma}
+ d="M 44.217117,47.159906 H 98.921356 V 82.664122 H 44.217117 Z"
+ />
+ <path
+ className={styles['seal-bottom']}
+ d="m 84.933665,80.775336 h 6.957554 V 90.992144 L 88.412426,87.2244 84.933665,90.992144 Z"
+ />
+ <path
+ className={styles['seal-top']}
+ d="m 93.326919,76.83334 a 4.914472,4.9188584 0 0 1 -4.914493,4.918858 4.914472,4.9188584 0 0 1 -4.914461,-4.918858 4.914472,4.9188584 0 0 1 4.914461,-4.918858 4.914472,4.9188584 0 0 1 4.914493,4.918858 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 54.53557,60.491974 h 34.067282 v 1.515453 H 54.53557 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 54.53557,67.437763 h 34.067282 v 1.515453 H 54.53557 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 54.53557,74.383628 h 17.563315 v 1.515454 H 54.53557 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 63.495911,53.546123 h 16.146628 v 1.515452 H 63.495911 Z"
+ />
+ <path
+ className={styles.lock}
+ d="M 34.048314,42.893007 H 44.145988 V 57.849688 H 34.048314 Z"
+ />
+ </svg>
+ );
+};
+
+export default Career;
diff --git a/src/components/atoms/icons/cc-by-sa.module.scss b/src/components/atoms/icons/cc-by-sa.module.scss
new file mode 100644
index 0000000..e1b2100
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.module.scss
@@ -0,0 +1,7 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(60)});
+ fill: var(--color-fg);
+}
diff --git a/src/components/atoms/icons/cc-by-sa.stories.tsx b/src/components/atoms/icons/cc-by-sa.stories.tsx
new file mode 100644
index 0000000..21d6cd5
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import CCBySAIcon from './cc-by-sa';
+
+export default {
+ title: 'Atoms/Icons',
+ component: CCBySAIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CCBySAIcon>;
+
+const Template: ComponentStory<typeof CCBySAIcon> = (args) => (
+ <IntlProvider locale="en">
+ <CCBySAIcon {...args} />
+ </IntlProvider>
+);
+
+export const CCBySA = Template.bind({});
diff --git a/src/components/atoms/icons/cc-by-sa.test.tsx b/src/components/atoms/icons/cc-by-sa.test.tsx
new file mode 100644
index 0000000..adb03e4
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import CCBySA from './cc-by-sa';
+
+describe('CCBySA', () => {
+ it('renders a CC BY SA icon', () => {
+ render(<CCBySA />);
+ expect(screen.getByTitle('CC BY SA')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/icons/cc-by-sa.tsx b/src/components/atoms/icons/cc-by-sa.tsx
new file mode 100644
index 0000000..552504e
--- /dev/null
+++ b/src/components/atoms/icons/cc-by-sa.tsx
@@ -0,0 +1,45 @@
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './cc-by-sa.module.scss';
+
+export type CCBySAProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * CCBySA component
+ *
+ * Render a CC BY SA svg icon.
+ */
+const CCBySA: VFC<CCBySAProps> = ({ className = '' }) => {
+ const intl = useIntl();
+
+ return (
+ <svg
+ className={`${styles.icon} ${className}`}
+ viewBox="0 0 211.99811 63.999996"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>
+ {intl.formatMessage({
+ defaultMessage: 'CC BY SA',
+ description: 'CCBySA: icon title',
+ id: 'cl7YNU',
+ })}
+ </title>
+ <path d="m 175.53911,15.829498 c 0,-3.008 1.485,-4.514 4.458,-4.514 2.973,0 4.457,1.504 4.457,4.514 0,2.971 -1.486,4.457 -4.457,4.457 -2.971,0 -4.458,-1.486 -4.458,-4.457 z" />
+ <path d="m 188.62611,24.057498 v 13.085 h -3.656 v 15.542 h -9.944 v -15.541 h -3.656 v -13.086 c 0,-0.572 0.2,-1.057 0.599,-1.457 0.401,-0.399 0.887,-0.6 1.457,-0.6 h 13.144 c 0.533,0 1.01,0.2 1.428,0.6 0.417,0.4 0.628,0.886 0.628,1.457 z" />
+ <path d="m 179.94147,-1.9073486e-6 c -8.839,0 -16.34167,3.0848125073486 -22.51367,9.2578125073486 -6.285,6.4000004 -9.42969,13.9811874 -9.42969,22.7421874 0,8.762 3.14469,16.284312 9.42969,22.570312 6.361,6.286 13.86467,9.429688 22.51367,9.429688 8.799,0 16.43611,-3.181922 22.91211,-9.544922 6.096,-5.98 9.14453,-13.464078 9.14453,-22.455078 0,-8.952 -3.10646,-16.532188 -9.31446,-22.7421874 -6.172,-6.172 -13.75418,-9.2578125073486 -22.74218,-9.2578125073486 z M 180.05475,5.7714825 c 7.238,0 13.40967,2.55225 18.51367,7.6562495 5.103,5.106 7.65625,11.294313 7.65625,18.570313 0,7.391 -2.51397,13.50575 -7.54297,18.34375 -5.295,5.221 -11.50591,7.828125 -18.6289,7.828125 -7.162,0 -13.33268,-2.589484 -18.51368,-7.771484 -5.18,-5.178001 -7.76953,-11.310485 -7.76953,-18.396485 0,-7.047 2.60813,-13.238266 7.82813,-18.572265 5.029,-5.1040004 11.18103,-7.6582035 18.45703,-7.6582035 z" />
+ <path d="m 91.998554,27.114498 c 0.609,-3.924 2.189,-6.962 4.742,-9.114 2.552,-2.152 5.655996,-3.228 9.313996,-3.228 5.027,0 9.029,1.62 12,4.856 2.971,3.238 4.457,7.391 4.457,12.457 0,4.915 -1.543,9 -4.627,12.256 -3.088,3.256 -7.086,4.886 -12.002,4.886 -3.619,0 -6.742996,-1.085 -9.370996,-3.257 -2.629,-2.172 -4.209,-5.257 -4.743,-9.257 h 8.059 c 0.189996,3.886 2.532996,5.829 7.028996,5.829 2.246,0 4.057,-0.972 5.428,-2.914 1.373,-1.942 2.059,-4.534 2.059,-7.771 0,-3.391 -0.629,-5.971 -1.885,-7.743 -1.258,-1.771 -3.066,-2.657 -5.43,-2.657 -4.268,0 -6.667,1.885 -7.199996,5.656 h 2.342996 l -6.341996,6.343 -6.343,-6.343 z" />
+ <path d="m 105.94241,-1.8610229e-6 c -8.799996,0 -16.304676,3.1054062610229 -22.513666,9.3164061610229 -6.285,6.3999997 -9.42969,13.9625467 -9.42969,22.6855467 0,8.763 3.14469,16.28336 9.42969,22.568359 6.361,6.286001 13.86467,9.429688 22.513666,9.429688 8.836,0 16.47511,-3.162328 22.91211,-9.486328 6.096,-6.057 9.14453,-13.559672 9.14453,-22.513672 0,-8.952 -3.10646,-16.513547 -9.31446,-22.6855468 -6.211,-6.21 -13.79118,-9.3144530610229 -22.74218,-9.3144530610229 z M 106.05569,5.7714825 c 7.275,0 13.44667,2.5698437 18.51367,7.7148435 5.103,5.028 7.65625,11.200672 7.65625,18.513672 0,7.353 -2.51397,13.46775 -7.54297,18.34375 -5.295,5.219 -11.50591,7.828125 -18.6289,7.828125 -7.161996,0 -13.332676,-2.589484 -18.513676,-7.771484 -5.18,-5.143 -7.76953,-11.275391 -7.76953,-18.400391 0,-7.046 2.60813,-13.217672 7.82813,-18.513672 5.029,-5.1429998 11.18103,-7.7148435 18.457026,-7.7148435 z" />
+ <path d="M 31.942383,5.9265138e-7 C 23.066111,5.9265138e-7 15.579851,3.1065496 9.484666,9.3147376 6.399571,12.400832 4.046856,15.896269 2.427808,19.801386 0.80876,23.706506 0,27.771846 0,32.000976 c 0,4.26713 0.800415,8.32413 2.400463,12.17225 1.600051,3.84811 3.933123,7.30532 7.000216,10.37141 3.067093,3.06609 6.534587,5.40951 10.400708,7.02756 3.867116,1.62105 7.914819,2.4278 12.142946,2.4278 4.22813,0 8.32441,-0.8171 12.28553,-2.45515 3.96313,-1.63805 7.50614,-4.00301 10.62923,-7.0881 3.0081,-2.93309 5.28529,-6.31477 6.82834,-10.14289 1.54104,-3.82712 2.31257,-7.93174 2.31257,-12.31288 0,-4.34313 -0.78277,-8.44771 -2.34382,-12.31483 C 60.094133,15.82003 57.808593,12.380471 54.800503,9.3713796 48.515313,3.1241896 40.893653,5.9265136e-7 31.942383,5.9265138e-7 Z M 32.057623,5.7716626 c 7.23822,0 13.42863,2.571923 18.57478,7.7150794 2.47408,2.478074 4.35948,5.297144 5.65252,8.459244 1.29504,3.16209 1.94342,6.51384 1.94342,10.05694 0,7.35423 -2.49445,13.46816 -7.4846,18.34432 -2.59208,2.51407 -5.49406,4.43661 -8.71316,5.77166 -3.2231,1.33404 -6.54486,1.9981 -9.97296,1.9981 -3.467107,0 -6.782568,-0.65672 -9.943661,-1.97076 -3.164098,-1.31604 -5.999858,-3.21894 -8.513933,-5.71502 -2.515077,-2.49507 -4.447918,-5.33279 -5.800959,-8.51588 -1.354042,-3.1791 -2.029358,-6.48331 -2.029358,-9.91242 0,-3.4671 0.675316,-6.79186 2.029358,-9.97295 1.352043,-3.1811 3.285882,-6.046798 5.800959,-8.599875 4.991151,-5.1041594 11.14337,-7.6584384 18.457594,-7.6584384 z" />
+ <path d="m 50.114533,26.687816 -4.22913,2.22907 c -0.45702,-0.95103 -1.02003,-1.61905 -1.68605,-2.00006 -0.66802,-0.38001 -1.30704,-0.57102 -1.91406,-0.57102 -2.85709,0 -4.28713,1.88506 -4.28713,5.65717 0,1.71406 0.363,3.0841 1.08603,4.11313 0.72302,1.02903 1.78906,1.54405 3.2011,1.54405 1.86506,0 3.1801,-0.91503 3.94112,-2.74309 l 4.00012,2.00007 c -0.87502,1.56304 -2.05706,2.79108 -3.54111,3.68611 -1.48604,0.89602 -3.10509,1.34304 -4.85715,1.34304 -2.89608,0 -5.20915,-0.87503 -6.94121,-2.62908 -1.73605,-1.75205 -2.60207,-4.19013 -2.60207,-7.31323 0,-3.04809 0.88502,-5.46616 2.65808,-7.25722 1.77005,-1.79005 4.00812,-2.68608 6.7132,-2.68608 3.96212,-0.002 6.78321,1.54105 8.45826,4.62714 z" />
+ <path d="m 31.656963,26.687816 -4.287128,2.22907 c -0.458013,-0.95103 -1.019029,-1.61905 -1.685048,-2.00006 -0.667024,-0.38001 -1.286042,-0.57102 -1.858057,-0.57102 -2.856087,0 -4.28613,1.88506 -4.28613,5.65717 0,1.71406 0.362014,3.0841 1.085029,4.11313 0.724025,1.02903 1.791056,1.54405 3.201101,1.54405 1.867057,0 3.181095,-0.91503 3.944118,-2.74309 l 3.942125,2.00007 c -0.83803,1.56304 -2.000065,2.79108 -3.486111,3.68611 -1.484043,0.89602 -3.123093,1.34304 -4.914149,1.34304 -2.857088,0 -5.163158,-0.87503 -6.915212,-2.62908 -1.752053,-1.75205 -2.62808,-4.19013 -2.62808,-7.31323 0,-3.04809 0.886028,-5.46616 2.657081,-7.25722 1.771054,-1.79005 4.009125,-2.68608 6.715205,-2.68608 3.963122,-0.002 6.800209,1.54105 8.515256,4.62714 z" />
+ </svg>
+ );
+};
+
+export default CCBySA;
diff --git a/src/components/atoms/icons/close.module.scss b/src/components/atoms/icons/close.module.scss
new file mode 100644
index 0000000..4a5d18d
--- /dev/null
+++ b/src/components/atoms/icons/close.module.scss
@@ -0,0 +1,12 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.line {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
diff --git a/src/components/atoms/icons/close.stories.tsx b/src/components/atoms/icons/close.stories.tsx
new file mode 100644
index 0000000..b1d88cd
--- /dev/null
+++ b/src/components/atoms/icons/close.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CloseIcon from './close';
+
+export default {
+ title: 'Atoms/Icons',
+ component: CloseIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CloseIcon>;
+
+const Template: ComponentStory<typeof CloseIcon> = (args) => (
+ <CloseIcon {...args} />
+);
+
+export const Close = Template.bind({});
diff --git a/src/components/atoms/icons/close.test.tsx b/src/components/atoms/icons/close.test.tsx
new file mode 100644
index 0000000..0357bec
--- /dev/null
+++ b/src/components/atoms/icons/close.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Close from './close';
+
+describe('Close', () => {
+ it('renders a Close icon', () => {
+ const { container } = render(<Close />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/close.tsx b/src/components/atoms/icons/close.tsx
new file mode 100644
index 0000000..eb9ce7c
--- /dev/null
+++ b/src/components/atoms/icons/close.tsx
@@ -0,0 +1,35 @@
+import { VFC } from 'react';
+import styles from './close.module.scss';
+
+export type CloseProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Close component
+ *
+ * Render a close svg icon.
+ */
+const Close: VFC<CloseProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.line}
+ d="m 3.6465461,3.6465455 c 2.8785908,-2.87859092 7.5134339,-2.87859092 10.3920249,0 L 96.353457,85.96143 c 2.878587,2.878591 2.878587,7.513434 0,10.392025 -2.878597,2.878591 -7.513432,2.878591 -10.392029,0 L 3.6465451,14.038571 C 0.76795421,11.15998 0.76795421,6.5251364 3.6465461,3.6465455 Z"
+ />
+ <path
+ className={styles.line}
+ d="m 96.353453,3.646546 c 2.878592,2.8785909 2.878592,7.513435 0,10.392026 L 14.03857,96.353457 c -2.878589,2.878587 -7.5134337,2.878587 -10.3920246,0 -2.87859084,-2.878597 -2.87858985,-7.513442 -1e-6,-10.392029 L 85.961428,3.646546 c 2.878591,-2.87859097 7.513434,-2.87859097 10.392025,0 z"
+ />
+ </svg>
+ );
+};
+
+export default Close;
diff --git a/src/components/atoms/icons/cog.module.scss b/src/components/atoms/icons/cog.module.scss
new file mode 100644
index 0000000..5201598
--- /dev/null
+++ b/src/components/atoms/icons/cog.module.scss
@@ -0,0 +1,8 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ width: var(--icon-size, #{fun.convert-px(40)});
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/cog.stories.tsx b/src/components/atoms/icons/cog.stories.tsx
new file mode 100644
index 0000000..ee883d8
--- /dev/null
+++ b/src/components/atoms/icons/cog.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CogIcon from './cog';
+
+export default {
+ title: 'Atoms/Icons',
+ component: CogIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CogIcon>;
+
+const Template: ComponentStory<typeof CogIcon> = (args) => (
+ <CogIcon {...args} />
+);
+
+export const Cog = Template.bind({});
diff --git a/src/components/atoms/icons/cog.test.tsx b/src/components/atoms/icons/cog.test.tsx
new file mode 100644
index 0000000..89090fa
--- /dev/null
+++ b/src/components/atoms/icons/cog.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Cog from './cog';
+
+describe('Cog', () => {
+ it('renders a Cog icon', () => {
+ const { container } = render(<Cog />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/cog.tsx b/src/components/atoms/icons/cog.tsx
new file mode 100644
index 0000000..df6d54d
--- /dev/null
+++ b/src/components/atoms/icons/cog.tsx
@@ -0,0 +1,29 @@
+import { VFC } from 'react';
+import styles from './cog.module.scss';
+
+export type CogProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Cog component
+ *
+ * Render a cog svg icon.
+ */
+const Cog: VFC<CogProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path d="m 71.782287,3.1230469 c -1.164356,0 -2.3107,0.076326 -3.435131,0.2227895 L 66.33766,9.1021499 C 64.651951,9.5517047 63.049493,10.204637 61.558109,11.033725 L 56.112383,8.2889128 c -1.970928,1.4609237 -3.730521,3.1910632 -5.22513,5.1351362 l 2.648234,5.494014 c -0.855644,1.477262 -1.537042,3.067161 -2.016082,4.743334 l -5.791433,1.911821 c -0.188001,1.269731 -0.286444,2.568579 -0.286444,3.890587 0,1.164355 0.07633,2.310701 0.222789,3.435131 l 5.756315,2.009497 c 0.449555,1.685708 1.102486,3.288168 1.931575,4.779551 l -2.744813,5.445725 c 1.460924,1.970927 3.191063,3.730521 5.135137,5.22513 l 5.494014,-2.648233 c 1.477261,0.85564 3.067161,1.537039 4.743334,2.016081 L 67.8917,55.51812 c 1.26973,0.188002 2.568578,0.286444 3.890587,0.286444 1.16565,0 2.313889,-0.07601 3.43952,-0.222789 l 2.008399,-5.756314 c 1.684332,-0.449523 3.285984,-1.103103 4.776259,-1.931575 l 5.445725,2.744812 c 1.970928,-1.460924 3.730521,-3.191061 5.22513,-5.135136 l -2.648233,-5.494015 c 0.85564,-1.477262 1.537039,-3.067161 2.016082,-4.743334 l 5.79253,-1.91182 c 0.187995,-1.269731 0.285346,-2.56858 0.285346,-3.890588 0,-1.16565 -0.07601,-2.313889 -0.222789,-3.439521 L 92.143942,24.015886 C 91.694419,22.331554 91.04084,20.729903 90.212367,19.239628 l 2.744812,-5.445726 C 91.496255,11.822973 89.766118,10.063381 87.822043,8.5687715 L 82.328028,11.217006 C 80.850766,10.361361 79.260867,9.6799641 77.584694,9.2009234 L 75.672874,3.4094907 C 74.403143,3.2214898 73.104295,3.1230469 71.782287,3.1230469 Z m 0,15.0520191 a 11.288679,11.288679 0 0 1 11.288739,11.288739 11.288679,11.288679 0 0 1 -11.288739,11.28874 11.288679,11.288679 0 0 1 -11.28874,-11.28874 11.288679,11.288679 0 0 1 11.28874,-11.288739 z" />
+ <path d="m 38.326115,25.84777 c -1.583642,0 -3.142788,0.103807 -4.672127,0.303016 l -2.73312,7.829173 c -2.292736,0.611441 -4.472242,1.499494 -6.500676,2.627139 L 17.01345,32.873874 c -2.680664,1.987004 -5.073889,4.340169 -7.1067095,6.984309 l 3.6018685,7.472418 c -1.163764,2.009226 -2.090533,4.171652 -2.742078,6.451418 l -7.8769382,2.60027 C 2.6338924,58.109252 2.5,59.875819 2.5,61.673885 c 0,1.583642 0.1038125,3.142788 0.3030165,4.672128 l 7.8291725,2.73312 c 0.611441,2.292734 1.499494,4.472243 2.627139,6.500673 L 9.5261037,82.98655 c 1.9870063,2.680661 4.3401703,5.07389 6.9843093,7.106709 l 7.472419,-3.601867 c 2.009226,1.16376 4.171651,2.090533 6.451418,2.742079 l 2.60027,7.876932 C 34.761483,97.366114 36.528049,97.5 38.326115,97.5 c 1.585404,0 3.147126,-0.103373 4.678099,-0.303015 l 2.731628,-7.829178 c 2.290862,-0.611397 4.469272,-1.500329 6.496197,-2.627132 l 7.406741,3.733224 c 2.680664,-1.987007 5.07389,-4.340171 7.10671,-6.984313 l -3.601866,-7.472415 c 1.163756,-2.00923 2.090529,-4.171655 2.742076,-6.45142 l 7.878431,-2.60027 c 0.255691,-1.726964 0.3881,-3.49353 0.3881,-5.291596 0,-1.585404 -0.103373,-3.147127 -0.303016,-4.678099 L 66.020041,54.264159 C 65.408645,51.973296 64.51971,49.794888 63.392903,47.767962 l 3.733224,-7.406742 c -1.987006,-2.680664 -4.340168,-5.073889 -6.984309,-7.10671 l -7.472419,3.601867 c -2.009228,-1.163762 -4.171651,-2.090533 -6.451418,-2.742076 l -2.60027,-7.876939 C 41.890748,25.981661 40.124181,25.84777 38.326115,25.84777 Z m 0,20.472278 A 15.353754,15.353754 0 0 1 53.679952,61.673885 15.353754,15.353754 0 0 1 38.326115,77.027724 15.353754,15.353754 0 0 1 22.972279,61.673885 15.353754,15.353754 0 0 1 38.326115,46.320048 Z" />
+ </svg>
+ );
+};
+
+export default Cog;
diff --git a/src/components/atoms/icons/computer-screen.module.scss b/src/components/atoms/icons/computer-screen.module.scss
new file mode 100644
index 0000000..6c8f701
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.module.scss
@@ -0,0 +1,39 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.root,
+.separator,
+.cursor,
+.line,
+.text {
+ fill: var(--color-fg);
+}
+
+.stand {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-dark);
+
+ &--top {
+ stroke-width: 3;
+ }
+
+ &--bottom {
+ stroke-width: 2;
+ }
+}
+
+.screen {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-dark);
+ stroke-width: 3;
+}
+
+.contour {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-dark);
+ stroke-width: 3;
+}
diff --git a/src/components/atoms/icons/computer-screen.stories.tsx b/src/components/atoms/icons/computer-screen.stories.tsx
new file mode 100644
index 0000000..46e3ad4
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ComputerScreenIcon from './computer-screen';
+
+export default {
+ title: 'Atoms/Icons',
+ component: ComputerScreenIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ComputerScreenIcon>;
+
+const Template: ComponentStory<typeof ComputerScreenIcon> = (args) => (
+ <ComputerScreenIcon {...args} />
+);
+
+export const ComputerScreen = Template.bind({});
diff --git a/src/components/atoms/icons/computer-screen.test.tsx b/src/components/atoms/icons/computer-screen.test.tsx
new file mode 100644
index 0000000..c0e53e0
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import ComputerScreen from './computer-screen';
+
+describe('ComputerScreen', () => {
+ it('renders a computer screen icon', () => {
+ const { container } = render(<ComputerScreen />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/computer-screen.tsx b/src/components/atoms/icons/computer-screen.tsx
new file mode 100644
index 0000000..310836f
--- /dev/null
+++ b/src/components/atoms/icons/computer-screen.tsx
@@ -0,0 +1,79 @@
+import { VFC } from 'react';
+import styles from './computer-screen.module.scss';
+
+export type ComputerScreenProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * ComputerScreen component
+ *
+ * Render a computer screen svg icon.
+ */
+const ComputerScreen: VFC<ComputerScreenProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ d="M 1.0206528,11.991149 H 98.979347 V 78.466748 H 1.0206528 Z"
+ className={styles.contour}
+ />
+ <path
+ d="M 6.2503581,18.032451 H 93.563283 V 71.12731 H 6.2503581 Z"
+ className={styles.screen}
+ />
+ <path
+ d="m 40.038268,78.939276 c 4.614714,2.7794 4.333151,10.099225 0,17.60572 H 50 59.961731 c -4.333151,-7.506495 -4.614715,-14.82632 0,-17.60572 H 50 Z"
+ className={`${styles.stand} ${styles['stand--top']}`}
+ />
+ <path
+ d="m 31.084262,96.254656 h 37.831475 c 1.394769,0 2.517635,0.404907 2.517635,0.907864 v 1.179616 c 0,0.502956 -1.122866,0.907864 -2.517635,0.907864 H 31.084262 c -1.394769,0 -2.517635,-0.404908 -2.517635,-0.907864 V 97.16252 c 0,-0.502957 1.122866,-0.907864 2.517635,-0.907864 z"
+ className={`${styles.stand} ${styles['stand--bottom']}`}
+ />
+ <path
+ d="m 13.259277,26.737199 h 29.132596 v 2.567314 H 13.259277 Z"
+ className={styles.line}
+ />
+ <path
+ d="M 13.259277,36.439141 H 36.46805 v 2.567315 H 13.259277 Z"
+ className={styles.line}
+ />
+ <path
+ d="m 13.259277,46.141084 h 26.586812 v 2.567314 H 13.259277 Z"
+ className={styles.line}
+ />
+ <path
+ d="m 18.443194,65.930804 h 4.417548 v 1 h -4.417548 z"
+ className={styles.cursor}
+ />
+ <path
+ d="m 77.586096,42.217577 v -1.680914 l 6.160884,-2.39919 -6.160884,-2.406595 v -1.68832 l 7.604842,2.89532 v 2.38438 z"
+ className={styles.text}
+ />
+ <path
+ d="m 68.396606,43.291289 6.07943,-11.136982 h 1.688318 l -6.049809,11.136982 z"
+ className={styles.text}
+ />
+ <path
+ d="m 59.384832,39.322258 v -2.38438 l 7.604841,-2.89532 v 1.68832 l -6.168289,2.406595 6.168289,2.399191 v 1.680915 z"
+ className={styles.text}
+ />
+ <path
+ d="M 7.1079167,57.876372 H 92.892083 v 0.813634 H 7.1079167 Z"
+ className={styles.separator}
+ />
+ <path
+ d="m 17.042456,64.960616 q 0,0.632276 -0.426175,0.9816 -0.422681,0.345831 -1.254074,0.37727 v 0.611318 h -0.380763 v -0.600838 q -0.751047,-0.02795 -1.170236,-0.352818 -0.419189,-0.328364 -0.551931,-1.002559 l 0.89427,-0.164183 q 0.06637,0.394736 0.261992,0.579878 0.199115,0.181648 0.565905,0.216581 v -1.365857 q -0.01048,-0.007 -0.0524,-0.01398 -0.04192,-0.01048 -0.05589,-0.01048 -0.562412,-0.129244 -0.848857,-0.303907 -0.286445,-0.178155 -0.443642,-0.447135 -0.153701,-0.272472 -0.153701,-0.663715 0,-0.579878 0.394736,-0.894269 0.394736,-0.317886 1.159755,-0.349325 v -0.468093 h 0.380763 v 0.468095 q 0.681183,0.02445 1.047973,0.303911 0.36679,0.275967 0.527479,0.918723 l -0.92222,0.136236 q -0.104797,-0.600837 -0.653236,-0.674195 v 1.22962 l 0.03843,0.007 q 0.101305,0 0.614811,0.167676 0.517,0.167676 0.772007,0.496041 0.255006,0.324871 0.255006,0.817418 z m -2.061012,-2.731715 q -0.639264,0.04891 -0.639264,0.558918 0,0.157196 0.0524,0.2585 0.0524,0.09781 0.157197,0.167676 0.104797,0.06986 0.429668,0.174662 z m 1.152769,2.745688 q 0,-0.174662 -0.06288,-0.282954 -0.06288,-0.111783 -0.185141,-0.181648 -0.118771,-0.06986 -0.523987,-0.185142 v 1.28202 q 0.772006,-0.0524 0.772006,-0.632276 z"
+ className={styles.root}
+ />
+ </svg>
+ );
+};
+
+export default ComputerScreen;
diff --git a/src/components/atoms/icons/envelop.module.scss b/src/components/atoms/icons/envelop.module.scss
new file mode 100644
index 0000000..202900b
--- /dev/null
+++ b/src/components/atoms/icons/envelop.module.scss
@@ -0,0 +1,28 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.envelop {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.lines {
+ fill: var(--color-fg);
+}
+
+.background {
+ fill: var(--color-shadow-dark);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.paper {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/envelop.stories.tsx b/src/components/atoms/icons/envelop.stories.tsx
new file mode 100644
index 0000000..9139b44
--- /dev/null
+++ b/src/components/atoms/icons/envelop.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import EnvelopIcon from './envelop';
+
+export default {
+ title: 'Atoms/Icons',
+ component: EnvelopIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof EnvelopIcon>;
+
+const Template: ComponentStory<typeof EnvelopIcon> = (args) => (
+ <EnvelopIcon {...args} />
+);
+
+export const Envelop = Template.bind({});
diff --git a/src/components/atoms/icons/envelop.test.tsx b/src/components/atoms/icons/envelop.test.tsx
new file mode 100644
index 0000000..072dc85
--- /dev/null
+++ b/src/components/atoms/icons/envelop.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Envelop from './envelop';
+
+describe('Envelop', () => {
+ it('renders an envelop icon', () => {
+ const { container } = render(<Envelop />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/envelop.tsx b/src/components/atoms/icons/envelop.tsx
new file mode 100644
index 0000000..7b50d1d
--- /dev/null
+++ b/src/components/atoms/icons/envelop.tsx
@@ -0,0 +1,67 @@
+import { VFC } from 'react';
+import styles from './envelop.module.scss';
+
+export type EnvelopProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Envelop Component
+ *
+ * Render an envelop svg icon.
+ */
+const Envelop: VFC<EnvelopProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.background}
+ d="M 1.5262527,42.535416 H 98.473747 V 98.371662 H 1.5262527 Z"
+ />
+ <path
+ className={styles.envelop}
+ d="m 49.999985,1.6283075 c 2.855148,0 48.473753,40.8563885 48.473753,40.8563885 H 1.5262359 c 0,0 45.6186001,-40.8563885 48.4737491,-40.8563885 z"
+ />
+ <path
+ className={styles.paper}
+ d="M 8.3434839,28.463842 H 91.656465 V 97.348661 H 8.3434839 Z"
+ />
+ <path
+ className={styles.envelop}
+ d="M 49.999985,63.571925 98.473738,98.371692 H 1.5262359 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 24.562439,37.640923 h 50.875053 v 1.5 H 24.562439 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 24.562439,45.140923 h 50.875053 v 1.5 H 24.562439 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 24.562443,52.640923 h 50.875053 v 1.5 H 24.562443 Z"
+ />
+ <path
+ className={styles.lines}
+ d="M 24.562447,60.140923 H 75.4375 v 1.5 H 24.562447 Z"
+ />
+ <path
+ className={styles.envelop}
+ d="M 39.93749,70.965004 1.5262559,43.55838 v 54.813242 z"
+ />
+ <path
+ className={styles.envelop}
+ d="M 60.0625,70.965004 98.473738,43.55838 v 54.813242 z"
+ />
+ </svg>
+ );
+};
+
+export default Envelop;
diff --git a/src/components/atoms/icons/hamburger.module.scss b/src/components/atoms/icons/hamburger.module.scss
new file mode 100644
index 0000000..4fba4df
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.module.scss
@@ -0,0 +1,42 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ display: flex;
+ align-items: center;
+ width: var(--icon-size, #{fun.convert-px(50)});
+ height: var(--icon-size, #{fun.convert-px(50)});
+ position: relative;
+}
+
+.icon {
+ &,
+ &::before,
+ &::after {
+ display: block;
+ height: fun.convert-px(7);
+ width: 100%;
+ position: absolute;
+ background: var(--color-primary-lighter);
+ background-image: linear-gradient(
+ to right,
+ var(--color-primary-light) 0%,
+ var(--color-primary-lighter) 100%
+ );
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ border-radius: fun.convert-px(3);
+ transition: all 0.25s ease-in-out 0s, transform 0.4s ease-in 0s;
+ }
+
+ &::before,
+ &::after {
+ content: "";
+ }
+
+ &::before {
+ top: fun.convert-px(-15);
+ }
+
+ &::after {
+ bottom: fun.convert-px(-15);
+ }
+}
diff --git a/src/components/atoms/icons/hamburger.stories.tsx b/src/components/atoms/icons/hamburger.stories.tsx
new file mode 100644
index 0000000..c753e69
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.stories.tsx
@@ -0,0 +1,41 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HamburgerIcon from './hamburger';
+
+export default {
+ title: 'Atoms/Icons',
+ component: HamburgerIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the icon wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ iconClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the icon.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof HamburgerIcon>;
+
+const Template: ComponentStory<typeof HamburgerIcon> = (args) => (
+ <HamburgerIcon {...args} />
+);
+
+export const Hamburger = Template.bind({});
diff --git a/src/components/atoms/icons/hamburger.test.tsx b/src/components/atoms/icons/hamburger.test.tsx
new file mode 100644
index 0000000..7173a23
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Hamburger from './hamburger';
+
+describe('Hamburger', () => {
+ it('renders a Hamburger icon', () => {
+ const { container } = render(<Hamburger />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/hamburger.tsx b/src/components/atoms/icons/hamburger.tsx
new file mode 100644
index 0000000..7e7c2c9
--- /dev/null
+++ b/src/components/atoms/icons/hamburger.tsx
@@ -0,0 +1,32 @@
+import { FC } from 'react';
+import styles from './hamburger.module.scss';
+
+type HamburgerProps = {
+ /**
+ * Set additional classnames to the icon wrapper.
+ */
+ className?: string;
+
+ /**
+ * Set additional classnames to the icon.
+ */
+ iconClassName?: string;
+};
+
+/**
+ * Hamburger component
+ *
+ * Render a Hamburger icon.
+ */
+const Hamburger: FC<HamburgerProps> = ({
+ className = '',
+ iconClassName = '',
+}) => {
+ return (
+ <span className={`${styles.wrapper} ${className}`}>
+ <span className={`${styles.icon} ${iconClassName}`}></span>
+ </span>
+ );
+};
+
+export default Hamburger;
diff --git a/src/components/atoms/icons/home.module.scss b/src/components/atoms/icons/home.module.scss
new file mode 100644
index 0000000..48dcc6c
--- /dev/null
+++ b/src/components/atoms/icons/home.module.scss
@@ -0,0 +1,41 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.wall {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.indoor {
+ fill: var(--color-shadow-dark);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.door {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.roof {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.chimney {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
+
+.lines {
+ fill: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/home.stories.tsx b/src/components/atoms/icons/home.stories.tsx
new file mode 100644
index 0000000..b1c995c
--- /dev/null
+++ b/src/components/atoms/icons/home.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HomeIcon from './home';
+
+export default {
+ title: 'Atoms/Icons',
+ component: HomeIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof HomeIcon>;
+
+const Template: ComponentStory<typeof HomeIcon> = (args) => (
+ <HomeIcon {...args} />
+);
+
+export const Home = Template.bind({});
diff --git a/src/components/atoms/icons/home.test.tsx b/src/components/atoms/icons/home.test.tsx
new file mode 100644
index 0000000..a08a3cf
--- /dev/null
+++ b/src/components/atoms/icons/home.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Home from './home';
+
+describe('Home', () => {
+ it('renders a home icon', () => {
+ const { container } = render(<Home />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/home.tsx b/src/components/atoms/icons/home.tsx
new file mode 100644
index 0000000..71bbc4a
--- /dev/null
+++ b/src/components/atoms/icons/home.tsx
@@ -0,0 +1,55 @@
+import { VFC } from 'react';
+import styles from './home.module.scss';
+
+export type HomeProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Home component.
+ *
+ * Render a home svg icon.
+ */
+const Home: VFC<HomeProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.wall}
+ d="M 9.2669392,15.413749 H 90.709833 V 97.751815 H 9.2669392 Z"
+ />
+ <path
+ className={styles.indoor}
+ d="m 39.190941,65.836418 h 21.594871 v 31.91539 H 39.190941 Z"
+ />
+ <path
+ className={styles.door}
+ d="m 39.190941,65.836418 h 21.594871 v 31.91539 H 39.190941 Z"
+ />
+ <path
+ className={styles.roof}
+ d="M 4.8219096,11.719266 H 94.720716 l 3.47304,33.365604 H 1.7830046 Z"
+ />
+ <path
+ className={styles.chimney}
+ d="M 70.41848,2.2481852 H 82.957212 V 22.636212 H 70.41848 Z"
+ />
+ <path
+ className={styles.lines}
+ d="M 3.9536645,19.342648 H 61.003053 v 3.293563 H 3.9536645 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 38.973709,32.057171 h 57.049389 v 3.293563 H 38.973709 Z"
+ />
+ </svg>
+ );
+};
+
+export default Home;
diff --git a/src/components/atoms/icons/magnifying-glass.module.scss b/src/components/atoms/icons/magnifying-glass.module.scss
new file mode 100644
index 0000000..d14bec5
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.module.scss
@@ -0,0 +1,29 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.big-handle {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
+
+.glass {
+ fill: var(--color-bg-opacity);
+ stroke: var(--color-primary-darker);
+ stroke-width: 2;
+}
+
+.upright {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 3;
+}
+
+.small-handle {
+ fill: var(--color-primary);
+ stroke: var(--color-primary-darker);
+ stroke-width: 2;
+}
diff --git a/src/components/atoms/icons/magnifying-glass.stories.tsx b/src/components/atoms/icons/magnifying-glass.stories.tsx
new file mode 100644
index 0000000..80e183e
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MagnifyingGlassIcon from './magnifying-glass';
+
+export default {
+ title: 'Atoms/Icons',
+ component: MagnifyingGlassIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof MagnifyingGlassIcon>;
+
+const Template: ComponentStory<typeof MagnifyingGlassIcon> = (args) => (
+ <MagnifyingGlassIcon {...args} />
+);
+
+export const MagnifyingGlass = Template.bind({});
diff --git a/src/components/atoms/icons/magnifying-glass.test.tsx b/src/components/atoms/icons/magnifying-glass.test.tsx
new file mode 100644
index 0000000..8e788f7
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import MagnifyingGlass from './magnifying-glass';
+
+describe('MagnifyingGlass', () => {
+ it('renders a magnifying glass icon', () => {
+ const { container } = render(<MagnifyingGlass />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/magnifying-glass.tsx b/src/components/atoms/icons/magnifying-glass.tsx
new file mode 100644
index 0000000..445ef10
--- /dev/null
+++ b/src/components/atoms/icons/magnifying-glass.tsx
@@ -0,0 +1,43 @@
+import { VFC } from 'react';
+import styles from './magnifying-glass.module.scss';
+
+export type MagnifyingGlassProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * MagnifyingGlass component
+ *
+ * Render a magnifying glass svg icon.
+ */
+const MagnifyingGlass: VFC<MagnifyingGlassProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles['small-handle']}
+ d="m 45.39268,48.064692 5.611922,4.307881 -10.292886,13.414321 -5.611923,-4.307882 z"
+ />
+ <path
+ className={styles.upright}
+ d="M 90.904041,28.730105 A 27.725691,27.730085 0 0 1 63.17835,56.46019 27.725691,27.730085 0 0 1 35.45266,28.730105 27.725691,27.730085 0 0 1 63.17835,1.00002 27.725691,27.730085 0 0 1 90.904041,28.730105 Z"
+ />
+ <path
+ className={styles.glass}
+ d="M 82.438984,28.730105 A 19.260633,19.263685 0 0 1 63.17835,47.99379 19.260633,19.263685 0 0 1 43.917716,28.730105 19.260633,19.263685 0 0 1 63.17835,9.4664203 19.260633,19.263685 0 0 1 82.438984,28.730105 Z"
+ />
+ <path
+ className={styles['big-handle']}
+ d="m 35.826055,60.434903 5.75193,4.415356 c 0.998045,0.766128 1.184879,2.186554 0.418913,3.184809 L 18.914717,98.117182 c -0.765969,0.998256 -2.186094,1.185131 -3.18414,0.418997 L 9.9786472,94.120827 C 8.9806032,93.354698 8.7937692,91.934273 9.5597392,90.936014 L 32.641919,60.853903 c 0.765967,-0.998254 2.186091,-1.185129 3.184136,-0.419 z"
+ />
+ </svg>
+ );
+};
+
+export default MagnifyingGlass;
diff --git a/src/components/atoms/icons/moon.module.scss b/src/components/atoms/icons/moon.module.scss
new file mode 100644
index 0000000..e0b53d6
--- /dev/null
+++ b/src/components/atoms/icons/moon.module.scss
@@ -0,0 +1,8 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+ width: var(--icon-size, #{fun.convert-px(25)});
+}
diff --git a/src/components/atoms/icons/moon.stories.tsx b/src/components/atoms/icons/moon.stories.tsx
new file mode 100644
index 0000000..4d2fb9a
--- /dev/null
+++ b/src/components/atoms/icons/moon.stories.tsx
@@ -0,0 +1,41 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MoonIcon from './moon';
+
+export default {
+ title: 'Atoms/Icons',
+ component: MoonIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The SVG title.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof MoonIcon>;
+
+const Template: ComponentStory<typeof MoonIcon> = (args) => (
+ <MoonIcon {...args} />
+);
+
+export const Moon = Template.bind({});
diff --git a/src/components/atoms/icons/moon.test.tsx b/src/components/atoms/icons/moon.test.tsx
new file mode 100644
index 0000000..1c96303
--- /dev/null
+++ b/src/components/atoms/icons/moon.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Moon from './moon';
+
+describe('Moon', () => {
+ it('renders a moon icon', () => {
+ const { container } = render(<Moon />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/moon.tsx b/src/components/atoms/icons/moon.tsx
new file mode 100644
index 0000000..4f52319
--- /dev/null
+++ b/src/components/atoms/icons/moon.tsx
@@ -0,0 +1,28 @@
+import { VFC } from 'react';
+import styles from './moon.module.scss';
+
+type MoonProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * The SVG title.
+ */
+ title?: string;
+};
+
+const Moon: VFC<MoonProps> = ({ className = '', title }) => {
+ return (
+ <svg
+ className={`${styles.icon} ${className}`}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ {title !== 'undefined' && <title>{title}</title>}
+ <path d="M 51.077315,1.9893942 A 43.319985,43.319985 0 0 1 72.840039,39.563145 43.319985,43.319985 0 0 1 29.520053,82.88313 43.319985,43.319985 0 0 1 5.4309911,75.569042 48.132997,48.132997 0 0 0 46.126047,98 48.132997,48.132997 0 0 0 94.260004,49.867002 48.132997,48.132997 0 0 0 51.077315,1.9893942 Z" />
+ </svg>
+ );
+};
+
+export default Moon;
diff --git a/src/components/atoms/icons/plus-minus.module.scss b/src/components/atoms/icons/plus-minus.module.scss
new file mode 100644
index 0000000..c54db33
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.module.scss
@@ -0,0 +1,39 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: flex;
+ place-content: center;
+ place-items: center;
+ width: var(--icon-size, #{fun.convert-px(30)});
+ height: var(--icon-size, #{fun.convert-px(30)});
+ position: relative;
+ background: var(--color-bg);
+ border: fun.convert-px(1) solid var(--color-primary);
+ border-radius: fun.convert-px(3);
+ color: var(--color-primary);
+
+ &::before,
+ &::after {
+ content: "";
+ position: absolute;
+ background: var(--color-primary);
+ transition: transform 0.4s ease-out 0s;
+ }
+
+ &::after {
+ height: fun.convert-px(3);
+ width: 60%;
+ }
+
+ &::before {
+ height: 60%;
+ width: fun.convert-px(3);
+ transform: scaleY(1);
+ }
+
+ &--minus {
+ &::before {
+ transform: scaleY(0);
+ }
+ }
+}
diff --git a/src/components/atoms/icons/plus-minus.stories.tsx b/src/components/atoms/icons/plus-minus.stories.tsx
new file mode 100644
index 0000000..ffa28f2
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.stories.tsx
@@ -0,0 +1,43 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PlusMinusIcon from './plus-minus';
+
+export default {
+ title: 'Atoms/Icons',
+ component: PlusMinusIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ state: {
+ control: {
+ type: 'radio',
+ options: ['plus', 'minus'],
+ },
+ description: 'Which state should be displayed.',
+ type: {
+ name: 'enum',
+ required: true,
+ value: ['plus', 'minus'],
+ },
+ },
+ },
+} as ComponentMeta<typeof PlusMinusIcon>;
+
+const Template: ComponentStory<typeof PlusMinusIcon> = (args) => (
+ <PlusMinusIcon {...args} />
+);
+
+export const PlusMinus = Template.bind({});
+PlusMinus.args = {
+ state: 'plus',
+};
diff --git a/src/components/atoms/icons/plus-minus.test.tsx b/src/components/atoms/icons/plus-minus.test.tsx
new file mode 100644
index 0000000..6903c7a
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import PlusMinus from './plus-minus';
+
+describe('PlusMinus', () => {
+ it('renders a plus/minus icon', () => {
+ const { container } = render(<PlusMinus state="plus" />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/plus-minus.tsx b/src/components/atoms/icons/plus-minus.tsx
new file mode 100644
index 0000000..78aa14a
--- /dev/null
+++ b/src/components/atoms/icons/plus-minus.tsx
@@ -0,0 +1,31 @@
+import { FC } from 'react';
+import styles from './plus-minus.module.scss';
+
+type PlusMinusProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * Which state should be displayed.
+ */
+ state: 'plus' | 'minus';
+};
+
+/**
+ * PlusMinus component
+ *
+ * Render a plus or a minus icon.
+ */
+const PlusMinus: FC<PlusMinusProps> = ({ className, state }) => {
+ const stateClass = `icon--${state}`;
+
+ return (
+ <div
+ className={`${styles.icon} ${styles[stateClass]} ${className}`}
+ aria-hidden={true}
+ ></div>
+ );
+};
+
+export default PlusMinus;
diff --git a/src/components/atoms/icons/posts-stack.module.scss b/src/components/atoms/icons/posts-stack.module.scss
new file mode 100644
index 0000000..a22d265
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.module.scss
@@ -0,0 +1,22 @@
+@use "@styles/abstracts/functions" as fun;
+
+.icon {
+ display: block;
+ width: var(--icon-size, #{fun.convert-px(40)});
+}
+
+.lines {
+ fill: var(--color-fg);
+ stroke-width: 4;
+}
+
+.picture {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+}
+
+.background {
+ fill: var(--color-bg);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+}
diff --git a/src/components/atoms/icons/posts-stack.stories.tsx b/src/components/atoms/icons/posts-stack.stories.tsx
new file mode 100644
index 0000000..46bb39f
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PostsStackIcon from './posts-stack';
+
+export default {
+ title: 'Atoms/Icons',
+ component: PostsStackIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof PostsStackIcon>;
+
+const Template: ComponentStory<typeof PostsStackIcon> = (args) => (
+ <PostsStackIcon {...args} />
+);
+
+export const PostsStack = Template.bind({});
diff --git a/src/components/atoms/icons/posts-stack.test.tsx b/src/components/atoms/icons/posts-stack.test.tsx
new file mode 100644
index 0000000..8f44fa9
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import PostsStack from './posts-stack';
+
+describe('PostsStack', () => {
+ it('renders a posts stack icon', () => {
+ const { container } = render(<PostsStack />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/posts-stack.tsx b/src/components/atoms/icons/posts-stack.tsx
new file mode 100644
index 0000000..1998d25
--- /dev/null
+++ b/src/components/atoms/icons/posts-stack.tsx
@@ -0,0 +1,75 @@
+import { VFC } from 'react';
+import styles from './posts-stack.module.scss';
+
+export type PostsStackProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+};
+
+/**
+ * Posts stack component.
+ *
+ * Render a posts stack svg icon.
+ */
+const PostsStack: VFC<PostsStackProps> = ({ className = '' }) => {
+ return (
+ <svg
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ className={`${styles.icon} ${className}`}
+ >
+ <path
+ className={styles.background}
+ d="M 28.992096,1.4822128 H 90.770752 V 82.312253 H 28.992096 Z"
+ />
+ <path
+ className={styles.background}
+ d="m 19.110672,8.992094 h 61.778656 v 80.83004 H 19.110672 Z"
+ />
+ <path
+ className={styles.background}
+ d="m 9.229248,17.687748 h 61.778656 v 80.83004 H 9.229248 Z"
+ />
+ <path
+ className={styles.picture}
+ d="M 18.149242,74.65544 H 33.375246 V 90.194215 H 18.149242 Z"
+ />
+ <path
+ className={styles.picture}
+ d="M 18.142653,24.858688 H 62.094499 V 35.908926 H 18.142653 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 17.618576,41.908926 h 45 v 2 h -45 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 17.618576,49.908926 h 45 v 2 h -45 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 17.618576,57.908926 h 45 v 2 h -45 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 17.618576,65.908926 h 45 v 2 h -45 z"
+ />
+ <path
+ className={styles.lines}
+ d="m 41.833105,73.424828 h 20.785471 v 2 H 41.833105 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 41.833105,81.424828 h 20.785471 v 2 H 41.833105 Z"
+ />
+ <path
+ className={styles.lines}
+ d="m 41.833105,89.424828 h 20.785471 v 2 H 41.833105 Z"
+ />
+ </svg>
+ );
+};
+
+export default PostsStack;
diff --git a/src/components/atoms/icons/sun.module.scss b/src/components/atoms/icons/sun.module.scss
new file mode 100644
index 0000000..5682aa3
--- /dev/null
+++ b/src/components/atoms/icons/sun.module.scss
@@ -0,0 +1,8 @@
+@use "@styles/abstracts/functions" as fun;
+
+.sun {
+ fill: var(--color-primary-lighter);
+ stroke: var(--color-primary-darker);
+ stroke-width: 4;
+ width: var(--icon-size, #{fun.convert-px(25)});
+}
diff --git a/src/components/atoms/icons/sun.stories.tsx b/src/components/atoms/icons/sun.stories.tsx
new file mode 100644
index 0000000..23c5b27
--- /dev/null
+++ b/src/components/atoms/icons/sun.stories.tsx
@@ -0,0 +1,41 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SunIcon from './sun';
+
+export default {
+ title: 'Atoms/Icons',
+ component: SunIcon,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The SVG title.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SunIcon>;
+
+const Template: ComponentStory<typeof SunIcon> = (args) => (
+ <SunIcon {...args} />
+);
+
+export const Sun = Template.bind({});
diff --git a/src/components/atoms/icons/sun.test.tsx b/src/components/atoms/icons/sun.test.tsx
new file mode 100644
index 0000000..21661a9
--- /dev/null
+++ b/src/components/atoms/icons/sun.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '@test-utils';
+import Sun from './sun';
+
+describe('Sun', () => {
+ it('renders a sun icon', () => {
+ const { container } = render(<Sun />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/icons/sun.tsx b/src/components/atoms/icons/sun.tsx
new file mode 100644
index 0000000..fa9d922
--- /dev/null
+++ b/src/components/atoms/icons/sun.tsx
@@ -0,0 +1,33 @@
+import { VFC } from 'react';
+import styles from './sun.module.scss';
+
+type SunProps = {
+ /**
+ * Set additional classnames to the icon.
+ */
+ className?: string;
+ /**
+ * The SVG title.
+ */
+ title?: string;
+};
+
+/**
+ * Sun component.
+ *
+ * Render a svg sun icon.
+ */
+const Sun: VFC<SunProps> = ({ className = '', title }) => {
+ return (
+ <svg
+ className={`${styles.sun} ${className}`}
+ viewBox="0 0 100 100"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ {title !== 'undefined' && <title>{title}</title>}
+ <path d="M 69.398043,50.000437 A 19.399259,19.399204 0 0 1 49.998784,69.399641 19.399259,19.399204 0 0 1 30.599525,50.000437 19.399259,19.399204 0 0 1 49.998784,30.601234 19.399259,19.399204 0 0 1 69.398043,50.000437 Z m 27.699233,1.125154 c 2.657696,0.0679 1.156196,12.061455 -1.435545,11.463959 L 80.113224,59.000697 c -2.589801,-0.597494 -1.625657,-8.345536 1.032041,-8.278609 z m -18.06653,37.251321 c 1.644087,2.091234 -9.030355,8.610337 -10.126414,6.188346 L 62.331863,80.024585 c -1.096058,-2.423931 5.197062,-6.285342 6.839209,-4.194107 z M 38.611418,97.594444 C 38.02653,100.18909 26.24148,95.916413 27.436475,93.54001 l 7.168026,-14.256474 c 1.194024,-2.376403 8.102101,0.151313 7.517214,2.744986 z M 6.1661563,71.834242 C 3.7916868,73.028262 -0.25499873,61.16274 2.3386824,60.577853 L 17.905618,57.067567 c 2.593681,-0.584886 4.894434,6.403678 2.518995,7.598668 z M 6.146757,30.055146 c -2.3764094,-1.194991 4.46571,-11.714209 6.479353,-9.97798 l 12.090589,10.414462 c 2.014613,1.736229 -1.937017,7.926514 -4.314396,6.731524 z M 38.56777,4.2639045 C 37.982883,1.6682911 50.480855,0.41801247 50.415868,3.0766733 L 50.020123,19.028638 c -0.06596,2.657691 -7.357169,3.394862 -7.943027,0.800218 z m 40.403808,9.1622435 c 1.635357,-2.098023 10.437771,6.872168 8.339742,8.506552 l -12.58818,9.805327 c -2.099,1.634383 -7.192276,-3.626682 -5.557888,-5.724706 z M 97.096306,50.69105 c 2.657696,-0.06596 1.164926,12.462047 -1.425846,11.863582 L 80.122924,58.96578 c -2.590771,-0.597496 -1.636327,-7.814 1.021371,-7.879957 z" />
+ </svg>
+ );
+};
+
+export default Sun;
diff --git a/src/components/atoms/images/logo.module.scss b/src/components/atoms/images/logo.module.scss
new file mode 100644
index 0000000..f802a4b
--- /dev/null
+++ b/src/components/atoms/images/logo.module.scss
@@ -0,0 +1,28 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ width: var(--logo-size, fun.convert-px(100));
+ height: var(--logo-size, fun.convert-px(100));
+ max-width: 100%;
+ max-height: 100%;
+}
+
+.bg-left {
+ fill: var(--color-primary-light);
+}
+
+.bg-right {
+ fill: var(--color-primary-dark);
+}
+
+.letter {
+ fill: var(--color-fg-inverted);
+ stroke: var(--color-primary-darker);
+ stroke-width: 5;
+}
+
+.letter-shadow {
+ fill: var(--color-shadow-darker);
+ stroke: var(--color-shadow-darker);
+ stroke-width: 5;
+}
diff --git a/src/components/atoms/images/logo.stories.tsx b/src/components/atoms/images/logo.stories.tsx
new file mode 100644
index 0000000..fbc7501
--- /dev/null
+++ b/src/components/atoms/images/logo.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LogoComponent from './logo';
+
+export default {
+ title: 'Atoms/Images',
+ component: LogoComponent,
+ argTypes: {
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The SVG title.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof LogoComponent>;
+
+const Template: ComponentStory<typeof LogoComponent> = (args) => (
+ <LogoComponent {...args} />
+);
+
+export const Logo = Template.bind({});
diff --git a/src/components/atoms/images/logo.test.tsx b/src/components/atoms/images/logo.test.tsx
new file mode 100644
index 0000000..3e0d238
--- /dev/null
+++ b/src/components/atoms/images/logo.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Logo from './logo';
+
+describe('Logo', () => {
+ it('renders a logo with a title', () => {
+ render(<Logo title="My title" />);
+ expect(screen.getByTitle('My title')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/images/logo.tsx b/src/components/atoms/images/logo.tsx
new file mode 100644
index 0000000..2e52110
--- /dev/null
+++ b/src/components/atoms/images/logo.tsx
@@ -0,0 +1,46 @@
+import { VFC } from 'react';
+import styles from './logo.module.scss';
+
+type LogoProps = {
+ /**
+ * SVG Image title.
+ */
+ title?: string;
+};
+
+/**
+ * Logo component.
+ *
+ * Render a SVG logo.
+ */
+const Logo: VFC<LogoProps> = ({ title }) => {
+ return (
+ <svg
+ viewBox="0 0 512 512"
+ xmlns="http://www.w3.org/2000/svg"
+ className={styles.wrapper}
+ >
+ {title && <title>{title}</title>}
+ <path className={styles['bg-left']} d="M 0,0 H 506 L 0,506 Z" />
+ <path className={styles['bg-right']} d="M 512,512 H 6 L 512,6 Z" />
+ <path
+ className={styles['letter-shadow']}
+ d="m 66.049088,353.26557 h 57.233082 l 15.4763,-40.00476 h 56.64908 l 15.76831,40.00476 h 57.2331 L 196.28357,165.21398 h -58.10911 z m 80.009522,-79.42552 21.02441,-55.18904 21.02439,55.18904 z"
+ />
+ <path
+ className={styles['letter']}
+ d="m 59.569539,346.78602 h 57.233081 l 15.4763,-40.00476 H 188.928 l 15.76831,40.00476 h 57.2331 L 189.80402,158.73443 h -58.10911 z m 80.009521,-79.42552 21.02441,-55.18904 21.02439,55.18904 z"
+ />
+ <path
+ className={styles['letter-shadow']}
+ d="m 288.84935,353.26557 h 54.89704 v -50.51696 h 40.88078 c 42.04881,0 68.91332,-28.61654 68.91332,-68.32931 0,-38.5447 -21.60841,-69.20532 -67.74528,-69.20532 h -96.94586 z m 54.89704,-92.56578 v -53.437 h 29.78458 c 16.35231,0 23.94446,10.51221 23.94446,27.15651 0,15.47629 -8.46817,26.28049 -25.40449,26.28049 z"
+ />
+ <path
+ className={styles['letter']}
+ d="m 282.3698,346.78602 h 54.89704 v -50.51696 h 40.88078 c 42.04881,0 68.91332,-28.61654 68.91332,-68.3293 0,-38.54471 -21.60841,-69.20533 -67.74528,-69.20533 H 282.3698 Z m 54.89704,-92.56578 v -53.437 h 29.78458 c 16.35231,0 23.94446,10.51221 23.94446,27.15652 0,15.47628 -8.46817,26.28048 -25.40449,26.28048 z"
+ />
+ </svg>
+ );
+};
+
+export default Logo;
diff --git a/src/components/atoms/layout/copyright.module.scss b/src/components/atoms/layout/copyright.module.scss
new file mode 100644
index 0000000..a0e5347
--- /dev/null
+++ b/src/components/atoms/layout/copyright.module.scss
@@ -0,0 +1,32 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ --icon-size: #{fun.convert-px(70)};
+
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ place-content: center;
+ gap: var(--spacing-2xs);
+ margin: 0;
+ font-family: var(--font-family-secondary);
+ font-size: var(--font-size-md);
+ text-align: center;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ text-align: left;
+ }
+ }
+}
+
+.owner {
+ flex: 1 0 100%;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ flex: initial;
+ }
+ }
+}
diff --git a/src/components/atoms/layout/copyright.stories.tsx b/src/components/atoms/layout/copyright.stories.tsx
new file mode 100644
index 0000000..3b315fa
--- /dev/null
+++ b/src/components/atoms/layout/copyright.stories.tsx
@@ -0,0 +1,55 @@
+import CCBySA from '@components/atoms/icons/cc-by-sa';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import CopyrightComponent from './copyright';
+
+export default {
+ title: 'Atoms/Layout',
+ component: CopyrightComponent,
+ argTypes: {
+ dates: {
+ description: 'The copyright dates.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ icon: {
+ control: {
+ type: null,
+ },
+ description: 'The copyright icon.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ owner: {
+ control: {
+ type: 'text',
+ },
+ description: 'The copyright owner',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CopyrightComponent>;
+
+const Template: ComponentStory<typeof CopyrightComponent> = (args) => (
+ <IntlProvider locale="en">
+ <CopyrightComponent {...args} />
+ </IntlProvider>
+);
+
+export const Copyright = Template.bind({});
+Copyright.args = {
+ dates: {
+ start: '2012',
+ end: '2022',
+ },
+ icon: <CCBySA />,
+ owner: 'Your name',
+};
diff --git a/src/components/atoms/layout/copyright.test.tsx b/src/components/atoms/layout/copyright.test.tsx
new file mode 100644
index 0000000..6bfe612
--- /dev/null
+++ b/src/components/atoms/layout/copyright.test.tsx
@@ -0,0 +1,32 @@
+import CCBySA from '@components/atoms/icons/cc-by-sa';
+import { render, screen } from '@test-utils';
+import Copyright from './copyright';
+
+const dates = {
+ start: '2012',
+ end: '2022',
+};
+const icon = <CCBySA />;
+const owner = 'Your name';
+
+describe('Copyright', () => {
+ it('renders the copyright owner', () => {
+ render(<Copyright dates={dates} icon={icon} owner={owner} />);
+ expect(screen.getByText(owner)).toBeInTheDocument();
+ });
+
+ it('renders the copyright start date', () => {
+ render(<Copyright dates={dates} icon={icon} owner={owner} />);
+ expect(screen.getByText(dates.start)).toBeInTheDocument();
+ });
+
+ it('renders the copyright end date', () => {
+ render(<Copyright dates={dates} icon={icon} owner={owner} />);
+ expect(screen.getByText(dates.end)).toBeInTheDocument();
+ });
+
+ it('renders the copyright icon', () => {
+ render(<Copyright dates={dates} icon={icon} owner={owner} />);
+ expect(screen.getByTitle('CC BY SA')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/copyright.tsx b/src/components/atoms/layout/copyright.tsx
new file mode 100644
index 0000000..76252ee
--- /dev/null
+++ b/src/components/atoms/layout/copyright.tsx
@@ -0,0 +1,59 @@
+import { ReactNode, VFC } from 'react';
+import styles from './copyright.module.scss';
+
+export type CopyrightDates = {
+ /**
+ * The copyright start year.
+ */
+ start: string;
+ /**
+ * The copyright end year.
+ */
+ end?: string;
+};
+
+export type CopyrightProps = {
+ /**
+ * The copyright owner.
+ */
+ owner: string;
+ /**
+ * The copyright dates.
+ */
+ dates: CopyrightDates;
+ /**
+ * The copyright icon.
+ */
+ icon: ReactNode;
+};
+
+/**
+ * Copyright component
+ *
+ * Renders a copyright information (owner, dates, license icon).
+ */
+const Copyright: VFC<CopyrightProps> = ({ owner, dates, icon }) => {
+ const getFormattedDate = (date: string) => {
+ const datetime = new Date(date).toISOString();
+
+ return <time dateTime={datetime}>{date}</time>;
+ };
+
+ return (
+ <div className={styles.wrapper}>
+ <span className={styles.owner}>{owner}</span>
+ {icon}
+ {getFormattedDate(dates.start)}
+ {dates.end ? (
+ <>
+ <span>-</span>
+ {getFormattedDate(dates.end)}
+ </>
+ ) : (
+ ''
+ )}
+ </div>
+ );
+};
+
+export default Copyright;
diff --git a/src/components/atoms/layout/main.stories.tsx b/src/components/atoms/layout/main.stories.tsx
new file mode 100644
index 0000000..64df890
--- /dev/null
+++ b/src/components/atoms/layout/main.stories.tsx
@@ -0,0 +1,52 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import MainComponent from './main';
+
+export default {
+ title: 'Atoms/Layout',
+ component: MainComponent,
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The content.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the main element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ id: {
+ control: {
+ type: 'text',
+ },
+ description: 'The main element id.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MainComponent>;
+
+const Template: ComponentStory<typeof MainComponent> = (args) => (
+ <MainComponent {...args} />
+);
+
+export const Main = Template.bind({});
+Main.args = {
+ children: 'The main content.',
+ id: '#main',
+};
diff --git a/src/components/atoms/layout/main.test.tsx b/src/components/atoms/layout/main.test.tsx
new file mode 100644
index 0000000..f91846f
--- /dev/null
+++ b/src/components/atoms/layout/main.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@test-utils';
+import Main from './main';
+
+const id = 'main';
+const children = 'The main content.';
+
+describe('Main', () => {
+ it('renders the content of main element', () => {
+ render(<Main id={id}>{children}</Main>);
+ expect(screen.getByRole('main')).toHaveTextContent(children);
+ });
+});
diff --git a/src/components/atoms/layout/main.tsx b/src/components/atoms/layout/main.tsx
new file mode 100644
index 0000000..4549328
--- /dev/null
+++ b/src/components/atoms/layout/main.tsx
@@ -0,0 +1,23 @@
+import { FC } from 'react';
+
+export type MainProps = {
+ /**
+ * Set additional classnames to the main element.
+ */
+ className?: string;
+ /**
+ * The main wrapper id.
+ */
+ id: string;
+};
+
+/**
+ * Main component
+ *
+ * Render a main element.
+ */
+const Main: FC<MainProps> = ({ children, ...props }) => {
+ return <main {...props}>{children}</main>;
+};
+
+export default Main;
diff --git a/src/components/atoms/layout/no-script.module.scss b/src/components/atoms/layout/no-script.module.scss
new file mode 100644
index 0000000..d8712af
--- /dev/null
+++ b/src/components/atoms/layout/no-script.module.scss
@@ -0,0 +1,19 @@
+@use "@styles/abstracts/functions" as fun;
+
+.noscript {
+ color: var(--color-primary-darker);
+
+ &--top {
+ padding: var(--spacing-xs) var(--spacing-sm);
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 10;
+ background: var(--color-bg);
+ border-bottom: fun.convert-px(3) solid var(--color-border);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ text-align: center;
+ }
+}
diff --git a/src/components/atoms/layout/no-script.stories.tsx b/src/components/atoms/layout/no-script.stories.tsx
new file mode 100644
index 0000000..474e2fb
--- /dev/null
+++ b/src/components/atoms/layout/no-script.stories.tsx
@@ -0,0 +1,46 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoScriptComponent from './no-script';
+
+export default {
+ title: 'Atoms/Layout',
+ component: NoScriptComponent,
+ args: {
+ position: 'initial',
+ },
+ argTypes: {
+ message: {
+ control: {
+ type: 'text',
+ },
+ description: 'A message to display when Javascript is disabled.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ position: {
+ control: {
+ type: 'select',
+ },
+ description: 'The message position.',
+ options: ['initial', 'top'],
+ table: {
+ category: 'Options',
+ defaultValue: 'initial',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof NoScriptComponent>;
+
+const Template: ComponentStory<typeof NoScriptComponent> = (args) => (
+ <NoScriptComponent {...args} />
+);
+
+export const NoScript = Template.bind({});
+NoScript.args = {
+ message: 'A noscript only message.',
+};
diff --git a/src/components/atoms/layout/no-script.test.tsx b/src/components/atoms/layout/no-script.test.tsx
new file mode 100644
index 0000000..9ed9c4c
--- /dev/null
+++ b/src/components/atoms/layout/no-script.test.tsx
@@ -0,0 +1,11 @@
+import { render, screen } from '@test-utils';
+import NoScript from './no-script';
+
+const message = 'A noscript message.';
+
+describe('NoScript', () => {
+ it('renders a message', () => {
+ render(<NoScript message={message} />);
+ expect(screen.getByText(message)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/no-script.tsx b/src/components/atoms/layout/no-script.tsx
new file mode 100644
index 0000000..6358cf8
--- /dev/null
+++ b/src/components/atoms/layout/no-script.tsx
@@ -0,0 +1,21 @@
+import { VFC } from 'react';
+import styles from './no-script.module.scss';
+
+export type NoScriptProps = {
+ /**
+ * The noscript message.
+ */
+ message: string;
+ /**
+ * The message position. Default: initial.
+ */
+ position?: 'initial' | 'top';
+};
+
+const NoScript: VFC<NoScriptProps> = ({ message, position = 'initial' }) => {
+ const positionClass = styles[`noscript--${position}`];
+
+ return <div className={`${styles.noscript} ${positionClass}`}>{message}</div>;
+};
+
+export default NoScript;
diff --git a/src/components/atoms/layout/notice.module.scss b/src/components/atoms/layout/notice.module.scss
new file mode 100644
index 0000000..38ec7ee
--- /dev/null
+++ b/src/components/atoms/layout/notice.module.scss
@@ -0,0 +1,28 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ width: max-content;
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ border: fun.convert-px(2) solid;
+ font-weight: bold;
+
+ &--error {
+ border-color: var(--color-token-red);
+ color: var(--color-token-red);
+ }
+
+ &--info {
+ border-color: var(--color-token-blue);
+ color: var(--color-token-blue);
+ }
+
+ &--success {
+ border-color: var(--color-token-green);
+ color: var(--color-token-green);
+ }
+
+ &--warning {
+ border-color: var(--color-token-orange);
+ color: var(--color-token-orange);
+ }
+}
diff --git a/src/components/atoms/layout/notice.stories.tsx b/src/components/atoms/layout/notice.stories.tsx
new file mode 100644
index 0000000..0555a2e
--- /dev/null
+++ b/src/components/atoms/layout/notice.stories.tsx
@@ -0,0 +1,40 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoticeComponent from './notice';
+
+export default {
+ title: 'Atoms/Layout',
+ component: NoticeComponent,
+ argTypes: {
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The notice kind.',
+ options: ['error', 'info', 'success', 'warning'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ message: {
+ control: {
+ type: 'text',
+ },
+ description: 'The notice body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof NoticeComponent>;
+
+const Template: ComponentStory<typeof NoticeComponent> = (args) => (
+ <NoticeComponent {...args} />
+);
+
+export const Notice = Template.bind({});
+Notice.args = {
+ kind: 'info',
+ message: 'Nisi provident sapiente.',
+};
diff --git a/src/components/atoms/layout/notice.test.tsx b/src/components/atoms/layout/notice.test.tsx
new file mode 100644
index 0000000..4501f8f
--- /dev/null
+++ b/src/components/atoms/layout/notice.test.tsx
@@ -0,0 +1,11 @@
+import { render, screen } from '@test-utils';
+import Notice from './notice';
+
+const message = 'Tenetur consequuntur tempore.';
+
+describe('Notice', () => {
+ it('renders a message', () => {
+ render(<Notice kind="info" message={message} />);
+ expect(screen.getByText(message)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/notice.tsx b/src/components/atoms/layout/notice.tsx
new file mode 100644
index 0000000..b6e09c5
--- /dev/null
+++ b/src/components/atoms/layout/notice.tsx
@@ -0,0 +1,30 @@
+import { VFC } from 'react';
+import styles from './notice.module.scss';
+
+export type NoticeKind = 'error' | 'info' | 'success' | 'warning';
+
+export type NoticeProps = {
+ /**
+ * The notice kind.
+ */
+ kind: NoticeKind;
+ /**
+ * The notice body.
+ */
+ message: string;
+};
+
+/**
+ * Notice component
+ *
+ * Render a colored message depending on notice kind.
+ */
+const Notice: VFC<NoticeProps> = ({ kind, message }) => {
+ const kindClass = `wrapper--${kind}`;
+
+ return (
+ <div className={`${styles.wrapper} ${styles[kindClass]}`}>{message}</div>
+ );
+};
+
+export default Notice;
diff --git a/src/components/atoms/layout/section.module.scss b/src/components/atoms/layout/section.module.scss
new file mode 100644
index 0000000..012493a
--- /dev/null
+++ b/src/components/atoms/layout/section.module.scss
@@ -0,0 +1,25 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ @extend %grid;
+
+ padding: var(--spacing-md) 0;
+
+ &--borders {
+ border-bottom: fun.convert-px(1) solid var(--color-border);
+ }
+
+ &--dark {
+ background: var(--color-bg-secondary);
+ }
+
+ &--light {
+ background: var(--color-bg);
+ }
+}
+
+.body,
+.title {
+ grid-column: 2;
+}
diff --git a/src/components/atoms/layout/section.stories.tsx b/src/components/atoms/layout/section.stories.tsx
new file mode 100644
index 0000000..abbbeed
--- /dev/null
+++ b/src/components/atoms/layout/section.stories.tsx
@@ -0,0 +1,85 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SectionComponent from './section';
+
+export default {
+ title: 'Atoms/Layout',
+ component: SectionComponent,
+ args: {
+ variant: 'dark',
+ withBorder: true,
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the section element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ content: {
+ control: {
+ type: 'text',
+ },
+ description: 'The section content.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The section title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ variant: {
+ control: {
+ type: 'select',
+ },
+ description: 'The section variant.',
+ options: ['light', 'dark'],
+ table: {
+ category: 'Styles',
+ defaultValue: { summary: 'dark' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ withBorder: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Add a border at the bottom of the section.',
+ table: {
+ category: 'Styles',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SectionComponent>;
+
+const Template: ComponentStory<typeof SectionComponent> = (args) => (
+ <SectionComponent {...args} />
+);
+
+export const Section = Template.bind({});
+Section.args = {
+ title: 'A title',
+ content: 'The content.',
+};
diff --git a/src/components/atoms/layout/section.test.tsx b/src/components/atoms/layout/section.test.tsx
new file mode 100644
index 0000000..ca5f03a
--- /dev/null
+++ b/src/components/atoms/layout/section.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@test-utils';
+import Section from './section';
+
+const title = 'Section title';
+const content = 'Section content.';
+
+describe('Section', () => {
+ it('renders a title (h2)', () => {
+ render(<Section title={title} content={content} />);
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(title);
+ });
+
+ it('renders a content', () => {
+ render(<Section title={title} content={content} />);
+ expect(screen.getByText(content)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/layout/section.tsx b/src/components/atoms/layout/section.tsx
new file mode 100644
index 0000000..f1bbb34
--- /dev/null
+++ b/src/components/atoms/layout/section.tsx
@@ -0,0 +1,57 @@
+import { ReactNode, VFC } from 'react';
+import Heading from '../headings/heading';
+import styles from './section.module.scss';
+
+export type SectionVariant = 'dark' | 'light';
+
+export type SectionProps = {
+ /**
+ * Set additional classnames to the section element.
+ */
+ className?: string;
+ /**
+ * The section content.
+ */
+ content: ReactNode;
+ /**
+ * The section title.
+ */
+ title: string;
+ /**
+ * The section variant.
+ */
+ variant?: SectionVariant;
+ /**
+ * Add a border at the bottom of the section. Default: true.
+ */
+ withBorder?: boolean;
+};
+
+/**
+ * Section component
+ *
+ * Render a section element.
+ */
+const Section: VFC<SectionProps> = ({
+ className = '',
+ content,
+ title,
+ variant = 'dark',
+ withBorder = true,
+}) => {
+ const borderClass = withBorder ? styles[`wrapper--borders`] : '';
+ const variantClass = styles[`wrapper--${variant}`];
+
+ return (
+ <section
+ className={`${styles.wrapper} ${borderClass} ${variantClass} ${className}`}
+ >
+ <Heading level={2} className={styles.title}>
+ {title}
+ </Heading>
+ <div className={styles.body}>{content}</div>
+ </section>
+ );
+};
+
+export default Section;
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
new file mode 100644
index 0000000..e7ead86
--- /dev/null
+++ b/src/components/atoms/links/link.module.scss
@@ -0,0 +1,37 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/variables" as var;
+
+.link {
+ &[hreflang] {
+ &::after {
+ display: inline-block;
+ content: "\0000a0["attr(hreflang) "]";
+ font-size: var(--font-size-sm);
+ }
+ }
+
+ &--external {
+ &::after {
+ display: inline-block;
+ content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+ }
+
+ &:focus:not(:active)::after {
+ content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+ }
+
+ &[hreflang] {
+ &::after {
+ content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
+ '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
+ ));
+ }
+
+ &:focus:not(:active)::after {
+ content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
+ '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
+ ));
+ }
+ }
+ }
+}
diff --git a/src/components/atoms/links/link.stories.tsx b/src/components/atoms/links/link.stories.tsx
new file mode 100644
index 0000000..569c874
--- /dev/null
+++ b/src/components/atoms/links/link.stories.tsx
@@ -0,0 +1,79 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LinkComponent from './link';
+
+export default {
+ title: 'Atoms/Links',
+ component: LinkComponent,
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ external: {
+ control: {
+ type: 'boolean',
+ },
+ table: {
+ category: 'Options',
+ },
+ description: 'Determine if the link is external of the current website.',
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ href: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link target.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ lang: {
+ control: {
+ type: 'text',
+ },
+ table: {
+ category: 'Options',
+ },
+ description: 'The target language as code language.',
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof LinkComponent>;
+
+const Template: ComponentStory<typeof LinkComponent> = (args) => (
+ <LinkComponent {...args} />
+);
+
+export const Link = Template.bind({});
+Link.args = {
+ children: 'A link',
+ href: '#',
+ external: false,
+};
diff --git a/src/components/atoms/links/link.test.tsx b/src/components/atoms/links/link.test.tsx
new file mode 100644
index 0000000..54e2414
--- /dev/null
+++ b/src/components/atoms/links/link.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import Link from './link';
+
+describe('Link', () => {
+ it('render a link', () => {
+ render(<Link href="#">A link</Link>);
+ expect(screen.getByRole('link')).toHaveTextContent('A link');
+ });
+});
diff --git a/src/components/atoms/links/link.tsx b/src/components/atoms/links/link.tsx
new file mode 100644
index 0000000..87f11fc
--- /dev/null
+++ b/src/components/atoms/links/link.tsx
@@ -0,0 +1,51 @@
+import NextLink from 'next/link';
+import { FC } from 'react';
+import styles from './link.module.scss';
+
+export type LinkProps = {
+ /**
+ * Set additional classnames to the link.
+ */
+ className?: string;
+ /**
+ * True if it is an external link. Default: false.
+ */
+ external?: boolean;
+ /**
+ * The link target.
+ */
+ href: string;
+ /**
+ * The link target code language.
+ */
+ lang?: string;
+};
+
+/**
+ * Link Component
+ *
+ * Render a link.
+ */
+const Link: FC<LinkProps> = ({
+ children,
+ className = '',
+ href,
+ lang,
+ external = false,
+}) => {
+ return external ? (
+ <a
+ href={href}
+ hrefLang={lang}
+ className={`${styles.link} ${styles['link--external']} ${className}`}
+ >
+ {children}
+ </a>
+ ) : (
+ <NextLink href={href}>
+ <a className={`${styles.link} ${className}`}>{children}</a>
+ </NextLink>
+ );
+};
+
+export default Link;
diff --git a/src/components/atoms/links/nav-link.module.scss b/src/components/atoms/links/nav-link.module.scss
new file mode 100644
index 0000000..241c9c3
--- /dev/null
+++ b/src/components/atoms/links/nav-link.module.scss
@@ -0,0 +1,46 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.link {
+ --draw-border-thickness: #{fun.convert-px(4)};
+ --draw-border-color1: var(--color-primary-light);
+ --draw-border-color2: var(--color-primary-lighter);
+ --icon-size: #{fun.convert-px(30)};
+
+ display: inline-flex;
+ flex-flow: column nowrap;
+ place-items: center;
+ place-content: center;
+ row-gap: var(--spacing-2xs);
+ min-width: var(--link-min-width, fun.convert-px(85));
+ padding: var(--spacing-xs);
+ background: inherit;
+ font-size: var(--font-size-sm);
+ font-variant: small-caps;
+ font-weight: 600;
+ line-height: 1;
+ text-decoration: none;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ border-radius: 8%;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ @extend %draw-borders;
+ }
+
+ &:focus {
+ color: var(--color-primary-light);
+ }
+
+ &:active {
+ --draw-border-color1: var(--color-primary-dark);
+ --draw-border-color2: var(--color-primary-light);
+
+ @extend %draw-borders;
+ }
+}
diff --git a/src/components/atoms/links/nav-link.stories.tsx b/src/components/atoms/links/nav-link.stories.tsx
new file mode 100644
index 0000000..08553be
--- /dev/null
+++ b/src/components/atoms/links/nav-link.stories.tsx
@@ -0,0 +1,49 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NavLinkComponent from './nav-link';
+
+export default {
+ title: 'Atoms/Links',
+ component: NavLinkComponent,
+ argTypes: {
+ href: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link target.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'The link label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ logo: {
+ control: {
+ type: null,
+ },
+ description: 'The link logo.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof NavLinkComponent>;
+
+const Template: ComponentStory<typeof NavLinkComponent> = (args) => (
+ <NavLinkComponent {...args} />
+);
+
+export const NavLink = Template.bind({});
+NavLink.args = {
+ href: '#',
+ label: 'A nav link',
+};
diff --git a/src/components/atoms/links/nav-link.test.tsx b/src/components/atoms/links/nav-link.test.tsx
new file mode 100644
index 0000000..7750cee
--- /dev/null
+++ b/src/components/atoms/links/nav-link.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@test-utils';
+import NavLink from './nav-link';
+
+describe('NavLink', () => {
+ it('renders a nav link to blog page', () => {
+ render(<NavLink href="/blog" label="Blog" />);
+ expect(screen.getByRole('link', { name: 'Blog' })).toHaveAttribute(
+ 'href',
+ '/blog'
+ );
+ });
+});
diff --git a/src/components/atoms/links/nav-link.tsx b/src/components/atoms/links/nav-link.tsx
new file mode 100644
index 0000000..25c0e7d
--- /dev/null
+++ b/src/components/atoms/links/nav-link.tsx
@@ -0,0 +1,36 @@
+import Link from 'next/link';
+import { VFC, ReactNode } from 'react';
+import styles from './nav-link.module.scss';
+
+export type NavLinkProps = {
+ /**
+ * Link target.
+ */
+ href: string;
+ /**
+ * Link label.
+ */
+ label: string;
+ /**
+ * Link logo.
+ */
+ logo?: ReactNode;
+};
+
+/**
+ * NavLink component
+ *
+ * Render a navigation link.
+ */
+const NavLink: VFC<NavLinkProps> = ({ href, label, logo }) => {
+ return (
+ <Link href={href}>
+ <a className={styles.link}>
+ {logo}
+ {label}
+ </a>
+ </Link>
+ );
+};
+
+export default NavLink;
diff --git a/src/components/atoms/links/sharing-link.module.scss b/src/components/atoms/links/sharing-link.module.scss
new file mode 100644
index 0000000..26ca737
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.module.scss
@@ -0,0 +1,157 @@
+@use "@styles/abstracts/functions" as fun;
+
+.link {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ border-radius: fun.convert-px(3);
+
+ &:hover,
+ &:focus {
+ transform: translateX(#{fun.convert-px(-3)})
+ translateY(#{fun.convert-px(-3)});
+ }
+
+ &:active {
+ transform: translateX(#{fun.convert-px(2)}) translateY(#{fun.convert-px(2)});
+ }
+
+ &::before {
+ content: "";
+ display: block;
+ width: fun.convert-px(30);
+ height: fun.convert-px(30);
+ background-repeat: no-repeat;
+ filter: drop-shadow(
+ #{fun.convert-px(1)} #{fun.convert-px(1)} #{fun.convert-px(1)} hsl(0, 0%, 0%)
+ );
+ }
+
+ &--diaspora {
+ background: hsl(0, 0%, 13%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(0, 0%, 3%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 hsl(0, 0%, 3%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 hsl(0, 0%, 3%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path style="fill:#ffffff;" d="M15.257 21.928l-2.33-3.255c-.622-.87-1.128-1.549-1.155-1.55-.027 0-1.007 1.317-2.317 3.115-1.248 1.713-2.28 3.115-2.292 3.115-.035 0-4.5-3.145-4.51-3.178-.006-.016 1.003-1.497 2.242-3.292 1.239-1.794 2.252-3.29 2.252-3.325 0-.056-.401-.197-3.55-1.247a1604.93 1604.93 0 01-3.593-1.2c-.033-.013.153-.635.79-2.648.46-1.446.845-2.642.857-2.656.013-.015 1.71.528 3.772 1.207 2.062.678 3.766 1.233 3.787 1.233.021 0 .045-.032.053-.07.008-.039.026-1.794.04-3.902.013-2.107.036-3.848.05-3.87.02-.03.599-.038 2.725-.038 1.485 0 2.716.01 2.735.023.023.016.064 1.175.132 3.776.112 4.273.115 4.33.183 4.33.026 0 1.66-.547 3.631-1.216 1.97-.668 3.593-1.204 3.605-1.191.04.045 1.656 5.307 1.636 5.327-.011.01-1.656.574-3.655 1.252-2.75.932-3.638 1.244-3.645 1.284-.006.029.94 1.442 2.143 3.202 1.184 1.733 2.148 3.164 2.143 3.18-.012.036-4.442 3.299-4.48 3.299-.015 0-.577-.767-1.249-1.705z"/></svg>'
+ ));
+ }
+ }
+
+ &--email {
+ background: hsl(0, 0%, 44%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(0, 0%, 34%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0 hsl(0, 0%, 34%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0 hsl(0, 0%, 34%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M15.909 12.123L24 17.238V6.792zM0 6.792v10.446l8.091-5.115zM22.5 3.75h-21c-.748 0-1.343.558-1.455 1.276L12 12.904l11.955-7.877c-.112-.718-.706-1.276-1.455-1.276zm-7.965 9.279l-2.123 1.398a.75.75 0 01-.825 0l-2.122-1.4-9.417 5.957c.116.712.707 1.266 1.452 1.266h21c.746 0 1.337-.553 1.452-1.266z"/></svg>'
+ ));
+ }
+ }
+
+ &--facebook {
+ background: hsl(214, 89%, 52%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(214, 89%, 42%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0
+ hsl(214, 89%, 42%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0
+ hsl(214, 89%, 42%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'
+ ));
+ }
+ }
+
+ &--journal-du-hacker {
+ background: hsl(210, 24%, 51%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(210, 24%, 41%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0
+ hsl(210, 24%, 41%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0
+ hsl(210, 24%, 41%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M17.822 23.297a6.644 6.644 0 00-.654.032c-1.104.1-2.451-.378-3.244-1.15a3.223 3.223 0 01-.52-.739c-.209-.425-.22-.489-.211-1.178a8.174 8.174 0 01.19-1.585c.243-1.151.155-1.449-.514-1.737-.4-.172-.632-.135-1 .16-.268.215-.28.463-.07 1.532.298 1.526.286 2.238-.05 2.907-.28.56-.443.703-1.287 1.133-1.005.513-1.461.638-2.332.638-.73 0-1.014-.082-1.276-.366-.134-.145-.148-.2-.085-.32.099-.184.329-.3.959-.488.277-.082.604-.236.727-.341.123-.105.329-.265.457-.354.32-.222.562-.761.563-1.254 0-.331-.188-1.034-.45-1.676-.138-.338-.38.085-.38.666 0 .434-.673 1.569-.93 1.569-.048 0-.288.101-.532.225-.43.219-.47.225-1.31.225-.815 0-.889-.011-1.235-.194-.42-.22-.902-.694-1.094-1.073a2.752 2.752 0 00-.227-.377c-.083-.102-.08-.143.018-.293.206-.314.473-.317 1.186-.011.583.25 1.22.215 1.582-.086.168-.139.325-.697.342-1.217.02-.598-.049-.66-.596-.528-.86.206-1.762-.084-2.76-.887-.916-.739-1.362-.845-2.241-.538-.262.092-.51.153-.552.137-.042-.016-.134-.136-.204-.268-.118-.218-.12-.252-.02-.403.156-.24.714-.573 1.185-.708.297-.086.588-.11 1.076-.09.655.026.687.035 1.567.458.54.259.99.43 1.127.43.27 0 1.014-.37 1.159-.577.167-.238.124-.34-.322-.776-1.19-1.16-1.943-2.608-2.24-4.31-.124-.702-.14-1.888-.035-2.483.116-.656.677-2.273.915-2.64.385-.59 1.823-1.965 2.585-2.469C9.187.905 11.43.395 13.715.785c2.457.42 4.507 1.61 5.849 3.394 1.062 1.414 1.554 2.859 1.553 4.57 0 1.778-.497 3.238-1.599 4.693a6.207 6.207 0 00-.34.476c0 .013.205.12.456.238.737.345 1.169.844 1.726 1.994.256.527.531 1.031.613 1.12.225.247.614.42 1.099.49.588.085.804.178.9.388.109.24-.111.55-.402.563-.11.005-.394.033-.63.062-.887.107-1.851-.251-2.416-.898-.17-.193-.503-.616-.74-.939-.455-.616-.818-.922-1.054-.888-.117.017-.14.066-.127.28.008.142.068.34.133.438.09.137.127.412.161 1.196.05 1.153.147 1.458.55 1.726.306.204.552.198 1.11-.025.581-.233.923-.238 1.159-.018.243.227.2.637-.11 1.026-.33.419-1.338.899-2.001.954-1.194.1-2.371-.602-2.828-1.686-.062-.147-.197-.61-.301-1.03-.12-.486-.221-.762-.28-.762-.109 0-.263.401-.27.705-.003.12-.056.417-.118.657-.328 1.282.307 2.309 1.66 2.684.657.182.808.299.808.623 0 .319-.165.494-.454.481z"/></svg>'
+ ));
+ }
+ }
+
+ &--linkedin {
+ background: hsl(210, 90%, 40%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(210, 90%, 30%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0
+ hsl(210, 90%, 30%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0
+ hsl(210, 90%, 30%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path style="fill:#ffffff;" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'
+ ));
+ }
+ }
+
+ &--twitter {
+ background: hsl(203, 89%, 53%);
+ box-shadow: #{fun.convert-px(3)} #{fun.convert-px(3)} 0 0 hsl(203, 89%, 43%);
+
+ &:hover,
+ &:focus {
+ box-shadow: #{fun.convert-px(6)} #{fun.convert-px(6)} 0 0
+ hsl(203, 89%, 43%);
+ }
+
+ &:active {
+ box-shadow: #{fun.convert-px(1)} #{fun.convert-px(1)} 0 0
+ hsl(203, 89%, 43%);
+ }
+
+ &::before {
+ background-image: url(fun.encode-svg(
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path style="fill:#ffffff;" d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>'
+ ));
+ }
+ }
+}
diff --git a/src/components/atoms/links/sharing-link.stories.tsx b/src/components/atoms/links/sharing-link.stories.tsx
new file mode 100644
index 0000000..335fa50
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.stories.tsx
@@ -0,0 +1,50 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SharingLinkComponent from './sharing-link';
+
+export default {
+ title: 'Atoms/Links',
+ component: SharingLinkComponent,
+ argTypes: {
+ medium: {
+ control: {
+ type: 'select',
+ },
+ description: 'The sharing medium.',
+ options: [
+ 'diaspora',
+ 'email',
+ 'facebook',
+ 'journal-du-hacker',
+ 'linkedin',
+ 'twitter',
+ ],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'The sharing url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SharingLinkComponent>;
+
+const Template: ComponentStory<typeof SharingLinkComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SharingLinkComponent {...args} />
+ </IntlProvider>
+);
+
+export const SharingLink = Template.bind({});
+SharingLink.args = {
+ medium: 'diaspora',
+ url: '#',
+};
diff --git a/src/components/atoms/links/sharing-link.test.tsx b/src/components/atoms/links/sharing-link.test.tsx
new file mode 100644
index 0000000..e4c849c
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@test-utils';
+import SharingLink from './sharing-link';
+
+describe('SharingLink', () => {
+ it('render a Diaspora sharing link', () => {
+ render(<SharingLink medium="diaspora" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on diaspora' })).toHaveClass(
+ 'link--diaspora'
+ );
+ });
+
+ it('render an Email sharing link', () => {
+ render(<SharingLink medium="email" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on email' })).toHaveClass(
+ 'link--email'
+ );
+ });
+
+ it('render a Facebook sharing link', () => {
+ render(<SharingLink medium="facebook" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on facebook' })).toHaveClass(
+ 'link--facebook'
+ );
+ });
+
+ it('render a Journal du Hacker sharing link', () => {
+ render(<SharingLink medium="journal-du-hacker" url="#" />);
+ expect(
+ screen.getByRole('link', { name: 'Share on journal-du-hacker' })
+ ).toHaveClass('link--journal-du-hacker');
+ });
+
+ it('render a LinkedIn sharing link', () => {
+ render(<SharingLink medium="linkedin" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on linkedin' })).toHaveClass(
+ 'link--linkedin'
+ );
+ });
+
+ it('render a Twitter sharing link', () => {
+ render(<SharingLink medium="twitter" url="#" />);
+ expect(screen.getByRole('link', { name: 'Share on twitter' })).toHaveClass(
+ 'link--twitter'
+ );
+ });
+});
diff --git a/src/components/atoms/links/sharing-link.tsx b/src/components/atoms/links/sharing-link.tsx
new file mode 100644
index 0000000..3cd2dd1
--- /dev/null
+++ b/src/components/atoms/links/sharing-link.tsx
@@ -0,0 +1,48 @@
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './sharing-link.module.scss';
+
+export type SharingMedium =
+ | 'diaspora'
+ | 'email'
+ | 'facebook'
+ | 'journal-du-hacker'
+ | 'linkedin'
+ | 'twitter';
+
+export type SharingLinkProps = {
+ /**
+ * The sharing medium id.
+ */
+ medium: SharingMedium;
+ /**
+ * The sharing url.
+ */
+ url: string;
+};
+
+/**
+ * SharingLink component
+ *
+ * Render a sharing link.
+ */
+const SharingLink: VFC<SharingLinkProps> = ({ medium, url }) => {
+ const intl = useIntl();
+ const text = intl.formatMessage(
+ {
+ defaultMessage: 'Share on {name}',
+ description: 'Sharing: share on social network text',
+ id: 'ureXFw',
+ },
+ { name: medium }
+ );
+ const mediumClass = `link--${medium}`;
+
+ return (
+ <a href={url} className={`${styles.link} ${styles[mediumClass]}`}>
+ <span className="screen-reader-text">{text}</span>
+ </a>
+ );
+};
+
+export default SharingLink;
diff --git a/src/components/atoms/links/social-link.module.scss b/src/components/atoms/links/social-link.module.scss
new file mode 100644
index 0000000..02fc61c
--- /dev/null
+++ b/src/components/atoms/links/social-link.module.scss
@@ -0,0 +1,43 @@
+@use "@styles/abstracts/functions" as fun;
+
+.link {
+ display: flex;
+ width: var(--link-size, #{fun.convert-px(60)});
+ height: var(--link-size, #{fun.convert-px(60)});
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow),
+ fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-1)
+ var(--color-shadow),
+ fun.convert-px(3) fun.convert-px(4) fun.convert-px(4) fun.convert-px(-3)
+ var(--color-shadow),
+ 0 0 0 0 var(--color-shadow);
+ transition: all 0.25s linear 0s;
+
+ &:hover,
+ &:focus {
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+ var(--color-shadow),
+ fun.convert-px(1) fun.convert-px(1) fun.convert-px(2) fun.convert-px(-1)
+ var(--color-shadow-light),
+ fun.convert-px(3) fun.convert-px(3) fun.convert-px(4) fun.convert-px(-4)
+ var(--color-shadow-light),
+ fun.convert-px(6) fun.convert-px(6) fun.convert-px(10) fun.convert-px(-3)
+ var(--color-shadow);
+ transform: scale(1.15);
+ }
+
+ &:focus {
+ outline: var(--color-primary) dashed fun.convert-px(2);
+ }
+
+ &:active {
+ box-shadow: 0 0 0 0 var(--color-shadow);
+ outline: none;
+ transform: scale(0.9);
+ }
+}
+
+.icon {
+ max-width: 100%;
+ max-height: 100%;
+}
diff --git a/src/components/atoms/links/social-link.stories.tsx b/src/components/atoms/links/social-link.stories.tsx
new file mode 100644
index 0000000..bd9a364
--- /dev/null
+++ b/src/components/atoms/links/social-link.stories.tsx
@@ -0,0 +1,40 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SocialLinkComponent from './social-link';
+
+export default {
+ title: 'Atoms/Links',
+ component: SocialLinkComponent,
+ argTypes: {
+ name: {
+ control: {
+ type: 'select',
+ },
+ description: 'Social website name.',
+ options: ['Github', 'Gitlab', 'LinkedIn', 'Twitter'],
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ url: {
+ control: {
+ type: null,
+ },
+ description: 'Social profile url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SocialLinkComponent>;
+
+const Template: ComponentStory<typeof SocialLinkComponent> = (args) => (
+ <SocialLinkComponent {...args} />
+);
+
+export const SocialLink = Template.bind({});
+SocialLink.args = {
+ name: 'Github',
+ url: '#',
+};
diff --git a/src/components/atoms/links/social-link.test.tsx b/src/components/atoms/links/social-link.test.tsx
new file mode 100644
index 0000000..f49fb5a
--- /dev/null
+++ b/src/components/atoms/links/social-link.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@test-utils';
+import SocialLink from './social-link';
+
+/**
+ * Next.js mock images to use next/image component. So for now, I need to mock
+ * the svg files manually.
+ */
+jest.mock('@assets/images/social-media/github.svg', () => 'svg-file');
+
+describe('SocialLink', () => {
+ it('render a social link', () => {
+ render(<SocialLink name="Github" url="#" />);
+ expect(screen.getByRole('link')).toHaveAccessibleName('Github');
+ });
+});
diff --git a/src/components/atoms/links/social-link.tsx b/src/components/atoms/links/social-link.tsx
new file mode 100644
index 0000000..8c7c790
--- /dev/null
+++ b/src/components/atoms/links/social-link.tsx
@@ -0,0 +1,53 @@
+import GithubIcon from '@assets/images/social-media/github.svg';
+import GitlabIcon from '@assets/images/social-media/gitlab.svg';
+import LinkedInIcon from '@assets/images/social-media/linkedin.svg';
+import TwitterIcon from '@assets/images/social-media/twitter.svg';
+import { VFC } from 'react';
+import styles from './social-link.module.scss';
+
+export type SocialWebsite = 'Github' | 'Gitlab' | 'LinkedIn' | 'Twitter';
+
+export type SocialLinkProps = {
+ /**
+ * The social website name.
+ */
+ name: SocialWebsite;
+ /**
+ * The social profile url.
+ */
+ url: string;
+};
+
+/**
+ * SocialLink component
+ *
+ * Render a social icon link.
+ */
+const SocialLink: VFC<SocialLinkProps> = ({ name, url }) => {
+ /**
+ * Retrieve a social link icon by id.
+ * @param {string} id - The social website id.
+ */
+ const getIcon = (id: string) => {
+ switch (id) {
+ case 'Github':
+ return <GithubIcon className={styles.icon} aria-hidden="true" />;
+ case 'Gitlab':
+ return <GitlabIcon className={styles.icon} aria-hidden="true" />;
+ case 'LinkedIn':
+ return <LinkedInIcon className={styles.icon} aria-hidden="true" />;
+ case 'Twitter':
+ return <TwitterIcon className={styles.icon} aria-hidden="true" />;
+ default:
+ break;
+ }
+ };
+
+ return (
+ <a href={url} className={styles.link} aria-label={name}>
+ {getIcon(name)}
+ </a>
+ );
+};
+
+export default SocialLink;
diff --git a/src/components/atoms/lists/description-list.module.scss b/src/components/atoms/lists/description-list.module.scss
new file mode 100644
index 0000000..caa2711
--- /dev/null
+++ b/src/components/atoms/lists/description-list.module.scss
@@ -0,0 +1,54 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.list {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-2xs);
+ margin: 0;
+
+ &__term {
+ flex: 0 0 max-content;
+ color: var(--color-fg-light);
+ font-weight: 600;
+ }
+
+ &__description {
+ flex: 0 0 auto;
+ margin: 0;
+ }
+
+ &__item {
+ display: flex;
+ }
+
+ &--inline &__item {
+ flex-flow: column wrap;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ flex-flow: row wrap;
+ gap: var(--spacing-2xs);
+
+ .list__description:not(:first-of-type) {
+ &::before {
+ content: "/";
+ margin-right: var(--spacing-2xs);
+ }
+ }
+ }
+ }
+ }
+
+ &--column#{&}--responsive {
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ flex-flow: row wrap;
+ gap: var(--spacing-lg);
+ }
+ }
+ }
+
+ &--column &__item {
+ flex-flow: column wrap;
+ }
+}
diff --git a/src/components/atoms/lists/description-list.stories.tsx b/src/components/atoms/lists/description-list.stories.tsx
new file mode 100644
index 0000000..66d94af
--- /dev/null
+++ b/src/components/atoms/lists/description-list.stories.tsx
@@ -0,0 +1,73 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import DescriptionListComponent, {
+ DescriptionListItem,
+} from './description-list';
+
+export default {
+ title: 'Atoms/Lists',
+ component: DescriptionListComponent,
+ args: {
+ layout: 'column',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ control: {
+ type: null,
+ },
+ description: 'The list items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ layout: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list layout.',
+ options: ['column', 'inline'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'column' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof DescriptionListComponent>;
+
+const Template: ComponentStory<typeof DescriptionListComponent> = (args) => (
+ <DescriptionListComponent {...args} />
+);
+
+const items: DescriptionListItem[] = [
+ { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] },
+ { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] },
+ {
+ id: 'term-3',
+ term: 'Term 3:',
+ value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'],
+ },
+ { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] },
+];
+
+export const DescriptionList = Template.bind({});
+DescriptionList.args = {
+ items,
+};
diff --git a/src/components/atoms/lists/description-list.test.tsx b/src/components/atoms/lists/description-list.test.tsx
new file mode 100644
index 0000000..d3f7045
--- /dev/null
+++ b/src/components/atoms/lists/description-list.test.tsx
@@ -0,0 +1,20 @@
+import { render } from '@test-utils';
+import DescriptionList, { DescriptionListItem } from './description-list';
+
+const items: DescriptionListItem[] = [
+ { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] },
+ { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] },
+ {
+ id: 'term-3',
+ term: 'Term 3:',
+ value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'],
+ },
+ { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] },
+];
+
+describe('DescriptionList', () => {
+ it('renders a list of terms and description', () => {
+ const { container } = render(<DescriptionList items={items} />);
+ expect(container).toBeDefined();
+ });
+});
diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx
new file mode 100644
index 0000000..0a92465
--- /dev/null
+++ b/src/components/atoms/lists/description-list.tsx
@@ -0,0 +1,100 @@
+import { VFC } from 'react';
+import styles from './description-list.module.scss';
+
+export type DescriptionListItem = {
+ /**
+ * The item id.
+ */
+ id: string;
+ /**
+ * A list term.
+ */
+ term: string;
+ /**
+ * An array of values for the list term.
+ */
+ value: any[];
+};
+
+export type DescriptionListProps = {
+ /**
+ * Set additional classnames to the list wrapper.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the `dd` element.
+ */
+ descriptionClassName?: string;
+ /**
+ * Set additional classnames to the `dt`/`dd` couple wrapper.
+ */
+ groupClassName?: string;
+ /**
+ * The list items.
+ */
+ items: DescriptionListItem[];
+ /**
+ * The list items layout. Default: column.
+ */
+ layout?: 'inline' | 'column';
+ /**
+ * Define if the layout should automatically create rows/columns.
+ */
+ responsiveLayout?: boolean;
+ /**
+ * Set additional classnames to the `dt` element.
+ */
+ termClassName?: string;
+};
+
+/**
+ * DescriptionList component
+ *
+ * Render a description list.
+ */
+const DescriptionList: VFC<DescriptionListProps> = ({
+ className = '',
+ descriptionClassName = '',
+ groupClassName = '',
+ items,
+ layout = 'column',
+ responsiveLayout = false,
+ termClassName = '',
+}) => {
+ const layoutModifier = `list--${layout}`;
+ const responsiveModifier = responsiveLayout ? 'list--responsive' : '';
+
+ /**
+ * Retrieve the description list items wrapped in a div element.
+ *
+ * @param {DescriptionListItem[]} listItems - An array of term and description couples.
+ * @returns {JSX.Element[]} The description list items.
+ */
+ const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => {
+ return listItems.map(({ id, term, value }) => {
+ return (
+ <div key={id} className={`${styles.list__item} ${groupClassName}`}>
+ <dt className={`${styles.list__term} ${termClassName}`}>{term}</dt>
+ {value.map((currentValue, index) => (
+ <dd
+ key={`${id}-${index}`}
+ className={`${styles.list__description} ${descriptionClassName}`}
+ >
+ {currentValue}
+ </dd>
+ ))}
+ </div>
+ );
+ });
+ };
+
+ return (
+ <dl
+ className={`${styles.list} ${styles[layoutModifier]} ${styles[responsiveModifier]} ${className}`}
+ >
+ {getItems(items)}
+ </dl>
+ );
+};
+
+export default DescriptionList;
diff --git a/src/components/atoms/lists/list.module.scss b/src/components/atoms/lists/list.module.scss
new file mode 100644
index 0000000..df3b49c
--- /dev/null
+++ b/src/components/atoms/lists/list.module.scss
@@ -0,0 +1,39 @@
+.list {
+ margin: 0;
+
+ ::marker {
+ color: var(--color-primary-dark);
+ }
+
+ & & {
+ margin-top: var(--spacing-2xs);
+ }
+
+ &--ordered {
+ padding: 0;
+ counter-reset: li;
+ list-style-type: none;
+ }
+
+ &--ordered &__item {
+ display: table;
+ counter-increment: li;
+
+ &::before {
+ content: counters(li, ".") ". ";
+ display: table-cell;
+ padding-right: var(--spacing-2xs);
+ color: var(--color-secondary);
+ }
+ }
+
+ &--unordered {
+ padding: 0 0 0 var(--spacing-sm);
+ }
+
+ &--has-margin &__item {
+ &:not(:last-child) {
+ margin-bottom: var(--spacing-2xs);
+ }
+ }
+}
diff --git a/src/components/atoms/lists/list.stories.tsx b/src/components/atoms/lists/list.stories.tsx
new file mode 100644
index 0000000..30079cb
--- /dev/null
+++ b/src/components/atoms/lists/list.stories.tsx
@@ -0,0 +1,80 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ListComponent, { type ListItem } from './list';
+
+export default {
+ title: 'Atoms/Lists',
+ component: ListComponent,
+ args: {
+ kind: 'unordered',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ control: {
+ type: null,
+ },
+ description: 'The list items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind: ordered or unordered.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ListComponent>;
+
+const Template: ComponentStory<typeof ListComponent> = (args) => (
+ <ListComponent {...args} />
+);
+
+const items: ListItem[] = [
+ { id: 'item-1', value: 'Item 1' },
+ { id: 'item-2', value: 'Item 2' },
+ {
+ child: [
+ { id: 'nested-item-1', value: 'Nested item 1' },
+ { id: 'nested-item-2', value: 'Nested item 2' },
+ ],
+ id: 'item-3',
+ value: 'Item 3',
+ },
+ { id: 'item-4', value: 'Item 4' },
+];
+
+export const Unordered = Template.bind({});
+Unordered.args = {
+ items,
+};
+
+export const Ordered = Template.bind({});
+Ordered.args = {
+ items,
+ kind: 'ordered',
+};
diff --git a/src/components/atoms/lists/list.test.tsx b/src/components/atoms/lists/list.test.tsx
new file mode 100644
index 0000000..fcf8813
--- /dev/null
+++ b/src/components/atoms/lists/list.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@test-utils';
+import List, { type ListItem } from './list';
+
+const items: ListItem[] = [
+ { id: 'item-1', value: 'Item 1' },
+ { id: 'item-2', value: 'Item 2' },
+ {
+ child: [
+ { id: 'nested-item-1', value: 'Nested item 1' },
+ { id: 'nested-item-2', value: 'Nested item 2' },
+ ],
+ id: 'item-3',
+ value: 'Item 3',
+ },
+ { id: 'item-4', value: 'Item 4' },
+];
+
+describe('List', () => {
+ it('renders a nested unordered list', () => {
+ render(<List items={items} />);
+ const listItems = screen.getAllByRole('list');
+ listItems.forEach((listItem) =>
+ expect(listItem).toHaveClass('list--unordered')
+ );
+ });
+});
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
new file mode 100644
index 0000000..d100a31
--- /dev/null
+++ b/src/components/atoms/lists/list.tsx
@@ -0,0 +1,87 @@
+import { VFC } from 'react';
+import styles from './list.module.scss';
+
+export type ListItem = {
+ /**
+ * Nested list.
+ */
+ child?: ListItem[];
+ /**
+ * Item id.
+ */
+ id: string;
+ /**
+ * Item value.
+ */
+ value: any;
+};
+
+export type ListProps = {
+ /**
+ * Set additional classnames to the list wrapper.
+ */
+ className?: string;
+ /**
+ * An array of list items.
+ */
+ items: ListItem[];
+ /**
+ * Set additional classnames to the list items.
+ */
+ itemsClassName?: string;
+ /**
+ * The list kind (ordered or unordered).
+ */
+ kind?: 'ordered' | 'unordered';
+ /**
+ * Set margin between list items. Default: true.
+ */
+ withMargin?: boolean;
+};
+
+/**
+ * List component
+ *
+ * Render either an ordered or an unordered list.
+ */
+const List: VFC<ListProps> = ({
+ className = '',
+ items,
+ itemsClassName = '',
+ kind = 'unordered',
+ withMargin = true,
+}) => {
+ const ListTag = kind === 'ordered' ? 'ol' : 'ul';
+ const kindClass = `list--${kind}`;
+ const marginClass = withMargin ? 'list--has-margin' : 'list--no-margin';
+
+ /**
+ * Retrieve the list items.
+ * @param array - An array of items.
+ * @returns {JSX.Element[]} - An array of li elements.
+ */
+ const getItems = (array: ListItem[]): JSX.Element[] => {
+ return array.map(({ child, id, value }) => (
+ <li key={id} className={`${styles.list__item} ${itemsClassName}`}>
+ {value}
+ {child && (
+ <ListTag
+ className={`${styles.list} ${styles[kindClass]} ${styles[marginClass]} ${className}`}
+ >
+ {getItems(child)}
+ </ListTag>
+ )}
+ </li>
+ ));
+ };
+
+ return (
+ <ListTag
+ className={`${styles.list} ${styles[kindClass]} ${styles[marginClass]} ${className}`}
+ >
+ {getItems(items)}
+ </ListTag>
+ );
+};
+
+export default List;
diff --git a/src/components/atoms/loaders/progress-bar.module.scss b/src/components/atoms/loaders/progress-bar.module.scss
new file mode 100644
index 0000000..166b7c4
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.module.scss
@@ -0,0 +1,43 @@
+@use "@styles/abstracts/functions" as fun;
+
+.progress {
+ width: max-content;
+ margin: var(--spacing-sm) auto var(--spacing-md);
+ text-align: center;
+
+ &__info {
+ margin-bottom: var(--spacing-2xs);
+ font-size: var(--font-size-sm);
+ }
+
+ &__bar[value] {
+ display: block;
+ width: clamp(25ch, 20vw, 30ch);
+ max-width: 100%;
+ height: fun.convert-px(13);
+ appearance: none;
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ border-radius: 1em;
+ box-shadow: inset 0 0 fun.convert-px(4) fun.convert-px(1)
+ var(--color-shadow-light);
+
+ &::-webkit-progress-value {
+ background-color: var(--color-primary-dark);
+ border-radius: 1em;
+ }
+
+ &::-moz-progress-bar {
+ background-color: var(--color-primary-dark);
+ border-radius: 1em;
+ }
+
+ &::-webkit-progress-bar {
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ border-radius: 1em;
+ box-shadow: inset 0 0 fun.convert-px(4) fun.convert-px(1)
+ var(--color-shadow-light);
+ }
+ }
+}
diff --git a/src/components/atoms/loaders/progress-bar.stories.tsx b/src/components/atoms/loaders/progress-bar.stories.tsx
new file mode 100644
index 0000000..4fde5a7
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.stories.tsx
@@ -0,0 +1,76 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ProgressBarComponent from './progress-bar';
+
+export default {
+ title: 'Atoms/Loaders',
+ component: ProgressBarComponent,
+ argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'string',
+ },
+ description: 'An accessible name.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ current: {
+ control: {
+ type: 'number',
+ },
+ description: 'The current value.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ info: {
+ control: {
+ type: 'text',
+ },
+ description: 'An additional information to display.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ max: {
+ control: {
+ type: 'number',
+ },
+ description: 'The maximal value.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ min: {
+ control: {
+ type: 'number',
+ },
+ description: 'The minimal value.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ProgressBarComponent>;
+
+const Template: ComponentStory<typeof ProgressBarComponent> = (args) => (
+ <ProgressBarComponent {...args} />
+);
+
+export const ProgressBar = Template.bind({});
+ProgressBar.args = {
+ current: 10,
+ min: 0,
+ max: 50,
+};
diff --git a/src/components/atoms/loaders/progress-bar.test.tsx b/src/components/atoms/loaders/progress-bar.test.tsx
new file mode 100644
index 0000000..37a7364
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import ProgressBar from './progress-bar';
+
+describe('ProgressBar', () => {
+ it('renders a progress bar', () => {
+ render(<ProgressBar min={0} max={50} current={10} />);
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/loaders/progress-bar.tsx b/src/components/atoms/loaders/progress-bar.tsx
new file mode 100644
index 0000000..1b1ff06
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar.tsx
@@ -0,0 +1,55 @@
+import { VFC } from 'react';
+import styles from './progress-bar.module.scss';
+
+export type ProgressBarProps = {
+ /**
+ * Accessible progress bar name.
+ */
+ 'aria-label'?: string;
+ /**
+ * Current value.
+ */
+ current: number;
+ /**
+ * Additional information to display before progress bar.
+ */
+ info?: string;
+ /**
+ * Minimal value.
+ */
+ min: number;
+ /**
+ * Maximal value.
+ */
+ max: number;
+};
+
+/**
+ * ProgressBar component
+ *
+ * Render a progress bar.
+ */
+const ProgressBar: VFC<ProgressBarProps> = ({
+ current,
+ info,
+ min,
+ max,
+ ...props
+}) => {
+ return (
+ <div className={styles.progress}>
+ {info && <div className={styles.progress__info}>{info}</div>}
+ <progress
+ className={styles.progress__bar}
+ max={max}
+ value={current}
+ aria-valuemin={min}
+ aria-valuemax={max}
+ aria-valuenow={current}
+ {...props}
+ ></progress>
+ </div>
+ );
+};
+
+export default ProgressBar;
diff --git a/src/components/atoms/loaders/spinner.module.scss b/src/components/atoms/loaders/spinner.module.scss
new file mode 100644
index 0000000..8d818a2
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.module.scss
@@ -0,0 +1,48 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-2xs);
+ margin: var(--spacing-md) 0;
+}
+
+.ball {
+ width: fun.convert-px(8);
+ height: fun.convert-px(8);
+ background: linear-gradient(
+ to right,
+ var(--color-primary-light) 0%,
+ var(--color-primary-lighter) 100%
+ );
+ border-radius: 50%;
+ animation: spinner 1.4s infinite ease-in-out both;
+
+ &:first-child {
+ animation-delay: -0.32s;
+ }
+
+ &:nth-child(2) {
+ animation-delay: -0.16s;
+ }
+}
+
+.text {
+ margin-left: var(--spacing-xs);
+ color: var(--color-primary-darker);
+ text-align: center;
+}
+
+@keyframes spinner {
+ 0%,
+ 80%,
+ 100% {
+ transform: scale(0);
+ }
+
+ 40% {
+ transform: scale(1);
+ }
+}
diff --git a/src/components/atoms/loaders/spinner.stories.tsx b/src/components/atoms/loaders/spinner.stories.tsx
new file mode 100644
index 0000000..5006ce4
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SpinnerComponent from './spinner';
+
+export default {
+ title: 'Atoms/Loaders',
+ component: SpinnerComponent,
+ argTypes: {
+ message: {
+ control: {
+ type: 'text',
+ },
+ description: 'Loading message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SpinnerComponent>;
+
+const Template: ComponentStory<typeof SpinnerComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SpinnerComponent {...args} />
+ </IntlProvider>
+);
+
+export const Spinner = Template.bind({});
diff --git a/src/components/atoms/loaders/spinner.test.tsx b/src/components/atoms/loaders/spinner.test.tsx
new file mode 100644
index 0000000..0a6db91
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@test-utils';
+import Spinner from './spinner';
+
+describe('Spinner', () => {
+ it('renders a spinner loader', () => {
+ render(<Spinner />);
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('renders a spinner loader with a custom message', () => {
+ render(<Spinner message="Submitting" />);
+ expect(screen.getByText('Submitting')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/atoms/loaders/spinner.tsx b/src/components/atoms/loaders/spinner.tsx
new file mode 100644
index 0000000..bff0f25
--- /dev/null
+++ b/src/components/atoms/loaders/spinner.tsx
@@ -0,0 +1,37 @@
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './spinner.module.scss';
+
+export type SpinnerProps = {
+ /**
+ * The loading message. Default: "Loading...".
+ */
+ message?: string;
+};
+
+/**
+ * Spinner component
+ *
+ * Render a loading message with animation.
+ */
+const Spinner: VFC<SpinnerProps> = ({ message }) => {
+ const intl = useIntl();
+
+ return (
+ <div className={styles.wrapper}>
+ <div className={styles.ball}></div>
+ <div className={styles.ball}></div>
+ <div className={styles.ball}></div>
+ <div className={styles.text}>
+ {message ||
+ intl.formatMessage({
+ defaultMessage: 'Loading...',
+ description: 'Spinner: loading text',
+ id: 'q9cJQe',
+ })}
+ </div>
+ </div>
+ );
+};
+
+export default Spinner;