diff --git a/README.md b/README.md index 6650a26..4ffaa7e 100644 --- a/README.md +++ b/README.md @@ -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. Следовать существующей структуре проекта - ---- - **Приятной разработки! 🚀** diff --git a/babel.config.README.md b/babel.config.README.md index ed5352c..6fdfb26 100644 --- a/babel.config.README.md +++ b/babel.config.README.md @@ -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 компонентов без потери состояния. diff --git a/config/build/buildLoaders.ts b/config/build/buildLoaders.ts index b08ae5e..2a3d3af 100644 --- a/config/build/buildLoaders.ts +++ b/config/build/buildLoaders.ts @@ -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 - Флаг режима разработки diff --git a/config/jest/setupTests.ts b/config/jest/setupTests.ts index b5c24bc..004e08d 100644 --- a/config/jest/setupTests.ts +++ b/config/jest/setupTests.ts @@ -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() + }, []) + }, +})) diff --git a/package.json b/package.json index 2acf48a..a0592c5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 770ab62..49ec6d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx b/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx index f803983..a8f3dec 100644 --- a/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx +++ b/src/widgets/TimeFrameSlider/ui/CircleTimeline/CircleTimeline.tsx @@ -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] } + ) /** * Мемоизированный расчет позиций точек на круге diff --git a/src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.module.scss b/src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.module.scss index 06d766a..c7b0618 100644 --- a/src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.module.scss +++ b/src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.module.scss @@ -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; } \ No newline at end of file diff --git a/src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.tsx b/src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.tsx index 0e9c4df..54326ba 100644 --- a/src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.tsx +++ b/src/widgets/TimeFrameSlider/ui/EventsCarousel/EventsCarousel.tsx @@ -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 diff --git a/src/widgets/TimeFrameSlider/ui/EventsCarousel/constants.ts b/src/widgets/TimeFrameSlider/ui/EventsCarousel/constants.ts index 47ec773..0740065 100644 --- a/src/widgets/TimeFrameSlider/ui/EventsCarousel/constants.ts +++ b/src/widgets/TimeFrameSlider/ui/EventsCarousel/constants.ts @@ -16,7 +16,7 @@ export const EVENT_CAROUSEL_CONFIG: SwiperOptions = { }, breakpoints: { 576: { - slidesPerView: 2, + slidesPerView: 2.5, }, 768: { slidesPerView: 2, diff --git a/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.module.scss b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.module.scss index f75e634..c478744 100644 --- a/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.module.scss +++ b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.module.scss @@ -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; diff --git a/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.tsx b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.tsx index 5293b6d..271a893 100644 --- a/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.tsx +++ b/src/widgets/TimeFrameSlider/ui/TimeFrameSlider/TimeFrameSlider.tsx @@ -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 ( -