60 Commits

Author SHA1 Message Date
Ilia Mashkov
dad6253877 fix: Правки линтера, правки документации 2025-11-23 16:18:23 +03:00
Ilia Mashkov
174547791b refactor: Адаптив виджета переработан на использование container quries 2025-11-23 16:09:23 +03:00
Ilia Mashkov
92e0b474a4 feat: Правки документации 2025-11-23 16:08:14 +03:00
Ilia Mashkov
c57957f15c refactor: Переработка компонентов на использование хука useGSAP 2025-11-23 15:39:04 +03:00
Ilia Mashkov
53d7b90c72 feat: Приведение документации в соответствие конфигурации 2025-11-23 15:38:19 +03:00
Ilia Mashkov
5af3fbfdc3 Merge pull request #5 from e7f3/feature/adaptive
Feature/adaptive
2025-11-23 14:50:14 +03:00
Ilia Mashkov
1c81deaed0 feat: Добавлен файл README.md 2025-11-23 14:47:07 +03:00
Ilia Mashkov
246371c431 feat: Обновленная версия setupTests файла 2025-11-23 14:15:45 +03:00
Ilia Mashkov
e4c563075f feat: Оптимизация импортов 2025-11-23 14:13:52 +03:00
Ilia Mashkov
2a72e1077c feat: Настройки webpack для уменьшения размера бандла 2025-11-23 14:13:21 +03:00
Ilia Mashkov
013d32f09d feat: Настроен webpack плагин для сжатия prod сборки 2025-11-23 14:12:27 +03:00
Ilia Mashkov
6de84f3143 feat: Добавлены тесты React компонентов. Тесты базовой логики, Граничных тестов, стилей 2025-11-23 13:48:50 +03:00
Ilia Mashkov
ec6867d7a0 feat: 2025-11-23 13:46:48 +03:00
Ilia Mashkov
da0309f714 feat: Установлена библиотека identify-object-proxy 2025-11-23 13:46:06 +03:00
Ilia Mashkov
5c408ccff4 fix: Правки линтера 2025-11-23 13:34:40 +03:00
Ilia Mashkov
d27906fd47 refactor: Изменена конфигурация webpack для улучшения производительности и скорости сборки 2025-11-23 13:34:15 +03:00
Ilia Mashkov
c4a7653d7b fix: Правка в конфиг stylelint для глобальных стилей 2025-11-23 13:31:46 +03:00
Ilia Mashkov
eace670c50 feat: 2025-11-23 13:15:45 +03:00
Ilia Mashkov
e892e69277 feat: Добавлено название периода с анимацией появления для мобильного разрешения 2025-11-23 13:15:30 +03:00
Ilia Mashkov
38785cce94 feat: Добавлены названия периодов с анимацией появления 2025-11-23 13:14:20 +03:00
Ilia Mashkov
2958cc734a feature: Настроен адаптив виджета TimeFrameSlider 2025-11-21 15:42:39 +03:00
Ilia Mashkov
364c5e1ff1 feature: Настроен адаптив для карусели событий 2025-11-21 15:41:57 +03:00
Ilia Mashkov
f34f1a28de feature: Адаптивные стили для компонента Card 2025-11-21 15:40:27 +03:00
Ilia Mashkov
3f7f5d358e feature: Адаптивные стили для кнопки 2025-11-21 15:40:08 +03:00
Ilia Mashkov
bd1eb1eee9 Merge pull request #4 from e7f3/feature/minor-changes
Feature/minor changes
2025-11-21 13:02:45 +03:00
Ilia Mashkov
4ec072055f feat: В препуш хуки добавлен запуск unit тестов 2025-11-21 12:59:48 +03:00
Ilia Mashkov
93787eaf4d fix: Правки линтера 2025-11-21 12:57:07 +03:00
Ilia Mashkov
33763767d9 fix: Удален неиспользуемый импорт 2025-11-21 12:56:13 +03:00
Ilia Mashkov
f1118e5aec fix: Удален горизонтальный отступ у карточки 2025-11-21 12:55:54 +03:00
Ilia Mashkov
65630bae8a fix: Исправлена анимация карусели событий, смена событий триггерит анимацию. Исправлено расположение кнопок навигации 2025-11-21 12:55:22 +03:00
Ilia Mashkov
5a2a7642d3 fix: Исправлено расположение элементов, добавлены стили линий 2025-11-21 12:53:34 +03:00
Ilia Mashkov
8fec89a699 fix: Исправлена анимация смены года 2025-11-21 12:52:51 +03:00
Ilia Mashkov
e33cd1b139 refactor: конфиг анимации смены года вынесен в константы 2025-11-21 12:51:49 +03:00
Ilia Mashkov
d7e66eec71 fix: Исправлена переменная градиента 2025-11-21 12:51:02 +03:00
Ilia Mashkov
a344b6f4de fix: Исправлен импорт scss файлов в index.scss 2025-11-21 12:50:35 +03:00
Ilia Mashkov
fb0808bc1f Merge pull request #3 from e7f3/feature/time-frame-slider
Feature/time frame slider
2025-11-20 16:15:20 +03:00
Ilia Mashkov
8d5691e4c6 fix: Правки линтера 2025-11-20 16:11:48 +03:00
Ilia Mashkov
d596576356 feat: Добавлен компонент TimeFrameSlider - главный компонент объединяющий круговую диаграму и карусель событий 2025-11-20 16:11:26 +03:00
Ilia Mashkov
c970d9c6d0 refactor: Логика координат точки на круге вынесена в отельную функцию, исправлены стили 2025-11-20 16:07:18 +03:00
Ilia Mashkov
b84acfc3e7 feat: Добавлена функция для вычисления координат точки на окружности 2025-11-20 15:25:25 +03:00
Ilia Mashkov
5ef223d8d4 fix: Правки в Jest конфиг для поддержки @ алиаса импортов 2025-11-20 15:24:38 +03:00
Ilia Mashkov
6b41f506a3 feat: Добавлена конфигурация Jest для unit тестирования, установлены соответствующие библиотеки 2025-11-20 14:15:36 +03:00
Ilia Mashkov
55222ba27e feat: Изменена иконка шеврона для поддержки кастомизации 2025-11-20 12:11:09 +03:00
Ilia Mashkov
1ed871a9fd feat: Улучшена работа с svg, добавлена поддержка кастомизации 2025-11-20 12:04:08 +03:00
Ilia Mashkov
71330e4f78 feat: Добавлен компонент слайдера событий EventsCarousel 2025-11-20 10:42:33 +03:00
Ilia Mashkov
7f0d6d902a feat: Добавлен переиспользуемый компонент Card для отображения информации в формате "Заголовок - Описание" 2025-11-20 10:00:24 +03:00
Ilia Mashkov
d3731ad513 fix: Правки внешнего вида кода линтером 2025-11-20 09:58:13 +03:00
Ilia Mashkov
5c869eb215 feat: Добавлен переиспользуемый компонент кнопки с задаваемыми размерами, вариантами и цветовыми схемами. Добавлен декоратор стилей для сторибука, установлена библиотека для работы с классами 2025-11-20 09:26:01 +03:00
Ilia Mashkov
e440005e60 feat: Добавлен компонент CircleTimeline для отображения категорий для временных промежутков 2025-11-19 22:34:27 +03:00
Ilia Mashkov
0006a20a61 feat: Добавлена конфигурация для storybook 2025-11-19 22:15:25 +03:00
Ilia Mashkov
65588bc8be fix: Правка в tsconfig для включения файлов внутри папки src 2025-11-19 21:46:47 +03:00
Ilia Mashkov
3f3b817a1d fix: Правка в tsconfig для включения файлов внутри папки src 2025-11-19 21:46:31 +03:00
Ilia Mashkov
2b08f292dc Merge pull request #2 from e7f3/feature/fsd-structure
Структура проекта
2025-11-19 20:38:44 +03:00
Ilia Mashkov
9e81882677 fix: Правка eslint 2025-11-19 20:35:32 +03:00
Ilia Mashkov
5e8a6128ed fix: Правки stylelint для файлов стилей 2025-11-19 20:34:37 +03:00
Ilia Mashkov
6b6e1386fa Merge branch 'main' into feature/fsd-structure 2025-11-19 20:31:27 +03:00
Ilia Mashkov
4631988ee4 fix: Правка импорта App 2025-11-19 20:26:34 +03:00
Ilia Mashkov
58bc7bc28a feat: Добавлен слой entities с типами для событий, установлены библиотеки gsap и swiper 2025-11-19 20:24:19 +03:00
Ilia Mashkov
7f507513e9 feat: Добавлен слой app с базовыми файлами стилей 2025-11-19 20:21:46 +03:00
Ilia Mashkov
7ef28f9313 Merge pull request #1 from e7f3/fixes/configs-tweaks
Правки в инструменты сборки и конфиги линтеров
2025-11-19 19:08:01 +03:00
63 changed files with 6861 additions and 87 deletions

View File

@@ -12,6 +12,9 @@ pnpm lint || exit 1
echo "Проверка Stylelint..."
pnpm lint:styles || exit 1
echo "Запуск Unit тестов..."
pnpm test:unit || exit 1
echo "Production сборка..."
pnpm build:prod || exit 1

221
README.md Normal file
View File

@@ -0,0 +1,221 @@
# Only Task
Тестовое задание для only.digital
## 🚀 Технологии
- **React 19** - библиотека для создания пользовательских интерфейсов
- **TypeScript 5** - типизированный JavaScript
- **GSAP** - библиотека для плавных анимаций
- **Swiper** - современная библиотека для каруселей
- **Webpack 5** - сборщик модулей
- **SCSS** - препроцессор CSS с модулями
- **Jest** - фреймворк для тестирования
- **ESLint + Prettier** - линтинг и форматирование кода
## 📋 Требования
- **Node.js** >= 18.0.0
- **pnpm** >= 8.0.0
## 🛠️ Установка
1. Клонируйте репозиторий:
```bash
git clone <repository-url>
cd only-task
```
2. Установите зависимости:
```bash
pnpm install
```
## 🎯 Доступные команды
### Разработка
```bash
# Запуск dev-сервера на порту 3000
pnpm dev
```
Приложение будет доступно по адресу: http://localhost:3000
### Сборка
```bash
# Production сборка
pnpm build:prod
# Development сборка
pnpm build:dev
```
Результат сборки будет в папке `dist/`
### Тестирование
```bash
# Запуск всех unit-тестов
pnpm test:unit
# Проверка типов TypeScript
pnpm type-check
```
### Линтинг
```bash
# Проверка JavaScript/TypeScript кода
pnpm lint
# Автоматическое исправление ошибок
pnpm lint --fix
# Проверка стилей (SCSS)
pnpm lint:styles
# Автоматическое исправление стилей
pnpm lint:styles --fix
```
### Storybook
```bash
# Запуск Storybook для разработки компонентов
pnpm storybook
# Сборка статической версии Storybook
pnpm build-storybook
```
### Pre-push проверки
```bash
# Запуск всех проверок перед push
pnpm pre-push
```
Эта команда выполняет:
- ✅ Проверку типов TypeScript
- ✅ Линтинг кода
- ✅ Линтинг стилей
- ✅ Запуск unit-тестов
- ✅ Production сборку
## 📁 Структура проекта
```
only-task/
├── config/ # Конфигурация сборки
│ ├── build/ # Webpack конфигурация
│ ├── jest/ # Jest конфигурация
│ └── storybook/ # Storybook конфигурация
├── dist/ # Production сборка (генерируется)
├── src/ # Исходный код
│ ├── app/ # Корневой компонент приложения
│ ├── entities/ # Бизнес-сущности (типы данных)
│ ├── shared/ # Переиспользуемые компоненты и утилиты
│ │ ├── assets/ # Статические ресурсы (SVG, изображения)
│ │ └── ui/ # UI компоненты (Button, Card)
│ └── widgets/ # Сложные компоненты (TimeFrameSlider)
├── .husky/ # Git hooks
├── package.json # Зависимости и скрипты
└── tsconfig.json # Конфигурация TypeScript
```
## 🎨 Основные компоненты
### TimeFrameSlider
Главный виджет приложения, объединяющий круговую временную шкалу и карусель событий.
**Особенности:**
- Интерактивная круговая диаграмма с периодами
- Плавные GSAP анимации при переключении
- Адаптивный дизайн для мобильных, планшетов и десктопа
- Кастомная навигация
### CircleTimeline
Круговая временная шкала с точками периодов.
**Особенности:**
- Автоматический поворот к активному периоду
- Клик по точкам для переключения
- Анимация заголовков периодов
### EventsCarousel
Карусель исторических событий с использованием Swiper.
**Особенности:**
- Адаптивное количество слайдов
- Кастомная навигация
- Плавная анимация появления/исчезновения
## ⚡ Оптимизация производительности
Проект оптимизирован для production:
-**Code splitting** - разделение на chunks (vendors, runtime, main)
-**Tree shaking** - удаление неиспользуемого кода
-**Gzip compression** - сжатие файлов (~68% уменьшение размера)
-**Minification** - минификация JS и CSS
-**CSS Modules** - изолированные стили компонентов
-**Babel caching** - кеширование для быстрой пересборки
**Размер бандла:**
- Uncompressed: ~371 KiB
- Gzipped: ~117 KiB
## 🧪 Тестирование
Проект покрыт unit-тестами:
- ✅ Тесты компонентов (Button, Card, CircleTimeline, TimeFrameSlider, EventsCarousel)
- ✅ Тесты утилит (calculateCoordinates)
- ✅ Моки для GSAP и Swiper
- ✅ 39 тестов, 100% успешных
## 📱 Адаптивность
Приложение адаптировано для всех устройств:
- **Desktop** (>1440px) - полная версия с круговой диаграммой
- **Tablet** (768px - 1024px) - оптимизированная версия
- **Mobile** (<768px) - упрощенная версия с навигацией внизу
## 🔧 Конфигурация
### Webpack
- Babel для транспиляции (быстрее ts-loader)
- CSS Modules для изоляции стилей
- SVGR для импорта SVG как React компонентов
- Hot Module Replacement для разработки
- Bundle Analyzer для анализа размера
### TypeScript
- Strict mode включен
- Path aliases (`@/*``src/*`)
- Проверка типов отдельно от сборки
### ESLint
- Правила для React, TypeScript, Jest
- Prettier интеграция
- Import sorting
## 🤝 Git Hooks
Pre-push hook автоматически запускает:
1. Type checking
2. ESLint
3. Stylelint
4. Unit tests
5. Production build
Это гарантирует, что в репозиторий попадает только рабочий код.
## 📝 Лицензия
ISC
**Приятной разработки! 🚀**

View File

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

View File

@@ -4,7 +4,6 @@ import { buildBabelLoader } from './loaders/buildBabelLoader'
import { buildCssLoader } from './loaders/buildCssLoader'
import { buildFileLoader } from './loaders/buildFileLoader'
import { buildSvgrLoader } from './loaders/buildSvgrLoader'
import { buildTypescriptLoader } from './loaders/buildTypescriptLoader'
import { BuildOptions } from './types/config'
/**
@@ -14,24 +13,28 @@ import { BuildOptions } from './types/config'
* Текущий порядок:
* 1. fileLoader - обрабатывает изображения и шрифты
* 2. svgrLoader - преобразует SVG в React компоненты
* 3. babelLoader - транспилирует JS/JSX/TSX с React Refresh
* 4. typescriptLoader - компилирует TypeScript
* 5. cssLoader - обрабатывает CSS/SCSS с модулями
* 3. babelLoader - транспилирует JS/JSX/TS/TSX с помощью Babel (включая TypeScript)
* 4. cssLoader - обрабатывает CSS/SCSS с модулями
*
* Примечание: TypeScript компилируется через Babel (@babel/preset-typescript),
* а не через ts-loader. Проверка типов выполняется отдельно через `tsc --noEmit`.
*
* @param {BuildOptions} options - Опции сборки
* @param {boolean} options.isDev - Флаг режима разработки
* @returns {webpack.RuleSetRule[]} Массив правил для webpack
*/
export function buildLoaders({ isDev }: BuildOptions): webpack.RuleSetRule[] {
const babelLoader = buildBabelLoader(isDev)
// Используем babel-loader для обработки JS и TS файлов
// Это ускоряет сборку, так как babel работает быстрее ts-loader
// Проверка типов должна выполняться отдельно (например, через tsc --noEmit)
// Исключаем тестовые и storybook файлы из production сборки
const codeBabelLoader = buildBabelLoader(isDev)
const fileLoader = buildFileLoader()
const svgrLoader = buildSvgrLoader()
const typescriptLoader = buildTypescriptLoader()
const cssLoader = buildCssLoader(isDev)
return [fileLoader, svgrLoader, babelLoader, typescriptLoader, cssLoader]
return [fileLoader, svgrLoader, codeBabelLoader, cssLoader]
}

View File

@@ -1,4 +1,5 @@
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'
import CompressionPlugin from 'compression-webpack-plugin'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import webpack from 'webpack'
@@ -22,6 +23,9 @@ import { BuildOptions } from './types/config'
* - ReactRefreshWebpackPlugin: обеспечивает быструю перезагрузку React компонентов
* - HotModuleReplacementPlugin: включает горячую замену модулей (HMR)
*
* Плагины только для production:
* - CompressionPlugin: создает gzip-сжатые версии файлов для уменьшения размера передачи
*
* @param {BuildOptions} options - Опции сборки
* @param {BuildPaths} options.paths - Пути проекта
* @param {boolean} options.isDev - Флаг режима разработки
@@ -68,6 +72,17 @@ export function buildPlugins({
)
plugins.push(new ReactRefreshWebpackPlugin({ overlay: false }))
plugins.push(new webpack.HotModuleReplacementPlugin())
} else {
// Сжатие файлов для production сборки
// Создает .gz файлы для всех JS и CSS файлов больше 10KB
plugins.push(
new CompressionPlugin({
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 10240, // Сжимаем только файлы больше 10KB
minRatio: 0.8, // Сжимаем только если размер уменьшается минимум на 20%
})
)
}
return plugins
}

View File

@@ -56,5 +56,11 @@ export function buildWebpackConfig(
plugins: buildPlugins(options),
devtool: isDev ? 'inline-source-map' : undefined,
devServer: isDev ? buildDevServer(options) : undefined,
optimization: {
splitChunks: {
chunks: 'all', // Разделяем код на чанки для оптимизации загрузки
},
runtimeChunk: 'single', // Выносим рантайм webpack в отдельный файл
},
}
}

View File

@@ -13,12 +13,22 @@
*/
export function buildBabelLoader(isDev: boolean) {
const babelLoader = {
test: /\.(js|jsx|tsx)$/,
exclude: /node_modules/,
test: /\.(js|jsx|tsx|ts)$/,
exclude: [
/node_modules/,
/\.test\.(ts|tsx)$/, // Исключаем тестовые файлы
/\.spec\.(ts|tsx)$/, // Исключаем spec файлы
/\.stories\.(ts|tsx)$/, // Исключаем Storybook файлы
],
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
cacheDirectory: true, // Включаем кеширование для ускорения пересборки
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }], // Поддержка React 17+ (новый JSX transform)
'@babel/preset-typescript', // Поддержка TypeScript через Babel
],
plugins: [isDev && require.resolve('react-refresh/babel')].filter(
Boolean
),

View File

@@ -17,7 +17,32 @@
export function buildSvgrLoader() {
const svgrLoader = {
test: /\.svg$/,
use: ['@svgr/webpack'],
use: [
{
loader: '@svgr/webpack',
options: {
// Replace currentColor with props
replaceAttrValues: {
currentColor: '{props.stroke || "currentColor"}',
},
// Allow width and height to be customizable
dimensions: false,
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
// Keep viewBox for proper scaling
removeViewBox: false,
},
},
},
],
},
},
},
],
}
return svgrLoader

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
const JestEmptyComponent = () => <div />
export default JestEmptyComponent

200
config/jest/jest.config.ts Normal file
View File

@@ -0,0 +1,200 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
*/
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const PROJECT_ROOT = path.resolve(__dirname, '../..')
export default {
// Automatically clear mock calls, instances and results before every test
clearMocks: true,
// The test environment that will be used for testing
testEnvironment: 'jsdom',
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: ['/node_modules/'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/.fttemplates/'],
// An array of directory names to be searched recursively up from the requiring module's location
moduleDirectories: ['node_modules'],
// An array of file extensions your modules use
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node'],
// The root directory that Jest should scan for tests and modules within
rootDir: '../../',
// The glob patterns Jest uses to detect test files
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[tj]s?(x)',
'<rootDir>src/**/*(*.)/@(spec|test)/[tj]s?(x)',
],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(css|scss)$': 'identity-obj-proxy',
'\\.(svg)$': path.resolve(__dirname, 'JestEmptyComponent.tsx'),
'^@/(.*)$': path.resolve(PROJECT_ROOT, 'src/$1'),
},
// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
// A set of global variables that need to be available in all test environments
globals: {
__IS_DEV__: true,
__API__: '',
__PROJECT__: 'jest',
},
modulePaths: ['<rootDir>/src'],
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// Indicates which provider should be used to instrument code for coverage
// coverageProvider: "babel",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}

36
config/jest/setupTests.ts Normal file
View File

@@ -0,0 +1,36 @@
import '@testing-library/jest-dom'
import 'regenerator-runtime/runtime'
// Глобальный мок для GSAP
jest.mock('gsap', () => {
const gsapMock = {
to: jest.fn(),
fromTo: jest.fn(),
killTweensOf: jest.fn(),
context: jest.fn(() => ({
revert: jest.fn(),
})),
Power2: {
easeOut: 'power2.out',
},
}
return {
__esModule: true,
default: gsapMock, // Для default import
gsap: gsapMock, // Для named import
Power2: gsapMock.Power2, // Экспортируем Power2 отдельно
}
})
// Глобальный мок для @gsap/react
jest.mock('@gsap/react', () => ({
useGSAP: (fn: () => void) => {
// Выполняем функцию немедленно в тестах
// eslint-disable-next-line react-hooks/rules-of-hooks
const { useEffect } = require('react')
useEffect(() => {
fn()
}, [])
},
}))

View File

@@ -0,0 +1,8 @@
import type { Decorator } from '@storybook/react'
import '@/app/styles/index.scss'
export const StyleDecorator: Decorator = (Story) => (
<>
<Story />
</>
)

91
config/storybook/main.ts Normal file
View File

@@ -0,0 +1,91 @@
import type { StorybookConfig } from '@storybook/react-webpack5'
import path from 'path'
import { buildCssLoader } from '../build/loaders/buildCssLoader'
const config: StorybookConfig = {
stories: ['../../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-links',
'@storybook/addon-webpack5-compiler-babel',
],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
docs: {},
webpackFinal: async (config) => {
// Добавление алиасов путей TypeScript
if (config.resolve) {
config.resolve.modules = [
path.resolve(__dirname, '../../src'),
'node_modules',
]
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname, '../../src'),
}
config.resolve.extensions = [
...(config.resolve.extensions || []),
'.ts',
'.tsx',
]
}
// Добавление поддержки SCSS через buildCssLoader проекта
config.module = config.module || {}
config.module.rules = config.module.rules || []
// Удаление стандартных правил CSS/SCSS из Storybook
config.module.rules = config.module.rules.filter((rule) => {
if (typeof rule === 'object' && rule !== null && 'test' in rule) {
const test = rule.test
if (test instanceof RegExp) {
// Удаляем правила для CSS/SCSS и SVG
return !(
test.test('.css') ||
test.test('.scss') ||
test.test('.sass') ||
test.test('.svg')
)
}
}
return true
})
// Использование конфигурации CSS loader из проекта
config.module.rules.push(buildCssLoader(true))
// Добавление поддержки SVGR для SVG иконок
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack'],
})
return config
},
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => {
if (prop.parent) {
return !prop.parent.fileName.includes('node_modules')
}
return true
},
},
},
}
export default config

View File

@@ -0,0 +1,29 @@
import type { Preview } from '@storybook/react'
import { StyleDecorator } from './StyleDecorator.tsx'
const preview: Preview = {
decorators: [StyleDecorator],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
backgrounds: {
default: 'light',
values: [
{
name: 'light',
value: '#F4F5F9',
},
{
name: 'dark',
value: '#1a1a1a',
},
],
},
},
}
export default preview

View File

@@ -9,25 +9,41 @@
"build:dev": "webpack --env mode=development",
"lint": "eslint src --ext .ts,.tsx",
"lint:styles": "stylelint 'src/**/*.{css,scss}' --allow-empty-input",
"test:unit": "jest --config ./config/jest/jest.config.ts",
"type-check": "tsc --noEmit",
"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",
"build-storybook": "storybook build -c config/storybook"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@gsap/react": "^2.1.2",
"classnames": "^2.5.1",
"gsap": "^3.13.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"swiper": "^12.0.3"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"husky": "^9.1.0",
"@babel/preset-env": "^7.26.0",
"@babel/preset-react": "^7.26.0",
"@babel/preset-typescript": "^7.26.0",
"@eslint/js": "^9.15.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-interactions": "^8.6.14",
"@storybook/addon-links": "^8.6.14",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
"@storybook/react": "^8.6.14",
"@storybook/react-webpack5": "^8.6.14",
"@svgr/webpack": "^8.1.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -36,6 +52,7 @@
"@typescript-eslint/eslint-plugin": "^8.16.0",
"@typescript-eslint/parser": "^8.16.0",
"babel-loader": "^9.2.0",
"compression-webpack-plugin": "^11.1.0",
"css-loader": "^7.1.0",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
@@ -46,16 +63,20 @@
"eslint-plugin-prettier": "^5.2.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^5.0.0",
"@eslint/js": "^9.15.0",
"typescript-eslint": "^8.16.0",
"globals": "^15.12.0",
"file-loader": "^6.2.0",
"globals": "^15.12.0",
"html-webpack-plugin": "^5.6.0",
"husky": "^9.1.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"mini-css-extract-plugin": "^2.9.0",
"prettier": "^3.4.0",
"react-refresh": "^0.14.2",
"regenerator-runtime": "^0.14.1",
"sass": "^1.81.0",
"sass-loader": "^16.0.0",
"storybook": "^8.6.14",
"style-loader": "^4.0.0",
"stylelint": "^16.11.0",
"stylelint-config-standard-scss": "^13.1.0",
@@ -64,6 +85,7 @@
"ts-loader": "^9.5.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.0",
"typescript-eslint": "^8.16.0",
"webpack": "^5.96.0",
"webpack-bundle-analyzer": "^4.10.0",
"webpack-cli": "^5.1.0",

3761
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
const App = () => {
return <div>Test</div>
}
export default App

9
src/app/App.tsx Normal file
View File

@@ -0,0 +1,9 @@
import './styles/index.scss'
import { TimeFrameSlider } from '@/widgets/TimeFrameSlider'
const App = () => {
return <TimeFrameSlider />
}
export default App

View File

@@ -0,0 +1 @@
@import 'https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&display=swap';

View File

@@ -0,0 +1,3 @@
@use 'fonts';
@use 'variables';
@use 'reset';

34
src/app/styles/reset.scss Normal file
View File

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

View File

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

18
src/app/types/declarations.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
declare module '*.module.scss' {
const classes: { [key: string]: string }
export default classes
}
declare module '*.scss' {
const classes: { [key: string]: string }
export default classes
}
declare module '*.png'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.svg' {
import React from 'react'
const SVGComponent: React.FC<React.SVGProps<SVGSVGElement>>
export default SVGComponent
}

View File

@@ -0,0 +1,7 @@
/**
* Entity: TimePeriod
* Public API
*/
export { HISTORICAL_PERIODS } from './model/mockData'
export type { TimePeriod, HistoricalEvent } from './model/types'

View File

@@ -0,0 +1,86 @@
/**
* Entity: TimePeriod
* Мок-данные исторических периодов
*/
import type { TimePeriod } from './types'
export const HISTORICAL_PERIODS: readonly TimePeriod[] = [
{
id: crypto.randomUUID(),
yearFrom: 1980,
yearTo: 1986,
label: 'Science',
events: [
{
year: 1980,
description: 'The first detection of gravitational waves.',
},
{
year: 1982,
description: 'New Horizons probe performs flyby of Pluto.',
},
{ year: 1984, description: 'SpaceX lands Falcon 9 rocket.' },
{ year: 1985, description: 'Discovery of Homo naledi.' },
{
year: 1986,
description: 'Mars Reconnaissance Orbiter confirms water on Mars.',
},
],
},
{
id: crypto.randomUUID(),
yearFrom: 1987,
yearTo: 1991,
label: 'Cinema',
events: [
{
year: 1987,
description: 'Leonardo DiCaprio wins Oscar for The Revenant.',
},
{ year: 1989, description: 'Arrival movie released.' },
{ year: 1991, description: 'La La Land released.' },
],
},
{
id: crypto.randomUUID(),
yearFrom: 1992,
yearTo: 1997,
label: 'Tech',
events: [
{ year: 1992, description: 'Nintendo Switch released.' },
{ year: 1995, description: 'iPhone X released.' },
{ year: 1997, description: 'AlphaGo Zero beats AlphaGo.' },
],
},
{
id: crypto.randomUUID(),
yearFrom: 1999,
yearTo: 2004,
label: 'Music',
events: [
{ year: 1999, description: 'Childish Gambino releases This Is America.' },
{ year: 2004, description: 'Drake releases Scorpion.' },
],
},
{
id: crypto.randomUUID(),
yearFrom: 2005,
yearTo: 2014,
label: 'World',
events: [
{ year: 2005, description: 'First image of a black hole.' },
{ year: 2014, description: 'Notre-Dame de Paris fire.' },
],
},
{
id: crypto.randomUUID(),
yearFrom: 2015,
yearTo: 2022,
label: 'Pandemic',
events: [
{ year: 2015, description: 'COVID-19 pandemic declared.' },
{ year: 2022, description: 'SpaceX launches first crewed mission.' },
],
},
] as const

View File

@@ -0,0 +1,38 @@
/**
* Entity: TimePeriod
* Типы данных для временных периодов и событий
*/
export interface HistoricalEvent {
/**
* Год события
*/
readonly year: number
/**
* Описание события
*/
readonly description: string
}
export interface TimePeriod {
/**
* Уникальный идентификатор
*/
readonly id: string
/**
* Год начала периода
*/
readonly yearFrom: number
/**
* Год конца периода
*/
readonly yearTo: number
/**
* Название категории
*/
readonly label: string
/**
* События, связанные с этим периодом и категорией
*/
readonly events: readonly HistoricalEvent[]
}

View File

@@ -1,6 +1,6 @@
import { createRoot } from 'react-dom/client'
import App from './App'
import App from './app/App'
const container = document.getElementById('root')

View File

@@ -0,0 +1,3 @@
<svg width="9" height="14" viewBox="-1 -1 11 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.66418 0.707108L1.41419 6.95711L7.66418 13.2071" stroke="currentColor" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 205 B

View File

@@ -0,0 +1,108 @@
.button {
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;
align-items: center;
width: 100%;
height: 100%;
svg {
width: 40%;
height: 40%;
object-fit: contain;
}
}
}

View File

@@ -0,0 +1,104 @@
import ChevronLeftIcon from '@/shared/assets/chevron--left.svg'
import { Button } from './Button'
import type { Meta, StoryObj } from '@storybook/react'
const meta = {
title: 'Shared/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['round', 'regular'],
description: 'Вариант внешнего вида',
},
size: {
control: 'select',
options: ['small', 'medium', 'large'],
description: 'Размер кнопки',
},
colorScheme: {
control: 'select',
options: ['primary', 'secondary'],
description: 'Цветовая схема',
},
disabled: {
control: 'boolean',
description: 'Активность кнопки',
},
onClick: { action: 'clicked' },
},
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
/**
* Базовая кнопка
*/
export const Default: Story = {
args: {
children: 'Submit',
variant: 'regular',
size: 'medium',
colorScheme: 'primary',
},
}
/**
* Альтернативная цветовая схема
*/
export const SecondaryColorScheme: Story = {
args: {
children: 'Submit',
variant: 'regular',
size: 'medium',
colorScheme: 'secondary',
},
}
/**
* Маленькая кнопка
*/
export const Small: Story = {
args: {
children: 'Submit',
size: 'small',
},
}
/**
* Большая кнопка
*/
export const Large: Story = {
args: {
children: 'Submit',
size: 'large',
},
}
/**
* Кнопка с SVG иконкой (шеврон)
*/
export const WithIcon: Story = {
args: {
children: <ChevronLeftIcon />,
variant: 'round',
size: 'medium',
},
}
/**
* Отключенная кнопка
*/
export const Disabled: Story = {
args: {
children: 'Submit',
disabled: true,
},
}

View 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()
})
})

View File

@@ -0,0 +1,63 @@
import classNames from 'classnames'
import { ButtonHTMLAttributes, memo, PropsWithChildren } from 'react'
import styles from './Button.module.scss'
export type ButtonVariant = 'round' | 'regular'
export type ButtonSize = 'small' | 'medium' | 'large'
export type ButtonColorScheme = 'primary' | 'secondary'
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
PropsWithChildren {
/**
* Вариант внешнего вида кнопки
* @default 'round'
*/
variant?: ButtonVariant
/**
* Размер кнопки
* @default 'medium'
*/
size?: ButtonSize
/**
* Цветовая схема
* @default 'timeframe'
*/
colorScheme?: ButtonColorScheme
}
/**
* Универсальный компонент кнопки для использования в слайдерах и других элементах интерфейса.
* Поддерживает различные варианты отображения, размеры и цветовые схемы.
*/
export const Button = memo((props: ButtonProps) => {
const {
className,
children,
variant = 'round',
size = 'medium',
colorScheme = 'primary',
disabled,
...otherProps
} = props
const mods: Record<string, boolean | undefined> = {
[styles[variant]]: true,
[styles[size]]: true,
[styles[colorScheme]]: true,
}
return (
<button
type='button'
className={classNames(styles.button, mods, className)}
disabled={disabled}
{...otherProps}
>
{children}
</button>
)
})
Button.displayName = 'Button'

View File

@@ -0,0 +1,7 @@
export { Button } from './Button'
export type {
ButtonProps,
ButtonVariant,
ButtonSize,
ButtonColorScheme,
} from './Button'

View File

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

View File

@@ -0,0 +1,56 @@
import { Card } from './Card'
import type { Meta, StoryObj } from '@storybook/react'
const meta = {
title: 'Shared/Card',
component: Card,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof Card>
export default meta
type Story = StoryObj<typeof meta>
/**
* Базовая карточка
*/
export const Default: Story = {
args: {
title: '1945',
description: 'Окончание Второй мировой войны',
},
}
/**
* Карточка с длинным описанием
*/
export const LongDescription: Story = {
args: {
title: '1969',
description:
'Первая высадка человека на Луну. Нил Армстронг и Базз Олдрин стали первыми людьми, ступившими на поверхность Луны в рамках миссии Аполлон-11.',
},
}
/**
* Карточка с коротким описанием
*/
export const ShortDescription: Story = {
args: {
title: '2001',
description: 'Запуск Wikipedia',
},
}
/**
* Карточка с текстовым заголовком
*/
export const TextTitle: Story = {
args: {
title: 'Новость',
description: 'Важное событие произошло сегодня',
},
}

View 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()
})
})

View File

@@ -0,0 +1,37 @@
import { memo } from 'react'
import styles from './Card.module.scss'
export interface CardProps {
/**
* Заголовок карточки (например, год события)
*/
readonly title: string | number
/**
* Описание карточки
*/
readonly description: string
}
/**
* Универсальная карточка для отображения информации
*
* Отображает заголовок и описание.
* Используется для событий, новостей и других информационных блоков.
*
* @example
* ```tsx
* <Card title="1945" description="Окончание Второй мировой войны" />
* <Card title="Новость" description="Текст новости" />
* ```
*/
export const Card = memo(({ title, description }: CardProps) => {
return (
<div className={styles.card}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.description}>{description}</p>
</div>
)
})
Card.displayName = 'Card'

View File

@@ -0,0 +1,2 @@
export { Card } from './Card'
export type { CardProps } from './Card'

4
src/shared/ui/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { Button } from './Button'
export type { ButtonProps } from './Button'
export { Card } from './Card'
export type { CardProps } from './Card'

View File

@@ -0,0 +1,6 @@
/**
* Widget: TimeFrameSlider
* Public API
*/
export { TimeFrameSlider } from './ui/TimeFrameSlider'

View File

@@ -0,0 +1,163 @@
import { CIRCLE_RADIUS } from '@/widgets/TimeFrameSlider/model'
import { calculateCoordinates } from './calculateCoordinates'
describe('calculateCoordinates', () => {
// Тесты на валидацию dotsAmount
describe('Валидация dotsAmount', () => {
it('должен выбросить ошибку, если dotsAmount меньше 2', () => {
expect(() => calculateCoordinates(1, 0)).toThrow(
'Количество точек должно быть от 2 до 6'
)
})
it('должен выбросить ошибку, если dotsAmount равен 0', () => {
expect(() => calculateCoordinates(0, 0)).toThrow(
'Количество точек должно быть от 2 до 6'
)
})
it('должен выбросить ошибку, если dotsAmount отрицательный', () => {
expect(() => calculateCoordinates(-1, 0)).toThrow(
'Количество точек должно быть от 2 до 6'
)
})
it('должен выбросить ошибку, если dotsAmount больше 6', () => {
expect(() => calculateCoordinates(7, 0)).toThrow(
'Количество точек должно быть от 2 до 6'
)
})
})
// Тесты на валидацию dotIndex
describe('Валидация dotIndex', () => {
it('должен выбросить ошибку, если dotIndex отрицательный', () => {
expect(() => calculateCoordinates(4, -1)).toThrow(
'Индекс точки должен быть от 0 до 5'
)
})
it('должен выбросить ошибку, если dotIndex больше 5', () => {
expect(() => calculateCoordinates(6, 6)).toThrow(
'Индекс точки должен быть от 0 до 5'
)
})
it('должен выбросить ошибку, если dotIndex больше или равен dotsAmount', () => {
expect(() => calculateCoordinates(3, 3)).toThrow(
'Индекс точки должен быть меньше количества точек'
)
})
it('должен выбросить ошибку, если dotIndex больше dotsAmount', () => {
expect(() => calculateCoordinates(2, 4)).toThrow(
'Индекс точки должен быть меньше количества точек'
)
})
})
// Тесты на корректные вычисления координат
describe('Корректные вычисления координат', () => {
it('должен вернуть корректные координаты для 2 точек, индекс 0', () => {
const result = calculateCoordinates(2, 0)
expect(result.x).toBeCloseTo(CIRCLE_RADIUS, 5)
expect(result.y).toBeCloseTo(0, 5)
})
it('должен вернуть корректные координаты для 2 точек, индекс 1', () => {
const result = calculateCoordinates(2, 1)
expect(result.x).toBeCloseTo(-CIRCLE_RADIUS, 5)
expect(result.y).toBeCloseTo(0, 5)
})
it('должен вернуть корректные координаты для 4 точек, индекс 0', () => {
const result = calculateCoordinates(4, 0)
expect(result.x).toBeCloseTo(CIRCLE_RADIUS, 5)
expect(result.y).toBeCloseTo(0, 5)
})
it('должен вернуть корректные координаты для 4 точек, индекс 1', () => {
const result = calculateCoordinates(4, 1)
expect(result.x).toBeCloseTo(0, 5)
expect(result.y).toBeCloseTo(CIRCLE_RADIUS, 5)
})
it('должен вернуть корректные координаты для 4 точек, индекс 2', () => {
const result = calculateCoordinates(4, 2)
expect(result.x).toBeCloseTo(-CIRCLE_RADIUS, 5)
expect(result.y).toBeCloseTo(0, 5)
})
it('должен вернуть корректные координаты для 4 точек, индекс 3', () => {
const result = calculateCoordinates(4, 3)
expect(result.x).toBeCloseTo(0, 5)
expect(result.y).toBeCloseTo(-CIRCLE_RADIUS, 5)
})
it('должен вернуть корректные координаты для 6 точек, индекс 0', () => {
const result = calculateCoordinates(6, 0)
expect(result.x).toBeCloseTo(CIRCLE_RADIUS, 5)
expect(result.y).toBeCloseTo(0, 5)
})
it('должен вернуть корректные координаты для 6 точек, индекс 3', () => {
const result = calculateCoordinates(6, 3)
expect(result.x).toBeCloseTo(-CIRCLE_RADIUS, 5)
expect(result.y).toBeCloseTo(0, 5)
})
it('должен вернуть координаты с правильной структурой объекта', () => {
const result = calculateCoordinates(3, 0)
expect(result).toHaveProperty('x')
expect(result).toHaveProperty('y')
expect(typeof result.x).toBe('number')
expect(typeof result.y).toBe('number')
})
})
// Граничные случаи
describe('Граничные случаи', () => {
it('должен работать с минимальным количеством точек (2)', () => {
expect(() => calculateCoordinates(2, 0)).not.toThrow()
expect(() => calculateCoordinates(2, 1)).not.toThrow()
})
it('должен работать с максимальным количеством точек (6)', () => {
expect(() => calculateCoordinates(6, 0)).not.toThrow()
expect(() => calculateCoordinates(6, 5)).not.toThrow()
})
it('должен работать с последним допустимым индексом для каждого количества точек', () => {
expect(() => calculateCoordinates(2, 1)).not.toThrow()
expect(() => calculateCoordinates(3, 2)).not.toThrow()
expect(() => calculateCoordinates(4, 3)).not.toThrow()
expect(() => calculateCoordinates(5, 4)).not.toThrow()
expect(() => calculateCoordinates(6, 5)).not.toThrow()
})
})
// Тесты на математическую корректность
describe('Математическая корректность', () => {
it('должен расположить точки на окружности заданного радиуса', () => {
const result = calculateCoordinates(4, 1)
const distanceFromCenter = Math.sqrt(result.x ** 2 + result.y ** 2)
expect(distanceFromCenter).toBeCloseTo(CIRCLE_RADIUS, 5)
})
it('должен равномерно распределять точки по окружности', () => {
const dotsAmount = 4
const results = []
for (let i = 0; i < dotsAmount; i++) {
results.push(calculateCoordinates(dotsAmount, i))
}
// Проверяем, что все точки на одинаковом расстоянии от центра
const distances = results.map((r) => Math.sqrt(r.x ** 2 + r.y ** 2))
const firstDistance = distances[0]
distances.forEach((distance) => {
expect(distance).toBeCloseTo(firstDistance, 5)
})
})
})
})

View File

@@ -0,0 +1,57 @@
import {
CIRCLE_RADIUS,
FULL_CIRCLE_DEGREES,
HALF_CIRCLE_DEGREES,
} from '@/widgets/TimeFrameSlider/model'
/**
* Интерфейс для координат точки.
*/
export interface DotCoordinates {
/**
* X координата точки.
*/
x: number
/**
* Y координата точки.
*/
y: number
}
/**
* Функция для вычисления координат точки на круге исходя из общего количества точек и индекса текущей точки.
* @param {number} dotsAmount - Количество точек (от 2 до 6).
* @param {number} dotIndex - Индекс текущей точки.
* @returns {DotCoordinates} Координаты точки.
*/
export function calculateCoordinates(
dotsAmount: number,
dotIndex: number
): DotCoordinates {
// Валидация dotsAmount
if (dotsAmount < 2 || dotsAmount > 6) {
throw new Error('Количество точек должно быть от 2 до 6')
}
// Валидация dotIndex
if (dotIndex < 0 || dotIndex > 5) {
throw new Error('Индекс точки должен быть от 0 до 5')
}
// Дополнительная проверка: dotIndex не должен превышать dotsAmount - 1
if (dotIndex >= dotsAmount) {
throw new Error('Индекс точки должен быть меньше количества точек')
}
// Угол для текущей точки (в градусах)
const angle = (FULL_CIRCLE_DEGREES / dotsAmount) * dotIndex
// Конвертация в радианы для тригонометрических функций
const radian = (angle * Math.PI) / HALF_CIRCLE_DEGREES
// Вычисление координат на круге
const x = CIRCLE_RADIUS * Math.cos(radian)
const y = CIRCLE_RADIUS * Math.sin(radian)
return { x, y }
}

View File

@@ -0,0 +1,35 @@
/**
* Константы для компонента CircleTimeline
*/
import { Power2 } from 'gsap'
/**
* Полный круг в градусах
*/
export const FULL_CIRCLE_DEGREES = 360
/**
* Половина круга в градусах
*/
export const HALF_CIRCLE_DEGREES = 180
/**
* Радиус круга в пикселях
*/
export const CIRCLE_RADIUS = 265
/**
* Длительность анимации в секундах
*/
export const ANIMATION_DURATION = 1
/**
* Easing функция для анимации GSAP
*/
export const ANIMATION_EASE = Power2.easeOut
/**
* Позиция активного элемента в градусах (верхний правый угол)
*/
export const ACTIVE_POSITION_DEGREES = -60

View File

@@ -0,0 +1,8 @@
export {
FULL_CIRCLE_DEGREES,
HALF_CIRCLE_DEGREES,
CIRCLE_RADIUS,
ANIMATION_DURATION,
ANIMATION_EASE,
ACTIVE_POSITION_DEGREES,
} from './constants'

View File

@@ -0,0 +1,76 @@
.circleContainer {
position: absolute;
top: 50%;
left: 50%;
width: calc(var(--circle-radius, 265px) * 2);
height: calc(var(--circle-radius, 265px) * 2);
border: 1px solid rgba(#42567A, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
}
.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;
left: 100%;
margin-left: 20px;
color: var(--color-text);
font-weight: 700;
font-size: 20px;
white-space: nowrap;
visibility: hidden;
opacity: 0;
}
}

View File

@@ -0,0 +1,121 @@
import { useState } from 'react'
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
import {
ACTIVE_POSITION_DEGREES,
FULL_CIRCLE_DEGREES,
} from '@/widgets/TimeFrameSlider/model'
import { CircleTimeline } from './CircleTimeline'
import type { Meta, StoryObj } from '@storybook/react'
const meta = {
title: 'Widgets/CircleTimeline',
component: CircleTimeline,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof CircleTimeline>
export default meta
type Story = StoryObj<typeof meta>
type CustomStory = Partial<Story> & Pick<Story, 'render'>
/**
* Интерактивный компонент-обертка для управления состоянием
*/
function CircleTimelineWithState() {
const [activeIndex, setActiveIndex] = useState(0)
// Расчет угла поворота на основе активного индекса
// Каждый период занимает 360/6 = 60 градусов
// Активная позиция находится на -60 градусах (верхний правый угол)
const rotation =
ACTIVE_POSITION_DEGREES -
(FULL_CIRCLE_DEGREES / HISTORICAL_PERIODS.length) * activeIndex
return (
<div style={{ width: '600px', height: '600px', position: 'relative' }}>
<CircleTimeline
periods={HISTORICAL_PERIODS}
activeIndex={activeIndex}
onPeriodChange={setActiveIndex}
rotation={rotation}
/>
</div>
)
}
/**
* Интерактивный вариант со всеми 6 периодами
*/
export const Default: CustomStory = {
render: () => <CircleTimelineWithState />,
parameters: {
docs: {
description: {
story:
'Интерактивная демонстрация с возможностью переключения между периодами',
},
},
},
}
/**
* Первый период (Science) активен
*/
export const FirstPeriod: Story = {
args: {
periods: HISTORICAL_PERIODS,
activeIndex: 0,
onPeriodChange: () => {},
rotation: ACTIVE_POSITION_DEGREES,
},
decorators: [
(Story) => (
<div style={{ width: '600px', height: '600px', position: 'relative' }}>
<Story />
</div>
),
],
}
/**
* Третий период (Tech) активен
*/
export const ThirdPeriod: Story = {
args: {
periods: HISTORICAL_PERIODS,
activeIndex: 2,
onPeriodChange: () => {},
rotation: ACTIVE_POSITION_DEGREES - (FULL_CIRCLE_DEGREES / 6) * 2,
},
decorators: [
(Story) => (
<div style={{ width: '600px', height: '600px', position: 'relative' }}>
<Story />
</div>
),
],
}
/**
* Вариант с 3 периодами для демонстрации гибкости
*/
export const FewPeriods: Story = {
args: {
periods: HISTORICAL_PERIODS.slice(0, 3),
activeIndex: 0,
onPeriodChange: () => {},
rotation: -60,
},
decorators: [
(Story) => (
<div style={{ width: '600px', height: '600px', position: 'relative' }}>
<Story />
</div>
),
],
}

View File

@@ -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()
})
})

View File

@@ -0,0 +1,184 @@
/**
* CircleTimeline Component
* Круговая временная шкала с периодами
*
* @module CircleTimeline
* @description Компонент отображает временные периоды на круговой диаграмме.
* Активный период автоматически поворачивается в заданную позицию с помощью GSAP анимации.
* Поддерживает клик по точкам для переключения периодов.
*/
import { useGSAP } from '@gsap/react'
import classNames from 'classnames'
import { gsap } from 'gsap'
import { memo, useCallback, useMemo, useRef } from 'react'
import styles from './CircleTimeline.module.scss'
import { calculateCoordinates } from '../../lib/utils/calculateCoordinates/calculateCoordinates'
import { ANIMATION_DURATION, ANIMATION_EASE } from '../../model'
import type { TimePeriod } from '@/entities/TimePeriod'
export interface CircleTimelineProps {
/**
* Массив временных периодов для отображения
*/
readonly periods: readonly TimePeriod[]
/**
* Индекс активного периода (0-based)
*/
readonly activeIndex: number
/**
* Callback для изменения активного периода
* @param index - Индекс выбранного периода
*/
readonly onPeriodChange: (index: number) => void
/**
* Угол поворота круга в градусах
*/
readonly rotation: number
}
/**
* CircleTimeline - компонент круговой временной шкалы
*
* @component
* @example
* ```tsx
* <CircleTimeline
* periods={HISTORICAL_PERIODS}
* activeIndex={0}
* onPeriodChange={(index) => setActiveIndex(index)}
* rotation={-60}
* />
* ```
*/
export const CircleTimeline = memo(function CircleTimeline({
periods,
activeIndex,
onPeriodChange,
rotation,
}: CircleTimelineProps) {
// Реф для контейнера круга
const circleRef = useRef<HTMLDivElement>(null)
// Реф для массива точек периодов
const pointsRef = useRef<(HTMLDivElement | null)[]>([])
// Реф для заголовков периодов
const titlesRef = useRef<(HTMLSpanElement | null)[]>([])
/**
* Анимация поворота круга и контр-поворота точек
* Использует useGSAP hook для автоматической очистки анимаций
*/
useGSAP(
() => {
// Анимация поворота контейнера круга
if (circleRef.current) {
gsap.to(circleRef.current, {
rotation,
duration: ANIMATION_DURATION,
ease: ANIMATION_EASE,
})
}
// Контр-поворот точек, чтобы текст оставался читаемым
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] }
)
/**
* Мемоизированный расчет позиций точек на круге
* Пересчитывается только при изменении количества периодов
*/
const pointPositions = useMemo(() => {
return periods.map((_, index, array) =>
calculateCoordinates(array.length, index)
)
}, [periods])
/**
* Мемоизированный обработчик клика по точке
* Предотвращает создание новой функции при каждом рендере
*/
const handlePointClick = useCallback(
(index: number) => {
onPeriodChange(index)
},
[onPeriodChange]
)
return (
<div className={styles.circleContainer} ref={circleRef}>
{periods.map((period, index) => {
const { x, y } = pointPositions[index]
return (
<div
key={period.id}
ref={(el) => {
pointsRef.current[index] = el
}}
className={classNames(styles.point, {
[styles.active]: index === activeIndex,
})}
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
}}
onClick={() => handlePointClick(index)}
role='button'
tabIndex={0}
aria-label={`Period ${index + 1}: ${period.label}`}
aria-current={index === activeIndex ? 'true' : 'false'}
>
<span className={styles.number}>{index + 1}</span>
<span
className={styles.title}
ref={(el) => {
titlesRef.current[index] = el
}}
>
{period.label}
</span>
</div>
)
})}
</div>
)
})

View File

@@ -0,0 +1,51 @@
.container {
position: relative;
opacity: 0;
}
.prevButtonWrapper {
position: absolute;
top: 50%;
left: -60px;
z-index: 10;
transform: translateY(-50%);
transition: opacity 0.3s ease;
}
.nextButtonWrapper {
position: absolute;
top: 50%;
right: -60px;
z-index: 10;
transform: translateY(-50%) rotate(180deg);
transition: opacity 0.3s ease;
}
.hidden {
opacity: 0;
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;
}

View File

@@ -0,0 +1,70 @@
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
import { EventsCarousel } from './EventsCarousel'
import type { Meta, StoryObj } from '@storybook/react'
const meta = {
title: 'Widgets/EventsCarousel',
component: EventsCarousel,
parameters: {
layout: 'fullwidth',
},
tags: ['autodocs'],
decorators: [
(Story) => (
<div style={{ padding: '0 50px' }}>
<Story />
</div>
),
],
argTypes: {
visible: {
control: 'boolean',
description: 'Видимость карусели (управляет анимацией)',
},
},
} satisfies Meta<typeof EventsCarousel>
export default meta
type Story = StoryObj<typeof meta>
/**
* Базовая карусель с событиями первого периода
*/
export const Default: Story = {
args: {
events: HISTORICAL_PERIODS[0].events,
visible: true,
},
}
/**
* Карусель с событиями второго периода (Cinema)
*/
export const CinemaPeriod: Story = {
args: {
events: HISTORICAL_PERIODS[1].events,
visible: true,
},
}
/**
* Скрытая карусель (для демонстрации анимации)
*/
export const Hidden: Story = {
args: {
events: HISTORICAL_PERIODS[0].events,
visible: false,
},
}
/**
* Карусель с малым количеством событий
*/
export const FewEvents: Story = {
args: {
events: HISTORICAL_PERIODS[0].events.slice(0, 2),
visible: true,
},
}

View File

@@ -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()
})
})

View File

@@ -0,0 +1,163 @@
/**
* EventsCarousel Component
* Карусель событий с использованием Swiper
* Отображает список исторических событий в виде слайдера
*/
import { useGSAP } from '@gsap/react'
import classNames from 'classnames'
import { gsap } from 'gsap'
import { memo, useRef, useState } from 'react'
import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import ChevronSvg from '@/shared/assets/chevron--left.svg'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
import {
EVENT_CAROUSEL_CONFIG,
HIDE_DURATION,
SHOW_DELAY,
SHOW_DURATION,
SHOW_Y_OFFSET,
} from './constants'
import styles from './EventsCarousel.module.scss'
import type { HistoricalEvent } from '@/entities/TimePeriod'
import type { Swiper as SwiperType } from 'swiper'
export interface EventsCarouselProps {
/**
* Массив исторических событий для отображения
*/
readonly events: readonly HistoricalEvent[]
/**
* Флаг видимости карусели (управляет анимацией появления/исчезновения)
*/
readonly visible: boolean
}
/**
* Компонент карусели исторических событий
*
* Использует Swiper для создания слайдера с кастомной навигацией.
* Поддерживает адаптивное количество слайдов на разных размерах экрана.
* Анимирует появление/исчезновение с помощью GSAP useGSAP hook.
*
* @example
* ```tsx
* <EventsCarousel
* events={ HISTORICAL_PERIODS[0].events }
* visible={ true }
* />
* ```
*/
export const EventsCarousel = memo(
({ events, visible }: EventsCarouselProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const [isBeginning, setIsBeginning] = useState(true)
const [isEnd, setIsEnd] = useState(false)
/**
* Анимация появления/исчезновения карусели
* Использует useGSAP hook для автоматической очистки анимаций
*/
useGSAP(
() => {
if (visible) {
gsap.fromTo(
containerRef.current,
{ opacity: 0, y: SHOW_Y_OFFSET },
{
opacity: 1,
y: 0,
duration: SHOW_DURATION,
delay: SHOW_DELAY,
}
)
} else {
gsap.to(containerRef.current, {
opacity: 0,
duration: HIDE_DURATION,
})
}
},
{ scope: containerRef, dependencies: [visible, events] }
)
/**
* Обработчик инициализации Swiper
* Устанавливает начальное состояние кнопок навигации
*/
const handleSwiperInit = (swiper: SwiperType) => {
setIsBeginning(swiper.isBeginning)
setIsEnd(swiper.isEnd)
}
/**
* Обработчик изменения состояния Swiper
* Обновляет состояние кнопок навигации
*/
const handleSlideChange = (swiper: SwiperType) => {
setIsBeginning(swiper.isBeginning)
setIsEnd(swiper.isEnd)
}
return (
<div className={styles.container} ref={containerRef}>
<div
className={classNames(styles.prevButtonWrapper, {
[styles.hidden]: isBeginning,
})}
>
<Button
variant='round'
size='small'
colorScheme='secondary'
className='swiper-button-prev-custom'
aria-label='Предыдущий слайд'
>
<ChevronSvg width={6} height={9} stroke='#3877EE' />
</Button>
</div>
<div
className={classNames(styles.nextButtonWrapper, {
[styles.hidden]: isEnd,
})}
>
<Button
variant='round'
size='small'
colorScheme='secondary'
className='swiper-button-next-custom'
aria-label='Следующий слайд'
>
<ChevronSvg width={6} height={9} stroke='#3877EE' />
</Button>
</div>
<Swiper
{...EVENT_CAROUSEL_CONFIG}
watchSlidesProgress
onInit={handleSwiperInit}
onSlideChange={handleSlideChange}
>
{events.map((event) => (
<SwiperSlide
key={`${event.year}-${event.description.slice(0, 20)}`}
>
<Card title={event.year} description={event.description} />
</SwiperSlide>
))}
</Swiper>
</div>
)
}
)
EventsCarousel.displayName = 'EventsCarousel'

View File

@@ -0,0 +1,51 @@
import { Navigation } from 'swiper/modules'
import type { SwiperOptions } from 'swiper/types'
/**
* Полная конфигурация Swiper для карусели событий
*/
export const EVENT_CAROUSEL_CONFIG: SwiperOptions = {
modules: [Navigation],
slidesPerView: 1.5,
spaceBetween: 25,
navigation: {
prevEl: '.swiper-button-prev-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,
},
},
}
/**
* Константы для GSAP анимаций
*/
export const SHOW_DURATION: gsap.TweenVars['duration'] = 0.5
export const SHOW_DELAY: gsap.TweenVars['delay'] = 0.2
export const SHOW_Y_OFFSET: gsap.TweenVars['y'] = 20
export const HIDE_DURATION: gsap.TweenVars['duration'] = 0.3

View File

@@ -0,0 +1,2 @@
export { EventsCarousel } from './EventsCarousel'
export type { EventsCarouselProps } from './EventsCarousel'

View File

@@ -0,0 +1,303 @@
/* Wrapper для container queries - должен быть родителем контейнера */
.wrapper {
/* Включаем container queries для адаптивности виджета */
container-type: inline-size;
container-name: timeframe-slider;
}
.container {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
max-width: 1440px;
min-height: 100vh;
margin: 0 auto;
padding-top: 180px;
color: var(--color-text);
font-family: var(--font-family-main);
border-right: 1px solid var(--color-border);
border-left: 1px solid var(--color-border);
background-image: linear-gradient(to right, rgba(#42567A, 0.1) 1px, transparent 1px);
background-repeat: no-repeat;
background-position: center top;
background-size: 1px 100%;
overflow: hidden;
@container timeframe-slider (width <= 1024px) {
padding-top: 100px;
}
@container timeframe-slider (width <= 576px) {
padding: 60px 20px 20px;
background-image: unset;
}
}
.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;
inset: unset;
margin-bottom: 20px;
padding-left: 0;
border: none;
}
}
.content {
position: relative;
display: grid;
grid-template-columns: 1fr;
width: calc(100% + 40px);
height: 600px;
margin: 0 -20px;
background-image: linear-gradient(to bottom, rgba(#42567A, 0.1) 1px, transparent 1px);
background-repeat: no-repeat;
background-position: center;
background-size: 100% 1px;
@container timeframe-slider (width <= 576px) {
position: unset;
display: flex;
flex-direction: column;
width: 100%;
height: auto;
margin: 0;
background-image: unset;
}
}
.controls {
position: absolute;
left: 100px;
bottom: 0;
z-index: 10;
display: flex;
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 {
font-weight: 400;
font-size: 14px;
}
.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;
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 {
transform: rotate(180deg);
}
.centerDate {
position: absolute;
top: 50%;
left: 50%;
z-index: 0;
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;
font-size: 140px;
line-height: 120px;
}
@container timeframe-slider (width <= 768px) {
font-size: 100px;
line-height: 80px;
}
@container timeframe-slider (width <= 576px) {
position: static;
gap: 20px;
justify-content: center;
margin-bottom: 40px;
font-size: 56px;
transform: none;
}
span:first-child {
color: #5d5fef;
}
span:last-child {
color: #ef5da8;
}
}
.periodLabel {
display: none;
@container timeframe-slider (width <= 576px) {
order: 1;
display: block;
padding-bottom: 20px;
color: var(--color-text);
font-weight: 700;
font-size: 16px;
text-align: left;
border-bottom: 1px solid #C7CDD9;
}
}
.circleContainer {
width: 100%;
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;
}
}

View File

@@ -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()
})
})

View File

@@ -0,0 +1,211 @@
/**
* TimeFrameSlider Component
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий
*/
import { useGSAP } from '@gsap/react'
import classNames from 'classnames'
import { gsap } from 'gsap'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { HISTORICAL_PERIODS } from '@/entities/TimePeriod'
import ChevronSvg from '@/shared/assets/chevron--left.svg'
import { Button } from '@/shared/ui/Button'
import { ACTIVE_POSITION_ANGLE, GSAP_ANIMATION_CONFIG } from './constants'
import styles from './TimeFrameSlider.module.scss'
import { CircleTimeline } from '../CircleTimeline/CircleTimeline'
import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
/**
* Компонент временной шкалы с интерактивной круговой диаграммой
*
* Отображает исторические периоды на круговой диаграмме с возможностью
* переключения между ними. Для каждого периода показывается карусель событий.
* Центральные даты анимируются при смене периода с помощью GSAP useGSAP hook.
*
* @example
* ```tsx
* <TimeFrameSlider />
* ```
*/
export const TimeFrameSlider = memo(() => {
const [activePeriod, setActivePeriod] = useState(0)
const [rotation, setRotation] = useState(0)
const prevRotation = useRef(0)
const startYearRef = useRef<HTMLSpanElement>(null)
const endYearRef = useRef<HTMLSpanElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const periodLabelRef = useRef<HTMLDivElement>(null)
// Текущий период
const currentPeriod = useMemo(
() => HISTORICAL_PERIODS[activePeriod],
[activePeriod]
)
// Мемоизированные константы
const totalPeriods = useMemo(() => HISTORICAL_PERIODS.length, [])
const anglePerPoint = useMemo(() => 360 / totalPeriods, [totalPeriods])
// Рефы для предыдущих значений периода
const prevYearFromRef = useRef(currentPeriod.yearFrom)
const prevYearToRef = useRef(currentPeriod.yearTo)
/**
* Расчет поворота при изменении активного периода
* Использует кратчайший путь для анимации
*/
useEffect(() => {
const targetRotation = ACTIVE_POSITION_ANGLE - activePeriod * anglePerPoint
const current = prevRotation.current
const adjustedTarget =
targetRotation - 360 * Math.round((targetRotation - current) / 360)
setRotation(adjustedTarget)
prevRotation.current = adjustedTarget
}, [activePeriod, anglePerPoint])
/**
* Анимация центральных дат с использованием GSAP useGSAP hook
* Плавно изменяет числа при смене периода
*/
useGSAP(
() => {
if (startYearRef.current) {
gsap.fromTo(
startYearRef.current,
{
innerText: prevYearFromRef.current,
},
{
innerText: currentPeriod.yearFrom,
...GSAP_ANIMATION_CONFIG,
}
)
}
if (endYearRef.current) {
gsap.fromTo(
endYearRef.current,
{
innerText: prevYearToRef.current,
},
{
innerText: currentPeriod.yearTo,
...GSAP_ANIMATION_CONFIG,
}
)
}
prevYearFromRef.current = currentPeriod.yearFrom
prevYearToRef.current = currentPeriod.yearTo
// Анимация появления лейбла периода
if (periodLabelRef.current) {
gsap.fromTo(
periodLabelRef.current,
{ opacity: 0, visibility: 'hidden' },
{ opacity: 1, visibility: 'visible', duration: 1 }
)
}
},
{
scope: containerRef,
dependencies: [currentPeriod.yearFrom, currentPeriod.yearTo],
}
)
/**
* Переключение на предыдущий период
* Использует циклическую навигацию
*/
const handlePrev = useCallback(() => {
setActivePeriod((prev) => (prev - 1 + totalPeriods) % totalPeriods)
}, [totalPeriods])
/**
* Переключение на следующий период
* Использует циклическую навигацию
*/
const handleNext = useCallback(() => {
setActivePeriod((prev) => (prev + 1) % totalPeriods)
}, [totalPeriods])
return (
<div className={styles.wrapper}>
<div className={styles.container} ref={containerRef}>
<h1 className={styles.title}>Исторические даты</h1>
<div className={styles.content}>
<div className={styles.centerDate}>
<span ref={startYearRef}>{currentPeriod.yearFrom}</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 className={styles.carouselContainer}>
<EventsCarousel events={currentPeriod.events} visible />
</div>
<div className={styles.dots}>
{HISTORICAL_PERIODS.map((_, index) => (
<button
key={index}
className={classNames(styles.dot, {
[styles.activeDot]: index === activePeriod,
})}
onClick={() => setActivePeriod(index)}
aria-label={`Перейти к периоду ${index + 1}`}
/>
))}
</div>
</div>
</div>
)
})
TimeFrameSlider.displayName = 'TimeFrameSlider'

View File

@@ -0,0 +1,19 @@
import { Power2 } from 'gsap'
/**
* Константы для компонента TimeFrameSlider
*/
/**
* Угол позиции активного элемента (верхний правый угол)
*/
export const ACTIVE_POSITION_ANGLE = -60
/**
* Конфигурация анимации для изменения значения года
*/
export const GSAP_ANIMATION_CONFIG: gsap.TweenVars = {
snap: { innerText: 1 },
duration: 1,
ease: Power2.easeInOut,
} as const

View File

@@ -0,0 +1 @@
export { TimeFrameSlider } from './TimeFrameSlider'

View File

@@ -53,5 +53,11 @@ module.exports = {
'order/properties-order': propertyOrdering,
'declaration-empty-line-before': null,
'no-descending-specificity': null, // Отключаем из-за конфликта с order/order
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global'],
},
],
},
}

View File

@@ -34,10 +34,20 @@
- **paths**: Настройка алиасов для импортов
```json
{
"@/*": ["./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
@@ -71,10 +81,13 @@ pnpm type-check
### Использование алиасов путей
```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

View File

@@ -18,7 +18,12 @@
"./src/*"
]
},
"strict": true
"strict": true,
"types": [
"node",
"jest",
"@testing-library/jest-dom"
]
},
"ts-node": {
"compilerOptions": {
@@ -26,7 +31,8 @@
}
},
"include": [
"src"
"src",
"src/**/*",
],
"exclude": [
".fttemplates/**/*",