9 Commits

19 changed files with 669 additions and 575 deletions

View File

@@ -1,6 +1,6 @@
# Only Task - Интерактивная временная шкала # Only Task
Современное React-приложение с интерактивной круговой временной шкалой и каруселью исторических событий. Тестовое задание для only.digital
## 🚀 Технологии ## 🚀 Технологии
@@ -218,14 +218,4 @@ Pre-push hook автоматически запускает:
ISC ISC
## 👨‍💻 Разработка
Для разработки рекомендуется:
1. Запустить `pnpm dev` для dev-сервера
2. Использовать `pnpm storybook` для разработки компонентов в изоляции
3. Писать тесты для новых компонентов
4. Следовать существующей структуре проекта
---
**Приятной разработки! 🚀** **Приятной разработки! 🚀**

View File

@@ -94,12 +94,22 @@ Babel используется через `babel-loader` в webpack конфиг
```typescript ```typescript
// config/build/loaders/buildBabelLoader.ts // config/build/loaders/buildBabelLoader.ts
{ {
test: /\.(js|jsx|tsx)$/, test: /\.(js|jsx|tsx|ts)$/,
exclude: /node_modules/, exclude: [
/node_modules/,
/\.test\.(ts|tsx)$/, // Исключаем тестовые файлы
/\.spec\.(ts|tsx)$/, // Исключаем spec файлы
/\.stories\.(ts|tsx)$/ // Исключаем Storybook файлы
],
use: { use: {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
presets: ['@babel/preset-env'], cacheDirectory: true, // Кеширование для ускорения пересборки
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript' // Компиляция TypeScript через Babel
],
plugins: [ plugins: [
isDev && require.resolve('react-refresh/babel') isDev && require.resolve('react-refresh/babel')
].filter(Boolean) ].filter(Boolean)
@@ -108,6 +118,9 @@ Babel используется через `babel-loader` в webpack конфиг
} }
``` ```
**Важно:** TypeScript компилируется через Babel, а не через `ts-loader`.
Проверка типов выполняется отдельно через `pnpm type-check` (tsc --noEmit).
## React Refresh (только в dev режиме) ## React Refresh (только в dev режиме)
В режиме разработки добавляется плагин `react-refresh/babel` для горячей перезагрузки React компонентов без потери состояния. В режиме разработки добавляется плагин `react-refresh/babel` для горячей перезагрузки React компонентов без потери состояния.

View File

@@ -13,9 +13,11 @@ import { BuildOptions } from './types/config'
* Текущий порядок: * Текущий порядок:
* 1. fileLoader - обрабатывает изображения и шрифты * 1. fileLoader - обрабатывает изображения и шрифты
* 2. svgrLoader - преобразует SVG в React компоненты * 2. svgrLoader - преобразует SVG в React компоненты
* 3. babelLoader - транспилирует JS/JSX/TSX с React Refresh * 3. babelLoader - транспилирует JS/JSX/TS/TSX с помощью Babel (включая TypeScript)
* 4. typescriptLoader - компилирует TypeScript * 4. cssLoader - обрабатывает CSS/SCSS с модулями
* 5. cssLoader - обрабатывает CSS/SCSS с модулями *
* Примечание: TypeScript компилируется через Babel (@babel/preset-typescript),
* а не через ts-loader. Проверка типов выполняется отдельно через `tsc --noEmit`.
* *
* @param {BuildOptions} options - Опции сборки * @param {BuildOptions} options - Опции сборки
* @param {boolean} options.isDev - Флаг режима разработки * @param {boolean} options.isDev - Флаг режима разработки

View File

@@ -22,3 +22,15 @@ jest.mock('gsap', () => {
Power2: gsapMock.Power2, // Экспортируем Power2 отдельно Power2: gsapMock.Power2, // Экспортируем Power2 отдельно
} }
}) })
// Глобальный мок для @gsap/react
jest.mock('@gsap/react', () => ({
useGSAP: (fn: () => void) => {
// Выполняем функцию немедленно в тестах
// eslint-disable-next-line react-hooks/rules-of-hooks
const { useEffect } = require('react')
useEffect(() => {
fn()
}, [])
},
}))

View File

@@ -20,6 +20,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@gsap/react": "^2.1.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -39,6 +40,7 @@
"@storybook/addon-webpack5-compiler-babel": "^4.0.0", "@storybook/addon-webpack5-compiler-babel": "^4.0.0",
"@storybook/react": "^8.6.14", "@storybook/react": "^8.6.14",
"@storybook/react-webpack5": "^8.6.14", "@storybook/react-webpack5": "^8.6.14",
"@stylistic/stylelint-plugin": "^4.0.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",

39
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@gsap/react':
specifier: ^2.1.2
version: 2.1.2(gsap@3.13.0)(react@19.2.0)
classnames: classnames:
specifier: ^2.5.1 specifier: ^2.5.1
version: 2.5.1 version: 2.5.1
@@ -60,6 +63,9 @@ importers:
'@storybook/react-webpack5': '@storybook/react-webpack5':
specifier: ^8.6.14 specifier: ^8.6.14
version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(esbuild@0.25.12)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@8.6.14(prettier@3.6.2))(typescript@5.9.3)(webpack-cli@5.1.4) version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(esbuild@0.25.12)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@8.6.14(prettier@3.6.2))(typescript@5.9.3)(webpack-cli@5.1.4)
'@stylistic/stylelint-plugin':
specifier: ^4.0.0
version: 4.0.0(stylelint@16.25.0(typescript@5.9.3))
'@svgr/webpack': '@svgr/webpack':
specifier: ^8.1.0 specifier: ^8.1.0
version: 8.1.0(typescript@5.9.3) version: 8.1.0(typescript@5.9.3)
@@ -1111,6 +1117,12 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@gsap/react@2.1.2':
resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==}
peerDependencies:
gsap: ^3.12.5
react: '>=17'
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@@ -1655,6 +1667,12 @@ packages:
peerDependencies: peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
'@stylistic/stylelint-plugin@4.0.0':
resolution: {integrity: sha512-CFwt3K4Y/7bygNCLCQ8Sy4Hzgbhxq3BsNW0FIuYxl17HD3ywptm54ocyeiLVRrk5jtz1Zwks7Xr9eiZt8SWHAw==}
engines: {node: ^18.12 || >=20.9}
peerDependencies:
stylelint: ^16.22.0
'@svgr/babel-plugin-add-jsx-attribute@8.0.0': '@svgr/babel-plugin-add-jsx-attribute@8.0.0':
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -5215,6 +5233,9 @@ packages:
peerDependencies: peerDependencies:
webpack: ^5.27.0 webpack: ^5.27.0
style-search@0.1.0:
resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==}
stylelint-config-recommended-scss@14.1.0: stylelint-config-recommended-scss@14.1.0:
resolution: {integrity: sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==} resolution: {integrity: sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==}
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
@@ -6799,6 +6820,11 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@gsap/react@2.1.2(gsap@3.13.0)(react@19.2.0)':
dependencies:
gsap: 3.13.0
react: 19.2.0
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7': '@humanfs/node@0.16.7':
@@ -7517,6 +7543,17 @@ snapshots:
dependencies: dependencies:
storybook: 8.6.14(prettier@3.6.2) storybook: 8.6.14(prettier@3.6.2)
'@stylistic/stylelint-plugin@4.0.0(stylelint@16.25.0(typescript@5.9.3))':
dependencies:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
'@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
postcss: 8.5.6
postcss-selector-parser: 7.1.0
postcss-value-parser: 4.2.0
style-search: 0.1.0
stylelint: 16.25.0(typescript@5.9.3)
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
@@ -11603,6 +11640,8 @@ snapshots:
dependencies: dependencies:
webpack: 5.103.0(esbuild@0.25.12)(webpack-cli@5.1.4) webpack: 5.103.0(esbuild@0.25.12)(webpack-cli@5.1.4)
style-search@0.1.0: {}
stylelint-config-recommended-scss@14.1.0(postcss@8.5.6)(stylelint@16.25.0(typescript@5.9.3)): stylelint-config-recommended-scss@14.1.0(postcss@8.5.6)(stylelint@16.25.0(typescript@5.9.3)):
dependencies: dependencies:
postcss-scss: 4.0.9(postcss@8.5.6) postcss-scss: 4.0.9(postcss@8.5.6)

View File

@@ -1,34 +1,34 @@
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
color: var(--color-text); color: var(--color-text);
font-family: var(--font-family-main); font-family: var(--font-family-main);
background-color: var(--color-bg); background-color: var(--color-bg);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
button { button {
font-family: inherit; font-family: inherit;
border: none; border: none;
background: none; background: none;
cursor: pointer; cursor: pointer;
} }
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
ul, ul,
ol { ol {
list-style: none; list-style: none;
} }

View File

@@ -1,23 +1,23 @@
:root { :root {
// Цвета // Цвета
--color-primary: #42567A; --color-primary: #42567A;
--color-accent: #EF5DA8; --color-accent: #EF5DA8;
--color-text: #42567A; --color-text: #42567A;
--color-bg: #F4F5F9; --color-bg: #F4F5F9;
--color-border: rgb(66 86 122 / 10%); --color-border: rgb(66 86 122 / 10%);
--color-blue: #3877EE; --color-blue: #3877EE;
--color-white: #FFF; --color-white: #FFF;
// Градиенты // Градиенты
--gradient-primary: linear-gradient(to bottom, #3877EE, #EF5DA8); --gradient-primary: linear-gradient(to bottom, #3877EE, #EF5DA8);
// Типографика // Типографика
--font-family-main: 'PT Sans', sans-serif; --font-family-main: 'PT Sans', sans-serif;
--font-size-h1: 56px; --font-size-h1: 56px;
--font-size-h2: 32px; --font-size-h2: 32px;
--font-size-h3: 20px; --font-size-h3: 20px;
--font-size-body: 16px; --font-size-body: 16px;
--font-size-small: 14px; --font-size-small: 14px;
--line-height-h1: 120%; --line-height-h1: 120%;
--line-height-body: 150%; --line-height-body: 150%;
} }

View File

@@ -1,108 +1,108 @@
.button { .button {
display: inline-flex; display: inline-flex;
justify-content: center;
align-items: center;
padding: 0;
font-family: var(--font-family-main);
border: none;
background: transparent;
outline: none;
cursor: pointer;
transition: all 0.3s ease;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
// Variants
&.round {
border-radius: 50%;
aspect-ratio: 1;
}
&.regular {
padding: 0.5em 1em;
border-radius: 1em;
}
// Sizes
&.small {
height: 40px;
font-size: 14px;
@media (width <=768px) {
height: 20px;
}
}
&.medium {
height: 50px;
font-size: 18px;
@media (width <=768px) {
height: 25px;
}
}
&.large {
height: 60px;
font-size: 24px;
@media (width <=768px) {
height: 30px;
}
}
// Color Schemes
&.primary {
$color-primary: var(--color-primary);
color: $color-primary;
border: 1px solid $color-primary;
background-color: transparent;
&:hover:not(:disabled) {
background-color: var(--color-white);
}
}
&.secondary {
$color-blue: var(--color-blue);
color: $color-blue;
background-color: var(--color-white);
box-shadow: 0 0 15px rgb($color-blue / 10%);
}
// Icon handling
.icon {
display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 0; width: 100%;
height: 100%;
font-family: var(--font-family-main); svg {
width: 40%;
height: 40%;
border: none; object-fit: contain;
background: transparent;
outline: none;
cursor: pointer;
transition: all 0.3s ease;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
// Variants
&.round {
border-radius: 50%;
aspect-ratio: 1;
}
&.regular {
padding: 0.5em 1em;
border-radius: 1em;
}
// Sizes
&.small {
height: 40px;
font-size: 14px;
@media (width <=768px) {
height: 20px;
}
}
&.medium {
height: 50px;
font-size: 18px;
@media (width <=768px) {
height: 25px;
}
}
&.large {
height: 60px;
font-size: 24px;
@media (width <=768px) {
height: 30px;
}
}
// Color Schemes
&.primary {
$color-primary: var(--color-primary);
color: $color-primary;
border: 1px solid $color-primary;
background-color: transparent;
&:hover:not(:disabled) {
background-color: var(--color-white);
}
}
&.secondary {
$color-blue: var(--color-blue);
color: $color-blue;
background-color: var(--color-white);
box-shadow: 0 0 15px rgb($color-blue / 10%);
}
// Icon handling
.icon {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
svg {
width: 40%;
height: 40%;
object-fit: contain;
}
} }
}
} }

View File

@@ -1,27 +1,27 @@
.card { .card {
padding: 20px 0; padding: 20px 0;
} }
.title { .title {
margin-bottom: 15px; margin-bottom: 15px;
color: var(--color-blue); color: var(--color-blue);
font-weight: 400; font-weight: 400;
font-size: 25px; font-size: 25px;
line-height: 120%; line-height: 120%;
@media (width <=768px) { @media (width <=768px) {
font-size: 16px; font-size: 16px;
} }
} }
.description { .description {
color: var(--color-text); color: var(--color-text);
font-weight: 400; font-weight: 400;
font-size: 20px; font-size: 20px;
line-height: 145%; line-height: 145%;
@media (width <=768px) { @media (width <=768px) {
font-size: 14px; font-size: 14px;
} }
} }

View File

@@ -1,76 +1,76 @@
.circleContainer { .circleContainer {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
width: calc(var(--circle-radius, 265px) * 2); width: calc(var(--circle-radius, 265px) * 2);
height: calc(var(--circle-radius, 265px) * 2); height: calc(var(--circle-radius, 265px) * 2);
border: 1px solid rgba(#42567A, 0.2); border: 1px solid rgba(#42567A, 0.2);
border-radius: 50%; border-radius: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.point { .point {
position: absolute;
width: 56px;
height: 56px;
margin-top: -28px;
margin-left: -28px;
border: 25px solid transparent;
border-radius: 50%;
background: var(--color-text);
background-clip: content-box;
cursor: pointer;
transform-origin: center;
transition: all 0.3s ease;
&:hover,
&.active {
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid rgba(#303E58, 0.5);
background: #F4F5F9;
background-clip: padding-box;
}
&:hover .number,
&.active .number {
display: block;
}
.number {
display: none;
color: var(--color-text);
font-size: 20px;
}
.title {
position: absolute; position: absolute;
left: 100%;
width: 56px; margin-left: 20px;
height: 56px;
margin-top: -28px;
margin-left: -28px;
border: 25px solid transparent; color: var(--color-text);
border-radius: 50%; font-weight: 700;
font-size: 20px;
white-space: nowrap;
background: var(--color-text); visibility: hidden;
background-clip: content-box;
cursor: pointer; opacity: 0;
}
transform-origin: center;
transition: all 0.3s ease;
&:hover,
&.active {
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid rgba(#303E58, 0.5);
background: #F4F5F9;
background-clip: padding-box;
}
&:hover .number,
&.active .number {
display: block;
}
.number {
display: none;
color: var(--color-text);
font-size: 20px;
}
.title {
position: absolute;
left: 100%;
margin-left: 20px;
color: var(--color-text);
font-weight: 700;
font-size: 20px;
white-space: nowrap;
visibility: hidden;
opacity: 0;
}
} }

View File

@@ -8,9 +8,10 @@
* Поддерживает клик по точкам для переключения периодов. * Поддерживает клик по точкам для переключения периодов.
*/ */
import { useGSAP } from '@gsap/react'
import classNames from 'classnames' import classNames from 'classnames'
import { gsap } from 'gsap' import { gsap } from 'gsap'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { memo, useCallback, useMemo, useRef } from 'react'
import styles from './CircleTimeline.module.scss' import styles from './CircleTimeline.module.scss'
import { calculateCoordinates } from '../../lib/utils/calculateCoordinates/calculateCoordinates' import { calculateCoordinates } from '../../lib/utils/calculateCoordinates/calculateCoordinates'
@@ -71,52 +72,55 @@ export const CircleTimeline = memo(function CircleTimeline({
const titlesRef = useRef<(HTMLSpanElement | null)[]>([]) const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
/** /**
* Эффект для анимации поворота круга и контр-поворота точек * Анимация поворота круга и контр-поворота точек
* Запускается при изменении rotation * Использует useGSAP hook для автоматической очистки анимаций
*/ */
useEffect(() => { useGSAP(
// Анимация поворота контейнера круга () => {
if (circleRef.current) { // Анимация поворота контейнера круга
gsap.to(circleRef.current, { if (circleRef.current) {
rotation, gsap.to(circleRef.current, {
duration: ANIMATION_DURATION, rotation,
ease: ANIMATION_EASE, duration: ANIMATION_DURATION,
})
}
// Контр-поворот точек, чтобы текст оставался читаемым
pointsRef.current.forEach((point, index) => {
if (point) {
gsap.to(point, {
rotation: -rotation,
duration: 0,
ease: ANIMATION_EASE, ease: ANIMATION_EASE,
}) })
}
// Анимация заголовка // Контр-поворот точек, чтобы текст оставался читаемым
const title = titlesRef.current[index] pointsRef.current.forEach((point, index) => {
if (title) { if (point) {
// Сбрасываем предыдущие анимации для этого элемента gsap.to(point, {
gsap.killTweensOf(title) rotation: -rotation,
duration: 0,
ease: ANIMATION_EASE,
})
if (index === activeIndex) { // Анимация заголовка
gsap.to(title, { const title = titlesRef.current[index]
opacity: 1, if (title) {
visibility: 'visible', // Останавливаем предыдущие анимации для предотвращения конфликтов
duration: 0.5, gsap.killTweensOf(title)
delay: ANIMATION_DURATION, // Ждем окончания вращения
}) if (index === activeIndex) {
} else { gsap.to(title, {
gsap.to(title, { opacity: 1,
opacity: 0, visibility: 'visible',
visibility: 'hidden', duration: 0.5,
duration: 0.2, delay: ANIMATION_DURATION, // Ждем окончания вращения
}) })
} else {
gsap.to(title, {
opacity: 0,
visibility: 'hidden',
duration: 0.2,
})
}
} }
} }
} })
}) },
}, [rotation, activeIndex]) { dependencies: [rotation, activeIndex] }
)
/** /**
* Мемоизированный расчет позиций точек на круге * Мемоизированный расчет позиций точек на круге

View File

@@ -1,59 +1,51 @@
.container { .container {
position: relative; position: relative;
opacity: 0; opacity: 0;
} }
.prevButtonWrapper { .prevButtonWrapper {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: -60px; left: -60px;
z-index: 10; z-index: 10;
transform: translateY(-50%); transform: translateY(-50%);
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.nextButtonWrapper { .nextButtonWrapper {
position: absolute; position: absolute;
top: 50%; top: 50%;
right: -60px; right: -60px;
z-index: 10; z-index: 10;
transform: translateY(-50%) rotate(180deg); transform: translateY(-50%) rotate(180deg);
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.hidden { .hidden {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
:global(.swiper) { :global(.swiper) {
@media (width <=768px) { @container timeframe-slider (width <= 768px) {
padding: 0 20px; padding: 0 40px;
} }
} }
:global(.swiper-slide-next) { :global(.swiper-slide-visible) {
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
@media (width <=768px) { @container timeframe-slider (width < 768px) {
opacity: 0.4; opacity: 0.4;
} }
} }
:global(.swiper-slide-prev) { :global(.swiper-slide-fully-visible) {
transition: opacity 0.3s ease; opacity: 1;
@media (width <=768px) {
opacity: 0.4;
}
}
:global(.swiper-slide-active) {
opacity: 1;
} }

View File

@@ -4,9 +4,10 @@
* Отображает список исторических событий в виде слайдера * Отображает список исторических событий в виде слайдера
*/ */
import { useGSAP } from '@gsap/react'
import classNames from 'classnames' import classNames from 'classnames'
import { gsap } from 'gsap' import { gsap } from 'gsap'
import { memo, useEffect, useRef, useState } from 'react' import { memo, useRef, useState } from 'react'
import { Swiper, SwiperSlide } from 'swiper/react' import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css' import 'swiper/css'
@@ -45,7 +46,7 @@ export interface EventsCarouselProps {
* *
* Использует Swiper для создания слайдера с кастомной навигацией. * Использует Swiper для создания слайдера с кастомной навигацией.
* Поддерживает адаптивное количество слайдов на разных размерах экрана. * Поддерживает адаптивное количество слайдов на разных размерах экрана.
* Анимирует появление/исчезновение с помощью GSAP. * Анимирует появление/исчезновение с помощью GSAP useGSAP hook.
* *
* @example * @example
* ```tsx * ```tsx
@@ -62,13 +63,11 @@ export const EventsCarousel = memo(
const [isEnd, setIsEnd] = useState(false) const [isEnd, setIsEnd] = useState(false)
/** /**
* Эффект для анимации появления/исчезновения карусели * Анимация появления/исчезновения карусели
* Использует GSAP для плавной анимации opacity и y-позиции * Использует useGSAP hook для автоматической очистки анимаций
*/ */
useEffect(() => { useGSAP(
if (!containerRef.current) return () => {
const ctx = gsap.context(() => {
if (visible) { if (visible) {
gsap.fromTo( gsap.fromTo(
containerRef.current, containerRef.current,
@@ -86,10 +85,9 @@ export const EventsCarousel = memo(
duration: HIDE_DURATION, duration: HIDE_DURATION,
}) })
} }
}, containerRef) },
{ scope: containerRef, dependencies: [visible, events] }
return () => ctx.revert() )
}, [visible, events])
/** /**
* Обработчик инициализации Swiper * Обработчик инициализации Swiper

View File

@@ -16,7 +16,7 @@ export const EVENT_CAROUSEL_CONFIG: SwiperOptions = {
}, },
breakpoints: { breakpoints: {
576: { 576: {
slidesPerView: 2, slidesPerView: 2.5,
}, },
768: { 768: {
slidesPerView: 2, slidesPerView: 2,

View File

@@ -1,282 +1,303 @@
/* Wrapper для container queries - должен быть родителем контейнера */
.wrapper {
/* Включаем container queries для адаптивности виджета */
container-type: inline-size;
container-name: timeframe-slider;
}
.container { .container {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
max-width: 1440px; max-width: 1440px;
min-height: 100vh; min-height: 100vh;
margin: 0 auto; margin: 0 auto;
padding-top: 180px; padding-top: 180px;
color: var(--color-text); color: var(--color-text);
font-family: var(--font-family-main); font-family: var(--font-family-main);
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
border-left: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
background-image: linear-gradient(to right, rgba(#42567A, 0.1) 1px, transparent 1px); background-image: linear-gradient(to right, rgba(#42567A, 0.1) 1px, transparent 1px);
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center top; background-position: center top;
background-size: 1px 100%; background-size: 1px 100%;
overflow: hidden; overflow: hidden;
@media (width <=1024px) { @container timeframe-slider (width <= 1024px) {
padding-top: 100px; padding-top: 100px;
} }
@media (width <=768px) { @container timeframe-slider (width <= 576px) {
padding: 60px 20px 20px; padding: 60px 20px 20px;
background-image: unset; background-image: unset;
} }
} }
.title { .title {
position: absolute; position: absolute;
top: 170px; top: 170px;
left: 0; left: 0;
z-index: 2; z-index: 2;
max-width: 15ch; max-width: 15ch;
padding-left: 75px; padding-left: 75px;
font-weight: 700; font-weight: 700;
font-size: 56px; font-size: 56px;
line-height: 120%; line-height: 120%;
border-left: 5px solid transparent; border-left: 5px solid transparent;
border-image: var(--gradient-primary) 1; border-image: var(--gradient-primary) 1;
@media (width <=1024px) { @container timeframe-slider (width <= 1024px) {
top: 80px; top: 80px;
font-size: 40px; font-size: 40px;
} }
@media (width <=768px) { @container timeframe-slider (width <= 768px) {
position: relative; padding-left: 20px;
inset: unset;
margin-bottom: 20px; font-size: 34px;
padding-left: 0; }
font-size: 20px; @container timeframe-slider (width <= 576px) {
position: relative;
inset: unset;
border: none; margin-bottom: 20px;
} padding-left: 0;
border: none;
}
} }
.content { .content {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
width: calc(100% + 40px); width: calc(100% + 40px);
height: 600px; height: 600px;
margin: 0 -20px; margin: 0 -20px;
background-image: linear-gradient(to bottom, rgba(#42567A, 0.1) 1px, transparent 1px); background-image: linear-gradient(to bottom, rgba(#42567A, 0.1) 1px, transparent 1px);
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;
background-size: 100% 1px; background-size: 100% 1px;
@media (width <=768px) { @container timeframe-slider (width <= 576px) {
position: unset; position: unset;
display: flex;
flex-direction: column;
width: 100%;
height: auto;
margin: 0;
background-image: unset;
}
}
.controls {
position: absolute;
left: 100px;
bottom: 0;
z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px;
transform-origin: left; width: 100%;
height: auto;
margin: 0;
@media (width <=1024px) { background-image: unset;
left: 100px; }
bottom: 40px; }
}
@media (width <=768px) { .controls {
left: 20px; position: absolute;
bottom: 13px; left: 100px;
bottom: 0;
z-index: 10;
order: 2; display: flex;
flex-direction: column;
gap: 20px;
gap: 10px; transform-origin: left;
margin-top: 20px; @container timeframe-slider (width <= 1024px) {
padding: 0; left: 100px;
} bottom: 40px;
}
@container timeframe-slider (width <= 768px) {
left: 40px;
gap: 10px;
}
@container timeframe-slider (width <= 576px) {
left: 20px;
bottom: 13px;
order: 2;
margin-top: 20px;
padding: 0;
}
} }
.pagination { .pagination {
font-weight: 400; font-weight: 400;
font-size: 14px; font-size: 14px;
} }
.buttons { .buttons {
display: flex; display: flex;
gap: 20px; gap: 20px;
@media (width <=768px) { @container timeframe-slider (width <= 768px) {
gap: 8px; gap: 8px;
} }
} }
.chevronIcon { .chevronIcon {
width: 9px; width: 9px;
height: 14px; height: 14px;
@media (width <=768px) { @container timeframe-slider (width <= 576px) {
width: 6px; width: 6px;
height: 11.5px; height: 11.5px;
} }
} }
.dots { .dots {
display: none; display: none;
@media (width <=768px) { @container timeframe-slider (width <= 576px) {
position: absolute; position: absolute;
left: 50%; left: 50%;
bottom: 32px; bottom: 32px;
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
} }
.dot { .dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
padding: 0; padding: 0;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background-color: var(--color-primary); background-color: var(--color-primary);
cursor: pointer; cursor: pointer;
opacity: 0.4; opacity: 0.4;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
&.activeDot { &.activeDot {
opacity: 1; opacity: 1;
} }
} }
.rotated { .rotated {
transform: rotate(180deg); transform: rotate(180deg);
} }
.centerDate { .centerDate {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
z-index: 0; z-index: 0;
display: flex; display: flex;
gap: 60px; gap: 60px;
color: var(--color-text); color: var(--color-text);
font-weight: 700; font-weight: 700;
font-size: 200px; font-size: 200px;
line-height: 160px; line-height: 160px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
@media (width <=1024px) { @container timeframe-slider (width <= 1024px) {
gap: 40px; gap: 40px;
font-size: 140px; font-size: 140px;
line-height: 120px; line-height: 120px;
} }
@media (width <=768px) { @container timeframe-slider (width <= 768px) {
position: static; font-size: 100px;
line-height: 80px;
}
gap: 20px; @container timeframe-slider (width <= 576px) {
justify-content: center; position: static;
margin-bottom: 40px; gap: 20px;
justify-content: center;
font-size: 56px; margin-bottom: 40px;
transform: none; font-size: 56px;
}
span:first-child { transform: none;
color: #5d5fef; }
}
span:last-child { span:first-child {
color: #ef5da8; color: #5d5fef;
} }
span:last-child {
color: #ef5da8;
}
} }
.periodLabel { .periodLabel {
display: none; display: none;
@media (width <=768px) { @container timeframe-slider (width <= 576px) {
order: 1; order: 1;
display: block; display: block;
padding-bottom: 20px; padding-bottom: 20px;
color: var(--color-text); color: var(--color-text);
font-weight: 700; font-weight: 700;
font-size: 16px; font-size: 16px;
text-align: left; text-align: left;
border-bottom: 1px solid #C7CDD9; border-bottom: 1px solid #C7CDD9;
} }
} }
.circleContainer { .circleContainer {
width: 100%; width: 100%;
height: 100%; height: 100%;
@media (width <=768px) { @container timeframe-slider (width <= 576px) {
display: none; display: none;
} }
} }
.carouselContainer { .carouselContainer {
padding: 55px 80px 105px; padding: 55px 80px 105px;
@media (width <=768px) { @container timeframe-slider (width <= 768px) {
width: calc(100% + 40px); width: calc(100% + 40px);
margin: 0 -20px; margin: 0 -20px;
padding: 0; padding: 0;
} }
} }

View File

@@ -3,6 +3,7 @@
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий * Главный компонент временной шкалы с круговой диаграммой и каруселью событий
*/ */
import { useGSAP } from '@gsap/react'
import classNames from 'classnames' import classNames from 'classnames'
import { gsap } from 'gsap' import { gsap } from 'gsap'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -21,7 +22,7 @@ import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
* *
* Отображает исторические периоды на круговой диаграмме с возможностью * Отображает исторические периоды на круговой диаграмме с возможностью
* переключения между ними. Для каждого периода показывается карусель событий. * переключения между ними. Для каждого периода показывается карусель событий.
* Центральные даты анимируются при смене периода с помощью GSAP. * Центральные даты анимируются при смене периода с помощью GSAP useGSAP hook.
* *
* @example * @example
* ```tsx * ```tsx
@@ -66,13 +67,11 @@ export const TimeFrameSlider = memo(() => {
}, [activePeriod, anglePerPoint]) }, [activePeriod, anglePerPoint])
/** /**
* Анимация центральных дат с использованием GSAP * Анимация центральных дат с использованием GSAP useGSAP hook
* Плавно изменяет числа при смене периода * Плавно изменяет числа при смене периода
*/ */
useEffect(() => { useGSAP(
if (!containerRef.current) return () => {
const ctx = gsap.context(() => {
if (startYearRef.current) { if (startYearRef.current) {
gsap.fromTo( gsap.fromTo(
startYearRef.current, startYearRef.current,
@@ -102,6 +101,7 @@ export const TimeFrameSlider = memo(() => {
prevYearFromRef.current = currentPeriod.yearFrom prevYearFromRef.current = currentPeriod.yearFrom
prevYearToRef.current = currentPeriod.yearTo prevYearToRef.current = currentPeriod.yearTo
// Анимация появления лейбла периода
if (periodLabelRef.current) { if (periodLabelRef.current) {
gsap.fromTo( gsap.fromTo(
periodLabelRef.current, periodLabelRef.current,
@@ -109,10 +109,12 @@ export const TimeFrameSlider = memo(() => {
{ opacity: 1, visibility: 'visible', duration: 1 } { opacity: 1, visibility: 'visible', duration: 1 }
) )
} }
}, containerRef) },
{
return () => ctx.revert() scope: containerRef,
}, [currentPeriod.yearFrom, currentPeriod.yearTo]) dependencies: [currentPeriod.yearFrom, currentPeriod.yearTo],
}
)
/** /**
* Переключение на предыдущий период * Переключение на предыдущий период
@@ -131,74 +133,76 @@ export const TimeFrameSlider = memo(() => {
}, [totalPeriods]) }, [totalPeriods])
return ( return (
<div className={styles.container} ref={containerRef}> <div className={styles.wrapper}>
<h1 className={styles.title}>Исторические даты</h1> <div className={styles.container} ref={containerRef}>
<h1 className={styles.title}>Исторические даты</h1>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.centerDate}> <div className={styles.centerDate}>
<span ref={startYearRef}>{currentPeriod.yearFrom}</span> <span ref={startYearRef}>{currentPeriod.yearFrom}</span>
<span ref={endYearRef}>{currentPeriod.yearTo}</span> <span ref={endYearRef}>{currentPeriod.yearTo}</span>
</div>
<div className={styles.periodLabel} ref={periodLabelRef}>
{currentPeriod.label}
</div>
<div className={styles.circleContainer}>
<CircleTimeline
periods={HISTORICAL_PERIODS}
activeIndex={activePeriod}
onPeriodChange={setActivePeriod}
rotation={rotation}
/>
</div>
<div className={styles.controls}>
<div className={styles.pagination}>
{String(activePeriod + 1).padStart(2, '0')}/
{String(totalPeriods).padStart(2, '0')}
</div> </div>
<div className={styles.buttons}>
<Button <div className={styles.periodLabel} ref={periodLabelRef}>
variant='round' {currentPeriod.label}
size='medium' </div>
colorScheme='primary'
onClick={handlePrev} <div className={styles.circleContainer}>
aria-label='Предыдущий период' <CircleTimeline
> periods={HISTORICAL_PERIODS}
<ChevronSvg className={styles.chevronIcon} stroke='#42567A' /> activeIndex={activePeriod}
</Button> onPeriodChange={setActivePeriod}
<Button rotation={rotation}
variant='round' />
size='medium' </div>
colorScheme='primary'
onClick={handleNext} <div className={styles.controls}>
aria-label='Следующий период' <div className={styles.pagination}>
> {String(activePeriod + 1).padStart(2, '0')}/
<ChevronSvg {String(totalPeriods).padStart(2, '0')}
className={classNames(styles.chevronIcon, styles.rotated)} </div>
stroke='#42567A' <div className={styles.buttons}>
/> <Button
</Button> variant='round'
size='medium'
colorScheme='primary'
onClick={handlePrev}
aria-label='Предыдущий период'
>
<ChevronSvg className={styles.chevronIcon} stroke='#42567A' />
</Button>
<Button
variant='round'
size='medium'
colorScheme='primary'
onClick={handleNext}
aria-label='Следующий период'
>
<ChevronSvg
className={classNames(styles.chevronIcon, styles.rotated)}
stroke='#42567A'
/>
</Button>
</div>
</div> </div>
</div> </div>
</div>
<div className={styles.carouselContainer}> <div className={styles.carouselContainer}>
<EventsCarousel events={currentPeriod.events} visible /> <EventsCarousel events={currentPeriod.events} visible />
</div> </div>
<div className={styles.dots}> <div className={styles.dots}>
{HISTORICAL_PERIODS.map((_, index) => ( {HISTORICAL_PERIODS.map((_, index) => (
<button <button
key={index} key={index}
className={classNames(styles.dot, { className={classNames(styles.dot, {
[styles.activeDot]: index === activePeriod, [styles.activeDot]: index === activePeriod,
})} })}
onClick={() => setActivePeriod(index)} onClick={() => setActivePeriod(index)}
aria-label={`Перейти к периоду ${index + 1}`} aria-label={`Перейти к периоду ${index + 1}`}
/> />
))} ))}
</div>
</div> </div>
</div> </div>
) )

View File

@@ -46,8 +46,12 @@ const {
module.exports = { module.exports = {
extends: 'stylelint-config-standard-scss', extends: 'stylelint-config-standard-scss',
plugins: ['stylelint-order'], plugins: [
'stylelint-order',
'@stylistic/stylelint-plugin',
],
rules: { rules: {
'@stylistic/indentation': 2,
'selector-class-pattern': null, 'selector-class-pattern': null,
'order/order': selectorOrdering, 'order/order': selectorOrdering,
'order/properties-order': propertyOrdering, 'order/properties-order': propertyOrdering,

View File

@@ -34,10 +34,20 @@
- **paths**: Настройка алиасов для импортов - **paths**: Настройка алиасов для импортов
```json ```json
{ {
"@/*": ["./src/*"],
"*": ["./src/*"] "*": ["./src/*"]
} }
``` ```
Позволяет импортировать файлы из `src` без указания полного пути Позволяет импортировать файлы из `src` с использованием `@/` или без префикса
### Типы
- **types**: Глобальные типы для проекта
```json
["node", "jest", "@testing-library/jest-dom"]
```
- `node` - типы Node.js API
- `jest` - типы для Jest тестов
- `@testing-library/jest-dom` - типы для дополнительных матчеров Jest
## Настройки ts-node ## Настройки ts-node
@@ -71,10 +81,13 @@ pnpm type-check
### Использование алиасов путей ### Использование алиасов путей
```typescript ```typescript
// Вместо: // Вместо:
import { Button } from '../../../components/Button' import { Button } from '../../../shared/ui/Button'
// Можно писать: // Можно писать с @ алиасом:
import { Button } from 'components/Button' import { Button } from '@/shared/ui/Button'
// Или без префикса (для совместимости):
import { Button } from 'shared/ui/Button'
``` ```
### JSX без импорта React ### JSX без импорта React