Compare commits
42 Commits
feature/ti
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48c8f7b1cf | ||
|
|
338e8b233b | ||
|
|
999da5fef4 | ||
|
|
d3816b0952 | ||
|
|
d3343f1f7b | ||
|
|
7c94fa8ffe | ||
|
|
dad6253877 | ||
|
|
174547791b | ||
|
|
92e0b474a4 | ||
|
|
c57957f15c | ||
|
|
53d7b90c72 | ||
|
|
5af3fbfdc3 | ||
|
|
1c81deaed0 | ||
|
|
246371c431 | ||
|
|
e4c563075f | ||
|
|
2a72e1077c | ||
|
|
013d32f09d | ||
|
|
6de84f3143 | ||
|
|
ec6867d7a0 | ||
|
|
da0309f714 | ||
|
|
5c408ccff4 | ||
|
|
d27906fd47 | ||
|
|
c4a7653d7b | ||
|
|
eace670c50 | ||
|
|
e892e69277 | ||
|
|
38785cce94 | ||
|
|
2958cc734a | ||
|
|
364c5e1ff1 | ||
|
|
f34f1a28de | ||
|
|
3f7f5d358e | ||
|
|
bd1eb1eee9 | ||
|
|
4ec072055f | ||
|
|
93787eaf4d | ||
|
|
33763767d9 | ||
|
|
f1118e5aec | ||
|
|
65630bae8a | ||
|
|
5a2a7642d3 | ||
|
|
8fec89a699 | ||
|
|
e33cd1b139 | ||
|
|
d7e66eec71 | ||
|
|
a344b6f4de | ||
|
|
fb0808bc1f |
@@ -12,6 +12,9 @@ pnpm lint || exit 1
|
|||||||
echo "Проверка Stylelint..."
|
echo "Проверка Stylelint..."
|
||||||
pnpm lint:styles || exit 1
|
pnpm lint:styles || exit 1
|
||||||
|
|
||||||
|
echo "Запуск Unit тестов..."
|
||||||
|
pnpm test:unit || exit 1
|
||||||
|
|
||||||
echo "Production сборка..."
|
echo "Production сборка..."
|
||||||
pnpm build:prod || exit 1
|
pnpm build:prod || exit 1
|
||||||
|
|
||||||
|
|||||||
515
README.md
Normal file
515
README.md
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
# GSAP Carousel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[🌐 English](#english) | [🇷🇺 Русский](#russian)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="english"></a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[🌍 Scroll to top](#) | [🇷🇺 Русский](#russian)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# GSAP Carousel
|
||||||
|
|
||||||
|
React-based UI component developed to explore advanced animation patterns using GSAP. This project focuses on synchronizing complex circular transitions with a dynamic content carousel.
|
||||||
|
|
||||||
|
## Technical Highlights
|
||||||
|
|
||||||
|
- **GSAP Integration**: Implementation of smooth, high-performance animations for state transitions
|
||||||
|
- **Circular Data Visualization**: An interactive timeline with selectable time periods
|
||||||
|
- **Adaptive Layout**: Fully responsive architecture supporting mobile, tablet, and desktop viewports
|
||||||
|
- **Custom Navigation**: Bespoke UI controls for seamless event browsing
|
||||||
|
|
||||||
|
## 🚀 Technologies
|
||||||
|
|
||||||
|
- **React 19** - User interface library
|
||||||
|
- **TypeScript 5** - Typed JavaScript
|
||||||
|
- **GSAP** - Smooth animation library
|
||||||
|
- **Swiper** - Modern carousel library
|
||||||
|
- **Webpack 5** - Module bundler
|
||||||
|
- **SCSS** - CSS preprocessor with modules
|
||||||
|
- **Jest** - Testing framework
|
||||||
|
- **ESLint + Prettier** - Code linting and formatting
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
- **Node.js** >= 18.0.0
|
||||||
|
- **pnpm** >= 8.0.0
|
||||||
|
|
||||||
|
## 🛠️ Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd gsap-carousel
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Available Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev-server on port 3000
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at: http://localhost:3000
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production build
|
||||||
|
pnpm build:prod
|
||||||
|
|
||||||
|
# Development build
|
||||||
|
pnpm build:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The build result will be in the `dist/` folder
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all unit tests
|
||||||
|
pnpm test:unit
|
||||||
|
|
||||||
|
# Check TypeScript types
|
||||||
|
pnpm type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check JavaScript/TypeScript code
|
||||||
|
pnpm lint
|
||||||
|
|
||||||
|
# Auto-fix errors
|
||||||
|
pnpm lint --fix
|
||||||
|
|
||||||
|
# Check styles (SCSS)
|
||||||
|
pnpm lint:styles
|
||||||
|
|
||||||
|
# Auto-fix styles
|
||||||
|
pnpm lint:styles --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storybook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Storybook for component development
|
||||||
|
pnpm storybook
|
||||||
|
|
||||||
|
# Build static version of Storybook
|
||||||
|
pnpm build-storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-push Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all checks before push
|
||||||
|
pnpm pre-push
|
||||||
|
```
|
||||||
|
|
||||||
|
This command runs:
|
||||||
|
|
||||||
|
- ✅ TypeScript type checking
|
||||||
|
- ✅ Code linting
|
||||||
|
- ✅ Style linting
|
||||||
|
- ✅ Unit tests
|
||||||
|
- ✅ Production build
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gsap-carousel/
|
||||||
|
├── config/ # Build configuration
|
||||||
|
│ ├── build/ # Webpack configuration
|
||||||
|
│ ├── jest/ # Jest configuration
|
||||||
|
│ └── storybook/ # Storybook configuration
|
||||||
|
├── dist/ # Production build (generated)
|
||||||
|
├── src/ # Source code
|
||||||
|
│ ├── app/ # Root application component
|
||||||
|
│ ├── entities/ # Business entities (data types)
|
||||||
|
│ ├── shared/ # Reusable components and utilities
|
||||||
|
│ │ ├── assets/ # Static resources (SVG, images)
|
||||||
|
│ │ └── ui/ # UI components (Button, Card)
|
||||||
|
│ └── widgets/ # Complex components (TimeFrameSlider)
|
||||||
|
├── .husky/ # Git hooks
|
||||||
|
├── package.json # Dependencies and scripts
|
||||||
|
└── tsconfig.json # TypeScript configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Main Components
|
||||||
|
|
||||||
|
### TimeFrameSlider
|
||||||
|
|
||||||
|
The main application widget combining a circular timeline and an events carousel.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Interactive circular chart with periods
|
||||||
|
- Smooth GSAP animations during transitions
|
||||||
|
- Responsive design for mobile, tablet, and desktop
|
||||||
|
- Custom navigation
|
||||||
|
|
||||||
|
### CircleTimeline
|
||||||
|
|
||||||
|
A circular timeline with period points.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Auto-rotation to the active period
|
||||||
|
- Click on points to switch periods
|
||||||
|
- Period title animations
|
||||||
|
|
||||||
|
### EventsCarousel
|
||||||
|
|
||||||
|
Historical events carousel using Swiper.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Adaptive number of slides
|
||||||
|
- Custom navigation
|
||||||
|
- Smooth appearance/disappearance animation
|
||||||
|
|
||||||
|
## ⚡ Performance Optimization
|
||||||
|
|
||||||
|
The project is optimized for production:
|
||||||
|
|
||||||
|
- ✅ **Code splitting** - chunk separation (vendors, runtime, main)
|
||||||
|
- ✅ **Tree shaking** - removal of unused code
|
||||||
|
- ✅ **Gzip compression** - file compression (~68% size reduction)
|
||||||
|
- ✅ **Minification** - JS and CSS minification
|
||||||
|
- ✅ **CSS Modules** - isolated component styles
|
||||||
|
- ✅ **Babel caching** - caching for fast rebuilds
|
||||||
|
|
||||||
|
**Bundle size:**
|
||||||
|
|
||||||
|
- Uncompressed: ~371 KiB
|
||||||
|
- Gzipped: ~117 KiB
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
The project is covered by unit tests:
|
||||||
|
|
||||||
|
- ✅ Component tests (Button, Card, CircleTimeline, TimeFrameSlider, EventsCarousel)
|
||||||
|
- ✅ Utility tests (calculateCoordinates)
|
||||||
|
- ✅ Mocks for GSAP and Swiper
|
||||||
|
- ✅ 39 tests, 100% successful
|
||||||
|
|
||||||
|
## 📱 Responsiveness
|
||||||
|
|
||||||
|
The application is adapted for all devices:
|
||||||
|
|
||||||
|
- **Desktop** (>1440px) - Full version with circular chart
|
||||||
|
- **Tablet** (768px - 1024px) - Optimized version
|
||||||
|
- **Mobile** (<768px) - Simplified version with bottom navigation
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Webpack
|
||||||
|
|
||||||
|
- Babel for transpilation (faster than ts-loader)
|
||||||
|
- CSS Modules for style isolation
|
||||||
|
- SVGR for importing SVG as React components
|
||||||
|
- Hot Module Replacement for development
|
||||||
|
- Bundle Analyzer for size analysis
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- Strict mode enabled
|
||||||
|
- Path aliases (`@/*` → `src/*`)
|
||||||
|
- Type checking separate from build
|
||||||
|
|
||||||
|
### ESLint
|
||||||
|
|
||||||
|
- Rules for React, TypeScript, Jest
|
||||||
|
- Prettier integration
|
||||||
|
- Import sorting
|
||||||
|
|
||||||
|
## 🤝 Git Hooks
|
||||||
|
|
||||||
|
Pre-push hook automatically runs:
|
||||||
|
|
||||||
|
1. Type checking
|
||||||
|
2. ESLint
|
||||||
|
3. Stylelint
|
||||||
|
4. Unit tests
|
||||||
|
5. Production build
|
||||||
|
|
||||||
|
This ensures that only working code enters the repository.
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
ISC
|
||||||
|
|
||||||
|
**Happy coding! 🚀**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="russian"></a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[🌍 Scroll to top](#) | [🌐 English](#english)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# GSAP Carousel
|
||||||
|
|
||||||
|
React-компонент UI для изучения продвинутых паттернов анимации с использованием GSAP. Проект фокусируется на синхронизации сложных круговых переходов с динамической каруселью контента.
|
||||||
|
|
||||||
|
## Технические особенности
|
||||||
|
|
||||||
|
- **Интеграция GSAP**: Реализация плавных высокопроизводительных анимаций для переходов между состояниями
|
||||||
|
- **Круговая визуализация данных**: Интерактивная временная шкала с выбираемыми периодами
|
||||||
|
- **Адаптивный макет**: Полностью адаптивная архитектура, поддерживающая мобильные устройства, планшеты и десктопы
|
||||||
|
- **Кастомная навигация**: Собственные элементы управления UI для удобного просмотра событий
|
||||||
|
|
||||||
|
## 🚀 Технологии
|
||||||
|
|
||||||
|
- **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 gsap-carousel
|
||||||
|
```
|
||||||
|
|
||||||
|
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 сборку
|
||||||
|
|
||||||
|
## 📁 Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
gsap-carousel/
|
||||||
|
├── 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
|
||||||
|
|
||||||
|
**Приятной разработки! 🚀**
|
||||||
BIN
assets/gsap-carousel.gif
Normal file
BIN
assets/gsap-carousel.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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 компонентов без потери состояния.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { buildBabelLoader } from './loaders/buildBabelLoader'
|
|||||||
import { buildCssLoader } from './loaders/buildCssLoader'
|
import { buildCssLoader } from './loaders/buildCssLoader'
|
||||||
import { buildFileLoader } from './loaders/buildFileLoader'
|
import { buildFileLoader } from './loaders/buildFileLoader'
|
||||||
import { buildSvgrLoader } from './loaders/buildSvgrLoader'
|
import { buildSvgrLoader } from './loaders/buildSvgrLoader'
|
||||||
import { buildTypescriptLoader } from './loaders/buildTypescriptLoader'
|
|
||||||
import { BuildOptions } from './types/config'
|
import { BuildOptions } from './types/config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,24 +13,28 @@ 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 - Флаг режима разработки
|
||||||
* @returns {webpack.RuleSetRule[]} Массив правил для webpack
|
* @returns {webpack.RuleSetRule[]} Массив правил для webpack
|
||||||
*/
|
*/
|
||||||
export function buildLoaders({ isDev }: BuildOptions): webpack.RuleSetRule[] {
|
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 fileLoader = buildFileLoader()
|
||||||
|
|
||||||
const svgrLoader = buildSvgrLoader()
|
const svgrLoader = buildSvgrLoader()
|
||||||
|
|
||||||
const typescriptLoader = buildTypescriptLoader()
|
|
||||||
|
|
||||||
const cssLoader = buildCssLoader(isDev)
|
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 ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'
|
||||||
|
import CompressionPlugin from 'compression-webpack-plugin'
|
||||||
import HtmlWebpackPlugin from 'html-webpack-plugin'
|
import HtmlWebpackPlugin from 'html-webpack-plugin'
|
||||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
|
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
|
||||||
import webpack from 'webpack'
|
import webpack from 'webpack'
|
||||||
@@ -22,6 +23,9 @@ import { BuildOptions } from './types/config'
|
|||||||
* - ReactRefreshWebpackPlugin: обеспечивает быструю перезагрузку React компонентов
|
* - ReactRefreshWebpackPlugin: обеспечивает быструю перезагрузку React компонентов
|
||||||
* - HotModuleReplacementPlugin: включает горячую замену модулей (HMR)
|
* - HotModuleReplacementPlugin: включает горячую замену модулей (HMR)
|
||||||
*
|
*
|
||||||
|
* Плагины только для production:
|
||||||
|
* - CompressionPlugin: создает gzip-сжатые версии файлов для уменьшения размера передачи
|
||||||
|
*
|
||||||
* @param {BuildOptions} options - Опции сборки
|
* @param {BuildOptions} options - Опции сборки
|
||||||
* @param {BuildPaths} options.paths - Пути проекта
|
* @param {BuildPaths} options.paths - Пути проекта
|
||||||
* @param {boolean} options.isDev - Флаг режима разработки
|
* @param {boolean} options.isDev - Флаг режима разработки
|
||||||
@@ -68,6 +72,17 @@ export function buildPlugins({
|
|||||||
)
|
)
|
||||||
plugins.push(new ReactRefreshWebpackPlugin({ overlay: false }))
|
plugins.push(new ReactRefreshWebpackPlugin({ overlay: false }))
|
||||||
plugins.push(new webpack.HotModuleReplacementPlugin())
|
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
|
return plugins
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,5 +56,11 @@ export function buildWebpackConfig(
|
|||||||
plugins: buildPlugins(options),
|
plugins: buildPlugins(options),
|
||||||
devtool: isDev ? 'inline-source-map' : undefined,
|
devtool: isDev ? 'inline-source-map' : undefined,
|
||||||
devServer: isDev ? buildDevServer(options) : undefined,
|
devServer: isDev ? buildDevServer(options) : undefined,
|
||||||
|
optimization: {
|
||||||
|
splitChunks: {
|
||||||
|
chunks: 'all', // Разделяем код на чанки для оптимизации загрузки
|
||||||
|
},
|
||||||
|
runtimeChunk: 'single', // Выносим рантайм webpack в отдельный файл
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,22 @@
|
|||||||
*/
|
*/
|
||||||
export function buildBabelLoader(isDev: boolean) {
|
export function buildBabelLoader(isDev: boolean) {
|
||||||
const babelLoader = {
|
const babelLoader = {
|
||||||
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' }], // Поддержка React 17+ (новый JSX transform)
|
||||||
|
'@babel/preset-typescript', // Поддержка TypeScript через Babel
|
||||||
|
],
|
||||||
plugins: [isDev && require.resolve('react-refresh/babel')].filter(
|
plugins: [isDev && require.resolve('react-refresh/babel')].filter(
|
||||||
Boolean
|
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,36 @@
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
import 'regenerator-runtime/runtime'
|
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 отдельно
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Глобальный мок для @gsap/react
|
||||||
|
jest.mock('@gsap/react', () => ({
|
||||||
|
useGSAP: (fn: () => void) => {
|
||||||
|
// Выполняем функцию немедленно в тестах
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const { useEffect } = require('react')
|
||||||
|
useEffect(() => {
|
||||||
|
fn()
|
||||||
|
}, [])
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"test:unit": "jest --config ./config/jest/jest.config.ts",
|
"test:unit": "jest --config ./config/jest/jest.config.ts",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"pre-push": "pnpm type-check && pnpm lint && pnpm lint:styles && pnpm build:prod",
|
"pre-push": "pnpm type-check && pnpm lint && pnpm lint:styles && pnpm test:unit && pnpm build:prod",
|
||||||
"storybook": "storybook dev -p 6006 -c config/storybook",
|
"storybook": "storybook dev -p 6006 -c config/storybook",
|
||||||
"build-storybook": "storybook build -c config/storybook"
|
"build-storybook": "storybook build -c config/storybook"
|
||||||
},
|
},
|
||||||
@@ -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",
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^8.16.0",
|
"@typescript-eslint/eslint-plugin": "^8.16.0",
|
||||||
"@typescript-eslint/parser": "^8.16.0",
|
"@typescript-eslint/parser": "^8.16.0",
|
||||||
"babel-loader": "^9.2.0",
|
"babel-loader": "^9.2.0",
|
||||||
|
"compression-webpack-plugin": "^11.1.0",
|
||||||
"css-loader": "^7.1.0",
|
"css-loader": "^7.1.0",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
@@ -65,6 +68,7 @@
|
|||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
"html-webpack-plugin": "^5.6.0",
|
"html-webpack-plugin": "^5.6.0",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"mini-css-extract-plugin": "^2.9.0",
|
"mini-css-extract-plugin": "^2.9.0",
|
||||||
|
|||||||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
@@ -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)
|
||||||
@@ -96,6 +102,9 @@ importers:
|
|||||||
babel-loader:
|
babel-loader:
|
||||||
specifier: ^9.2.0
|
specifier: ^9.2.0
|
||||||
version: 9.2.1(@babel/core@7.28.5)(webpack@5.103.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:
|
css-loader:
|
||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 7.1.2(webpack@5.103.0)
|
version: 7.1.2(webpack@5.103.0)
|
||||||
@@ -138,6 +147,9 @@ importers:
|
|||||||
husky:
|
husky:
|
||||||
specifier: ^9.1.0
|
specifier: ^9.1.0
|
||||||
version: 9.1.7
|
version: 9.1.7
|
||||||
|
identity-obj-proxy:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
jest:
|
jest:
|
||||||
specifier: ^30.2.0
|
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))
|
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))
|
||||||
@@ -1105,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'}
|
||||||
@@ -1649,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'}
|
||||||
@@ -2617,6 +2641,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
compression@1.8.1:
|
||||||
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
|
resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -3517,6 +3547,9 @@ packages:
|
|||||||
handle-thing@2.0.1:
|
handle-thing@2.0.1:
|
||||||
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
|
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
|
||||||
|
|
||||||
|
harmony-reflect@1.6.2:
|
||||||
|
resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==}
|
||||||
|
|
||||||
has-bigints@1.1.0:
|
has-bigints@1.1.0:
|
||||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3654,6 +3687,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.1.0
|
postcss: ^8.1.0
|
||||||
|
|
||||||
|
identity-obj-proxy@3.0.0:
|
||||||
|
resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -5196,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'}
|
||||||
@@ -6780,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':
|
||||||
@@ -7498,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
|
||||||
@@ -8604,6 +8660,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.54.0
|
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:
|
compression@1.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
@@ -9672,6 +9734,8 @@ snapshots:
|
|||||||
|
|
||||||
handle-thing@2.0.1: {}
|
handle-thing@2.0.1: {}
|
||||||
|
|
||||||
|
harmony-reflect@1.6.2: {}
|
||||||
|
|
||||||
has-bigints@1.1.0: {}
|
has-bigints@1.1.0: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
@@ -9817,6 +9881,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
|
|
||||||
|
identity-obj-proxy@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
harmony-reflect: 1.6.2
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
@@ -11572,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)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
@import './fonts';
|
@use 'fonts';
|
||||||
@import './variables';
|
@use 'variables';
|
||||||
@import './reset';
|
@use 'reset';
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 right, #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%;
|
||||||
}
|
}
|
||||||
@@ -1,96 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.medium {
|
|
||||||
height: 50px;
|
|
||||||
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.large {
|
|
||||||
height: 60px;
|
|
||||||
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
.card {
|
.card {
|
||||||
padding: 20px;
|
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) {
|
||||||
|
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: 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,60 +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 .label,
|
|
||||||
&.active .label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: none;
|
|
||||||
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -67,31 +68,59 @@ export const CircleTimeline = memo(function CircleTimeline({
|
|||||||
// Реф для массива точек периодов
|
// Реф для массива точек периодов
|
||||||
const pointsRef = useRef<(HTMLDivElement | null)[]>([])
|
const pointsRef = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
/**
|
// Реф для заголовков периодов
|
||||||
* Эффект для анимации поворота круга и контр-поворота точек
|
const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
|
||||||
* Запускается при изменении rotation
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
// Анимация поворота контейнера круга
|
|
||||||
if (circleRef.current) {
|
|
||||||
gsap.to(circleRef.current, {
|
|
||||||
rotation,
|
|
||||||
duration: ANIMATION_DURATION,
|
|
||||||
ease: ANIMATION_EASE,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Контр-поворот точек, чтобы текст оставался читаемым
|
/**
|
||||||
pointsRef.current.forEach((point) => {
|
* Анимация поворота круга и контр-поворота точек
|
||||||
if (point) {
|
* Использует useGSAP hook для автоматической очистки анимаций
|
||||||
gsap.to(point, {
|
*/
|
||||||
rotation: -rotation,
|
useGSAP(
|
||||||
duration: 0,
|
() => {
|
||||||
|
// Анимация поворота контейнера круга
|
||||||
|
if (circleRef.current) {
|
||||||
|
gsap.to(circleRef.current, {
|
||||||
|
rotation,
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
ease: ANIMATION_EASE,
|
ease: ANIMATION_EASE,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}, [rotation])
|
// Контр-поворот точек, чтобы текст оставался читаемым
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ dependencies: [rotation, activeIndex] }
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Мемоизированный расчет позиций точек на круге
|
* Мемоизированный расчет позиций точек на круге
|
||||||
@@ -138,7 +167,15 @@ export const CircleTimeline = memo(function CircleTimeline({
|
|||||||
aria-label={`Period ${index + 1}: ${period.label}`}
|
aria-label={`Period ${index + 1}: ${period.label}`}
|
||||||
aria-current={index === activeIndex ? 'true' : 'false'}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,33 +1,51 @@
|
|||||||
.container {
|
.container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prevButtonWrapper {
|
.prevButtonWrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: -25px;
|
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: -25px;
|
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) {
|
||||||
|
@container timeframe-slider (width <= 768px) {
|
||||||
|
padding: 0 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.swiper-slide-visible) {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
@container timeframe-slider (width < 768px) {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.swiper-slide-fully-visible) {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик инициализации Swiper
|
* Обработчик инициализации Swiper
|
||||||
@@ -145,6 +143,7 @@ export const EventsCarousel = memo(
|
|||||||
|
|
||||||
<Swiper
|
<Swiper
|
||||||
{...EVENT_CAROUSEL_CONFIG}
|
{...EVENT_CAROUSEL_CONFIG}
|
||||||
|
watchSlidesProgress
|
||||||
onInit={handleSwiperInit}
|
onInit={handleSwiperInit}
|
||||||
onSlideChange={handleSlideChange}
|
onSlideChange={handleSlideChange}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import gsap from 'gsap'
|
|
||||||
import { Navigation } from 'swiper/modules'
|
import { Navigation } from 'swiper/modules'
|
||||||
|
|
||||||
import type { SwiperOptions } from 'swiper/types'
|
import type { SwiperOptions } from 'swiper/types'
|
||||||
@@ -8,16 +7,38 @@ import type { SwiperOptions } from 'swiper/types'
|
|||||||
*/
|
*/
|
||||||
export const EVENT_CAROUSEL_CONFIG: SwiperOptions = {
|
export const EVENT_CAROUSEL_CONFIG: SwiperOptions = {
|
||||||
modules: [Navigation],
|
modules: [Navigation],
|
||||||
spaceBetween: 30,
|
|
||||||
slidesPerView: 1.5,
|
slidesPerView: 1.5,
|
||||||
breakpoints: {
|
spaceBetween: 25,
|
||||||
768: {
|
|
||||||
slidesPerView: 3.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
navigation: {
|
navigation: {
|
||||||
prevEl: '.swiper-button-prev-custom',
|
prevEl: '.swiper-button-prev-custom',
|
||||||
nextEl: '.swiper-button-next-custom',
|
nextEl: '.swiper-button-next-custom',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
breakpoints: {
|
||||||
|
576: {
|
||||||
|
slidesPerView: 2.5,
|
||||||
|
},
|
||||||
|
768: {
|
||||||
|
slidesPerView: 2,
|
||||||
|
navigation: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
spaceBetween: 25,
|
||||||
|
},
|
||||||
|
1024: {
|
||||||
|
slidesPerView: 3,
|
||||||
|
navigation: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
spaceBetween: 30,
|
||||||
|
},
|
||||||
|
1440: {
|
||||||
|
slidesPerView: 3,
|
||||||
|
navigation: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
spaceBetween: 80,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +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;
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1440px;
|
max-width: 1440px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
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);
|
||||||
|
|
||||||
overflow: hidden;
|
background-image: linear-gradient(to right, rgba(#42567A, 0.1) 1px, transparent 1px);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center top;
|
||||||
|
background-size: 1px 100%;
|
||||||
|
|
||||||
@media (width <=768px) {
|
overflow: hidden;
|
||||||
min-height: auto;
|
|
||||||
padding: 20px;
|
@container timeframe-slider (width <= 1024px) {
|
||||||
}
|
padding-top: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 576px) {
|
||||||
|
padding: 60px 20px 20px;
|
||||||
|
|
||||||
|
background-image: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
position: absolute;
|
||||||
|
top: 170px;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
max-width: 15ch;
|
||||||
|
padding-left: 75px;
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 56px;
|
||||||
|
line-height: 120%;
|
||||||
|
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-image: var(--gradient-primary) 1;
|
||||||
|
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 1024px) {
|
||||||
|
top: 80px;
|
||||||
|
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 768px) {
|
||||||
|
padding-left: 20px;
|
||||||
|
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 576px) {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
inset: unset;
|
||||||
|
|
||||||
margin-bottom: 40px;
|
margin-bottom: 20px;
|
||||||
padding-left: 60px;
|
padding-left: 0;
|
||||||
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 56px;
|
|
||||||
line-height: 120%;
|
|
||||||
|
|
||||||
@media (width <=768px) {
|
border: none;
|
||||||
margin-bottom: 20px;
|
}
|
||||||
padding-left: 0;
|
|
||||||
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
||||||
height: 600px;
|
width: calc(100% + 40px);
|
||||||
|
height: 600px;
|
||||||
|
margin: 0 -20px;
|
||||||
|
|
||||||
@media (width <=768px) {
|
background-image: linear-gradient(to bottom, rgba(#42567A, 0.1) 1px, transparent 1px);
|
||||||
display: flex;
|
background-repeat: no-repeat;
|
||||||
flex-direction: column;
|
background-position: center;
|
||||||
|
background-size: 100% 1px;
|
||||||
|
|
||||||
height: auto;
|
@container timeframe-slider (width <= 576px) {
|
||||||
}
|
position: unset;
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
position: absolute;
|
|
||||||
left: 60px;
|
|
||||||
bottom: 50px;
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
@media (width <=768px) {
|
width: 100%;
|
||||||
position: static;
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
order: 2;
|
background-image: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
margin-top: 20px;
|
.controls {
|
||||||
padding: 0;
|
position: absolute;
|
||||||
}
|
left: 100px;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
transform-origin: left;
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 1024px) {
|
||||||
|
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 {
|
||||||
margin-bottom: 10px;
|
font-weight: 400;
|
||||||
|
font-size: 14px;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 768px) {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronIcon {
|
||||||
|
width: 9px;
|
||||||
|
height: 14px;
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 576px) {
|
||||||
|
width: 6px;
|
||||||
|
height: 11.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 576px) {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 32px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
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 {
|
.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;
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 200px;
|
||||||
|
line-height: 160px;
|
||||||
|
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
@container timeframe-slider (width <= 1024px) {
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
|
|
||||||
color: var(--color-text);
|
font-size: 140px;
|
||||||
font-weight: 700;
|
line-height: 120px;
|
||||||
font-size: 200px;
|
}
|
||||||
line-height: 160px;
|
|
||||||
|
|
||||||
transform: translate(-50%, -50%);
|
@container timeframe-slider (width <= 768px) {
|
||||||
|
font-size: 100px;
|
||||||
|
line-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
pointer-events: none;
|
@container timeframe-slider (width <= 576px) {
|
||||||
|
position: static;
|
||||||
|
|
||||||
@media (width <=768px) {
|
gap: 20px;
|
||||||
position: static;
|
justify-content: center;
|
||||||
|
|
||||||
gap: 20px;
|
margin-bottom: 40px;
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
margin-bottom: 40px;
|
font-size: 56px;
|
||||||
|
|
||||||
font-size: 56px;
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
transform: none;
|
span:first-child {
|
||||||
}
|
color: #5d5fef;
|
||||||
|
}
|
||||||
|
|
||||||
span:first-child {
|
span:last-child {
|
||||||
color: #5d5fef;
|
color: #ef5da8;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
margin-bottom: 40px;
|
padding-bottom: 20px;
|
||||||
padding-top: 40px;
|
|
||||||
|
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
|
|
||||||
border-top: 1px solid var(--color-border);
|
border-bottom: 1px solid #C7CDD9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.circleContainer {
|
.circleContainer {
|
||||||
@media (width <=768px) {
|
width: 100%;
|
||||||
display: none;
|
height: 100%;
|
||||||
}
|
|
||||||
|
@container timeframe-slider (width <= 576px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.carouselContainer {
|
||||||
|
padding: 55px 80px 105px;
|
||||||
|
|
||||||
|
@container timeframe-slider (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,14 +3,16 @@
|
|||||||
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий
|
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import gsap from 'gsap'
|
import { useGSAP } from '@gsap/react'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
|
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
|
||||||
import ChevronSvg from '@/shared/assets/chevron--left.svg'
|
import ChevronSvg from '@/shared/assets/chevron--left.svg'
|
||||||
import { Button } from '@/shared/ui/Button'
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
|
||||||
import { ACTIVE_POSITION_ANGLE } from './constants'
|
import { ACTIVE_POSITION_ANGLE, GSAP_ANIMATION_CONFIG } from './constants'
|
||||||
import styles from './TimeFrameSlider.module.scss'
|
import styles from './TimeFrameSlider.module.scss'
|
||||||
import { CircleTimeline } from '../CircleTimeline/CircleTimeline'
|
import { CircleTimeline } from '../CircleTimeline/CircleTimeline'
|
||||||
import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
|
import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
|
||||||
@@ -20,7 +22,7 @@ import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
|
|||||||
*
|
*
|
||||||
* Отображает исторические периоды на круговой диаграмме с возможностью
|
* Отображает исторические периоды на круговой диаграмме с возможностью
|
||||||
* переключения между ними. Для каждого периода показывается карусель событий.
|
* переключения между ними. Для каждого периода показывается карусель событий.
|
||||||
* Центральные даты анимируются при смене периода с помощью GSAP.
|
* Центральные даты анимируются при смене периода с помощью GSAP useGSAP hook.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -34,10 +36,7 @@ export const TimeFrameSlider = memo(() => {
|
|||||||
const startYearRef = useRef<HTMLSpanElement>(null)
|
const startYearRef = useRef<HTMLSpanElement>(null)
|
||||||
const endYearRef = useRef<HTMLSpanElement>(null)
|
const endYearRef = useRef<HTMLSpanElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const periodLabelRef = useRef<HTMLDivElement>(null)
|
||||||
// Мемоизированные константы
|
|
||||||
const totalPeriods = useMemo(() => HISTORICAL_PERIODS.length, [])
|
|
||||||
const anglePerPoint = useMemo(() => 360 / totalPeriods, [totalPeriods])
|
|
||||||
|
|
||||||
// Текущий период
|
// Текущий период
|
||||||
const currentPeriod = useMemo(
|
const currentPeriod = useMemo(
|
||||||
@@ -45,6 +44,14 @@ export const TimeFrameSlider = memo(() => {
|
|||||||
[activePeriod]
|
[activePeriod]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Мемоизированные константы
|
||||||
|
const totalPeriods = useMemo(() => HISTORICAL_PERIODS.length, [])
|
||||||
|
const anglePerPoint = useMemo(() => 360 / totalPeriods, [totalPeriods])
|
||||||
|
|
||||||
|
// Рефы для предыдущих значений периода
|
||||||
|
const prevYearFromRef = useRef(currentPeriod.yearFrom)
|
||||||
|
const prevYearToRef = useRef(currentPeriod.yearTo)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Расчет поворота при изменении активного периода
|
* Расчет поворота при изменении активного периода
|
||||||
* Использует кратчайший путь для анимации
|
* Использует кратчайший путь для анимации
|
||||||
@@ -60,34 +67,54 @@ 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.to(startYearRef.current, {
|
gsap.fromTo(
|
||||||
innerText: currentPeriod.yearFrom,
|
startYearRef.current,
|
||||||
snap: { innerText: 1 },
|
{
|
||||||
duration: 1,
|
innerText: prevYearFromRef.current,
|
||||||
ease: 'power2.inOut',
|
},
|
||||||
})
|
{
|
||||||
|
innerText: currentPeriod.yearFrom,
|
||||||
|
...GSAP_ANIMATION_CONFIG,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endYearRef.current) {
|
if (endYearRef.current) {
|
||||||
gsap.to(endYearRef.current, {
|
gsap.fromTo(
|
||||||
innerText: currentPeriod.yearTo,
|
endYearRef.current,
|
||||||
snap: { innerText: 1 },
|
{
|
||||||
duration: 1,
|
innerText: prevYearToRef.current,
|
||||||
ease: 'power2.inOut',
|
},
|
||||||
})
|
{
|
||||||
|
innerText: currentPeriod.yearTo,
|
||||||
|
...GSAP_ANIMATION_CONFIG,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, containerRef)
|
|
||||||
|
|
||||||
return () => ctx.revert()
|
prevYearFromRef.current = currentPeriod.yearFrom
|
||||||
}, [currentPeriod.yearFrom, currentPeriod.yearTo])
|
prevYearToRef.current = currentPeriod.yearTo
|
||||||
|
|
||||||
|
// Анимация появления лейбла периода
|
||||||
|
if (periodLabelRef.current) {
|
||||||
|
gsap.fromTo(
|
||||||
|
periodLabelRef.current,
|
||||||
|
{ opacity: 0, visibility: 'hidden' },
|
||||||
|
{ opacity: 1, visibility: 'visible', duration: 1 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: containerRef,
|
||||||
|
dependencies: [currentPeriod.yearFrom, currentPeriod.yearTo],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переключение на предыдущий период
|
* Переключение на предыдущий период
|
||||||
@@ -106,58 +133,77 @@ 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 className={styles.buttons}>
|
||||||
|
<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 className={styles.periodLabel}>{currentPeriod.label}</div>
|
<div className={styles.carouselContainer}>
|
||||||
|
<EventsCarousel events={currentPeriod.events} visible />
|
||||||
|
</div>
|
||||||
|
|
||||||
<CircleTimeline
|
<div className={styles.dots}>
|
||||||
periods={HISTORICAL_PERIODS}
|
{HISTORICAL_PERIODS.map((_, index) => (
|
||||||
activeIndex={activePeriod}
|
<button
|
||||||
onPeriodChange={setActivePeriod}
|
key={index}
|
||||||
rotation={rotation}
|
className={classNames(styles.dot, {
|
||||||
/>
|
[styles.activeDot]: index === activePeriod,
|
||||||
|
})}
|
||||||
<div className={styles.controls}>
|
onClick={() => setActivePeriod(index)}
|
||||||
<div className={styles.pagination}>
|
aria-label={`Перейти к периоду ${index + 1}`}
|
||||||
{String(activePeriod + 1).padStart(2, '0')}/
|
/>
|
||||||
{String(totalPeriods).padStart(2, '0')}
|
))}
|
||||||
</div>
|
|
||||||
<div className={styles.buttons}>
|
|
||||||
<Button
|
|
||||||
variant='round'
|
|
||||||
size='medium'
|
|
||||||
colorScheme='primary'
|
|
||||||
onClick={handlePrev}
|
|
||||||
aria-label='Предыдущий период'
|
|
||||||
>
|
|
||||||
<ChevronSvg width={6.25} height={12.5} stroke='#42567A' />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='round'
|
|
||||||
size='medium'
|
|
||||||
colorScheme='primary'
|
|
||||||
onClick={handleNext}
|
|
||||||
aria-label='Следующий период'
|
|
||||||
>
|
|
||||||
<ChevronSvg
|
|
||||||
width={6.25}
|
|
||||||
height={12.5}
|
|
||||||
stroke='#42567A'
|
|
||||||
className={styles.rotated}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EventsCarousel events={currentPeriod.events} visible />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Power2 } from 'gsap'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Константы для компонента TimeFrameSlider
|
* Константы для компонента TimeFrameSlider
|
||||||
*/
|
*/
|
||||||
@@ -6,3 +8,12 @@
|
|||||||
* Угол позиции активного элемента (верхний правый угол)
|
* Угол позиции активного элемента (верхний правый угол)
|
||||||
*/
|
*/
|
||||||
export const ACTIVE_POSITION_ANGLE = -60
|
export const ACTIVE_POSITION_ANGLE = -60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация анимации для изменения значения года
|
||||||
|
*/
|
||||||
|
export const GSAP_ANIMATION_CONFIG: gsap.TweenVars = {
|
||||||
|
snap: { innerText: 1 },
|
||||||
|
duration: 1,
|
||||||
|
ease: Power2.easeInOut,
|
||||||
|
} as const
|
||||||
|
|||||||
@@ -46,12 +46,22 @@ 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,
|
||||||
'declaration-empty-line-before': null,
|
'declaration-empty-line-before': null,
|
||||||
'no-descending-specificity': null, // Отключаем из-за конфликта с order/order
|
'no-descending-specificity': null, // Отключаем из-за конфликта с order/order
|
||||||
|
'selector-pseudo-class-no-unknown': [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
ignorePseudoClasses: ['global'],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,7 +18,12 @@
|
|||||||
"./src/*"
|
"./src/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true
|
"strict": true,
|
||||||
|
"types": [
|
||||||
|
"node",
|
||||||
|
"jest",
|
||||||
|
"@testing-library/jest-dom"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"ts-node": {
|
"ts-node": {
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|||||||
Reference in New Issue
Block a user