diff --git a/config/storybook/decorators/withLangDecorator.tsx b/config/storybook/decorators/withLangDecorator.tsx
index e1e1506..164d93c 100644
--- a/config/storybook/decorators/withLangDecorator.tsx
+++ b/config/storybook/decorators/withLangDecorator.tsx
@@ -13,6 +13,7 @@ export const withLangDecorator: Decorator = (Story, context) => {
// const { i18n } = useTranslation();
// const [globals, updateGlobals] = useGlobals();
+ /* eslint-disable-next-line */
useEffect(() => {
/* eslint-disable-next-line */
i18n.changeLanguage(locale);
diff --git a/config/storybook/decorators/withThemeDecorator.tsx b/config/storybook/decorators/withThemeDecorator.tsx
index 02a99c3..cd78517 100644
--- a/config/storybook/decorators/withThemeDecorator.tsx
+++ b/config/storybook/decorators/withThemeDecorator.tsx
@@ -9,6 +9,7 @@ import { changeThemeInDOM } from '../../../src/app/providers/ThemeProvider/lib/c
export const withThemeDecorator: Decorator = (Story, context) => {
const { theme } = context.globals;
+ /* eslint-disable-next-line */
useEffect(() => {
changeThemeInDOM(theme as ETheme);
}, [theme]);
diff --git a/eslint.config.js b/eslint.config.js
index 9afa39a..d2d16af 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -7,8 +7,9 @@ import i18nextPlugin from 'eslint-plugin-i18next';
import stylistic from '@stylistic/eslint-plugin';
import jestDom from 'eslint-plugin-jest-dom';
import storybookPlugin from 'eslint-plugin-storybook';
+import reactHooksPlugin from 'eslint-plugin-react-hooks';
-// console.log(storybookPlugin.configs.recommended.overrides);
+// console.log(reactHooksPlugin.configs.recommended);
const jsFiles = '**/*.?(*)js?(x)';
const tsFiles = '**/*.?(*)ts?(x)';
@@ -26,7 +27,8 @@ export default [
'**/.*',
'**/.*.{js,ts}',
'**/*.config.{js,ts}',
- '**/jest-setup.ts'
+ '**/jest-setup.ts',
+ 'storybook-static'
],
},
{
@@ -116,11 +118,13 @@ export default [
},
plugins: {
react: reactPlugin,
+ 'react-hooks': reactHooksPlugin,
i18next: i18nextPlugin,
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules,
+ ...reactHooksPlugin.configs.recommended.rules,
...i18nextPlugin.configs.recommended.rules,
'@stylistic/jsx-max-props-per-line': [1, {
diff --git a/package.json b/package.json
index 2687663..33f2424 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"@svgr/webpack": "^8.1.0",
"@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^14.2.0",
+ "@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.8",
"@types/react": "^18.2.47",
@@ -69,6 +70,7 @@
"eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-storybook": "^0.6.15",
"globals": "^13.24.0",
"html-webpack-plugin": "^5.6.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a616790..0a64b5e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -85,6 +85,9 @@ devDependencies:
'@testing-library/react':
specifier: ^14.2.0
version: 14.2.0(react-dom@18.2.0)(react@18.2.0)
+ '@testing-library/user-event':
+ specifier: ^14.5.2
+ version: 14.5.2(@testing-library/dom@9.3.4)
'@types/jest':
specifier: ^29.5.11
version: 29.5.11
@@ -151,6 +154,9 @@ devDependencies:
eslint-plugin-react:
specifier: ^7.33.2
version: 7.33.2(eslint@8.56.0)
+ eslint-plugin-react-hooks:
+ specifier: ^4.6.0
+ version: 4.6.0(eslint@8.56.0)
eslint-plugin-storybook:
specifier: ^0.6.15
version: 0.6.15(eslint@8.56.0)(typescript@5.3.3)
@@ -4426,6 +4432,15 @@ packages:
'@testing-library/dom': 9.3.4
dev: true
+ /@testing-library/user-event@14.5.2(@testing-library/dom@9.3.4):
+ resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+ dependencies:
+ '@testing-library/dom': 9.3.4
+ dev: true
+
/@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -7413,6 +7428,15 @@ packages:
eslint: 8.56.0
dev: true
+ /eslint-plugin-react-hooks@4.6.0(eslint@8.56.0):
+ resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ dependencies:
+ eslint: 8.56.0
+ dev: true
+
/eslint-plugin-react@7.33.2(eslint@8.56.0):
resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==}
engines: {node: '>=4'}
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index a0c63d6..5b59d7a 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -4,5 +4,6 @@
"reload": "Reload",
"errorBoundaryText": "Something went wrong. Please, reload the page!",
"home": "Home",
- "about": "About"
+ "about": "About",
+ "Log In": "Log In"
}
diff --git a/public/locales/ua/translation.json b/public/locales/ua/translation.json
index c14763c..a4bfc9d 100644
--- a/public/locales/ua/translation.json
+++ b/public/locales/ua/translation.json
@@ -4,5 +4,6 @@
"reload": "Перезавантажити",
"errorBoundaryText": "Щось пішло не за планом. Будь ласка, перезавантажте сторінку!",
"home": "Головна",
- "about": "О нас"
+ "about": "О нас",
+ "Log In": "Увійти"
}
diff --git a/src/app/layouts/Layout.tsx b/src/app/layouts/Layout.tsx
index fbf878d..f08b2a1 100644
--- a/src/app/layouts/Layout.tsx
+++ b/src/app/layouts/Layout.tsx
@@ -19,8 +19,7 @@ const Layout: FC = () => {
-
-
+
100
200
diff --git a/src/app/providers/ThemeProvider/lib/useThemeRelyOnColorScheme.tsx b/src/app/providers/ThemeProvider/lib/useThemeRelyOnColorScheme.tsx
index cdd4138..41c9e31 100644
--- a/src/app/providers/ThemeProvider/lib/useThemeRelyOnColorScheme.tsx
+++ b/src/app/providers/ThemeProvider/lib/useThemeRelyOnColorScheme.tsx
@@ -18,5 +18,5 @@ export const useThemeRelyOnColorScheme = (): void => {
return () => {
OSColorSchemeDark.removeEventListener('change', onThemeChange);
};
- }, []);
+ }, [OSColorSchemeDark]);
};
diff --git a/src/app/providers/router/ui/AppRouterProvider.tsx b/src/app/providers/router/ui/AppRouterProvider.tsx
index 9349e62..8f4ac62 100644
--- a/src/app/providers/router/ui/AppRouterProvider.tsx
+++ b/src/app/providers/router/ui/AppRouterProvider.tsx
@@ -1,7 +1,7 @@
import { type FC } from 'react';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
-import routerConfig from '@/shared/config/routerConfig/routerConfig';
+import routerConfig from '@/shared/config/router/routerConfig';
const router = createBrowserRouter(routerConfig);
diff --git a/src/app/styles/themes/_dark.scss b/src/app/styles/themes/_dark.scss
index d10456a..30ca214 100644
--- a/src/app/styles/themes/_dark.scss
+++ b/src/app/styles/themes/_dark.scss
@@ -5,6 +5,7 @@
:root[data-theme="dark"] {
--color-bg: #{fn.pick-color($theme-colors, 'background', 'dark')};
--color-bg-accent: #{fn.pick-color($theme-colors, 'background2', 'dark')};
+ --color-backdrop: #{fn.pick-color($theme-colors, 'backdrop', 'dark')};
--color-text: #{fn.pick-color($theme-colors, 'text', 'dark')};
--color-heading: #{fn.pick-color($theme-colors, 'heading', 'dark')};
--color-primary: #{fn.pick-color($theme-colors, 'primary', 'dark')};
diff --git a/src/app/styles/themes/_light.scss b/src/app/styles/themes/_light.scss
index 301b8a6..ef97e8e 100644
--- a/src/app/styles/themes/_light.scss
+++ b/src/app/styles/themes/_light.scss
@@ -5,6 +5,7 @@
:root[data-theme="light"] {
--color-bg: #{fn.pick-color($theme-colors, 'background')};
--color-bg-accent: #{fn.pick-color($theme-colors, 'background2')};
+ --color-backdrop: #{fn.pick-color($theme-colors, 'backdrop')};
--color-text: #{fn.pick-color($theme-colors, 'text')};
--color-heading: #{fn.pick-color($theme-colors, 'heading')};
--color-primary: #{fn.pick-color($theme-colors, 'primary')};
diff --git a/src/app/styles/variables/_animations.scss b/src/app/styles/variables/_animations.scss
index 7d57fba..5d6e7ad 100644
--- a/src/app/styles/variables/_animations.scss
+++ b/src/app/styles/variables/_animations.scss
@@ -1,4 +1,19 @@
@keyframes fade-in {
0% { opacity: 0 }
100% { opacity: 1 }
+}
+
+@keyframes fade-out {
+ 0% { opacity: 1 }
+ 100% { opacity: 0 }
+}
+
+@keyframes scale-up {
+ from { transform: scale(.75) }
+ to { transform: scale(1) }
+}
+
+@keyframes scale-down {
+ from { transform: scale(1) }
+ to { transform: scale(.75) }
}
\ No newline at end of file
diff --git a/src/app/styles/variables/_theme-colors.scss b/src/app/styles/variables/_theme-colors.scss
index ab5c87a..45b38e0 100644
--- a/src/app/styles/variables/_theme-colors.scss
+++ b/src/app/styles/variables/_theme-colors.scss
@@ -108,6 +108,10 @@ $theme-colors: (
"light": $white-2,
"dark": $dark-blue,
),
+ "backdrop": (
+ "light": color.change(fn.pick-tint(p.$palette-gray, 900), $alpha: 0.8),
+ "dark": color.change(fn.pick-tint(p.$palette-gray, 900), $alpha: 0.8),
+ ),
"navbar-background": (
"light": $pale-pink,
"dark": $dark-blue
diff --git a/src/pages/HomePage/ui/HomePage.tsx b/src/pages/HomePage/ui/HomePage.tsx
index cfe5fb4..25fbe4c 100644
--- a/src/pages/HomePage/ui/HomePage.tsx
+++ b/src/pages/HomePage/ui/HomePage.tsx
@@ -1,9 +1,9 @@
import { type FC } from 'react';
-
import { useTranslation } from 'react-i18next';
const HomePage: FC = () => {
const { t } = useTranslation();
+
return (
<>
{t('home')}
diff --git a/src/shared/assets/icons/x-mark.svg b/src/shared/assets/icons/x-mark.svg
new file mode 100644
index 0000000..d9dca38
--- /dev/null
+++ b/src/shared/assets/icons/x-mark.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/src/shared/config/routerConfig/routerConfig.tsx b/src/shared/config/router/routerConfig.tsx
similarity index 100%
rename from src/shared/config/routerConfig/routerConfig.tsx
rename to src/shared/config/router/routerConfig.tsx
diff --git a/src/shared/ui/Modal/Modal.module.scss b/src/shared/ui/Modal/Modal.module.scss
new file mode 100644
index 0000000..1ccc8d5
--- /dev/null
+++ b/src/shared/ui/Modal/Modal.module.scss
@@ -0,0 +1,75 @@
+@use "@/app/styles/variables" as vars;
+
+.modal {
+ position: fixed;
+ inset: 0;
+ display: none;
+
+ &__backdrop {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ background-color: var(--color-backdrop);
+ transition: opacity .2s vars.$ease-3;
+ }
+
+ &__wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100vw;
+ height: 100vh;
+ }
+
+ &__content {
+ position: relative;
+
+ max-width: clamp(550px, 60vw, 700px);
+ padding: 24px 20px;
+
+ opacity: 0;
+ background-color: var(--color-bg);
+ border-radius: 4px;
+ }
+
+ &__close-btn {
+ position: absolute;
+ top: 0;
+ right: 0;
+
+ width: 28px;
+ height: 28px;
+
+ color: #000;
+
+ background-color: #fff;
+ border-radius: 4px;
+ }
+
+ &:global(.is-open) {
+ z-index: 10;
+ display: block;
+
+ .modal__backdrop {
+ opacity: 1;
+ animation: fade-in .2s vars.$ease-3;
+ }
+
+ .modal__content {
+ opacity: 1;
+ animation: scale-up .4s vars.$ease-elastic-in-out-5, fade-in .4s vars.$ease-elastic-in-out-5;
+ }
+ }
+
+ &:global(.is-closing) {
+ .modal__backdrop {
+ opacity: 0;
+ animation: fade-out .3s vars.$ease-3;
+ }
+
+ .modal__content {
+ opacity: 0;
+ animation: scale-down .15s vars.$ease-3, fade-out .15s vars.$ease-3;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/shared/ui/Modal/Modal.stories.tsx b/src/shared/ui/Modal/Modal.stories.tsx
new file mode 100644
index 0000000..38c48a8
--- /dev/null
+++ b/src/shared/ui/Modal/Modal.stories.tsx
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Modal } from './Modal';
+
+// 👇 This default export determines where your story goes in the story list
+const meta: Meta
= {
+ title: 'Shared/Modal',
+ component: Modal,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ children: 'Loasdoa sdas das daosdaosk doas dasdo kasdaskodkaso daso dkasodasod kasodkasodkaoskdoa sdo akosdk asod kasodk asodk asod kasod kaso dkaso kasodk asodj ashdu ashjhdasljdhasljdhlas jdhalsjhdlasjhdqwu hdsajhdlasjhdlajshld jasdh asjdhlasjhdasljdh saljdhlasjhdlasj hlsjakhdlajskhdlasjhda jsajldhlajshdaljsdals ljasd',
+ isOpen: true,
+ },
+};
diff --git a/src/shared/ui/Modal/Modal.test.tsx b/src/shared/ui/Modal/Modal.test.tsx
new file mode 100644
index 0000000..a91248d
--- /dev/null
+++ b/src/shared/ui/Modal/Modal.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react';
+// import userEvent from '@testing-library/user-event';
+
+import { Modal } from './Modal';
+
+describe('', () => {
+ test('modal is open', () => {
+ render(Modal);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByTestId('modal')).toHaveClass('is-open');
+ });
+
+ // test('close modal by a close button', async () => {
+ // const closeHandler = jest.fn();
+ // render(Modal);
+ // const modal = screen.getByTestId('modal');
+ // const modalBackdrop = screen.getByTestId('modal-backdrop');
+ // const closeBtn = screen.getByTestId('modal-close-btn');
+
+ // await userEvent.click(closeBtn);
+ // expect(modal).toHaveClass('is-closing');
+ // fireEvent.animationEnd(modalBackdrop);
+ // expect(modal).not.toHaveClass('is-closing');
+ // closeHandler();
+ // expect(closeHandler).toHaveBeenCalled();
+ // // await waitForElementToBeRemoved(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument());
+ // await waitForElementToBeRemoved(() => screen.queryByTestId('modal'));
+
+ // expect(modal).not.toBeInTheDocument();
+ // });
+});
diff --git a/src/shared/ui/Modal/Modal.tsx b/src/shared/ui/Modal/Modal.tsx
new file mode 100644
index 0000000..16fd033
--- /dev/null
+++ b/src/shared/ui/Modal/Modal.tsx
@@ -0,0 +1,110 @@
+import type { ReactNode, FC } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import classes from './Modal.module.scss';
+
+import { classNames } from '@/shared/lib/helpers/classNames/classNames';
+import { Button, Portal } from '@/shared/ui';
+
+import XMarkIcon from '@/shared/assets/icons/x-mark.svg';
+
+interface IProps {
+ children: ReactNode;
+ isOpen?: boolean;
+ onClose?: () => void;
+}
+
+export const Modal: FC = (props) => {
+ const {
+ children,
+ isOpen,
+ onClose,
+ } = props;
+
+ const backdropRef = useRef(null);
+ const contentRef = useRef(null);
+ const [isClosing, setIsClosing] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ document.addEventListener('keydown', onKeyDown);
+ }
+ return () => {
+ document.removeEventListener('keydown', onKeyDown);
+ };
+ }, [isOpen]);
+
+ const onAnimationEnd = useCallback(() => {
+ if (isClosing) {
+ setIsClosing(false);
+ onClose?.();
+ }
+ }, [isClosing, onClose]);
+
+ useEffect(() => {
+ // Listener should be on a backdrop element as it always has the longest duration among other animations
+ const backdropEl = backdropRef.current;
+ backdropEl?.addEventListener('animationend', onAnimationEnd);
+ return () => {
+ backdropEl?.removeEventListener('animationend', onAnimationEnd);
+ };
+ }, [isClosing, onAnimationEnd]);
+
+ const closeHandler = () => {
+ // if (contentRef.current === e.target) {
+ // return e.stopPropagation();
+ // }
+
+ setIsClosing(true);
+ };
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setIsClosing(true);
+ }
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/src/shared/ui/Portal/Portal.tsx b/src/shared/ui/Portal/Portal.tsx
new file mode 100644
index 0000000..e0af5ee
--- /dev/null
+++ b/src/shared/ui/Portal/Portal.tsx
@@ -0,0 +1,14 @@
+import type { FC, ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+
+interface IProps {
+ children: ReactNode;
+ element?: HTMLElement;
+}
+export const Portal: FC = (props) => {
+ const { children, element = document.body } = props;
+
+ return createPortal(
+ children, element
+ );
+};
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index a6bf884..b68f345 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -1,3 +1,6 @@
+export { AppLink } from './AppLink/AppLink';
export { Button } from './Button/Button';
export { LangSwitcher } from './LangSwitcher/LangSwitcher';
export { Loader } from './Loader/Loader';
+export { Modal } from './Modal/Modal';
+export { Portal } from './Portal/Portal';
diff --git a/src/widgets/Navbar/ui/Navbar.module.scss b/src/widgets/Navbar/ui/Navbar.module.scss
index 775be12..ffe7af1 100644
--- a/src/widgets/Navbar/ui/Navbar.module.scss
+++ b/src/widgets/Navbar/ui/Navbar.module.scss
@@ -1,7 +1,7 @@
.navbar {
display: flex;
align-items: center;
- justify-content: flex-start;
+ justify-content: flex-end;
width: 100%;
height: var(--navbar-height);
diff --git a/src/widgets/Navbar/ui/Navbar.tsx b/src/widgets/Navbar/ui/Navbar.tsx
index 7923b3f..8a0d4ea 100644
--- a/src/widgets/Navbar/ui/Navbar.tsx
+++ b/src/widgets/Navbar/ui/Navbar.tsx
@@ -1,22 +1,40 @@
-import { type FC } from 'react';
-
-// import { useTranslation } from 'react-i18next';
+import { useState, type FC } from 'react';
+import { useTranslation } from 'react-i18next';
import classes from './Navbar.module.scss';
import { classNames } from '@/shared/lib/helpers/classNames/classNames';
+import { Button, Modal } from '@/shared/ui';
interface IProps {
className?: string;
}
export const Navbar: FC = ({ className }) => {
- // const { t } = useTranslation();
+ const { t } = useTranslation();
+ const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
+
+ const openModal = () => {
+ setIsAuthModalOpen(true);
+ };
+
+ const closeModal = () => {
+ setIsAuthModalOpen(false);
+ };
return (
-
+
+
+ {/* eslint-disable-next-line */}
+ Loasdoa sdas das daosdaosk doas dasdo kasdaskodkaso daso dkasodasod kasodkasodkaoskdoa sdo akosdk asod kasodk asodk asod kasod kaso dkaso kasodk asodj ashdu ashjhdasljdhasljdhlas jdhalsjhdlasjhdqwu hdsajhdlasjhdlajshld jasdh asjdhlasjhdasljdh saljdhlasjhdlasj hlsjakhdlajskhdlasjhda jsajldhlajshdaljsdals ljasd
);
};
diff --git a/src/widgets/Sidebar/ui/Sidebar/Sidebar.tsx b/src/widgets/Sidebar/ui/Sidebar/Sidebar.tsx
index 01c433e..4f8c16a 100644
--- a/src/widgets/Sidebar/ui/Sidebar/Sidebar.tsx
+++ b/src/widgets/Sidebar/ui/Sidebar/Sidebar.tsx
@@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { classNames } from '@/shared/lib/helpers/classNames/classNames';
-import { RoutePath } from '@/shared/config/routerConfig/routerConfig';
+import { RoutePath } from '@/shared/config/router/routerConfig';
import { Button, LangSwitcher } from '@/shared/ui';
import { ThemeSwitcher } from '@/entities/ThemeSwitcher';