summaryrefslogtreecommitdiffstats
path: root/src/components/organisms
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/organisms')
-rw-r--r--src/components/organisms/forms/comment-form.module.scss8
-rw-r--r--src/components/organisms/forms/comment-form.stories.tsx86
-rw-r--r--src/components/organisms/forms/comment-form.test.tsx20
-rw-r--r--src/components/organisms/forms/comment-form.tsx174
-rw-r--r--src/components/organisms/forms/contact-form.module.scss8
-rw-r--r--src/components/organisms/forms/contact-form.stories.tsx59
-rw-r--r--src/components/organisms/forms/contact-form.test.tsx46
-rw-r--r--src/components/organisms/forms/contact-form.tsx150
-rw-r--r--src/components/organisms/forms/search-form.module.scss58
-rw-r--r--src/components/organisms/forms/search-form.stories.tsx34
-rw-r--r--src/components/organisms/forms/search-form.test.tsx16
-rw-r--r--src/components/organisms/forms/search-form.tsx57
-rw-r--r--src/components/organisms/layout/cards-list.module.scss27
-rw-r--r--src/components/organisms/layout/cards-list.stories.tsx105
-rw-r--r--src/components/organisms/layout/cards-list.test.tsx61
-rw-r--r--src/components/organisms/layout/cards-list.tsx80
-rw-r--r--src/components/organisms/layout/footer.module.scss41
-rw-r--r--src/components/organisms/layout/footer.stories.tsx74
-rw-r--r--src/components/organisms/layout/footer.test.tsx33
-rw-r--r--src/components/organisms/layout/footer.tsx52
-rw-r--r--src/components/organisms/layout/overview.module.scss12
-rw-r--r--src/components/organisms/layout/overview.stories.tsx50
-rw-r--r--src/components/organisms/layout/overview.test.tsx29
-rw-r--r--src/components/organisms/layout/overview.tsx33
-rw-r--r--src/components/organisms/layout/summary.module.scss84
-rw-r--r--src/components/organisms/layout/summary.stories.tsx114
-rw-r--r--src/components/organisms/layout/summary.test.tsx85
-rw-r--r--src/components/organisms/layout/summary.tsx105
-rw-r--r--src/components/organisms/modals/search-modal.module.scss11
-rw-r--r--src/components/organisms/modals/search-modal.stories.tsx16
-rw-r--r--src/components/organisms/modals/search-modal.test.tsx9
-rw-r--r--src/components/organisms/modals/search-modal.tsx34
-rw-r--r--src/components/organisms/modals/settings-modal.module.scss21
-rw-r--r--src/components/organisms/modals/settings-modal.stories.tsx31
-rw-r--r--src/components/organisms/modals/settings-modal.test.tsx32
-rw-r--r--src/components/organisms/modals/settings-modal.tsx59
-rw-r--r--src/components/organisms/toolbar/main-nav.module.scss88
-rw-r--r--src/components/organisms/toolbar/main-nav.stories.tsx75
-rw-r--r--src/components/organisms/toolbar/main-nav.test.tsx33
-rw-r--r--src/components/organisms/toolbar/main-nav.tsx74
-rw-r--r--src/components/organisms/toolbar/search.module.scss3
-rw-r--r--src/components/organisms/toolbar/search.stories.tsx63
-rw-r--r--src/components/organisms/toolbar/search.test.tsx19
-rw-r--r--src/components/organisms/toolbar/search.tsx66
-rw-r--r--src/components/organisms/toolbar/settings.module.scss10
-rw-r--r--src/components/organisms/toolbar/settings.stories.tsx76
-rw-r--r--src/components/organisms/toolbar/settings.test.tsx18
-rw-r--r--src/components/organisms/toolbar/settings.tsx72
-rw-r--r--src/components/organisms/toolbar/toolbar-items.module.scss96
-rw-r--r--src/components/organisms/toolbar/toolbar.module.scss80
-rw-r--r--src/components/organisms/toolbar/toolbar.stories.tsx26
-rw-r--r--src/components/organisms/toolbar/toolbar.test.tsx16
-rw-r--r--src/components/organisms/toolbar/toolbar.tsx51
-rw-r--r--src/components/organisms/widgets/image-widget.module.scss43
-rw-r--r--src/components/organisms/widgets/image-widget.stories.tsx113
-rw-r--r--src/components/organisms/widgets/image-widget.test.tsx54
-rw-r--r--src/components/organisms/widgets/image-widget.tsx75
-rw-r--r--src/components/organisms/widgets/links-list-widget.module.scss71
-rw-r--r--src/components/organisms/widgets/links-list-widget.stories.tsx92
-rw-r--r--src/components/organisms/widgets/links-list-widget.test.tsx32
-rw-r--r--src/components/organisms/widgets/links-list-widget.tsx81
-rw-r--r--src/components/organisms/widgets/sharing.module.scss10
-rw-r--r--src/components/organisms/widgets/sharing.stories.tsx78
-rw-r--r--src/components/organisms/widgets/sharing.test.tsx31
-rw-r--r--src/components/organisms/widgets/sharing.tsx190
-rw-r--r--src/components/organisms/widgets/social-media.module.scss10
-rw-r--r--src/components/organisms/widgets/social-media.stories.tsx56
-rw-r--r--src/components/organisms/widgets/social-media.test.tsx33
-rw-r--r--src/components/organisms/widgets/social-media.tsx41
69 files changed, 3790 insertions, 0 deletions
diff --git a/src/components/organisms/forms/comment-form.module.scss b/src/components/organisms/forms/comment-form.module.scss
new file mode 100644
index 0000000..f3f2646
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.module.scss
@@ -0,0 +1,8 @@
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: auto;
+}
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
new file mode 100644
index 0000000..1ab7cf2
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -0,0 +1,86 @@
+import Notice from '@components/atoms/layout/notice';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import CommentFormComponent from './comment-form';
+
+export default {
+ title: 'Organisms/Forms',
+ component: CommentFormComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ saveComment: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to process the comment form data.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The form title.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The title level (hn).',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof CommentFormComponent>;
+
+const Template: ComponentStory<typeof CommentFormComponent> = (args) => (
+ <IntlProvider locale="en">
+ <CommentFormComponent {...args} />
+ </IntlProvider>
+);
+
+export const CommentForm = Template.bind({});
+CommentForm.args = {
+ saveComment: (reset: () => void) => {
+ reset();
+ },
+};
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form.test.tsx
new file mode 100644
index 0000000..0d387b5
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from '@test-utils';
+import CommentForm from './comment-form';
+
+const title = 'Cum voluptas voluptatibus';
+
+describe('CommentForm', () => {
+ it('renders a form', () => {
+ render(<CommentForm saveComment={() => null} />);
+ expect(screen.getByRole('form')).toBeInTheDocument();
+ });
+
+ it('renders an optional title', () => {
+ render(
+ <CommentForm saveComment={() => null} title={title} titleLevel={2} />
+ );
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
new file mode 100644
index 0000000..6acbf94
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -0,0 +1,174 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { ReactNode, useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './comment-form.module.scss';
+
+export type CommentFormProps = {
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * A callback function to save comment. It takes a function as parameter to
+ * reset the form.
+ */
+ saveComment: (reset: () => void) => void;
+ /**
+ * The form title.
+ */
+ title?: string;
+ /**
+ * The title level.
+ */
+ titleLevel?: HeadingLevel;
+};
+
+const CommentForm: VFC<CommentFormProps> = ({
+ className = '',
+ Notice,
+ saveComment,
+ title,
+ titleLevel = 2,
+}) => {
+ const intl = useIntl();
+ const [name, setName] = useState<string>('');
+ const [email, setEmail] = useState<string>('');
+ const [website, setWebsite] = useState<string>('');
+ const [comment, setComment] = useState<string>('');
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setName('');
+ setEmail('');
+ setWebsite('');
+ setComment('');
+ setIsSubmitting(false);
+ };
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'CommentForm: name label',
+ id: 'ZIrTee',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'CommentForm: email label',
+ id: 'Bh7z5v',
+ });
+
+ const websiteLabel = intl.formatMessage({
+ defaultMessage: 'Website:',
+ description: 'CommentForm: website label',
+ id: 'u41qSk',
+ });
+
+ const commentLabel = intl.formatMessage({
+ defaultMessage: 'Comment:',
+ description: 'CommentForm: comment label',
+ id: 'A8hGaK',
+ });
+
+ const formTitle = intl.formatMessage({
+ defaultMessage: 'Comment form',
+ description: 'CommentForm: aria label',
+ id: 'dz2kDV',
+ });
+
+ const formAriaLabel = title ? undefined : formTitle;
+ const formId = 'comment-form-title';
+ const formLabelledBy = title ? formId : undefined;
+
+ /**
+ * Handle form submit.
+ */
+ const submitHandler = () => {
+ setIsSubmitting(true);
+ saveComment(resetForm);
+ };
+
+ return (
+ <Form
+ onSubmit={submitHandler}
+ className={className}
+ aria-label={formAriaLabel}
+ aria-labelledby={formLabelledBy}
+ >
+ {title && (
+ <Heading id={formId} level={titleLevel}>
+ {title}
+ </Heading>
+ )}
+ <LabelledField
+ type="text"
+ id="commenter-name"
+ name="commenter-name"
+ label={nameLabel}
+ required={true}
+ value={name}
+ setValue={setName}
+ className={styles.field}
+ />
+ <LabelledField
+ type="email"
+ id="commenter-email"
+ name="commenter-email"
+ label={emailLabel}
+ required={true}
+ value={email}
+ setValue={setEmail}
+ className={styles.field}
+ />
+ <LabelledField
+ type="text"
+ id="commenter-website"
+ name="commenter-website"
+ label={websiteLabel}
+ required={false}
+ value={website}
+ setValue={setWebsite}
+ className={styles.field}
+ />
+ <LabelledField
+ type="textarea"
+ id="commenter-comment"
+ name="commenter-comment"
+ label={commentLabel}
+ required={true}
+ value={comment}
+ setValue={setComment}
+ className={styles.field}
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Publish',
+ description: 'CommentForm: submit button',
+ id: 'OL0Yzx',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Submitting...',
+ description: 'CommentForm: spinner message on submit',
+ id: 'IY5ew6',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
+
+export default CommentForm;
diff --git a/src/components/organisms/forms/contact-form.module.scss b/src/components/organisms/forms/contact-form.module.scss
new file mode 100644
index 0000000..f3f2646
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.module.scss
@@ -0,0 +1,8 @@
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: auto;
+}
diff --git a/src/components/organisms/forms/contact-form.stories.tsx b/src/components/organisms/forms/contact-form.stories.tsx
new file mode 100644
index 0000000..2c8ab32
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.stories.tsx
@@ -0,0 +1,59 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import ContactFormComponent from './contact-form';
+
+export default {
+ title: 'Organisms/Forms',
+ component: ContactFormComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ sendMail: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to process the contact form data.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ContactFormComponent>;
+
+const Template: ComponentStory<typeof ContactFormComponent> = (args) => (
+ <IntlProvider locale="en">
+ <ContactFormComponent {...args} />
+ </IntlProvider>
+);
+
+export const ContactForm = Template.bind({});
+ContactForm.args = {
+ sendMail: (reset: () => void) => {
+ reset();
+ },
+};
diff --git a/src/components/organisms/forms/contact-form.test.tsx b/src/components/organisms/forms/contact-form.test.tsx
new file mode 100644
index 0000000..744f147
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@test-utils';
+import ContactForm from './contact-form';
+
+const props = {
+ sendMail: () => null,
+};
+
+describe('ContactForm', () => {
+ it('renders a contact form', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('form', { name: 'Contact form' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a name field', () => {
+ render(<ContactForm {...props} />);
+ expect(screen.getByRole('textbox', { name: /^Name:/ })).toBeInTheDocument();
+ });
+
+ it('renders an email field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Email:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an object field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Object:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a message field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Message:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ render(<ContactForm {...props} />);
+ expect(screen.getByRole('button', { name: /^Send/ })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx
new file mode 100644
index 0000000..994244a
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.tsx
@@ -0,0 +1,150 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { ReactNode, useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './contact-form.module.scss';
+
+export type ContactFormProps = {
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * A callback function to send mail. It takes a function as parameter to
+ * reset the form.
+ */
+ sendMail: (reset: () => void) => void;
+};
+
+/**
+ * ContactForm component
+ *
+ * Render a contact form.
+ */
+const ContactForm: VFC<ContactFormProps> = ({
+ className = '',
+ Notice,
+ sendMail,
+}) => {
+ const intl = useIntl();
+ const [name, setName] = useState<string>('');
+ const [email, setEmail] = useState<string>('');
+ const [object, setObject] = useState<string>('');
+ const [message, setMessage] = useState<string>('');
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setName('');
+ setEmail('');
+ setObject('');
+ setMessage('');
+ setIsSubmitting(false);
+ };
+
+ const formName = intl.formatMessage({
+ defaultMessage: 'Contact form',
+ description: 'ContactForm: form accessible name',
+ id: 'HFdzae',
+ });
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'ContactForm: name label',
+ id: '1dCuCx',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'ContactForm: email label',
+ id: 'w4B5PA',
+ });
+
+ const objectLabel = intl.formatMessage({
+ defaultMessage: 'Object:',
+ description: 'ContactForm: object label',
+ id: 's8/tyz',
+ });
+
+ const messageLabel = intl.formatMessage({
+ defaultMessage: 'Message:',
+ description: 'ContactForm: message label',
+ id: 'yN5P+m',
+ });
+
+ const submitHandler = () => {
+ setIsSubmitting(true);
+ sendMail(resetForm);
+ };
+
+ return (
+ <Form aria-label={formName} onSubmit={submitHandler} className={className}>
+ <LabelledField
+ type="text"
+ id="contact-name"
+ name="contact-name"
+ label={nameLabel}
+ required={true}
+ value={name}
+ setValue={setName}
+ className={styles.field}
+ />
+ <LabelledField
+ type="email"
+ id="contact-email"
+ name="contact-email"
+ label={emailLabel}
+ required={true}
+ value={email}
+ setValue={setEmail}
+ className={styles.field}
+ />
+ <LabelledField
+ type="text"
+ id="contact-object"
+ name="contact-object"
+ label={objectLabel}
+ value={object}
+ setValue={setObject}
+ className={styles.field}
+ />
+ <LabelledField
+ type="textarea"
+ id="contact-message"
+ name="contact-message"
+ label={messageLabel}
+ required={true}
+ value={message}
+ setValue={setMessage}
+ className={styles.field}
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Send',
+ description: 'ContactForm: send button',
+ id: 'VkAnvv',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Sending mail...',
+ description: 'ContactForm: spinner message on submit',
+ id: 'xaqaYQ',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
+
+export default ContactForm;
diff --git a/src/components/organisms/forms/search-form.module.scss b/src/components/organisms/forms/search-form.module.scss
new file mode 100644
index 0000000..1d388a4
--- /dev/null
+++ b/src/components/organisms/forms/search-form.module.scss
@@ -0,0 +1,58 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ align-items: center;
+ position: relative;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ max-width: 35ch;
+ }
+ }
+}
+
+.btn {
+ position: absolute;
+ right: 0;
+
+ &__icon {
+ transform: scale(0.85);
+ transition: all 0.3s ease-in-out 0s;
+ }
+
+ &:focus {
+ outline: var(--color-primary-light) solid fun.convert-px(3);
+ }
+
+ &:active {
+ outline: none;
+ }
+
+ &:hover &,
+ &:focus & {
+ &__icon {
+ transform: scale(0.85) rotate(20deg) translateY(#{fun.convert-px(3)});
+ }
+ }
+
+ &:active & {
+ &__icon {
+ transform: scale(0.7);
+ }
+ }
+}
+
+.field {
+ width: 100%;
+ padding-right: var(--spacing-lg);
+
+ &:hover ~ .btn {
+ transform: translate(fun.convert-px(-3), fun.convert-px(-3));
+ }
+
+ &:focus ~ .btn {
+ transform: translate(fun.convert-px(3), fun.convert-px(3));
+ }
+}
diff --git a/src/components/organisms/forms/search-form.stories.tsx b/src/components/organisms/forms/search-form.stories.tsx
new file mode 100644
index 0000000..4ec1c21
--- /dev/null
+++ b/src/components/organisms/forms/search-form.stories.tsx
@@ -0,0 +1,34 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SearchFormComponent from './search-form';
+
+export default {
+ title: 'Organisms/Forms',
+ component: SearchFormComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SearchFormComponent>;
+
+const Template: ComponentStory<typeof SearchFormComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SearchFormComponent {...args} />
+ </IntlProvider>
+);
+
+export const SearchForm = Template.bind({});
+SearchForm.args = {
+ hideLabel: true,
+};
diff --git a/src/components/organisms/forms/search-form.test.tsx b/src/components/organisms/forms/search-form.test.tsx
new file mode 100644
index 0000000..4e3d285
--- /dev/null
+++ b/src/components/organisms/forms/search-form.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@test-utils';
+import SearchForm from './search-form';
+
+describe('SearchForm', () => {
+ it('renders a search input', () => {
+ render(<SearchForm />);
+ expect(
+ screen.getByRole('searchbox', { name: 'Search for:' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ render(<SearchForm />);
+ expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/search-form.tsx b/src/components/organisms/forms/search-form.tsx
new file mode 100644
index 0000000..351d93c
--- /dev/null
+++ b/src/components/organisms/forms/search-form.tsx
@@ -0,0 +1,57 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
+import LabelledField, {
+ LabelledFieldProps,
+} from '@components/molecules/forms/labelled-field';
+import { useState, VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './search-form.module.scss';
+
+export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'>;
+
+const SearchForm: VFC<SearchFormProps> = ({ hideLabel }) => {
+ const intl = useIntl();
+ const fieldLabel = intl.formatMessage({
+ defaultMessage: 'Search for:',
+ description: 'SearchForm: field accessible label',
+ id: 'X8oujO',
+ });
+ const buttonLabel = intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'SearchForm: button accessible name',
+ id: 'WMqQrv',
+ });
+
+ const [value, setValue] = useState<string>('');
+
+ const submitHandler = () => {
+ return;
+ };
+
+ return (
+ <Form grouped={false} onSubmit={submitHandler} className={styles.wrapper}>
+ <LabelledField
+ type="search"
+ id="search-form"
+ name="search-form"
+ label={fieldLabel}
+ value={value}
+ setValue={setValue}
+ hideLabel={hideLabel}
+ className={styles.field}
+ />
+ <Button
+ type="submit"
+ kind="neutral"
+ shape="initial"
+ className={styles.btn}
+ aria-label={buttonLabel}
+ >
+ <MagnifyingGlass className={styles.btn__icon} />
+ </Button>
+ </Form>
+ );
+};
+
+export default SearchForm;
diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss
new file mode 100644
index 0000000..9fe428c
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.module.scss
@@ -0,0 +1,27 @@
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ --card-width: 30ch;
+
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width))
+ );
+ gap: var(--spacing-sm);
+ place-content: center;
+ align-items: stretch;
+ justify-items: stretch;
+
+ &--ordered {
+ @extend %reset-ordered-list;
+ }
+
+ &--unordered {
+ @extend %reset-list;
+ }
+}
+
+.card {
+ height: 100%;
+}
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
new file mode 100644
index 0000000..5182808
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -0,0 +1,105 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CardsListComponent, { type CardsListItem } from './cards-list';
+
+export default {
+ title: 'Organisms/Layout',
+ component: CardsListComponent,
+ args: {
+ kind: 'unordered',
+ },
+ argTypes: {
+ items: {
+ description: 'The cards data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level for each card.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CardsListComponent>;
+
+const Template: ComponentStory<typeof CardsListComponent> = (args) => (
+ <CardsListComponent {...args} />
+);
+
+const items: CardsListItem[] = [
+ {
+ id: 'card-1',
+ cover: {
+ alt: 'card 1 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] },
+ ],
+ tagline: 'Molestias ut error',
+ title: 'Et alias omnis',
+ url: '#',
+ },
+ {
+ id: 'card-2',
+ cover: {
+ alt: 'card 2 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }],
+ tagline: 'Quod vel accusamus',
+ title: 'Laboriosam doloremque mollitia',
+ url: '#',
+ },
+ {
+ id: 'card-3',
+ cover: {
+ alt: 'card 3 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ {
+ id: 'meta-1',
+ term: 'Omnis',
+ value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+ },
+ ],
+ tagline: 'Quo error eum',
+ title: 'Magni rem nulla',
+ url: '#',
+ },
+];
+
+export const CardsList = Template.bind({});
+CardsList.args = {
+ items,
+ titleLevel: 2,
+};
diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx
new file mode 100644
index 0000000..2df3f59
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.test.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@test-utils';
+import CardsList from './cards-list';
+
+const items = [
+ {
+ id: 'card-1',
+ cover: {
+ alt: 'card 1 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] },
+ ],
+ tagline: 'Molestias ut error',
+ title: 'Et alias omnis',
+ url: '#',
+ },
+ {
+ id: 'card-2',
+ cover: {
+ alt: 'card 2 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }],
+ tagline: 'Quod vel accusamus',
+ title: 'Laboriosam doloremque mollitia',
+ url: '#',
+ },
+ {
+ id: 'card-3',
+ cover: {
+ alt: 'card 3 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: [
+ {
+ id: 'meta-1',
+ term: 'Omnis',
+ value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+ },
+ ],
+ tagline: 'Quo error eum',
+ title: 'Magni rem nulla',
+ url: '#',
+ },
+];
+
+describe('CardsList', () => {
+ it('renders a list of cards', () => {
+ render(<CardsList items={items} titleLevel={2} />);
+ expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(
+ items.length
+ );
+ });
+});
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
new file mode 100644
index 0000000..a53df0d
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -0,0 +1,80 @@
+import List, {
+ type ListItem,
+ type ListProps,
+} from '@components/atoms/lists/list';
+import Card, { type CardProps } from '@components/molecules/layout/card';
+import { VFC } from 'react';
+import styles from './cards-list.module.scss';
+
+export type CardsListItem = Omit<
+ CardProps,
+ 'className' | 'coverFit' | 'titleLevel'
+> & {
+ id: string;
+};
+
+export type CardsListProps = {
+ /**
+ * The cover fit.
+ */
+ coverFit?: CardProps['coverFit'];
+ /**
+ * The cards data.
+ */
+ items: CardsListItem[];
+ /**
+ * The list kind. Either ordered or unordered.
+ */
+ kind?: ListProps['kind'];
+ /**
+ * The title level (hn).
+ */
+ titleLevel: CardProps['titleLevel'];
+};
+
+/**
+ * CardsList component
+ *
+ * Return a list of Card components.
+ */
+const CardsList: VFC<CardsListProps> = ({
+ coverFit,
+ items,
+ kind = 'unordered',
+ titleLevel,
+}) => {
+ const kindModifier = `wrapper--${kind}`;
+
+ /**
+ * Format the cards data to be used by the List component.
+ *
+ * @param {CardsListItem[]} cards - An array of card data.
+ * @returns {ListItem[]} The formatted cards data.
+ */
+ const getCards = (cards: CardsListItem[]): ListItem[] => {
+ return cards.map(({ id, ...card }) => {
+ return {
+ id,
+ value: (
+ <Card
+ key={id}
+ coverFit={coverFit}
+ titleLevel={titleLevel}
+ className={styles.card}
+ {...card}
+ />
+ ),
+ };
+ });
+ };
+
+ return (
+ <List
+ items={getCards(items)}
+ withMargin={false}
+ className={`${styles.wrapper} ${styles[kindModifier]}`}
+ />
+ );
+};
+
+export default CardsList;
diff --git a/src/components/organisms/layout/footer.module.scss b/src/components/organisms/layout/footer.module.scss
new file mode 100644
index 0000000..c180e86
--- /dev/null
+++ b/src/components/organisms/layout/footer.module.scss
@@ -0,0 +1,41 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-xs);
+ place-items: center;
+ place-content: center;
+ padding: var(--spacing-md) 0 calc(var(--toolbar-size) + var(--spacing-md));
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ --toolbar-size: 0px;
+
+ flex-flow: row wrap;
+ font-size: var(--font-size-sm);
+ }
+ }
+}
+
+.nav {
+ display: flex;
+ flex-flow: row wrap;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ &::before {
+ content: "\2022";
+ margin-right: var(--spacing-2xs);
+ }
+ }
+ }
+}
+
+.back-to-top {
+ position: fixed;
+ bottom: calc(var(--toolbar-size, 0px) + var(--spacing-md));
+ right: var(--spacing-md);
+ transition: all 0.4s ease-in 0s;
+}
diff --git a/src/components/organisms/layout/footer.stories.tsx b/src/components/organisms/layout/footer.stories.tsx
new file mode 100644
index 0000000..2ce7ee1
--- /dev/null
+++ b/src/components/organisms/layout/footer.stories.tsx
@@ -0,0 +1,74 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import FooterComponent from './footer';
+
+export default {
+ title: 'Organisms/Layout',
+ component: FooterComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the footer element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ copyright: {
+ description: 'The copyright information.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ navItems: {
+ description: 'The footer nav items.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ topId: {
+ control: {
+ type: 'text',
+ },
+ description:
+ 'An element id (without hashtag) used as target by back to top button.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FooterComponent>;
+
+const Template: ComponentStory<typeof FooterComponent> = (args) => (
+ <IntlProvider locale="en">
+ <FooterComponent {...args} />
+ </IntlProvider>
+);
+
+const copyright = {
+ dates: { start: '2017', end: '2022' },
+ owner: 'Lorem ipsum',
+ icon: 'CC',
+};
+
+const navItems = [{ id: 'legal-notice', href: '#', label: 'Legal notice' }];
+
+export const Footer = Template.bind({});
+Footer.args = {
+ copyright,
+ navItems,
+ topId: 'top',
+};
diff --git a/src/components/organisms/layout/footer.test.tsx b/src/components/organisms/layout/footer.test.tsx
new file mode 100644
index 0000000..bc23732
--- /dev/null
+++ b/src/components/organisms/layout/footer.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import Footer, { type FooterProps } from './footer';
+
+const copyright: FooterProps['copyright'] = {
+ dates: { start: '2017', end: '2022' },
+ owner: 'Lorem ipsum',
+ icon: 'CC',
+};
+
+const navItems: FooterProps['navItems'] = [
+ { id: 'legal-notice', href: '#', label: 'Legal notice' },
+];
+
+describe('Footer', () => {
+ it('renders the website copyright', () => {
+ render(<Footer copyright={copyright} topId="top" />);
+ expect(screen.getByText(copyright.owner)).toBeInTheDocument();
+ });
+
+ it('renders a back to top link', () => {
+ render(<Footer copyright={copyright} topId="top" />);
+ expect(
+ screen.getByRole('link', { name: 'Back to top' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders some nav items', () => {
+ render(<Footer copyright={copyright} navItems={navItems} topId="top" />);
+ expect(
+ screen.getByRole('link', { name: navItems[0].label })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
new file mode 100644
index 0000000..c9cb067
--- /dev/null
+++ b/src/components/organisms/layout/footer.tsx
@@ -0,0 +1,52 @@
+import Copyright, { CopyrightProps } from '@components/atoms/layout/copyright';
+import BackToTop from '@components/molecules/buttons/back-to-top';
+import Nav, { type NavItem } from '@components/molecules/nav/nav';
+import { VFC } from 'react';
+import styles from './footer.module.scss';
+
+export type FooterProps = {
+ /**
+ * Set additional classnames to the footer element.
+ */
+ className?: string;
+ /**
+ * Set the copyright information.
+ */
+ copyright: CopyrightProps;
+ /**
+ * The footer nav items.
+ */
+ navItems?: NavItem[];
+ /**
+ * An element id (without hashtag) used as anchor for back to top button.
+ */
+ topId: string;
+};
+
+/**
+ * Footer component
+ *
+ * Renders a footer with copyright and nav;
+ */
+const Footer: VFC<FooterProps> = ({
+ className,
+ copyright,
+ navItems,
+ topId,
+}) => {
+ return (
+ <footer className={`${styles.wrapper} ${className}`}>
+ <Copyright
+ dates={copyright.dates}
+ owner={copyright.owner}
+ icon={copyright.icon}
+ />
+ {navItems && (
+ <Nav kind="footer" items={navItems} className={styles.nav} />
+ )}
+ <BackToTop target={topId} className={styles['back-to-top']} />
+ </footer>
+ );
+};
+
+export default Footer;
diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss
new file mode 100644
index 0000000..4d50ad1
--- /dev/null
+++ b/src/components/organisms/layout/overview.module.scss
@@ -0,0 +1,12 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-border);
+}
+
+.cover {
+ max-height: fun.convert-px(150);
+ margin: 0 auto var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-border);
+}
diff --git a/src/components/organisms/layout/overview.stories.tsx b/src/components/organisms/layout/overview.stories.tsx
new file mode 100644
index 0000000..61d8b35
--- /dev/null
+++ b/src/components/organisms/layout/overview.stories.tsx
@@ -0,0 +1,50 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import OverviewComponent from './overview';
+
+export default {
+ title: 'Organisms/Layout',
+ component: OverviewComponent,
+ argTypes: {
+ cover: {
+ description: 'The overview cover.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ meta: {
+ description: 'The overview metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof OverviewComponent>;
+
+const Template: ComponentStory<typeof OverviewComponent> = (args) => (
+ <OverviewComponent {...args} />
+);
+
+const cover = {
+ alt: 'picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480/cats',
+ width: 640,
+};
+
+const meta = {
+ publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
+ update: {
+ name: 'Perspiciatis vel laudantium:',
+ value: 'Dignissimos ratione veritatis',
+ },
+};
+
+export const Overview = Template.bind({});
+Overview.args = {
+ cover,
+ meta,
+};
diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx
new file mode 100644
index 0000000..0738d3f
--- /dev/null
+++ b/src/components/organisms/layout/overview.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@test-utils';
+import Overview from './overview';
+
+const cover = {
+ alt: 'Incidunt unde quam',
+ height: 480,
+ src: 'http://placeimg.com/640/480/cats',
+ width: 640,
+};
+
+const meta = {
+ publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
+ update: {
+ name: 'Perspiciatis vel laudantium:',
+ value: 'Dignissimos ratione veritatis',
+ },
+};
+
+describe('Overview', () => {
+ it('renders some meta', () => {
+ render(<Overview meta={meta} />);
+ expect(screen.getByText(meta['publication'].name)).toBeInTheDocument();
+ });
+
+ it('renders a cover', () => {
+ render(<Overview meta={meta} cover={cover} />);
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx
new file mode 100644
index 0000000..3f83342
--- /dev/null
+++ b/src/components/organisms/layout/overview.tsx
@@ -0,0 +1,33 @@
+import ResponsiveImage, {
+ type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Meta, { type MetaMap } from '@components/molecules/layout/meta';
+import { VFC } from 'react';
+import styles from './overview.module.scss';
+
+export type OverviewProps = {
+ cover?: Pick<ResponsiveImageProps, 'alt' | 'src' | 'width' | 'height'>;
+ meta: MetaMap;
+};
+
+/**
+ * Overview component
+ *
+ * Render an overview.
+ */
+const Overview: VFC<OverviewProps> = ({ cover, meta }) => {
+ return (
+ <div className={styles.wrapper}>
+ {cover && (
+ <ResponsiveImage
+ objectFit="cover"
+ className={styles.cover}
+ {...cover}
+ />
+ )}
+ <Meta data={meta} layout="column" responsiveLayout={true} />
+ </div>
+ );
+};
+
+export default Overview;
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
new file mode 100644
index 0000000..5da0a18
--- /dev/null
+++ b/src/components/organisms/layout/summary.module.scss
@@ -0,0 +1,84 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-primary-dark);
+ border-radius: fun.convert-px(3);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+ var(--color-shadow),
+ fun.convert-px(3) fun.convert-px(3) fun.convert-px(3) fun.convert-px(-1)
+ var(--color-shadow-light),
+ fun.convert-px(5) fun.convert-px(5) fun.convert-px(7) fun.convert-px(-1)
+ var(--color-shadow-light);
+ }
+
+ @include mix.dimensions("sm") {
+ display: grid;
+ grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
+ grid-template-rows: repeat(3, max-content);
+ column-gap: var(--spacing-md);
+ }
+ }
+}
+
+.cover {
+ width: auto;
+ max-height: fun.convert-px(100);
+ max-width: 100%;
+ border: fun.convert-px(1) solid var(--color-border);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 2;
+ grid-row: 1;
+ }
+ }
+}
+
+.header {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 1;
+ grid-row: 1;
+ align-self: center;
+ }
+ }
+}
+
+.body {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ }
+}
+
+.footer {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 2;
+ grid-row: 2 / 4;
+ }
+ }
+}
+
+.title {
+ background: none;
+ text-shadow: none;
+}
+
+.read-more {
+ display: flex;
+ flex-flow: row nowrap;
+ column-gap: var(--spacing-xs);
+ width: max-content;
+ margin: var(--spacing-sm) 0;
+}
+
+.meta {
+ font-size: var(--font-size-sm);
+}
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
new file mode 100644
index 0000000..5214d70
--- /dev/null
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -0,0 +1,114 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SummaryComponent from './summary';
+
+export default {
+ title: 'Organisms/Layout',
+ component: SummaryComponent,
+ args: {
+ titleLevel: 2,
+ },
+ argTypes: {
+ cover: {
+ description: 'The cover data.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ excerpt: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page excerpt.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ meta: {
+ description: 'The page metadata.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page title',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ },
+ description: 'The page title level (hn)',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 2 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'The page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SummaryComponent>;
+
+const Template: ComponentStory<typeof SummaryComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SummaryComponent {...args} />
+ </IntlProvider>
+);
+
+const meta = {
+ publication: { name: 'Published on:', value: 'April 11th 2022' },
+ readingTime: { name: 'Reading time:', value: '5 minutes' },
+ categories: {
+ name: 'Categories:',
+ value: [
+ <a key="cat-1" href="#">
+ Cat 1
+ </a>,
+ <a key="cat-2" href="#">
+ Cat 2
+ </a>,
+ ],
+ },
+ comments: { name: 'Comments:', value: '1 comment' },
+};
+
+export const Summary = Template.bind({});
+Summary.args = {
+ cover: {
+ alt: 'A cover',
+ height: 480,
+ url: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ excerpt:
+ 'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.',
+ meta,
+ title: 'Odio odit necessitatibus',
+ url: '#',
+};
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
new file mode 100644
index 0000000..ce87c0c
--- /dev/null
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen } from '@test-utils';
+import Summary from './summary';
+
+const cover = {
+ alt: 'A cover',
+ height: 480,
+ url: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+const excerpt =
+ 'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
+
+const meta = {
+ publication: { name: 'Published on:', value: 'April 11th 2022' },
+ readingTime: { name: 'Reading time:', value: '5 minutes' },
+ categories: {
+ name: 'Categories:',
+ value: [
+ <a key="cat-1" href="#">
+ Cat 1
+ </a>,
+ <a key="cat-2" href="#">
+ Cat 2
+ </a>,
+ ],
+ },
+ comments: { name: 'Comments:', value: '1 comment' },
+};
+
+const title = 'Odio odit necessitatibus';
+
+const url = '#';
+
+describe('Summary', () => {
+ it('renders a title wrapped in a h2 element', () => {
+ render(
+ <Summary
+ excerpt={excerpt}
+ meta={meta}
+ title={title}
+ titleLevel={2}
+ url={url}
+ />
+ );
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an excerpt', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByText(excerpt)).toBeInTheDocument();
+ });
+
+ it('renders a cover', () => {
+ render(
+ <Summary
+ cover={cover}
+ excerpt={excerpt}
+ meta={meta}
+ title={title}
+ url={url}
+ />
+ );
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+
+ it('renders a link to the full post', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByRole('link', { name: title })).toBeInTheDocument();
+ });
+
+ it('renders a read more link', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(
+ screen.getByRole('link', { name: `Read more about ${title}` })
+ ).toBeInTheDocument();
+ });
+
+ it('renders some meta', () => {
+ render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+ expect(screen.getByText(meta.publication.name)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
new file mode 100644
index 0000000..3624e5d
--- /dev/null
+++ b/src/components/organisms/layout/summary.tsx
@@ -0,0 +1,105 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Arrow from '@components/atoms/icons/arrow';
+import Link from '@components/atoms/links/link';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import Meta, { type MetaItem } from '@components/molecules/layout/meta';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './summary.module.scss';
+
+export type Cover = {
+ alt: string;
+ height: number;
+ url: string;
+ width: number;
+};
+
+export type RequiredMetaKey = 'publication';
+
+export type RequiredMeta = {
+ [key in RequiredMetaKey]: MetaItem;
+};
+
+export type OptionalMetaKey =
+ | 'author'
+ | 'categories'
+ | 'comments'
+ | 'readingTime'
+ | 'update';
+
+export type OptionalMeta = {
+ [key in OptionalMetaKey]?: MetaItem;
+};
+
+export type Meta = RequiredMeta & OptionalMeta;
+
+export type SummaryProps = {
+ cover?: Cover;
+ excerpt: string;
+ meta: Meta;
+ title: string;
+ titleLevel?: HeadingLevel;
+ url: string;
+};
+
+/**
+ * Summary component
+ *
+ * Render a page summary.
+ */
+const Summary: VFC<SummaryProps> = ({
+ cover,
+ excerpt,
+ meta,
+ title,
+ titleLevel = 2,
+ url,
+}) => {
+ const intl = useIntl();
+
+ return (
+ <article className={styles.wrapper}>
+ {cover && (
+ <ResponsiveImage
+ alt={cover.alt}
+ src={cover.url}
+ width={cover.width}
+ height={cover.height}
+ className={styles.cover}
+ />
+ )}
+ <header className={styles.header}>
+ <Link href={url}>
+ <Heading level={titleLevel} className={styles.title}>
+ {title}
+ </Heading>
+ </Link>
+ </header>
+ <div className={styles.body}>
+ {excerpt}
+ <ButtonLink target={url} className={styles['read-more']}>
+ {intl.formatMessage(
+ {
+ defaultMessage: 'Read more<a11y> about {title}</a11y>',
+ description: 'Summary: read more link',
+ id: 'Zpgv+f',
+ },
+ {
+ title,
+ a11y: (chunks: string) => (
+ <span className="screen-reader-text">{chunks}</span>
+ ),
+ }
+ )}
+ <Arrow direction="right" />
+ </ButtonLink>
+ </div>
+ <footer className={styles.footer}>
+ <Meta data={meta} layout="column" className={styles.meta} />
+ </footer>
+ </article>
+ );
+};
+
+export default Summary;
diff --git a/src/components/organisms/modals/search-modal.module.scss b/src/components/organisms/modals/search-modal.module.scss
new file mode 100644
index 0000000..aba0593
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.module.scss
@@ -0,0 +1,11 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ padding-bottom: var(--spacing-md);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ max-width: 40ch;
+ }
+ }
+}
diff --git a/src/components/organisms/modals/search-modal.stories.tsx b/src/components/organisms/modals/search-modal.stories.tsx
new file mode 100644
index 0000000..b35c841
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.stories.tsx
@@ -0,0 +1,16 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SearchModalComponent from './search-modal';
+
+export default {
+ title: 'Organisms/Modals',
+ component: SearchModalComponent,
+} as ComponentMeta<typeof SearchModalComponent>;
+
+const Template: ComponentStory<typeof SearchModalComponent> = (args) => (
+ <IntlProvider locale="en">
+ <SearchModalComponent {...args} />
+ </IntlProvider>
+);
+
+export const SearchModal = Template.bind({});
diff --git a/src/components/organisms/modals/search-modal.test.tsx b/src/components/organisms/modals/search-modal.test.tsx
new file mode 100644
index 0000000..249c523
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import SearchModal from './search-modal';
+
+describe('SearchModal', () => {
+ it('renders a search modal', () => {
+ render(<SearchModal />);
+ expect(screen.getByText('Search')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx
new file mode 100644
index 0000000..883b783
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -0,0 +1,34 @@
+import Modal from '@components/molecules/modals/modal';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import SearchForm from '../forms/search-form';
+import styles from './search-modal.module.scss';
+
+export type SearchModalProps = {
+ /**
+ * Set additional classnames to modal wrapper.
+ */
+ className?: string;
+};
+
+/**
+ * SearchModal
+ *
+ * Render a search form modal.
+ */
+const SearchModal: VFC<SearchModalProps> = ({ className }) => {
+ const intl = useIntl();
+ const modalTitle = intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'SearchModal: modal title',
+ id: 'G+Twgm',
+ });
+
+ return (
+ <Modal title={modalTitle} className={`${styles.wrapper} ${className}`}>
+ <SearchForm hideLabel={true} />
+ </Modal>
+ );
+};
+
+export default SearchModal;
diff --git a/src/components/organisms/modals/settings-modal.module.scss b/src/components/organisms/modals/settings-modal.module.scss
new file mode 100644
index 0000000..ebae3da
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.module.scss
@@ -0,0 +1,21 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ .label {
+ margin-right: auto;
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ font-size: var(--font-size-sm);
+
+ .heading {
+ font-size: var(--font-size-lg);
+ }
+
+ .label {
+ font-size: var(--font-size-sm);
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx
new file mode 100644
index 0000000..c19a6d7
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.stories.tsx
@@ -0,0 +1,31 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SettingsModal from './settings-modal';
+
+export default {
+ title: 'Organisms/Modals',
+ component: SettingsModal,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SettingsModal>;
+
+const Template: ComponentStory<typeof SettingsModal> = (args) => (
+ <IntlProvider locale="en">
+ <SettingsModal {...args} />
+ </IntlProvider>
+);
+
+export const Settings = Template.bind({});
diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx
new file mode 100644
index 0000000..6291e54
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@test-utils';
+import SettingsModal from './settings-modal';
+
+describe('SettingsModal', () => {
+ it('renders a theme toggle setting', () => {
+ render(<SettingsModal />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Theme:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a code blocks toggle setting', () => {
+ render(<SettingsModal />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Code blocks:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a motion setting', () => {
+ render(<SettingsModal />);
+ expect(
+ screen.getByRole('checkbox', { name: /^Animations:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a Ackee setting', () => {
+ render(<SettingsModal />);
+ expect(
+ screen.getByRole('combobox', { name: /^Tracking:/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx
new file mode 100644
index 0000000..25d6f6f
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.tsx
@@ -0,0 +1,59 @@
+import Form from '@components/atoms/forms/form';
+import AckeeSelect from '@components/molecules/forms/ackee-select';
+import MotionToggle from '@components/molecules/forms/motion-toggle';
+import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle';
+import ThemeToggle from '@components/molecules/forms/theme-toggle';
+import Modal from '@components/molecules/modals/modal';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './settings-modal.module.scss';
+
+export type SettingsModalProps = {
+ /**
+ * Set additional classnames to the modal wrapper.
+ */
+ className?: string;
+ /**
+ * Set additional classnames to the tooltip wrapper.
+ */
+ tooltipClassName?: string;
+};
+
+/**
+ * SettingsModal component
+ *
+ * Render a modal with settings options.
+ */
+const SettingsModal: VFC<SettingsModalProps> = ({
+ className = '',
+ tooltipClassName = '',
+}) => {
+ const intl = useIntl();
+ const title = intl.formatMessage({
+ defaultMessage: 'Settings',
+ description: 'SettingsModal: title',
+ id: 'gPfT/K',
+ });
+
+ return (
+ <Modal
+ title={title}
+ icon="cogs"
+ className={`${styles.wrapper} ${className}`}
+ headingClassName={styles.heading}
+ >
+ <Form onSubmit={() => null}>
+ <ThemeToggle labelClassName={styles.label} value={false} />
+ <PrismThemeToggle labelClassName={styles.label} value={false} />
+ <MotionToggle labelClassName={styles.label} value={false} />
+ <AckeeSelect
+ initialValue="full"
+ labelClassName={styles.label}
+ tooltipClassName={tooltipClassName}
+ />
+ </Form>
+ </Modal>
+ );
+};
+
+export default SettingsModal;
diff --git a/src/components/organisms/toolbar/main-nav.module.scss b/src/components/organisms/toolbar/main-nav.module.scss
new file mode 100644
index 0000000..76af935
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.module.scss
@@ -0,0 +1,88 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.item {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ .checkbox,
+ .label {
+ display: none;
+ }
+
+ .modal {
+ position: relative;
+ }
+ }
+ }
+
+ .modal {
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "md") {
+ padding: var(--spacing-2xs);
+ background: var(--color-bg-secondary);
+ border-top: fun.convert-px(4) solid;
+ border-bottom: fun.convert-px(4) solid;
+ border-image: radial-gradient(
+ ellipse at top,
+ var(--color-primary-lighter) 20%,
+ var(--color-primary) 100%
+ )
+ 1;
+ box-shadow: fun.convert-px(2) fun.convert-px(-2) fun.convert-px(3)
+ fun.convert-px(-1) var(--color-shadow-dark);
+ }
+
+ @include mix.dimensions("sm", "md") {
+ border-left: fun.convert-px(4) solid;
+ border-right: fun.convert-px(4) solid;
+ }
+
+ @include mix.dimensions("md") {
+ top: unset;
+ }
+ }
+ }
+
+ .modal__list {
+ display: flex;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm", "md") {
+ flex-flow: column;
+ }
+ }
+ }
+
+ .checkbox {
+ &:checked {
+ ~ .label .icon {
+ background: transparent;
+ border: transparent;
+
+ &::before {
+ top: 0;
+ transform-origin: 50% 50%;
+ transform: rotate(-45deg);
+ }
+
+ &::after {
+ bottom: 0;
+ transform-origin: 50% 50%;
+ transform: rotate(45deg);
+ }
+ }
+ }
+
+ &:not(:checked) {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ ~ .modal {
+ opacity: 1;
+ visibility: visible;
+ transform: none;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/main-nav.stories.tsx b/src/components/organisms/toolbar/main-nav.stories.tsx
new file mode 100644
index 0000000..27387c0
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.stories.tsx
@@ -0,0 +1,75 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { IntlProvider } from 'react-intl';
+import MainNavComponent from './main-nav';
+
+export default {
+ title: 'Organisms/Toolbar',
+ component: MainNavComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the main nav wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Determine if the main nav is open or not.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ items: {
+ description: 'The main nav items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to change main nav state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MainNavComponent>;
+
+const Template: ComponentStory<typeof MainNavComponent> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return (
+ <IntlProvider locale="en">
+ <MainNavComponent isActive={isOpen} setIsActive={setIsOpen} {...args} />
+ </IntlProvider>
+ );
+};
+
+export const MainNav = Template.bind({});
+MainNav.args = {
+ isActive: false,
+ items: [
+ { id: 'home', label: 'Home', href: '#' },
+ { id: 'contact', label: 'Contact', href: '#' },
+ ],
+};
diff --git a/src/components/organisms/toolbar/main-nav.test.tsx b/src/components/organisms/toolbar/main-nav.test.tsx
new file mode 100644
index 0000000..6e50562
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import MainNav from './main-nav';
+
+const items = [
+ { id: 'home', label: 'Home', href: '/' },
+ { id: 'blog', label: 'Blog', href: '/blog' },
+ { id: 'contact', label: 'Contact', href: '/contact' },
+];
+
+describe('MainNav', () => {
+ it('renders a checkbox to open main nav', () => {
+ render(<MainNav items={items} isActive={false} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Open menu');
+ });
+
+ it('renders a checkbox to close main nav', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close menu');
+ });
+
+ it('renders the correct number of items', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(items.length);
+ });
+
+ it('renders some links with the right label', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('link', { name: items[0].label })).toHaveAttribute(
+ 'href',
+ items[0].href
+ );
+ });
+});
diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx
new file mode 100644
index 0000000..50bbe8b
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.tsx
@@ -0,0 +1,74 @@
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
+import Label from '@components/atoms/forms/label';
+import Hamburger from '@components/atoms/icons/hamburger';
+import Nav, { type NavItem } from '@components/molecules/nav/nav';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import sharedStyles from './toolbar-items.module.scss';
+import mainNavStyles from './main-nav.module.scss';
+
+export type MainNavProps = {
+ /**
+ * Set additional classnames to the nav element.
+ */
+ className?: string;
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * The main nav items.
+ */
+ items: NavItem[];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+};
+
+const MainNav: VFC<MainNavProps> = ({
+ className = '',
+ isActive,
+ items,
+ setIsActive,
+}) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close menu',
+ description: 'MainNav: Close label',
+ id: 'aJC7D2',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open menu',
+ description: 'MainNav: Open label',
+ id: 'GTbGMy',
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${mainNavStyles.item}`}>
+ <Checkbox
+ id="main-nav-button"
+ name="main-nav-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${mainNavStyles.checkbox}`}
+ />
+ <Label
+ htmlFor="main-nav-button"
+ aria-label={label}
+ className={`${sharedStyles.label} ${mainNavStyles.label}`}
+ >
+ <Hamburger iconClassName={mainNavStyles.icon} />
+ </Label>
+ <Nav
+ kind="main"
+ items={items}
+ className={`${sharedStyles.modal} ${mainNavStyles.modal} ${className}`}
+ listClassName={mainNavStyles.modal__list}
+ />
+ </div>
+ );
+};
+
+export default MainNav;
diff --git a/src/components/organisms/toolbar/search.module.scss b/src/components/organisms/toolbar/search.module.scss
new file mode 100644
index 0000000..c310594
--- /dev/null
+++ b/src/components/organisms/toolbar/search.module.scss
@@ -0,0 +1,3 @@
+.modal {
+ padding-bottom: var(--spacing-md);
+}
diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx
new file mode 100644
index 0000000..8c2e871
--- /dev/null
+++ b/src/components/organisms/toolbar/search.stories.tsx
@@ -0,0 +1,63 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { IntlProvider } from 'react-intl';
+import SearchComponent from './search';
+
+export default {
+ title: 'Organisms/Toolbar',
+ component: SearchComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Define the modal state: either opened or closed.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to update modal state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SearchComponent>;
+
+const Template: ComponentStory<typeof SearchComponent> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return (
+ <IntlProvider locale="en">
+ <SearchComponent isActive={isOpen} setIsActive={setIsOpen} {...args} />
+ </IntlProvider>
+ );
+};
+
+export const Search = Template.bind({});
+Search.args = {
+ isActive: false,
+};
diff --git a/src/components/organisms/toolbar/search.test.tsx b/src/components/organisms/toolbar/search.test.tsx
new file mode 100644
index 0000000..0ce09d8
--- /dev/null
+++ b/src/components/organisms/toolbar/search.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@test-utils';
+import Search from './search';
+
+describe('Search', () => {
+ it('renders a button to open search modal', () => {
+ render(<Search isActive={false} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Open search');
+ });
+
+ it('renders a button to close search modal', () => {
+ render(<Search isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close search');
+ });
+
+ it('renders a search form', () => {
+ render(<Search isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx
new file mode 100644
index 0000000..070bce0
--- /dev/null
+++ b/src/components/organisms/toolbar/search.tsx
@@ -0,0 +1,66 @@
+import Checkbox, { CheckboxProps } from '@components/atoms/forms/checkbox';
+import Label from '@components/atoms/forms/label';
+import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import SearchModal from '../modals/search-modal';
+import sharedStyles from './toolbar-items.module.scss';
+import searchStyles from './search.module.scss';
+
+export type SearchProps = {
+ /**
+ * Set additional classnames to the modal wrapper.
+ */
+ className?: string;
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+};
+
+const Search: VFC<SearchProps> = ({
+ className = '',
+ isActive,
+ setIsActive,
+}) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close search',
+ id: 'LDDUNO',
+ description: 'Search: Close label',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open search',
+ id: 'Xj+WXB',
+ description: 'Search: Open label',
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${searchStyles.item}`}>
+ <Checkbox
+ id="search-button"
+ name="search-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${searchStyles.checkbox}`}
+ />
+ <Label
+ htmlFor="search-button"
+ aria-label={label}
+ className={`${sharedStyles.label} ${searchStyles.label}`}
+ >
+ <MagnifyingGlass />
+ </Label>
+ <SearchModal
+ className={`${sharedStyles.modal} ${searchStyles.modal} ${className}`}
+ />
+ </div>
+ );
+};
+
+export default Search;
diff --git a/src/components/organisms/toolbar/settings.module.scss b/src/components/organisms/toolbar/settings.module.scss
new file mode 100644
index 0000000..08c8cd4
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.modal {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ width: 120%;
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx
new file mode 100644
index 0000000..f01e772
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.stories.tsx
@@ -0,0 +1,76 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import { IntlProvider } from 'react-intl';
+import SettingsComponent from './settings';
+
+export default {
+ title: 'Organisms/Toolbar',
+ component: SettingsComponent,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Define the modal state: either opened or closed.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to update modal state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ tooltipClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SettingsComponent>;
+
+const Template: ComponentStory<typeof SettingsComponent> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return (
+ <IntlProvider locale="en">
+ <SettingsComponent isActive={isOpen} setIsActive={setIsOpen} {...args} />
+ </IntlProvider>
+ );
+};
+
+export const Settings = Template.bind({});
+Settings.args = {
+ isActive: false,
+};
diff --git a/src/components/organisms/toolbar/settings.test.tsx b/src/components/organisms/toolbar/settings.test.tsx
new file mode 100644
index 0000000..96a32c9
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@test-utils';
+import Settings from './settings';
+
+describe('Settings', () => {
+ it('renders a button to open settings modal', () => {
+ render(<Settings isActive={false} setIsActive={() => null} />);
+ expect(
+ screen.getByRole('checkbox', { name: 'Open settings' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a button to close settings modal', () => {
+ render(<Settings isActive={true} setIsActive={() => null} />);
+ expect(
+ screen.getByRole('checkbox', { name: 'Close settings' })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx
new file mode 100644
index 0000000..88539fb
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -0,0 +1,72 @@
+import Checkbox, { CheckboxProps } from '@components/atoms/forms/checkbox';
+import Label from '@components/atoms/forms/label';
+import Cog from '@components/atoms/icons/cog';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import SettingsModal from '../modals/settings-modal';
+import sharedStyles from './toolbar-items.module.scss';
+import settingsStyles from './settings.module.scss';
+
+export type SettingsProps = {
+ /**
+ * Set additional classnames to the modal wrapper.
+ */
+ className?: string;
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+ /**
+ * Set additional classnames to the tooltip wrapper.
+ */
+ tooltipClassName?: string;
+};
+
+const Settings: VFC<SettingsProps> = ({
+ className = '',
+ isActive,
+ setIsActive,
+ tooltipClassName = '',
+}) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close settings',
+ id: '+viX9b',
+ description: 'Settings: Close label',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open settings',
+ id: 'QCW3cy',
+ description: 'Settings: Open label',
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${settingsStyles.item}`}>
+ <Checkbox
+ id="settings-button"
+ name="settings-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${settingsStyles.checkbox}`}
+ />
+ <Label
+ htmlFor="settings-button"
+ aria-label={label}
+ className={`${sharedStyles.label} ${settingsStyles.label}`}
+ >
+ <Cog />
+ </Label>
+ <SettingsModal
+ className={`${sharedStyles.modal} ${settingsStyles.modal} ${className}`}
+ tooltipClassName={tooltipClassName}
+ />
+ </div>
+ );
+};
+
+export default Settings;
diff --git a/src/components/organisms/toolbar/toolbar-items.module.scss b/src/components/organisms/toolbar/toolbar-items.module.scss
new file mode 100644
index 0000000..fd526d6
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar-items.module.scss
@@ -0,0 +1,96 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.item {
+ --btn-size: #{fun.convert-px(65)};
+
+ display: flex;
+ position: relative;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ justify-content: flex-end;
+ }
+
+ @include mix.dimensions("md") {
+ justify-content: flex-end;
+ }
+ }
+}
+
+.modal {
+ position: absolute;
+ top: var(--toolbar-size, calc(var(--btn-size) + var(--spacing-2xs)));
+ transition: all 0.8s ease-in-out 0s;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ left: 0;
+ right: 0;
+ }
+ }
+}
+
+.label {
+ --draw-border-thickness: #{fun.convert-px(4)};
+ --draw-border-color1: var(--color-primary-light);
+ --draw-border-color2: var(--color-primary-lighter);
+
+ display: flex;
+ place-content: center;
+ place-items: center;
+ width: var(--btn-size);
+ height: var(--btn-size);
+ padding: var(--spacing-2xs);
+
+ &:hover,
+ &:focus {
+ @extend %draw-borders;
+ }
+
+ &:focus {
+ color: var(--color-primary-light);
+ }
+
+ &:active {
+ --draw-border-color1: var(--color-primary-dark);
+ --draw-border-color2: var(--color-primary-light);
+
+ @extend %draw-borders;
+ }
+}
+
+.checkbox {
+ position: absolute;
+ top: calc(var(--btn-size) / 2);
+ left: calc(var(--btn-size) / 2);
+ opacity: 0;
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ ~ .label {
+ @extend %draw-borders;
+ }
+ }
+
+ &:not(:checked) {
+ ~ .modal {
+ opacity: 0;
+ visibility: hidden;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ transform: translateX(-100vw);
+ }
+
+ @include mix.dimensions("sm") {
+ transform: perspective(#{fun.convert-px(400)})
+ translate3d(0, 0, #{fun.convert-px(-400)});
+ transform-origin: 100% -50%;
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/toolbar.module.scss b/src/components/organisms/toolbar/toolbar.module.scss
new file mode 100644
index 0000000..bb0a91f
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.module.scss
@@ -0,0 +1,80 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ --toolbar-size: #{fun.convert-px(75)};
+
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: space-around;
+ width: 100%;
+ height: var(--toolbar-size);
+ position: relative;
+ background: var(--color-bg);
+ border-top: fun.convert-px(4) solid;
+ border-image: radial-gradient(
+ ellipse at top,
+ var(--color-primary-lighter) 20%,
+ var(--color-primary) 100%
+ )
+ 1;
+ box-shadow: 0 fun.convert-px(-2) fun.convert-px(3) fun.convert-px(-1)
+ var(--color-shadow-dark);
+
+ .modal {
+ &--search,
+ &--settings {
+ min-width: 30ch;
+ }
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ --toolbar-size: #{fun.convert-px(70)};
+ }
+
+ @include mix.dimensions(null, "sm") {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ z-index: 5;
+
+ .modal {
+ top: unset;
+ bottom: calc(var(--toolbar-size) - #{fun.convert-px(4)});
+ max-height: calc(100vh - var(--toolbar-size));
+ }
+
+ .tooltip {
+ top: unset;
+ bottom: calc(100% + var(--spacing-xs));
+ transform-origin: bottom right;
+ }
+ }
+
+ @include mix.dimensions("sm", "md") {
+ .modal {
+ top: calc(var(--toolbar-size) + var(--spacing-2xs));
+ bottom: unset;
+ }
+ }
+
+ @include mix.dimensions("sm") {
+ justify-content: flex-end;
+ gap: var(--spacing-sm);
+
+ .tooltip {
+ transform-origin: top right;
+ }
+ }
+
+ @include mix.dimensions("md") {
+ .tooltip {
+ width: 120%;
+ right: -10%;
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/toolbar.stories.tsx b/src/components/organisms/toolbar/toolbar.stories.tsx
new file mode 100644
index 0000000..75d70d8
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.stories.tsx
@@ -0,0 +1,26 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import ToolbarComponent from './toolbar';
+
+export default {
+ title: 'Organisms/Toolbar',
+ component: ToolbarComponent,
+} as ComponentMeta<typeof ToolbarComponent>;
+
+const Template: ComponentStory<typeof ToolbarComponent> = (args) => (
+ <IntlProvider locale="en">
+ <ToolbarComponent {...args} />
+ </IntlProvider>
+);
+
+const nav = [
+ { id: 'home-link', href: '#', label: 'Home' },
+ { id: 'blog-link', href: '#', label: 'Blog' },
+ { id: 'cv-link', href: '#', label: 'CV' },
+ { id: 'contact-link', href: '#', label: 'Contact' },
+];
+
+export const Toolbar = Template.bind({});
+Toolbar.args = {
+ nav,
+};
diff --git a/src/components/organisms/toolbar/toolbar.test.tsx b/src/components/organisms/toolbar/toolbar.test.tsx
new file mode 100644
index 0000000..4bfe8a8
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@test-utils';
+import Toolbar from './toolbar';
+
+const nav = [
+ { id: 'home-link', href: '/', label: 'Home' },
+ { id: 'blog-link', href: '/blog', label: 'Blog' },
+ { id: 'cv-link', href: '/cv', label: 'CV' },
+ { id: 'contact-link', href: '/contact', label: 'Contact' },
+];
+
+describe('Toolbar', () => {
+ it('renders a navigation menu', () => {
+ render(<Toolbar nav={nav} />);
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx
new file mode 100644
index 0000000..81e80cf
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -0,0 +1,51 @@
+import { useState, VFC } from 'react';
+import MainNav, { type MainNavProps } from '../toolbar/main-nav';
+import Search from '../toolbar/search';
+import Settings from '../toolbar/settings';
+import styles from './toolbar.module.scss';
+
+export type ToolbarProps = {
+ /**
+ * Set additional classnames to the toolbar wrapper.
+ */
+ className?: string;
+ /**
+ * The main nav items.
+ */
+ nav: MainNavProps['items'];
+};
+
+/**
+ * Toolbar component
+ *
+ * Render the website toolbar.
+ */
+const Toolbar: VFC<ToolbarProps> = ({ className = '', nav }) => {
+ const [isNavOpened, setIsNavOpened] = useState<boolean>(false);
+ const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false);
+ const [isSearchOpened, setIsSearchOpened] = useState<boolean>(false);
+
+ return (
+ <div className={`${styles.wrapper} ${className}`}>
+ <MainNav
+ items={nav}
+ isActive={isNavOpened}
+ setIsActive={setIsNavOpened}
+ className={styles.modal}
+ />
+ <Search
+ isActive={isSearchOpened}
+ setIsActive={setIsSearchOpened}
+ className={`${styles.modal} ${styles['modal--search']}`}
+ />
+ <Settings
+ isActive={isSettingsOpened}
+ setIsActive={setIsSettingsOpened}
+ className={`${styles.modal} ${styles['modal--settings']}`}
+ tooltipClassName={styles.tooltip}
+ />
+ </div>
+ );
+};
+
+export default Toolbar;
diff --git a/src/components/organisms/widgets/image-widget.module.scss b/src/components/organisms/widgets/image-widget.module.scss
new file mode 100644
index 0000000..78c0d26
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.module.scss
@@ -0,0 +1,43 @@
+@use "@styles/abstracts/functions" as fun;
+
+.img {
+ max-height: fun.convert-px(350);
+ margin: 0;
+}
+
+.txt {
+ padding: var(--spacing-sm);
+}
+
+.widget {
+ &--left {
+ .img {
+ margin-right: auto;
+ }
+
+ .txt {
+ text-align: left;
+ }
+ }
+
+ &--center {
+ .img {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .txt {
+ text-align: center;
+ }
+ }
+
+ &--right {
+ .img {
+ margin-left: auto;
+ }
+
+ .txt {
+ text-align: right;
+ }
+ }
+}
diff --git a/src/components/organisms/widgets/image-widget.stories.tsx b/src/components/organisms/widgets/image-widget.stories.tsx
new file mode 100644
index 0000000..1c2397b
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.stories.tsx
@@ -0,0 +1,113 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import ImageWidgetComponent from './image-widget';
+
+export default {
+ title: 'Organisms/Widgets',
+ component: ImageWidgetComponent,
+ args: {
+ alignment: 'left',
+ },
+ argTypes: {
+ alignment: {
+ control: {
+ type: 'select',
+ },
+ description: 'The content alignment.',
+ options: ['left', 'center', 'right'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'left' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ description: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add a caption image.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ expanded: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'The state of the widget.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ img: {
+ description: 'An image object.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The widget title level (hn).',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add a link to the image.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ImageWidgetComponent>;
+
+const Template: ComponentStory<typeof ImageWidgetComponent> = (args) => (
+ <IntlProvider locale="en">
+ <ImageWidgetComponent {...args} />
+ </IntlProvider>
+);
+
+const img = {
+ alt: 'Et perferendis quaerat',
+ height: 480,
+ src: 'http://placeimg.com/640/480/nature',
+ width: 640,
+};
+
+export const ImageWidget = Template.bind({});
+ImageWidget.args = {
+ expanded: true,
+ img,
+ level: 2,
+ title: 'Quo et totam',
+};
diff --git a/src/components/organisms/widgets/image-widget.test.tsx b/src/components/organisms/widgets/image-widget.test.tsx
new file mode 100644
index 0000000..8c24bd9
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@test-utils';
+import ImageWidget from './image-widget';
+
+const description = 'Ut vitae sit';
+
+const img = {
+ alt: 'Et perferendis quaerat',
+ height: 480,
+ src: 'http://placeimg.com/640/480/nature',
+ width: 640,
+};
+
+const title = 'Fugiat cumque et';
+const titleLevel = 2;
+
+const url = '/another-page';
+
+describe('ImageWidget', () => {
+ it('renders an image', () => {
+ render(
+ <ImageWidget expanded={true} img={img} title={title} level={titleLevel} />
+ );
+ expect(screen.getByRole('img', { name: img.alt })).toBeInTheDocument();
+ });
+
+ it('renders a link', () => {
+ render(
+ <ImageWidget
+ expanded={true}
+ img={img}
+ title={title}
+ level={titleLevel}
+ url={url}
+ />
+ );
+ expect(screen.getByRole('link', { name: img.alt })).toHaveAttribute(
+ 'href',
+ url
+ );
+ });
+
+ it('renders a description', () => {
+ render(
+ <ImageWidget
+ expanded={true}
+ img={img}
+ description={description}
+ title={title}
+ level={titleLevel}
+ />
+ );
+ expect(screen.getByText(description)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/widgets/image-widget.tsx b/src/components/organisms/widgets/image-widget.tsx
new file mode 100644
index 0000000..928d5ea
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.tsx
@@ -0,0 +1,75 @@
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { VFC } from 'react';
+import styles from './image-widget.module.scss';
+
+export type Alignment = 'left' | 'center' | 'right';
+
+export type Image = {
+ /**
+ * An alternative text for the image.
+ */
+ alt: string;
+ /**
+ * The image height.
+ */
+ height: number;
+ /**
+ * The image source.
+ */
+ src: string;
+ /**
+ * The image width.
+ */
+ width: number;
+};
+
+export type ImageWidgetProps = Pick<
+ WidgetProps,
+ 'expanded' | 'level' | 'title'
+> & {
+ /**
+ * The content alignment.
+ */
+ alignment?: Alignment;
+ /**
+ * Add a caption to the image.
+ */
+ description?: string;
+ /**
+ * An object describing the image.
+ */
+ img: Image;
+ /**
+ * Add a link to the image.
+ */
+ url?: string;
+};
+
+/**
+ * ImageWidget component
+ *
+ * Renders a widget that print an image and an optional text.
+ */
+const ImageWidget: VFC<ImageWidgetProps> = ({
+ alignment = 'left',
+ description,
+ img,
+ url,
+ ...props
+}) => {
+ const alignmentClass = `widget--${alignment}`;
+
+ return (
+ <Widget className={styles[alignmentClass]} {...props}>
+ <ResponsiveImage
+ target={url}
+ caption={description}
+ className={styles.img}
+ {...img}
+ />
+ </Widget>
+ );
+};
+
+export default ImageWidget;
diff --git a/src/components/organisms/widgets/links-list-widget.module.scss b/src/components/organisms/widgets/links-list-widget.module.scss
new file mode 100644
index 0000000..cbad83e
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.module.scss
@@ -0,0 +1,71 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/placeholders";
+
+.widget {
+ .list {
+ &__link {
+ display: block;
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ background: none;
+ text-decoration: underline solid transparent 0;
+
+ &:hover,
+ &:focus {
+ background: var(--color-bg-secondary);
+ font-weight: 600;
+ }
+
+ &:focus {
+ color: var(--color-primary);
+ text-decoration-color: var(--color-primary-light);
+ text-decoration-thickness: 0.25ex;
+ }
+
+ &:active {
+ background: var(--color-bg-tertiary);
+ text-decoration-color: transparent;
+ text-decoration-thickness: 0;
+ }
+ }
+
+ &--ordered {
+ @extend %reset-ordered-list;
+
+ counter-reset: link;
+
+ .list__link {
+ counter-increment: link;
+
+ &::before {
+ padding-right: var(--spacing-2xs);
+ content: counters(link, ".") ". ";
+ color: var(--color-secondary);
+ }
+ }
+ }
+
+ &--unordered {
+ @extend %reset-list;
+ }
+
+ &__item {
+ &:not(:last-child) {
+ .list__link {
+ border-bottom: fun.convert-px(1) solid var(--color-primary);
+ }
+ }
+
+ > .list {
+ .list__link {
+ padding-left: var(--spacing-md);
+ }
+
+ .list__item > .list {
+ .list__link {
+ padding-left: var(--spacing-xl);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/widgets/links-list-widget.stories.tsx b/src/components/organisms/widgets/links-list-widget.stories.tsx
new file mode 100644
index 0000000..528f6f7
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.stories.tsx
@@ -0,0 +1,92 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import LinksListWidget from './links-list-widget';
+
+export default {
+ title: 'Organisms/Widgets',
+ component: LinksListWidget,
+ args: {
+ kind: 'unordered',
+ },
+ argTypes: {
+ items: {
+ description: 'The widget data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind: either ordered or unordered.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof LinksListWidget>;
+
+const Template: ComponentStory<typeof LinksListWidget> = (args) => (
+ <IntlProvider locale="en">
+ <LinksListWidget {...args} />
+ </IntlProvider>
+);
+
+const items = [
+ { name: 'Level 1: Item 1', url: '#' },
+ {
+ name: 'Level 1: Item 2',
+ url: '#',
+ child: [
+ { name: 'Level 2: Item 1', url: '#' },
+ { name: 'Level 2: Item 2', url: '#' },
+ {
+ name: 'Level 2: Item 3',
+ url: '#',
+ child: [
+ { name: 'Level 3: Item 1', url: '#' },
+ { name: 'Level 3: Item 2', url: '#' },
+ ],
+ },
+ { name: 'Level 2: Item 4', url: '#' },
+ ],
+ },
+ { name: 'Level 1: Item 3', url: '#' },
+ { name: 'Level 1: Item 4', url: '#' },
+];
+
+export const LinksList = Template.bind({});
+LinksList.args = {
+ items,
+ level: 2,
+ title: 'A list of links',
+};
diff --git a/src/components/organisms/widgets/links-list-widget.test.tsx b/src/components/organisms/widgets/links-list-widget.test.tsx
new file mode 100644
index 0000000..a8d6a35
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@test-utils';
+import LinksListWidget from './links-list-widget';
+
+const title = 'Voluptatem minus autem';
+
+const items = [
+ { name: 'Item 1', url: '/item-1' },
+ { name: 'Item 2', url: '/item-2' },
+ { name: 'Item 3', url: '/item-3' },
+];
+
+describe('LinksListWidget', () => {
+ it('renders a widget title', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(
+ screen.getByRole('heading', { level: 2, name: new RegExp(title, 'i') })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the correct number of items', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(items.length);
+ });
+
+ it('renders some links', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(screen.getByRole('link', { name: items[0].name })).toHaveAttribute(
+ 'href',
+ items[0].url
+ );
+ });
+});
diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx
new file mode 100644
index 0000000..155354e
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.tsx
@@ -0,0 +1,81 @@
+import Link from '@components/atoms/links/link';
+import List, { ListProps, type ListItem } from '@components/atoms/lists/list';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { slugify } from '@utils/helpers/slugify';
+import { VFC } from 'react';
+import styles from './links-list-widget.module.scss';
+
+export type LinksListItems = {
+ /**
+ * An array of name/url couple child of this list item.
+ */
+ child?: LinksListItems[];
+ /**
+ * The item name.
+ */
+ name: string;
+ /**
+ * The item url.
+ */
+ url: string;
+};
+
+export type LinksListWidgetProps = Pick<WidgetProps, 'level' | 'title'> &
+ Pick<ListProps, 'kind'> & {
+ /**
+ * An array of name/url couple.
+ */
+ items: LinksListItems[];
+ };
+
+/**
+ * LinksListWidget component
+ *
+ * Render a list of links inside a widget.
+ */
+const LinksListWidget: VFC<LinksListWidgetProps> = ({
+ items,
+ kind = 'unordered',
+ ...props
+}) => {
+ const listKindClass = `list--${kind}`;
+
+ /**
+ * Format the widget data to be used as List items.
+ *
+ * @param {LinksListItems[]} data - The widget data.
+ * @returns {ListItem[]} The list items data.
+ */
+ const getListItems = (data: LinksListItems[]): ListItem[] => {
+ return data.map((item) => {
+ return {
+ id: slugify(item.name),
+ child: item.child && getListItems(item.child),
+ value: (
+ <Link href={item.url} className={styles.list__link}>
+ {item.name}
+ </Link>
+ ),
+ };
+ });
+ };
+
+ return (
+ <Widget
+ expanded={true}
+ withBorders={true}
+ className={styles.widget}
+ {...props}
+ >
+ <List
+ items={getListItems(items)}
+ kind={kind}
+ withMargin={false}
+ className={`${styles.list} ${styles[listKindClass]}`}
+ itemsClassName={styles.list__item}
+ />
+ </Widget>
+ );
+};
+
+export default LinksListWidget;
diff --git a/src/components/organisms/widgets/sharing.module.scss b/src/components/organisms/widgets/sharing.module.scss
new file mode 100644
index 0000000..e06d4e3
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.list {
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-xs);
+ margin: 0;
+ padding: 0 var(--spacing-2xs);
+ list-style-type: none;
+}
diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx
new file mode 100644
index 0000000..be20b84
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.stories.tsx
@@ -0,0 +1,78 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SharingWidget from './sharing';
+
+export default {
+ title: 'Organisms/Widgets',
+ component: SharingWidget,
+ argTypes: {
+ data: {
+ description: 'The page data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ expanded: {
+ control: {
+ type: null,
+ },
+ description: 'Default widget state (expanded or collapsed).',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ media: {
+ control: {
+ type: null,
+ },
+ description: 'An array of active and ordered sharing medium.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SharingWidget>;
+
+const Template: ComponentStory<typeof SharingWidget> = (args) => (
+ <IntlProvider locale="en">
+ <SharingWidget {...args} />
+ </IntlProvider>
+);
+
+export const Sharing = Template.bind({});
+Sharing.args = {
+ expanded: true,
+ data: {
+ excerpt:
+ 'Alias similique eius ducimus laudantium aspernatur. Est rem ut eum temporibus sit reprehenderit aut non molestias. Vel dolorem expedita labore quo inventore aliquid nihil nam. Possimus nobis enim quas corporis eos.',
+ title: 'Accusantium totam nostrum',
+ url: 'https://www.example.test',
+ },
+ level: 2,
+ media: ['diaspora', 'facebook', 'linkedin', 'twitter', 'email'],
+ title: 'Sharing',
+};
diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx
new file mode 100644
index 0000000..265dbe1
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@test-utils';
+import Sharing, { type SharingData } from './sharing';
+
+const postData: SharingData = {
+ excerpt: 'A post excerpt',
+ title: 'A post title',
+ url: 'https://sharing-website.test',
+};
+
+describe('Sharing', () => {
+ it('renders a sharing widget', () => {
+ render(
+ <Sharing
+ data={postData}
+ media={['facebook', 'twitter']}
+ expanded={true}
+ title="Sharing"
+ level={2}
+ />
+ );
+ expect(
+ screen.getByRole('link', { name: 'Share on facebook' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: 'Share on twitter' })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: 'Share on linkedin' })
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
new file mode 100644
index 0000000..ccd3a21
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -0,0 +1,190 @@
+import SharingLink, {
+ type SharingMedium,
+} from '@components/atoms/links/sharing-link';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { VFC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './sharing.module.scss';
+
+export type SharingData = {
+ /**
+ * The content excerpt.
+ */
+ excerpt: string;
+ /**
+ * The content title.
+ */
+ title: string;
+ /**
+ * The content url.
+ */
+ url: string;
+};
+
+export type SharingProps = WidgetProps & {
+ /**
+ * The page data to share.
+ */
+ data: SharingData;
+ /**
+ * A list of active and ordered sharing medium.
+ */
+ media: SharingMedium[];
+};
+
+/**
+ * Sharing widget component
+ *
+ * Render a list of sharing links inside a widget.
+ */
+const Sharing: VFC<SharingProps> = ({ data, media, ...props }) => {
+ const intl = useIntl();
+
+ /**
+ * Build the Diaspora sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Diaspora url.
+ */
+ const buildDiasporaUrl = (title: string, url: string): string => {
+ const titleParam = `title=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://share.diasporafoundation.org/?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Build the mailto url from provided data.
+ *
+ * @param {string} excerpt - The content excerpt.
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The mailto url with params.
+ */
+ const buildEmailUrl = (
+ excerpt: string,
+ title: string,
+ url: string
+ ): string => {
+ const intro = intl.formatMessage({
+ defaultMessage: 'Introduction:',
+ description: 'Sharing: email content prefix',
+ id: 'yfgMcl',
+ });
+ const readMore = intl.formatMessage({
+ defaultMessage: 'Read more here:',
+ description: 'Sharing: content link prefix',
+ id: 'UsQske',
+ });
+ const body = `${intro}\n\n"${excerpt}"\n\n${readMore} ${url}`;
+ const bodyParam = excerpt ? `body=${encodeURI(body)}` : '';
+
+ const subject = intl.formatMessage(
+ {
+ defaultMessage: 'You should read {title}',
+ description: 'Sharing: subject text',
+ id: 'azgQuH',
+ },
+ { title }
+ );
+ const subjectParam = `subject=${encodeURI(subject)}`;
+
+ return `mailto:?${bodyParam}&${subjectParam}`;
+ };
+
+ /**
+ * Build the Facebook sharing url with provided data.
+ *
+ * @param {string} url - The content url.
+ * @returns {string} The Facebook url.
+ */
+ const buildFacebookUrl = (url: string): string => {
+ const urlParam = `u=${encodeURI(url)}`;
+ return `https://www.facebook.com/sharer/sharer.php?${urlParam}`;
+ };
+
+ /**
+ * Build the Journal du Hacker sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Journal du Hacker url.
+ */
+ const buildJdHUrl = (title: string, url: string): string => {
+ const titleParam = `title=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://www.journalduhacker.net/stories/new?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Build the LinkedIn sharing url with provided data.
+ *
+ * @param {string} url - The content url.
+ * @returns {string} The LinkedIn url.
+ */
+ const buildLinkedInUrl = (url: string): string => {
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://www.linkedin.com/sharing/share-offsite/?${urlParam}`;
+ };
+
+ /**
+ * Build the Twitter sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Twitter url.
+ */
+ const buildTwitterUrl = (title: string, url: string): string => {
+ const titleParam = `text=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://twitter.com/intent/tweet?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Retrieve the sharing url by medium id.
+ *
+ * @param {SharingMedium} medium - A sharing medium id.
+ * @returns {string} The sharing url.
+ */
+ const getUrl = (medium: SharingMedium): string => {
+ const { excerpt, title, url } = data;
+
+ switch (medium) {
+ case 'diaspora':
+ return buildDiasporaUrl(title, url);
+ case 'email':
+ return buildEmailUrl(excerpt, title, url);
+ case 'facebook':
+ return buildFacebookUrl(url);
+ case 'journal-du-hacker':
+ return buildJdHUrl(title, url);
+ case 'linkedin':
+ return buildLinkedInUrl(url);
+ case 'twitter':
+ return buildTwitterUrl(title, url);
+ default:
+ return '#';
+ }
+ };
+
+ /**
+ * Get the sharing list items.
+ *
+ * @returns {JSX.Element[]} The sharing links wrapped with li element.
+ */
+ const getItems = (): JSX.Element[] => {
+ return media.map((medium) => (
+ <li key={medium}>
+ <SharingLink medium={medium} url={getUrl(medium)} />
+ </li>
+ ));
+ };
+
+ return (
+ <Widget {...props}>
+ <ul className={styles.list}>{getItems()}</ul>
+ </Widget>
+ );
+};
+
+export default Sharing;
diff --git a/src/components/organisms/widgets/social-media.module.scss b/src/components/organisms/widgets/social-media.module.scss
new file mode 100644
index 0000000..01b6c0e
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/placeholders";
+
+.list {
+ @extend %reset-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-xs);
+ padding: 0 var(--spacing-2xs);
+}
diff --git a/src/components/organisms/widgets/social-media.stories.tsx b/src/components/organisms/widgets/social-media.stories.tsx
new file mode 100644
index 0000000..2b84012
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.stories.tsx
@@ -0,0 +1,56 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import SocialMediaWidget, { Media } from './social-media';
+
+export default {
+ title: 'Organisms/Widgets',
+ component: SocialMediaWidget,
+ argTypes: {
+ level: {
+ control: {
+ type: 'number',
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ media: {
+ description: 'The links data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SocialMediaWidget>;
+
+const Template: ComponentStory<typeof SocialMediaWidget> = (args) => (
+ <IntlProvider locale="en">
+ <SocialMediaWidget {...args} />
+ </IntlProvider>
+);
+
+const media: Media[] = [
+ { name: 'Github', url: '#' },
+ { name: 'LinkedIn', url: '#' },
+];
+
+export const SocialMedia = Template.bind({});
+SocialMedia.args = {
+ media,
+ title: 'Follow me',
+ level: 2,
+};
diff --git a/src/components/organisms/widgets/social-media.test.tsx b/src/components/organisms/widgets/social-media.test.tsx
new file mode 100644
index 0000000..e40db30
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import SocialMedia, { Media } from './social-media';
+
+const media: Media[] = [
+ { name: 'Github', url: '#' },
+ { name: 'LinkedIn', url: '#' },
+];
+const title = 'Dolores ut ut';
+const titleLevel = 2;
+
+/**
+ * Next.js mock images with next/image component. So for now, I need to mock
+ * the svg files manually.
+ */
+jest.mock('@assets/images/social-media/github.svg', () => 'svg-file');
+jest.mock('@assets/images/social-media/linkedin.svg', () => 'svg-file');
+
+describe('SocialMedia', () => {
+ it('renders the widget title', () => {
+ render(<SocialMedia media={media} title={title} level={titleLevel} />);
+ expect(
+ screen.getByRole('heading', {
+ level: titleLevel,
+ name: new RegExp(title, 'i'),
+ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the correct number of items', () => {
+ render(<SocialMedia media={media} title={title} level={titleLevel} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(media.length);
+ });
+});
diff --git a/src/components/organisms/widgets/social-media.tsx b/src/components/organisms/widgets/social-media.tsx
new file mode 100644
index 0000000..58b2f73
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.tsx
@@ -0,0 +1,41 @@
+import SocialLink, {
+ type SocialLinkProps,
+} from '@components/atoms/links/social-link';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { FC } from 'react';
+import styles from './social-media.module.scss';
+
+export type Media = SocialLinkProps;
+
+export type SocialMediaProps = Pick<WidgetProps, 'level' | 'title'> & {
+ media: Media[];
+};
+
+/**
+ * Social Media widget component
+ *
+ * Render a social media list with links.
+ */
+const SocialMedia: FC<SocialMediaProps> = ({ media, ...props }) => {
+ /**
+ * Retrieve the social media items.
+ *
+ * @param {SocialMedia[]} links - An array of social media name and url.
+ * @returns {JSX.Element[]} The social links.
+ */
+ const getItems = (links: Media[]): JSX.Element[] => {
+ return links.map((link, index) => (
+ <li key={`media-${index}`}>
+ <SocialLink name={link.name} url={link.url} />
+ </li>
+ ));
+ };
+
+ return (
+ <Widget expanded={true} {...props}>
+ <ul className={styles.list}>{getItems(media)}</ul>
+ </Widget>
+ );
+};
+
+export default SocialMedia;