231
README.md
Normal file
231
README.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# Only Task - Интерактивная временная шкала
|
||||
|
||||
Современное React-приложение с интерактивной круговой временной шкалой и каруселью исторических событий.
|
||||
|
||||
## 🚀 Технологии
|
||||
|
||||
- **React 19** - библиотека для создания пользовательских интерфейсов
|
||||
- **TypeScript 5** - типизированный JavaScript
|
||||
- **GSAP** - библиотека для плавных анимаций
|
||||
- **Swiper** - современная библиотека для каруселей
|
||||
- **Webpack 5** - сборщик модулей
|
||||
- **SCSS** - препроцессор CSS с модулями
|
||||
- **Jest** - фреймворк для тестирования
|
||||
- **ESLint + Prettier** - линтинг и форматирование кода
|
||||
|
||||
## 📋 Требования
|
||||
|
||||
- **Node.js** >= 18.0.0
|
||||
- **pnpm** >= 8.0.0
|
||||
|
||||
## 🛠️ Установка
|
||||
|
||||
1. Клонируйте репозиторий:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd only-task
|
||||
```
|
||||
|
||||
2. Установите зависимости:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 🎯 Доступные команды
|
||||
|
||||
### Разработка
|
||||
|
||||
```bash
|
||||
# Запуск dev-сервера на порту 3000
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу: http://localhost:3000
|
||||
|
||||
### Сборка
|
||||
|
||||
```bash
|
||||
# Production сборка
|
||||
pnpm build:prod
|
||||
|
||||
# Development сборка
|
||||
pnpm build:dev
|
||||
```
|
||||
|
||||
Результат сборки будет в папке `dist/`
|
||||
|
||||
### Тестирование
|
||||
|
||||
```bash
|
||||
# Запуск всех unit-тестов
|
||||
pnpm test:unit
|
||||
|
||||
# Проверка типов TypeScript
|
||||
pnpm type-check
|
||||
```
|
||||
|
||||
### Линтинг
|
||||
|
||||
```bash
|
||||
# Проверка JavaScript/TypeScript кода
|
||||
pnpm lint
|
||||
|
||||
# Автоматическое исправление ошибок
|
||||
pnpm lint --fix
|
||||
|
||||
# Проверка стилей (SCSS)
|
||||
pnpm lint:styles
|
||||
|
||||
# Автоматическое исправление стилей
|
||||
pnpm lint:styles --fix
|
||||
```
|
||||
|
||||
### Storybook
|
||||
|
||||
```bash
|
||||
# Запуск Storybook для разработки компонентов
|
||||
pnpm storybook
|
||||
|
||||
# Сборка статической версии Storybook
|
||||
pnpm build-storybook
|
||||
```
|
||||
|
||||
### Pre-push проверки
|
||||
|
||||
```bash
|
||||
# Запуск всех проверок перед push
|
||||
pnpm pre-push
|
||||
```
|
||||
|
||||
Эта команда выполняет:
|
||||
- ✅ Проверку типов TypeScript
|
||||
- ✅ Линтинг кода
|
||||
- ✅ Линтинг стилей
|
||||
- ✅ Запуск unit-тестов
|
||||
- ✅ Production сборку
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
only-task/
|
||||
├── config/ # Конфигурация сборки
|
||||
│ ├── build/ # Webpack конфигурация
|
||||
│ ├── jest/ # Jest конфигурация
|
||||
│ └── storybook/ # Storybook конфигурация
|
||||
├── dist/ # Production сборка (генерируется)
|
||||
├── src/ # Исходный код
|
||||
│ ├── app/ # Корневой компонент приложения
|
||||
│ ├── entities/ # Бизнес-сущности (типы данных)
|
||||
│ ├── shared/ # Переиспользуемые компоненты и утилиты
|
||||
│ │ ├── assets/ # Статические ресурсы (SVG, изображения)
|
||||
│ │ └── ui/ # UI компоненты (Button, Card)
|
||||
│ └── widgets/ # Сложные компоненты (TimeFrameSlider)
|
||||
├── .husky/ # Git hooks
|
||||
├── package.json # Зависимости и скрипты
|
||||
└── tsconfig.json # Конфигурация TypeScript
|
||||
```
|
||||
|
||||
## 🎨 Основные компоненты
|
||||
|
||||
### TimeFrameSlider
|
||||
Главный виджет приложения, объединяющий круговую временную шкалу и карусель событий.
|
||||
|
||||
**Особенности:**
|
||||
- Интерактивная круговая диаграмма с периодами
|
||||
- Плавные GSAP анимации при переключении
|
||||
- Адаптивный дизайн для мобильных, планшетов и десктопа
|
||||
- Кастомная навигация
|
||||
|
||||
### CircleTimeline
|
||||
Круговая временная шкала с точками периодов.
|
||||
|
||||
**Особенности:**
|
||||
- Автоматический поворот к активному периоду
|
||||
- Клик по точкам для переключения
|
||||
- Анимация заголовков периодов
|
||||
|
||||
### EventsCarousel
|
||||
Карусель исторических событий с использованием Swiper.
|
||||
|
||||
**Особенности:**
|
||||
- Адаптивное количество слайдов
|
||||
- Кастомная навигация
|
||||
- Плавная анимация появления/исчезновения
|
||||
|
||||
## ⚡ Оптимизация производительности
|
||||
|
||||
Проект оптимизирован для production:
|
||||
|
||||
- ✅ **Code splitting** - разделение на chunks (vendors, runtime, main)
|
||||
- ✅ **Tree shaking** - удаление неиспользуемого кода
|
||||
- ✅ **Gzip compression** - сжатие файлов (~68% уменьшение размера)
|
||||
- ✅ **Minification** - минификация JS и CSS
|
||||
- ✅ **CSS Modules** - изолированные стили компонентов
|
||||
- ✅ **Babel caching** - кеширование для быстрой пересборки
|
||||
|
||||
**Размер бандла:**
|
||||
- Uncompressed: ~371 KiB
|
||||
- Gzipped: ~117 KiB
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
Проект покрыт unit-тестами:
|
||||
|
||||
- ✅ Тесты компонентов (Button, Card, CircleTimeline, TimeFrameSlider, EventsCarousel)
|
||||
- ✅ Тесты утилит (calculateCoordinates)
|
||||
- ✅ Моки для GSAP и Swiper
|
||||
- ✅ 39 тестов, 100% успешных
|
||||
|
||||
## 📱 Адаптивность
|
||||
|
||||
Приложение адаптировано для всех устройств:
|
||||
|
||||
- **Desktop** (>1440px) - полная версия с круговой диаграммой
|
||||
- **Tablet** (768px - 1024px) - оптимизированная версия
|
||||
- **Mobile** (<768px) - упрощенная версия с навигацией внизу
|
||||
|
||||
## 🔧 Конфигурация
|
||||
|
||||
### Webpack
|
||||
- Babel для транспиляции (быстрее ts-loader)
|
||||
- CSS Modules для изоляции стилей
|
||||
- SVGR для импорта SVG как React компонентов
|
||||
- Hot Module Replacement для разработки
|
||||
- Bundle Analyzer для анализа размера
|
||||
|
||||
### TypeScript
|
||||
- Strict mode включен
|
||||
- Path aliases (`@/*` → `src/*`)
|
||||
- Проверка типов отдельно от сборки
|
||||
|
||||
### ESLint
|
||||
- Правила для React, TypeScript, Jest
|
||||
- Prettier интеграция
|
||||
- Import sorting
|
||||
|
||||
## 🤝 Git Hooks
|
||||
|
||||
Pre-push hook автоматически запускает:
|
||||
1. Type checking
|
||||
2. ESLint
|
||||
3. Stylelint
|
||||
4. Unit tests
|
||||
5. Production build
|
||||
|
||||
Это гарантирует, что в репозиторий попадает только рабочий код.
|
||||
|
||||
## 📝 Лицензия
|
||||
|
||||
ISC
|
||||
|
||||
## 👨💻 Разработка
|
||||
|
||||
Для разработки рекомендуется:
|
||||
1. Запустить `pnpm dev` для dev-сервера
|
||||
2. Использовать `pnpm storybook` для разработки компонентов в изоляции
|
||||
3. Писать тесты для новых компонентов
|
||||
4. Следовать существующей структуре проекта
|
||||
|
||||
---
|
||||
|
||||
**Приятной разработки! 🚀**
|
||||
@@ -4,7 +4,6 @@ import { buildBabelLoader } from './loaders/buildBabelLoader'
|
||||
import { buildCssLoader } from './loaders/buildCssLoader'
|
||||
import { buildFileLoader } from './loaders/buildFileLoader'
|
||||
import { buildSvgrLoader } from './loaders/buildSvgrLoader'
|
||||
import { buildTypescriptLoader } from './loaders/buildTypescriptLoader'
|
||||
import { BuildOptions } from './types/config'
|
||||
|
||||
/**
|
||||
@@ -23,15 +22,17 @@ import { BuildOptions } from './types/config'
|
||||
* @returns {webpack.RuleSetRule[]} Массив правил для webpack
|
||||
*/
|
||||
export function buildLoaders({ isDev }: BuildOptions): webpack.RuleSetRule[] {
|
||||
const babelLoader = buildBabelLoader(isDev)
|
||||
// Используем babel-loader для обработки JS и TS файлов
|
||||
// Это ускоряет сборку, так как babel работает быстрее ts-loader
|
||||
// Проверка типов должна выполняться отдельно (например, через tsc --noEmit)
|
||||
// Исключаем тестовые и storybook файлы из production сборки
|
||||
const codeBabelLoader = buildBabelLoader(isDev)
|
||||
|
||||
const fileLoader = buildFileLoader()
|
||||
|
||||
const svgrLoader = buildSvgrLoader()
|
||||
|
||||
const typescriptLoader = buildTypescriptLoader()
|
||||
|
||||
const cssLoader = buildCssLoader(isDev)
|
||||
|
||||
return [fileLoader, svgrLoader, babelLoader, typescriptLoader, cssLoader]
|
||||
return [fileLoader, svgrLoader, codeBabelLoader, cssLoader]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'
|
||||
import CompressionPlugin from 'compression-webpack-plugin'
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin'
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
|
||||
import webpack from 'webpack'
|
||||
@@ -22,6 +23,9 @@ import { BuildOptions } from './types/config'
|
||||
* - ReactRefreshWebpackPlugin: обеспечивает быструю перезагрузку React компонентов
|
||||
* - HotModuleReplacementPlugin: включает горячую замену модулей (HMR)
|
||||
*
|
||||
* Плагины только для production:
|
||||
* - CompressionPlugin: создает gzip-сжатые версии файлов для уменьшения размера передачи
|
||||
*
|
||||
* @param {BuildOptions} options - Опции сборки
|
||||
* @param {BuildPaths} options.paths - Пути проекта
|
||||
* @param {boolean} options.isDev - Флаг режима разработки
|
||||
@@ -68,6 +72,17 @@ export function buildPlugins({
|
||||
)
|
||||
plugins.push(new ReactRefreshWebpackPlugin({ overlay: false }))
|
||||
plugins.push(new webpack.HotModuleReplacementPlugin())
|
||||
} else {
|
||||
// Сжатие файлов для production сборки
|
||||
// Создает .gz файлы для всех JS и CSS файлов больше 10KB
|
||||
plugins.push(
|
||||
new CompressionPlugin({
|
||||
algorithm: 'gzip',
|
||||
test: /\.(js|css|html|svg)$/,
|
||||
threshold: 10240, // Сжимаем только файлы больше 10KB
|
||||
minRatio: 0.8, // Сжимаем только если размер уменьшается минимум на 20%
|
||||
})
|
||||
)
|
||||
}
|
||||
return plugins
|
||||
}
|
||||
|
||||
@@ -56,5 +56,11 @@ export function buildWebpackConfig(
|
||||
plugins: buildPlugins(options),
|
||||
devtool: isDev ? 'inline-source-map' : undefined,
|
||||
devServer: isDev ? buildDevServer(options) : undefined,
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all', // Разделяем код на чанки для оптимизации загрузки
|
||||
},
|
||||
runtimeChunk: 'single', // Выносим рантайм webpack в отдельный файл
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,22 @@
|
||||
*/
|
||||
export function buildBabelLoader(isDev: boolean) {
|
||||
const babelLoader = {
|
||||
test: /\.(js|jsx|tsx)$/,
|
||||
exclude: /node_modules/,
|
||||
test: /\.(js|jsx|tsx|ts)$/,
|
||||
exclude: [
|
||||
/node_modules/,
|
||||
/\.test\.(ts|tsx)$/, // Исключаем тестовые файлы
|
||||
/\.spec\.(ts|tsx)$/, // Исключаем spec файлы
|
||||
/\.stories\.(ts|tsx)$/, // Исключаем Storybook файлы
|
||||
],
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env'],
|
||||
cacheDirectory: true, // Включаем кеширование для ускорения пересборки
|
||||
presets: [
|
||||
'@babel/preset-env',
|
||||
['@babel/preset-react', { runtime: 'automatic' }], // Поддержка React 17+ (новый JSX transform)
|
||||
'@babel/preset-typescript', // Поддержка TypeScript через Babel
|
||||
],
|
||||
plugins: [isDev && require.resolve('react-refresh/babel')].filter(
|
||||
Boolean
|
||||
),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Конфигурация TypeScript loader для webpack
|
||||
*
|
||||
* Компилирует TypeScript файлы (.ts, .tsx) в JavaScript.
|
||||
* Выполняет проверку типов во время сборки.
|
||||
*
|
||||
* @returns {Object} Конфигурация ts-loader
|
||||
*
|
||||
* @example
|
||||
* // Обрабатывает файлы:
|
||||
* // - Component.tsx
|
||||
* // - utils.ts
|
||||
* // - types.d.ts
|
||||
*/
|
||||
export function buildTypescriptLoader() {
|
||||
const typescriptLoader = {
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: '/node-modules/',
|
||||
}
|
||||
|
||||
return typescriptLoader
|
||||
}
|
||||
@@ -1,2 +1,24 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import 'regenerator-runtime/runtime'
|
||||
|
||||
// Глобальный мок для GSAP
|
||||
jest.mock('gsap', () => {
|
||||
const gsapMock = {
|
||||
to: jest.fn(),
|
||||
fromTo: jest.fn(),
|
||||
killTweensOf: jest.fn(),
|
||||
context: jest.fn(() => ({
|
||||
revert: jest.fn(),
|
||||
})),
|
||||
Power2: {
|
||||
easeOut: 'power2.out',
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: gsapMock, // Для default import
|
||||
gsap: gsapMock, // Для named import
|
||||
Power2: gsapMock.Power2, // Экспортируем Power2 отдельно
|
||||
}
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.16.0",
|
||||
"@typescript-eslint/parser": "^8.16.0",
|
||||
"babel-loader": "^9.2.0",
|
||||
"compression-webpack-plugin": "^11.1.0",
|
||||
"css-loader": "^7.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
@@ -65,6 +66,7 @@
|
||||
"globals": "^15.12.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"husky": "^9.1.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"mini-css-extract-plugin": "^2.9.0",
|
||||
|
||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -96,6 +96,9 @@ importers:
|
||||
babel-loader:
|
||||
specifier: ^9.2.0
|
||||
version: 9.2.1(@babel/core@7.28.5)(webpack@5.103.0)
|
||||
compression-webpack-plugin:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0(webpack@5.103.0)
|
||||
css-loader:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.2(webpack@5.103.0)
|
||||
@@ -138,6 +141,9 @@ importers:
|
||||
husky:
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.7
|
||||
identity-obj-proxy:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
jest:
|
||||
specifier: ^30.2.0
|
||||
version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
@@ -2617,6 +2623,12 @@ packages:
|
||||
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
compression-webpack-plugin@11.1.0:
|
||||
resolution: {integrity: sha512-zDOQYp10+upzLxW+VRSjEpRRwBXJdsb5lBMlRxx1g8hckIFBpe3DTI0en2w7h+beuq89576RVzfiXrkdPGrHhA==}
|
||||
engines: {node: '>= 18.12.0'}
|
||||
peerDependencies:
|
||||
webpack: ^5.1.0
|
||||
|
||||
compression@1.8.1:
|
||||
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -3517,6 +3529,9 @@ packages:
|
||||
handle-thing@2.0.1:
|
||||
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
|
||||
|
||||
harmony-reflect@1.6.2:
|
||||
resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==}
|
||||
|
||||
has-bigints@1.1.0:
|
||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3654,6 +3669,10 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
identity-obj-proxy@3.0.0:
|
||||
resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -8604,6 +8623,12 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
compression-webpack-plugin@11.1.0(webpack@5.103.0):
|
||||
dependencies:
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 6.0.2
|
||||
webpack: 5.103.0(esbuild@0.25.12)(webpack-cli@5.1.4)
|
||||
|
||||
compression@1.8.1:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -9672,6 +9697,8 @@ snapshots:
|
||||
|
||||
handle-thing@2.0.1: {}
|
||||
|
||||
harmony-reflect@1.6.2: {}
|
||||
|
||||
has-bigints@1.1.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
@@ -9817,6 +9844,10 @@ snapshots:
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
|
||||
identity-obj-proxy@3.0.0:
|
||||
dependencies:
|
||||
harmony-reflect: 1.6.2
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
@@ -40,18 +40,30 @@
|
||||
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
|
||||
|
||||
34
src/shared/ui/Button/Button.test.tsx
Normal file
34
src/shared/ui/Button/Button.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import { Button } from './Button'
|
||||
|
||||
describe('Button', () => {
|
||||
// Тест на рендеринг кнопки
|
||||
it('должна рендериться корректно', () => {
|
||||
render(<Button>Test Button</Button>)
|
||||
expect(screen.getByText('Test Button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Тест на применение класса варианта
|
||||
it('должна применять класс варианта', () => {
|
||||
render(<Button variant='regular'>Regular Button</Button>)
|
||||
const button = screen.getByText('Regular Button')
|
||||
// Проверяем наличие класса, который генерируется CSS модулями (частичное совпадение)
|
||||
expect(button.className).toMatch(/regular/)
|
||||
})
|
||||
|
||||
// Тест на обработку клика
|
||||
it('должна вызывать обработчик onClick при клике', () => {
|
||||
const handleClick = jest.fn()
|
||||
render(<Button onClick={handleClick}>Click Me</Button>)
|
||||
|
||||
fireEvent.click(screen.getByText('Click Me'))
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Тест на отключенное состояние
|
||||
it('должна быть отключена при передаче пропса disabled', () => {
|
||||
render(<Button disabled>Disabled Button</Button>)
|
||||
expect(screen.getByText('Disabled Button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -9,11 +9,19 @@
|
||||
font-weight: 400;
|
||||
font-size: 25px;
|
||||
line-height: 120%;
|
||||
|
||||
@media (width <=768px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text);
|
||||
font-weight: 400;
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
line-height: 145%;
|
||||
|
||||
@media (width <=768px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
18
src/shared/ui/Card/Card.test.tsx
Normal file
18
src/shared/ui/Card/Card.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import { Card } from './Card'
|
||||
|
||||
describe('Card', () => {
|
||||
// Тест на рендеринг заголовка и описания
|
||||
it('должна рендерить заголовок и описание', () => {
|
||||
render(<Card title='1992' description='Test Description' />)
|
||||
expect(screen.getByText('1992')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Тест на рендеринг числового заголовка
|
||||
it('должна корректно рендерить числовой заголовок', () => {
|
||||
render(<Card title={2023} description='Year Description' />)
|
||||
expect(screen.getByText('2023')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -46,15 +46,31 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
&:hover .label,
|
||||
&.active .label {
|
||||
&:hover .number,
|
||||
&.active .number {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.label {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import gsap from 'gsap'
|
||||
|
||||
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
|
||||
|
||||
import { CircleTimeline } from './CircleTimeline'
|
||||
|
||||
describe('CircleTimeline', () => {
|
||||
const mockOnPeriodChange = jest.fn()
|
||||
const defaultProps = {
|
||||
periods: HISTORICAL_PERIODS,
|
||||
activeIndex: 0,
|
||||
onPeriodChange: mockOnPeriodChange,
|
||||
rotation: 0,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Тест на рендеринг правильного количества точек
|
||||
it('должна рендерить правильное количество точек', () => {
|
||||
render(<CircleTimeline {...defaultProps} />)
|
||||
const points = screen.getAllByRole('button')
|
||||
expect(points).toHaveLength(HISTORICAL_PERIODS.length)
|
||||
})
|
||||
|
||||
// Тест на активную точку
|
||||
it('должна корректно отображать активную точку', () => {
|
||||
render(<CircleTimeline {...defaultProps} activeIndex={1} />)
|
||||
const points = screen.getAllByRole('button')
|
||||
|
||||
// Проверяем aria-current для доступности
|
||||
expect(points[1]).toHaveAttribute('aria-current', 'true')
|
||||
expect(points[0]).toHaveAttribute('aria-current', 'false')
|
||||
})
|
||||
|
||||
// Тест на клик по точке
|
||||
it('должна вызывать onPeriodChange при клике по точке', () => {
|
||||
render(<CircleTimeline {...defaultProps} />)
|
||||
const points = screen.getAllByRole('button')
|
||||
|
||||
fireEvent.click(points[2])
|
||||
expect(mockOnPeriodChange).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
// Тест на вызов GSAP анимации
|
||||
it('должна вызывать GSAP анимацию при изменении rotation', () => {
|
||||
const { rerender } = render(<CircleTimeline {...defaultProps} />)
|
||||
|
||||
rerender(<CircleTimeline {...defaultProps} rotation={60} />)
|
||||
|
||||
// Проверяем, что gsap.to был вызван
|
||||
expect(gsap.to).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import classNames from 'classnames'
|
||||
import gsap from 'gsap'
|
||||
import { gsap } from 'gsap'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import styles from './CircleTimeline.module.scss'
|
||||
@@ -67,6 +67,9 @@ export const CircleTimeline = memo(function CircleTimeline({
|
||||
// Реф для массива точек периодов
|
||||
const pointsRef = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
// Реф для заголовков периодов
|
||||
const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
|
||||
|
||||
/**
|
||||
* Эффект для анимации поворота круга и контр-поворота точек
|
||||
* Запускается при изменении rotation
|
||||
@@ -82,16 +85,38 @@ export const CircleTimeline = memo(function CircleTimeline({
|
||||
}
|
||||
|
||||
// Контр-поворот точек, чтобы текст оставался читаемым
|
||||
pointsRef.current.forEach((point) => {
|
||||
pointsRef.current.forEach((point, index) => {
|
||||
if (point) {
|
||||
gsap.to(point, {
|
||||
rotation: -rotation,
|
||||
duration: 0,
|
||||
ease: ANIMATION_EASE,
|
||||
})
|
||||
|
||||
// Анимация заголовка
|
||||
const title = titlesRef.current[index]
|
||||
if (title) {
|
||||
// Сбрасываем предыдущие анимации для этого элемента
|
||||
gsap.killTweensOf(title)
|
||||
|
||||
if (index === activeIndex) {
|
||||
gsap.to(title, {
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
duration: 0.5,
|
||||
delay: ANIMATION_DURATION, // Ждем окончания вращения
|
||||
})
|
||||
} else {
|
||||
gsap.to(title, {
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
duration: 0.2,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [rotation])
|
||||
}, [rotation, activeIndex])
|
||||
|
||||
/**
|
||||
* Мемоизированный расчет позиций точек на круге
|
||||
@@ -138,7 +163,15 @@ export const CircleTimeline = memo(function CircleTimeline({
|
||||
aria-label={`Period ${index + 1}: ${period.label}`}
|
||||
aria-current={index === activeIndex ? 'true' : 'false'}
|
||||
>
|
||||
<span className={styles.label}>{index + 1}</span>
|
||||
<span className={styles.number}>{index + 1}</span>
|
||||
<span
|
||||
className={styles.title}
|
||||
ref={(el) => {
|
||||
titlesRef.current[index] = el
|
||||
}}
|
||||
>
|
||||
{period.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -31,3 +31,29 @@
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.swiper) {
|
||||
@media (width <=768px) {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.swiper-slide-next) {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@media (width <=768px) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.swiper-slide-prev) {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@media (width <=768px) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.swiper-slide-active) {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import { EventsCarousel } from './EventsCarousel'
|
||||
|
||||
// Мокаем Swiper
|
||||
jest.mock('swiper/react', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Swiper: ({ children }: any) => <div data-testid='swiper'>{children}</div>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
SwiperSlide: ({ children }: any) => (
|
||||
<div data-testid='swiper-slide'>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('swiper/modules', () => ({
|
||||
Navigation: jest.fn(),
|
||||
Pagination: jest.fn(),
|
||||
FreeMode: jest.fn(),
|
||||
}))
|
||||
|
||||
// Мокаем стили Swiper
|
||||
jest.mock('swiper/css', () => ({}))
|
||||
jest.mock('swiper/css/navigation', () => ({}))
|
||||
jest.mock('swiper/css/pagination', () => ({}))
|
||||
|
||||
describe('EventsCarousel', () => {
|
||||
const mockEvents = [
|
||||
{ id: 1, year: 1990, description: 'Event 1' },
|
||||
{ id: 2, year: 1991, description: 'Event 2' },
|
||||
]
|
||||
|
||||
// Тест на рендеринг событий
|
||||
it('должен рендерить переданные события', () => {
|
||||
render(<EventsCarousel events={mockEvents} visible={true} />)
|
||||
|
||||
// Проверяем, что слайды отрендерились
|
||||
const slides = screen.getAllByTestId('swiper-slide')
|
||||
expect(slides).toHaveLength(mockEvents.length)
|
||||
|
||||
// Проверяем контент внутри слайдов (Card компонент)
|
||||
expect(screen.getByText('1990')).toBeInTheDocument()
|
||||
expect(screen.getByText('Event 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Тест на видимость
|
||||
it('должен применять класс visible, когда visible=true', () => {
|
||||
render(<EventsCarousel events={mockEvents} visible={true} />)
|
||||
// В реальном компоненте класс применяется к контейнеру Swiper или обертке
|
||||
// Здесь мы проверяем наличие контента, так как opacity управляется через CSS/GSAP
|
||||
expect(screen.getByTestId('swiper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import classNames from 'classnames'
|
||||
import gsap from 'gsap'
|
||||
import { gsap } from 'gsap'
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
|
||||
@@ -145,6 +145,7 @@ export const EventsCarousel = memo(
|
||||
|
||||
<Swiper
|
||||
{...EVENT_CAROUSEL_CONFIG}
|
||||
watchSlidesProgress
|
||||
onInit={handleSwiperInit}
|
||||
onSlideChange={handleSlideChange}
|
||||
>
|
||||
|
||||
@@ -7,16 +7,38 @@ import type { SwiperOptions } from 'swiper/types'
|
||||
*/
|
||||
export const EVENT_CAROUSEL_CONFIG: SwiperOptions = {
|
||||
modules: [Navigation],
|
||||
spaceBetween: 30,
|
||||
slidesPerView: 1.5,
|
||||
breakpoints: {
|
||||
768: {
|
||||
slidesPerView: 3.5,
|
||||
},
|
||||
},
|
||||
spaceBetween: 25,
|
||||
navigation: {
|
||||
prevEl: '.swiper-button-prev-custom',
|
||||
nextEl: '.swiper-button-next-custom',
|
||||
enabled: false,
|
||||
},
|
||||
breakpoints: {
|
||||
576: {
|
||||
slidesPerView: 2,
|
||||
},
|
||||
768: {
|
||||
slidesPerView: 2,
|
||||
navigation: {
|
||||
enabled: true,
|
||||
},
|
||||
spaceBetween: 25,
|
||||
},
|
||||
1024: {
|
||||
slidesPerView: 3,
|
||||
navigation: {
|
||||
enabled: true,
|
||||
},
|
||||
spaceBetween: 30,
|
||||
},
|
||||
1440: {
|
||||
slidesPerView: 3,
|
||||
navigation: {
|
||||
enabled: true,
|
||||
},
|
||||
spaceBetween: 80,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,14 @@
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
@media (width <=1024px) {
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
@media (width <=768px) {
|
||||
min-height: auto;
|
||||
padding: 20px;
|
||||
padding: 60px 20px 20px;
|
||||
|
||||
background-image: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +51,22 @@
|
||||
border-image: var(--gradient-primary) 1;
|
||||
|
||||
|
||||
@media (width <=1024px) {
|
||||
top: 80px;
|
||||
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
@media (width <=768px) {
|
||||
position: relative;
|
||||
inset: unset;
|
||||
|
||||
margin-bottom: 20px;
|
||||
padding-left: 0;
|
||||
|
||||
font-size: 20px;
|
||||
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +86,16 @@
|
||||
background-size: 100% 1px;
|
||||
|
||||
@media (width <=768px) {
|
||||
position: unset;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
|
||||
background-image: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +111,19 @@
|
||||
|
||||
transform-origin: left;
|
||||
|
||||
@media (width <=1024px) {
|
||||
left: 100px;
|
||||
bottom: 40px;
|
||||
}
|
||||
|
||||
@media (width <=768px) {
|
||||
position: static;
|
||||
left: 20px;
|
||||
bottom: 13px;
|
||||
|
||||
order: 2;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
margin-top: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -107,6 +137,59 @@
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
@media (width <=768px) {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.chevronIcon {
|
||||
width: 9px;
|
||||
height: 14px;
|
||||
|
||||
@media (width <=768px) {
|
||||
width: 6px;
|
||||
height: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: none;
|
||||
|
||||
@media (width <=768px) {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 32px;
|
||||
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: var(--color-primary);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
opacity: 0.4;
|
||||
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&.activeDot {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rotated {
|
||||
@@ -120,7 +203,7 @@
|
||||
z-index: 0;
|
||||
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
gap: 60px;
|
||||
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
@@ -131,6 +214,13 @@
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
@media (width <=1024px) {
|
||||
gap: 40px;
|
||||
|
||||
font-size: 140px;
|
||||
line-height: 120px;
|
||||
}
|
||||
|
||||
@media (width <=768px) {
|
||||
position: static;
|
||||
|
||||
@@ -161,24 +251,32 @@
|
||||
|
||||
display: block;
|
||||
|
||||
margin-bottom: 40px;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 20px;
|
||||
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid #C7CDD9;
|
||||
}
|
||||
}
|
||||
|
||||
.circleContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@media (width <=768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.eventCarousel {
|
||||
.carouselContainer {
|
||||
padding: 55px 80px 105px;
|
||||
|
||||
@media (width <=768px) {
|
||||
width: calc(100% + 40px);
|
||||
margin: 0 -20px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
|
||||
|
||||
import { TimeFrameSlider } from './TimeFrameSlider'
|
||||
|
||||
// Мокаем дочерние компоненты, чтобы тестировать изолированно
|
||||
jest.mock('../CircleTimeline/CircleTimeline', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
CircleTimeline: ({ activeIndex, onPeriodChange }: any) => (
|
||||
<div data-testid='circle-timeline'>
|
||||
<button onClick={() => onPeriodChange(activeIndex + 1)}>
|
||||
Next Period
|
||||
</button>
|
||||
<span>Active: {activeIndex}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('../EventsCarousel/EventsCarousel', () => ({
|
||||
EventsCarousel: () => <div data-testid='events-carousel'>Carousel</div>,
|
||||
}))
|
||||
|
||||
describe('TimeFrameSlider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Тест на рендеринг заголовка
|
||||
it('должен рендерить заголовок', () => {
|
||||
render(<TimeFrameSlider />)
|
||||
expect(screen.getByText('Исторические даты')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Тест на отображение начального периода
|
||||
it('должен отображать начальный период (первый в списке)', () => {
|
||||
render(<TimeFrameSlider />)
|
||||
const firstPeriod = HISTORICAL_PERIODS[0]
|
||||
expect(screen.getByText(firstPeriod.yearFrom)).toBeInTheDocument()
|
||||
expect(screen.getByText(firstPeriod.yearTo)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Тест на переключение вперед
|
||||
it('должен переключать период вперед при клике на кнопку "Следующий"', () => {
|
||||
render(<TimeFrameSlider />)
|
||||
|
||||
const nextButton = screen.getByLabelText('Следующий период')
|
||||
fireEvent.click(nextButton)
|
||||
|
||||
// Проверяем, что отображается второй период
|
||||
const secondPeriod = HISTORICAL_PERIODS[1]
|
||||
expect(screen.getByText(secondPeriod.yearFrom)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Тест на переключение назад
|
||||
it('должен переключать период назад при клике на кнопку "Предыдущий"', () => {
|
||||
render(<TimeFrameSlider />)
|
||||
|
||||
// Сначала переключаем вперед, чтобы не быть на первом элементе (хотя логика циклична)
|
||||
const nextButton = screen.getByLabelText('Следующий период')
|
||||
fireEvent.click(nextButton)
|
||||
|
||||
// Теперь назад
|
||||
const prevButton = screen.getByLabelText('Предыдущий период')
|
||||
fireEvent.click(prevButton)
|
||||
|
||||
// Должны вернуться к первому периоду
|
||||
const firstPeriod = HISTORICAL_PERIODS[0]
|
||||
expect(screen.getByText(firstPeriod.yearFrom)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Тест на пагинацию (точки)
|
||||
it('должен переключать период при клике на точку пагинации', () => {
|
||||
render(<TimeFrameSlider />)
|
||||
|
||||
const dots = screen.getAllByLabelText(/Перейти к периоду/)
|
||||
fireEvent.click(dots[2]) // Клик по 3-й точке
|
||||
|
||||
const thirdPeriod = HISTORICAL_PERIODS[2]
|
||||
expect(screen.getByText(thirdPeriod.yearFrom)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,8 @@
|
||||
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий
|
||||
*/
|
||||
|
||||
import gsap from 'gsap'
|
||||
import classNames from 'classnames'
|
||||
import { gsap } from 'gsap'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
|
||||
@@ -34,6 +35,7 @@ export const TimeFrameSlider = memo(() => {
|
||||
const startYearRef = useRef<HTMLSpanElement>(null)
|
||||
const endYearRef = useRef<HTMLSpanElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const periodLabelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Текущий период
|
||||
const currentPeriod = useMemo(
|
||||
@@ -99,6 +101,14 @@ export const TimeFrameSlider = memo(() => {
|
||||
|
||||
prevYearFromRef.current = currentPeriod.yearFrom
|
||||
prevYearToRef.current = currentPeriod.yearTo
|
||||
|
||||
if (periodLabelRef.current) {
|
||||
gsap.fromTo(
|
||||
periodLabelRef.current,
|
||||
{ opacity: 0, visibility: 'hidden' },
|
||||
{ opacity: 1, visibility: 'visible', duration: 1 }
|
||||
)
|
||||
}
|
||||
}, containerRef)
|
||||
|
||||
return () => ctx.revert()
|
||||
@@ -127,18 +137,21 @@ export const TimeFrameSlider = memo(() => {
|
||||
<div className={styles.content}>
|
||||
<div className={styles.centerDate}>
|
||||
<span ref={startYearRef}>{currentPeriod.yearFrom}</span>
|
||||
{'\u00A0'}
|
||||
<span ref={endYearRef}>{currentPeriod.yearTo}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.periodLabel}>{currentPeriod.label}</div>
|
||||
<div className={styles.periodLabel} ref={periodLabelRef}>
|
||||
{currentPeriod.label}
|
||||
</div>
|
||||
|
||||
<CircleTimeline
|
||||
periods={HISTORICAL_PERIODS}
|
||||
activeIndex={activePeriod}
|
||||
onPeriodChange={setActivePeriod}
|
||||
rotation={rotation}
|
||||
/>
|
||||
<div className={styles.circleContainer}>
|
||||
<CircleTimeline
|
||||
periods={HISTORICAL_PERIODS}
|
||||
activeIndex={activePeriod}
|
||||
onPeriodChange={setActivePeriod}
|
||||
rotation={rotation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.pagination}>
|
||||
@@ -153,7 +166,7 @@ export const TimeFrameSlider = memo(() => {
|
||||
onClick={handlePrev}
|
||||
aria-label='Предыдущий период'
|
||||
>
|
||||
<ChevronSvg width={9} height={14} stroke='#42567A' />
|
||||
<ChevronSvg className={styles.chevronIcon} stroke='#42567A' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='round'
|
||||
@@ -163,19 +176,30 @@ export const TimeFrameSlider = memo(() => {
|
||||
aria-label='Следующий период'
|
||||
>
|
||||
<ChevronSvg
|
||||
width={9}
|
||||
height={14}
|
||||
className={classNames(styles.chevronIcon, styles.rotated)}
|
||||
stroke='#42567A'
|
||||
className={styles.rotated}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.eventCarousel}>
|
||||
<div className={styles.carouselContainer}>
|
||||
<EventsCarousel events={currentPeriod.events} visible />
|
||||
</div>
|
||||
|
||||
<div className={styles.dots}>
|
||||
{HISTORICAL_PERIODS.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={classNames(styles.dot, {
|
||||
[styles.activeDot]: index === activePeriod,
|
||||
})}
|
||||
onClick={() => setActivePeriod(index)}
|
||||
aria-label={`Перейти к периоду ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,5 +53,11 @@ module.exports = {
|
||||
'order/properties-order': propertyOrdering,
|
||||
'declaration-empty-line-before': null,
|
||||
'no-descending-specificity': null, // Отключаем из-за конфликта с order/order
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ['global'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"types": [
|
||||
"node",
|
||||
"jest",
|
||||
"@testing-library/jest-dom"
|
||||
]
|
||||
},
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
|
||||
Reference in New Issue
Block a user