6 Commits

13 changed files with 242 additions and 178 deletions

View File

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

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

@@ -13,9 +13,11 @@ 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 - Флаг режима разработки

View File

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

View File

@@ -20,6 +20,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@gsap/react": "^2.1.2",
"classnames": "^2.5.1",
"gsap": "^3.13.0",
"react": "^19.0.0",

14
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@gsap/react':
specifier: ^2.1.2
version: 2.1.2(gsap@3.13.0)(react@19.2.0)
classnames:
specifier: ^2.5.1
version: 2.5.1
@@ -1111,6 +1114,12 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
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':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -6799,6 +6808,11 @@ snapshots:
'@eslint/core': 0.17.0
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/node@0.16.7':

View File

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

View File

@@ -33,27 +33,19 @@
}
:global(.swiper) {
@media (width <=768px) {
padding: 0 20px;
@container timeframe-slider (width <= 768px) {
padding: 0 40px;
}
}
:global(.swiper-slide-next) {
:global(.swiper-slide-visible) {
transition: opacity 0.3s ease;
@media (width <=768px) {
@container timeframe-slider (width < 768px) {
opacity: 0.4;
}
}
:global(.swiper-slide-prev) {
transition: opacity 0.3s ease;
@media (width <=768px) {
opacity: 0.4;
}
}
:global(.swiper-slide-active) {
:global(.swiper-slide-fully-visible) {
opacity: 1;
}

View File

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

View File

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

View File

@@ -1,3 +1,10 @@
/* Wrapper для container queries - должен быть родителем контейнера */
.wrapper {
/* Включаем container queries для адаптивности виджета */
container-type: inline-size;
container-name: timeframe-slider;
}
.container {
position: relative;
@@ -23,11 +30,11 @@
overflow: hidden;
@media (width <=1024px) {
@container timeframe-slider (width <= 1024px) {
padding-top: 100px;
}
@media (width <=768px) {
@container timeframe-slider (width <= 576px) {
padding: 60px 20px 20px;
background-image: unset;
@@ -51,20 +58,25 @@
border-image: var(--gradient-primary) 1;
@media (width <=1024px) {
@container timeframe-slider (width <= 1024px) {
top: 80px;
font-size: 40px;
}
@media (width <=768px) {
@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;
font-size: 20px;
border: none;
}
@@ -85,7 +97,7 @@
background-position: center;
background-size: 100% 1px;
@media (width <=768px) {
@container timeframe-slider (width <= 576px) {
position: unset;
display: flex;
@@ -111,19 +123,23 @@
transform-origin: left;
@media (width <=1024px) {
@container timeframe-slider (width <= 1024px) {
left: 100px;
bottom: 40px;
}
@media (width <=768px) {
@container timeframe-slider (width <= 768px) {
left: 40px;
gap: 10px;
}
@container timeframe-slider (width <= 576px) {
left: 20px;
bottom: 13px;
order: 2;
gap: 10px;
margin-top: 20px;
padding: 0;
}
@@ -138,7 +154,7 @@
display: flex;
gap: 20px;
@media (width <=768px) {
@container timeframe-slider (width <= 768px) {
gap: 8px;
}
}
@@ -147,7 +163,7 @@
width: 9px;
height: 14px;
@media (width <=768px) {
@container timeframe-slider (width <= 576px) {
width: 6px;
height: 11.5px;
}
@@ -156,7 +172,7 @@
.dots {
display: none;
@media (width <=768px) {
@container timeframe-slider (width <= 576px) {
position: absolute;
left: 50%;
bottom: 32px;
@@ -214,14 +230,19 @@
pointer-events: none;
@media (width <=1024px) {
@container timeframe-slider (width <= 1024px) {
gap: 40px;
font-size: 140px;
line-height: 120px;
}
@media (width <=768px) {
@container timeframe-slider (width <= 768px) {
font-size: 100px;
line-height: 80px;
}
@container timeframe-slider (width <= 576px) {
position: static;
gap: 20px;
@@ -246,7 +267,7 @@
.periodLabel {
display: none;
@media (width <=768px) {
@container timeframe-slider (width <= 576px) {
order: 1;
display: block;
@@ -266,7 +287,7 @@
width: 100%;
height: 100%;
@media (width <=768px) {
@container timeframe-slider (width <= 576px) {
display: none;
}
}
@@ -274,7 +295,7 @@
.carouselContainer {
padding: 55px 80px 105px;
@media (width <=768px) {
@container timeframe-slider (width <= 768px) {
width: calc(100% + 40px);
margin: 0 -20px;
padding: 0;

View File

@@ -3,6 +3,7 @@
* Главный компонент временной шкалы с круговой диаграммой и каруселью событий
*/
import { useGSAP } from '@gsap/react'
import classNames from 'classnames'
import { gsap } from 'gsap'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -21,7 +22,7 @@ import { EventsCarousel } from '../EventsCarousel/EventsCarousel'
*
* Отображает исторические периоды на круговой диаграмме с возможностью
* переключения между ними. Для каждого периода показывается карусель событий.
* Центральные даты анимируются при смене периода с помощью GSAP.
* Центральные даты анимируются при смене периода с помощью GSAP useGSAP hook.
*
* @example
* ```tsx
@@ -66,13 +67,11 @@ export const TimeFrameSlider = memo(() => {
}, [activePeriod, anglePerPoint])
/**
* Анимация центральных дат с использованием GSAP
* Анимация центральных дат с использованием GSAP useGSAP hook
* Плавно изменяет числа при смене периода
*/
useEffect(() => {
if (!containerRef.current) return
const ctx = gsap.context(() => {
useGSAP(
() => {
if (startYearRef.current) {
gsap.fromTo(
startYearRef.current,
@@ -102,6 +101,7 @@ export const TimeFrameSlider = memo(() => {
prevYearFromRef.current = currentPeriod.yearFrom
prevYearToRef.current = currentPeriod.yearTo
// Анимация появления лейбла периода
if (periodLabelRef.current) {
gsap.fromTo(
periodLabelRef.current,
@@ -109,10 +109,12 @@ export const TimeFrameSlider = memo(() => {
{ opacity: 1, visibility: 'visible', duration: 1 }
)
}
}, containerRef)
return () => ctx.revert()
}, [currentPeriod.yearFrom, currentPeriod.yearTo])
},
{
scope: containerRef,
dependencies: [currentPeriod.yearFrom, currentPeriod.yearTo],
}
)
/**
* Переключение на предыдущий период
@@ -131,74 +133,76 @@ export const TimeFrameSlider = memo(() => {
}, [totalPeriods])
return (
<div className={styles.container} ref={containerRef}>
<h1 className={styles.title}>Исторические даты</h1>
<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 className={styles.content}>
<div className={styles.centerDate}>
<span ref={startYearRef}>{currentPeriod.yearFrom}</span>
<span ref={endYearRef}>{currentPeriod.yearTo}</span>
</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 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.carouselContainer}>
<EventsCarousel events={currentPeriod.events} visible />
</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 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>
)

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