Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Gallery): add rtl support #8085

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,11 @@
opacity: 0;
}

.arrowAreaStretch {
.arrow.arrowAreaStretch {
inset-block-start: 0;
}

.arrowAreaFit {
.arrow.arrowAreaFit {
inset-block-start: 50%;
transform: translateY(-50%);
block-size: auto;
Expand Down
103 changes: 79 additions & 24 deletions packages/vkui/src/components/CarouselBase/CarouselBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as React from 'react';
import { classNames } from '@vkontakte/vkjs';
import { useAdaptivityHasPointer } from '../../hooks/useAdaptivityHasPointer';
import { useDirection } from '../../hooks/useDirection';
import { useExternRef } from '../../hooks/useExternRef';
import { useMutationObserver } from '../../hooks/useMutationObserver';
import { useResizeObserver } from '../../hooks/useResizeObserver';
Expand All @@ -20,7 +21,14 @@ import {
SLIDE_THRESHOLD,
SLIDES_MANAGER_STATE,
} from './constants';
import { calcMax, calcMin, calculateIndent, getLoopPoints, getTargetIndex } from './helpers';
import {
calcMax,
calcMin,
calculateIndent,
getLoopPoints,
getTargetIndex,
revertRtlValue,
} from './helpers';
import { useSlideAnimation } from './hooks';
import {
type BaseGalleryProps,
Expand Down Expand Up @@ -59,8 +67,10 @@ export const CarouselBase = ({
}: BaseGalleryProps): React.ReactNode => {
const slidesStore = React.useRef<Record<string, HTMLDivElement | null>>({});
const slidesManager = React.useRef<SlidesManagerState>(SLIDES_MANAGER_STATE);
const [directionRef, textDirection = 'ltr'] = useDirection();
const isRtl = textDirection === 'rtl';

const rootRef = useExternRef(getRootRef);
const rootRef = useExternRef(getRootRef, directionRef);
const viewportRef = useExternRef(getRef);
const layerRef = React.useRef<HTMLDivElement>(null);
const animationFrameRef = React.useRef<ReturnType<typeof requestAnimationFrame> | null>(null);
Expand All @@ -87,9 +97,12 @@ export const CarouselBase = ({
const localMin = slidesManager.current.min ?? 0;
const indent = shiftXCurrentRef.current + shiftXDeltaRef.current;

if (indent > localMax) {
const moreThanMax = (isRtl && indent < localMax) || (!isRtl && indent > localMax);
const lessThanMin = (isRtl && indent > localMin) || (!isRtl && indent < localMin);

if (moreThanMax) {
return localMax + Number((indent - localMax) / 3);
} else if (indent < localMin) {
} else if (lessThanMin) {
return localMin + Number((indent - localMin) / 3);
}

Expand All @@ -100,7 +113,8 @@ export const CarouselBase = ({
if (looped) {
return !slidesManager.current.isFullyVisible;
}
return !slidesManager.current.isFullyVisible && shiftXCurrentRef.current < 0;
const isStartShiftX = isRtl ? shiftXCurrentRef.current <= 0 : shiftXCurrentRef.current >= 0;
return !slidesManager.current.isFullyVisible && !isStartShiftX;
};

const calculateCanSlideRight = () => {
Expand All @@ -111,7 +125,7 @@ export const CarouselBase = ({
!slidesManager.current.isFullyVisible &&
// we can't move right when gallery layer fully scrolled right, if gallery aligned by left side
((align === 'left' &&
slidesManager.current.containerWidth - shiftXCurrentRef.current <
slidesManager.current.containerWidth + shiftXCurrentRef.current <
(slidesManager.current.layerWidth ?? 0)) ||
// otherwise we need to check current slide index (align = right or align = center)
(align !== 'left' && slideIndex < slidesManager.current.slides.length - 1))
Expand Down Expand Up @@ -146,20 +160,47 @@ export const CarouselBase = ({
}
};

const checkShiftOutOfBoundsFromStart = (shiftX: number, snaps: number[]) =>
(isRtl && shiftX < snaps[0]) || (!isRtl && shiftX > snaps[0]);

const checkShiftOutOfBoundsFromEnd = (shiftX: number, slides: GallerySlidesState[]) => {
/**
* Поскольку при `align="center"` слайды сдвинуты, прежде чем рассчитать крайнюю правую точку,
* нужно вычесть сдвиг слайдов
*/
const firstSlideShift =
align === 'center'
? (slidesManager.current.containerWidth - slidesManager.current.slides[0].width) / 2
: 0;
const lastPoint = isRtl
? slides[slides.length - 1].coordX - slides[slides.length - 1].width + firstSlideShift
: slides[slides.length - 1].width + slides[slides.length - 1].coordX - firstSlideShift;

return (isRtl && shiftX >= -lastPoint) || (!isRtl && shiftX <= -lastPoint);
};

const requestTransform = (shiftX: number, animation = false) => {
const { snaps, contentSize, slides } = slidesManager.current;

if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = requestAnimationFrame(() => {
if (looped && shiftX > snaps[0]) {
shiftXCurrentRef.current = -contentSize + snaps[0];
/**
* Для бесконечной галереи проверяем, что при dnd мы прокрутили левее, чем первый слайд,
* чтобы сбросить `shiftXCurrentRef`
*/
if (looped && checkShiftOutOfBoundsFromStart(shiftX, snaps)) {
const firstSnap = revertRtlValue(snaps[0], isRtl);
shiftXCurrentRef.current = revertRtlValue(-contentSize + firstSnap, isRtl);
shiftX = shiftXCurrentRef.current + shiftXDeltaRef.current;
}
const lastPoint = slides[slides.length - 1].width + slides[slides.length - 1].coordX;

if (looped && shiftX <= -lastPoint) {
/**
* Для бесконечной галереи проверяем, что при dnd мы прокрутили правее, чем последний слайд,
* чтобы правильно пересчитать `shiftXCurrentRef`
*/
if (looped && checkShiftOutOfBoundsFromEnd(shiftX, slides)) {
shiftXCurrentRef.current = Math.abs(shiftXDeltaRef.current) + snaps[0];
}
transformCssStyles(shiftX, animation);
Expand Down Expand Up @@ -220,6 +261,7 @@ export const CarouselBase = ({
slides: localSlides,
containerWidth,
isCenterAlign,
isRtl,
}),
min: looped
? null
Expand All @@ -229,21 +271,27 @@ export const CarouselBase = ({
slides: localSlides,
viewportOffsetWidth,
align,
isRtl,
}),
};
const snaps = localSlides.map((_, index) =>
calculateIndent(index, slidesManager.current, isCenterAlign, looped),
calculateIndent(index, slidesManager.current, isCenterAlign, looped, isRtl),
);

let contentSize = -snaps[snaps.length - 1] + localSlides[localSlides.length - 1].width;
let contentSize =
revertRtlValue(-snaps[snaps.length - 1], isRtl) + localSlides[localSlides.length - 1].width;
if (align === 'center') {
contentSize += snaps[0];
contentSize += revertRtlValue(snaps[0], isRtl);
}

slidesManager.current.snaps = snaps;
slidesManager.current.contentSize = contentSize;
if (looped) {
slidesManager.current.loopPoints = getLoopPoints(slidesManager.current, containerWidth);
slidesManager.current.loopPoints = getLoopPoints(
slidesManager.current,
containerWidth,
isRtl,
);
}

shiftXCurrentRef.current = snaps[slideIndex];
Expand All @@ -270,15 +318,20 @@ export const CarouselBase = ({
const indent = snaps[slideIndex];
let startPoint = shiftXCurrentRef.current;

const fromLastToFirst = isRtl
? shiftXCurrentRef.current >= snaps[snaps.length - 1]
: shiftXCurrentRef.current <= snaps[snaps.length - 1];
/**
* Переключаемся с последнего элемента на первый
* Для корректной анимации мы прокручиваем последний слайд на всю длину (shiftX) "вперед"
* В конце анимации при отрисовке следующего кадра задаем всем слайдам начальные значения
*/
if (indent === snaps[0] && shiftXCurrentRef.current <= snaps[snaps.length - 1]) {
const distance =
Math.abs(snaps[snaps.length - 1]) + slides[slides.length - 1].width + startPoint;

if (indent === snaps[0] && fromLastToFirst) {
const endEdge = revertRtlValue(
Math.abs(snaps[snaps.length - 1]) + slides[slides.length - 1].width,
isRtl,
);
const distance = endEdge + startPoint;
addToAnimationQueue(
getAnimateFunction((progress) => {
const shiftX = startPoint + progress * distance * -1;
Expand All @@ -299,15 +352,16 @@ export const CarouselBase = ({
* В следующем кадре начинаем анимация прокрутки "назад"
*/
} else if (indent === snaps[snaps.length - 1] && shiftXCurrentRef.current === snaps[0]) {
startPoint = indent - slides[slides.length - 1].width;
startPoint = indent - revertRtlValue(slides[slides.length - 1].width, isRtl);

addToAnimationQueue(() => {
requestAnimationFrame(() => {
const shiftX = indent - slides[slides.length - 1].width;
const shiftX = indent - revertRtlValue(slides[slides.length - 1].width, isRtl);
transformCssStyles(shiftX);

getAnimateFunction((progress) => {
transformCssStyles(startPoint + progress * slides[slides.length - 1].width);
const diff = revertRtlValue(progress * slides[slides.length - 1].width, isRtl);
transformCssStyles(startPoint + diff);
})();
});
});
Expand Down Expand Up @@ -376,15 +430,15 @@ export const CarouselBase = ({

useMutationObserver(layerRef, initializeSlides);

useIsomorphicLayoutEffect(initializeSlides, [align, slideWidth, looped]);
useIsomorphicLayoutEffect(initializeSlides, [align, slideWidth, looped, isRtl]);

const calculateMinDeltaXToSlide = () => {
return slidesManager.current.slides[slideIndex].width * SLIDE_THRESHOLD;
};

const slideLeft = (event: React.MouseEvent) => {
if (slideIndex > 0) {
shiftXCurrentRef.current += calculateMinDeltaXToSlide();
shiftXCurrentRef.current += revertRtlValue(calculateMinDeltaXToSlide(), isRtl);
}
onChange?.(
(slideIndex - 1 + slidesManager.current.slides.length) % slidesManager.current.slides.length,
Expand All @@ -394,7 +448,7 @@ export const CarouselBase = ({

const slideRight = (event: React.MouseEvent) => {
if (slideIndex < slidesManager.current.slides.length - 1) {
shiftXCurrentRef.current -= calculateMinDeltaXToSlide();
shiftXCurrentRef.current -= revertRtlValue(calculateMinDeltaXToSlide(), isRtl);
}
onChange?.((slideIndex + 1) % slidesManager.current.slides.length);
onNextClick?.(event);
Expand Down Expand Up @@ -434,6 +488,7 @@ export const CarouselBase = ({
shiftXCurrentRef.current,
shiftXDeltaRef.current,
looped,
isRtl,
);
}
onDragEnd?.(e, targetIndex);
Expand Down
49 changes: 43 additions & 6 deletions packages/vkui/src/components/CarouselBase/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { calculateIndent, getLoopPoints, getShiftedIndexes, getTargetIndex } from './helpers';
import {
calculateIndent,
getLoopPoints,
getShiftedIndexes,
getTargetIndex,
revertRtlValue,
} from './helpers';
import type { SlidesManagerState } from './types';

const createSlides = (slidesCount?: number, slideWidth?: number) => {
Expand All @@ -18,9 +24,11 @@ const createSlidesManager = ({
snaps,
slides,
containerWidth,
isRtl = false,
}: Partial<SlidesManagerState> & {
slidesCount?: number;
slideWidth?: number;
isRtl?: boolean;
}): SlidesManagerState => {
return {
isFullyVisible: isFullyVisible || false,
Expand All @@ -31,7 +39,7 @@ const createSlidesManager = ({
slides:
slides ||
new Array(slidesCount || 0).fill(0).map((_, index) => ({
coordX: index * (slideWidth || 100),
coordX: revertRtlValue(index * (slideWidth || 100), isRtl),
width: slideWidth || 100,
})),
min: 0,
Expand All @@ -50,6 +58,7 @@ describe(calculateIndent, () => {
}),
targetIndex: 4,
isCenterAlign: false,
isRtl: false,
result: -800,
},
{
Expand All @@ -60,6 +69,7 @@ describe(calculateIndent, () => {
}),
targetIndex: 4,
isCenterAlign: true,
isRtl: false,
result: -800,
},
{
Expand All @@ -69,6 +79,7 @@ describe(calculateIndent, () => {
}),
targetIndex: 3,
isCenterAlign: false,
isRtl: false,
result: 0,
},
{
Expand All @@ -77,6 +88,7 @@ describe(calculateIndent, () => {
}),
targetIndex: 3,
isCenterAlign: false,
isRtl: false,
result: 0,
},
{
Expand All @@ -86,12 +98,24 @@ describe(calculateIndent, () => {
}),
targetIndex: 3,
isCenterAlign: false,
isRtl: false,
result: 0,
},
{
slidesManager: createSlidesManager({
slidesCount: 5,
slideWidth: 200,
isRtl: true,
}),
targetIndex: 4,
isCenterAlign: false,
isRtl: true,
result: 800,
},
])(
'should return $result when targetIndex $targetIndex and isCenterWithCustomWidth $isCenterWithCustomWidth',
({ slidesManager, result, isCenterAlign, targetIndex }) => {
expect(calculateIndent(targetIndex, slidesManager, isCenterAlign, true)).toBe(result);
({ slidesManager, result, isCenterAlign, targetIndex, isRtl }) => {
expect(calculateIndent(targetIndex, slidesManager, isCenterAlign, true, isRtl)).toBe(result);
},
);
});
Expand Down Expand Up @@ -142,11 +166,24 @@ describe(getLoopPoints, () => {
containerWidth: 220,
result: [-1000, -1000],
},
{
slidesManager: createSlidesManager({
slidesCount: 5,
slideWidth: 200,
viewportOffsetWidth: 200,
snaps: [0, 200, 400, 600, 800],
contentSize: 1000,
isRtl: true,
}),
isRtl: true,
containerWidth: 220,
result: [-1000, -1000],
},
])(
'should return correct result $result with containerWidth $containerWidth',
({ slidesManager, result, containerWidth }) => {
({ slidesManager, result, containerWidth, isRtl }) => {
expect(
getLoopPoints(slidesManager, containerWidth).map(({ target }) => {
getLoopPoints(slidesManager, containerWidth, isRtl).map(({ target }) => {
return target(1000);
}),
).toEqual(result);
Expand Down
Loading
Loading